Webhooks and events
Get signed HTTPS notifications when envelopes change, verify them, and catch up on events you missed.
Every change in eSignStack is recorded as an event, whether or not a webhook is listening. Events are kept for 30 days. A webhook endpoint receives the events it subscribes to as signed POST requests.
Add an endpoint in the app
- Open Developers → Webhooks and click Add endpoint.
- Enter the Endpoint URL, an optional Description, and the Events to send. All events is selected by default.
- Copy the signing secret from the dialog. It's shown once.
Endpoints follow the Sandbox switch: an endpoint added while Sandbox is on receives only test events, and a live endpoint receives only live events.
Open an endpoint to edit it, Send test event (a real signed delivery with a sample payload), Resend failed deliveries, Roll secret, or Delete endpoint. It also lists recent deliveries.
- Developers → Events lists events with their data and deliveries. Resend sends an event to an endpoint again.
- Developers → Webhook logs shows every webhook request with the response code, response body, and request headers and body.
Add an endpoint with the API
POST /api/v1/webhook_endpoints (ability webhooks:write):
{
"url": "https://example.com/webhooks/esignstack",
"enabled_events": ["envelope.completed", "recipient.signed"],
"description": "Order system"
}- The URL must use HTTPS and resolve to a public address. Redirects aren't followed.
enabled_eventstakes event types, or["*"]for all of them.- The response includes the
secretonce. Store it. - A test key creates a test endpoint. A platform key without
Workspace-Idcreates a platform endpoint that receives events from all the platform's workspaces. - Payloads don't include field values. Add
"include": ["field_values"]to get them. Signature and initials images are never included.
Change an endpoint with PATCH /api/v1/webhook_endpoints/{id}, including status (enabled or disabled). DELETE removes it; its delivery history stays for 30 days.
The event
{
"id": "evt_01J…",
"object": "event",
"type": "envelope.completed",
"created": "2026-11-02T18:15:00+00:00",
"api_version": "2026-10-14",
"livemode": true,
"platform": null,
"workspace": "ws_01J…",
"data": {
"object": {
"id": "env_01J…",
"object": "envelope",
"name": "Acme service agreement",
"external_id": "order-456",
"status": "completed"
}
}
}data.object is a snapshot of the envelope, recipient, file, template or workspace the event is about. Recipient events include the recipient's envelope ID.
Events can arrive out of order, and the same event can arrive more than once. De-duplicate on the Webhook-Id header, order events by created, and fetch the envelope with the API when you need its current state.
Verify the signature
Each request carries these headers:
| Header | Value |
|---|---|
Webhook-Signature |
t=<unix time>,v1=<hex>. Two v1 values while a secret is rolling over. |
Webhook-Id |
The event ID |
Webhook-Delivery-Id |
The delivery ID |
Webhook-Attempt |
The attempt number, starting at 1 |
The signature is the hex HMAC-SHA256 of t + "." + raw request body, keyed with the endpoint secret. Compute it over the raw body, not re-encoded JSON. Reject requests whose t is more than 5 minutes old.
$header = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
if ($key === 't') { $timestamp = (int) $value; }
if ($key === 'v1') { $signatures[] = $value; }
}
$expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);
$valid = $timestamp !== null
&& abs(time() - $timestamp) <= 300
&& array_filter($signatures, fn ($signature) => hash_equals($expected, $signature)) !== [];import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, header, secret) {
const parts = header.split(',').map((part) => part.split('='));
const t = parts.find(([key]) => key === 't')?.[1];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return parts.some(([key, value]) =>
key === 'v1' &&
value.length === expected.length &&
timingSafeEqual(Buffer.from(value), Buffer.from(expected)),
);
}Roll the secret
Click Roll secret on the endpoint, or call POST /api/v1/webhook_endpoints/{id}/rotate_secret with expire_old_in (0 to 86400 seconds). Until the old secret expires, each delivery has a v1 signature for each secret. Accept either, then remove the old one.
Delivery and retries
- Respond with any 2xx status within 15 seconds.
- Connection errors, timeouts,
408,409,425,429and5xxare retried after about 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, then every 12 hours, for up to 3 days. ARetry-Afterheader is honored up to 1 hour. Other statuses fail at once. - A delivery that runs out of retries is marked
failedand keeps its last response. Resend it withPOST /api/v1/events/{id}/resend(body:webhook_endpoint), or resend all of an endpoint's failed deliveries withPOST /api/v1/webhook_endpoints/{id}/resend_failedand an optionalcreated[gte]/created[lte]range. - If every delivery to an endpoint fails for 5 days, the endpoint is turned off, workspace admins get an email, and a
webhook_endpoint.disabledevent is recorded. Fix your receiver, then setstatusback toenabled.
GET /api/v1/webhook_endpoints/{id}/deliveries lists deliveries, filterable with filter[status] (pending, retrying, delivered, failed).
Catch up on missed events
GET /api/v1/events (ability webhooks:read) lists the last 30 days of events, newest first, even for types no endpoint subscribes to. Filter with filter[type], filter[object] (an object's ID) and filter[created][gt], [gte], [lt] or [lte] (a date such as 2026-11-01T00:00:00Z, or Unix time), and page with starting_after. After downtime, list everything since your last processed event.
Event types
| Group | Types |
|---|---|
| Envelope | envelope.created, envelope.sent, envelope.corrected, envelope.completed, envelope.voided, envelope.declined, envelope.expired, envelope.expiration_extended, envelope.finalization_failed |
| Recipient | recipient.activated, recipient.viewed, recipient.consented, recipient.signed, recipient.approved, recipient.acknowledged, recipient.declined, recipient.reassigned, recipient.authentication_failed, recipient.authentication_locked, recipient.delivery_sent, recipient.delivery_bounced, reminder.sent |
| File | file.ready, file.failed |
| Template | template.created, template.updated, template.deleted |
| Workspace | workspace.created, workspace.updated, workspace.suspended |
| Endpoint | webhook_endpoint.disabled |
recipient.activated fires when it's a recipient's turn to act. recipient.reassigned fires when a correction or delegation changes who a recipient is. Ignore event types you don't recognize.
Delivery events
recipient.delivery_sent and recipient.delivery_bounced report what the mail provider said about a signing request or reminder email. A spam complaint is reported as a bounce.
The recipient object has a delivery block:
| Key | Value |
|---|---|
message |
An ID for the email |
kind |
signing_request or reminder |
bounce_type |
hard, soft, unknown or complaint |
failure_code |
Set when the email can't be delivered: hard_bounce, provider_rejected or policy_rejection |
reason |
A short reason from the provider |
A failure_code means the invitation won't arrive. Correct the address with a correction or reach the person another way.