Skip to content
On this page

Developers

Markdown

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 or reach the person another way.