# 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

1. Open **Developers → Webhooks** and click **Add endpoint**.
2. Enter the **Endpoint URL**, an optional **Description**, and the **Events** to send. **All events** is selected by default.
3. 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`):

```json
{
  "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_events` takes event types, or `["*"]` for all of them.
- The response includes the `secret` once. Store it.
- A test key creates a test endpoint. A platform key without `Workspace-Id` creates 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

```json
{
  "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.

```php
$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)) !== [];
```

```js
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`, `429` and `5xx` are 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. A `Retry-After` header is honored up to 1 hour. Other statuses fail at once.
- A delivery that runs out of retries is marked `failed` and keeps its last response. Resend it with `POST /api/v1/events/{id}/resend` (body: `webhook_endpoint`), or resend all of an endpoint's failed deliveries with `POST /api/v1/webhook_endpoints/{id}/resend_failed` and an optional `created[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.disabled` event is recorded. Fix your receiver, then set `status` back to `enabled`.

`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](/docs/envelopes#corrections) or reach the person another way.
