08 · WEBHOOKS

Webhooks

Seven events, exact signature verification, what the retry policy really is, and the delivery log.

All documentation

Webhooks are how your system learns that something happened without asking. They arrive server to server and are signed, which makes them the only signal you should write to your database.

The seven events

Event types
FieldTypeNotes
document.sentdocumentThe document went out for signature.
recipient.viewedrecipientA recipient opened it. Fires on the first open only, not every visit.
recipient.signedrecipientOne recipient finished. Others may still be pending.
document.completeddocumentEveryone signed and the PDF is sealed. This is the one most integrations act on.
document.declinedrecipientA recipient declined, which ends the document.
document.expireddocumentIt passed its expiry date unsigned.
document.cancelleddocumentIt was withdrawn.

Create an endpoint

The URL must be https://. The endpoint inherits the mode of the key that created it, so a test key creates a test endpoint that will only ever receive sandbox traffic.

bash
curl https://api.documentesign.com/v1/webhooks \
  -H "Authorization: Bearer $ESIGN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/hooks/esign",
    "events": ["document.completed", "document.declined"]
  }'
201 Created
{
  "id": "whk_2f8d...",
  "url": "https://api.example.com/hooks/esign",
  "mode": "LIVE",
  "events": ["document.completed", "document.declined"],
  "status": "ACTIVE",
  "created_at": "2026-09-01T10:00:00.000Z",
  "signing_secret": "whsec_..."
}
The signing secret is shown once
It is returned only on create and stored encrypted afterwards. Put it straight into your secret manager. If you lose it, delete the endpoint and make a new one.

What a delivery looks like

json
{
  "id": "evt_doc7a1b_document.completed_doc",
  "event": "document.completed",
  "mode": "LIVE",
  "occurred_at": "2026-09-01T10:41:00.000Z",
  "document": {
    "id": "doc_7a1b...",
    "status": "completed",
    "title": "NDA - Acme Inc",
    "signed_pdf": null,
    "certificate": null
  },
  "recipient": {
    "id": "rcp_3d9e...",
    "name": "Sam Rivera",
    "email": "sam@example.com",
    "status": "signed"
  }
}

recipient is present only on the events that concern one person: viewed, signed and declined.

signed_pdf and certificate are always null
Do not build on those two fields. Download URLs are short-lived, so putting one inside a payload that may be retried or replayed would hand you a dead link. When document.completed arrives, call GET /documents/{id}/files/signed to mint a fresh URL, and /files/certificate for the audit trail.

Verify the signature

Every delivery carries x-esign-signature in the form t=<unix seconds>,v1=<hex>. The signature is HMAC-SHA256 over "<t>.<raw body>", lowercase hex. The timestamp is inside the signed string, so a captured delivery cannot be replayed forever - reject anything more than 300 seconds old.

express
import crypto from "node:crypto";
import express from "express";

const app = express();

// The raw body, before any JSON parsing. This is the whole trick.
app.post("/hooks/esign",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.get("x-esign-signature") ?? "";
    const parts = Object.fromEntries(
      header.split(",").map((kv) => kv.split("=")),
    );
    const t = Number(parts.t);
    const sent = parts.v1 ?? "";

    if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
      return res.sendStatus(400);
    }

    const expected = crypto
      .createHmac("sha256", process.env.ESIGN_WEBHOOK_SECRET)
      .update(`${t}.${req.body}`)      // req.body is a Buffer here
      .digest("hex");

    const a = Buffer.from(expected, "utf8");
    const b = Buffer.from(sent, "utf8");
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.sendStatus(400);
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // Acknowledge fast, then do the work out of band.
    res.sendStatus(200);
    void handle(event);
  });
Parse the body after you verify, never before
The signature covers the exact bytes we sent. If you let a JSON body parser run first and then re-serialise, key order and whitespace change, the hash will not match, and every delivery fails with no useful clue. This is the single most common webhook bug, and it is not specific to us.

Other headers on every delivery: x-esign-event, x-esign-event-id, and a DocumentESign-Webhooks/1 user agent.

Retries, and what they are not

A delivery is attempted three times, with exponential backoff from a five second base - so roughly immediately, five seconds later, and ten seconds after that. Any 2xx counts as success. After the third failure the delivery is abandoned.

That is a narrow window, so design for it. If your receiver may be slow or briefly down, acknowledge with a 200 as soon as you have the payload and do the real work asynchronously. Anything you miss can be recovered from the delivery log or by reading the document directly.

An endpoint whose last twenty deliveries all failed is disabled automatically, and a disabled endpoint drops queued deliveries rather than sending them. Check the status if events stop arriving.

Idempotency

Every event has a stable id, and duplicate emissions are collapsed before they are queued. Store the id and ignore one you have already processed anyway - it costs a single index lookup and protects you from a replay, from a retry your handler half-finished, and from your own at-least-once processing.

The delivery log

Every attempt is recorded whether it succeeded or not, which makes debugging a receiver much less guesswork.

bash
curl "https://api.documentesign.com/v1/webhooks/whk_2f8d.../deliveries?limit=20" \
  -H "Authorization: Bearer $ESIGN_API_KEY"

Each row carries the event, the attempt number, your response status, up to 2000 characters of your response body, and the payload that was sent. Replay one once you have fixed the receiver:

bash
curl -X POST \
  https://api.documentesign.com/v1/webhooks/whk_2f8d.../deliveries/dlv_5b3c.../replay \
  -H "Authorization: Bearer $ESIGN_API_KEY"

A replay re-sends the original stored payload and is never deduplicated against the delivery it came from, so it always produces a fresh attempt.

Limits

  • Five endpoints per mode, so five test and five live.
  • Ten second timeout per delivery.
  • Signature tolerance of 300 seconds.

Full status and error tables are on errors and limits.