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
| Field | Type | Notes |
|---|---|---|
document.sent | document | The document went out for signature. |
recipient.viewed | recipient | A recipient opened it. Fires on the first open only, not every visit. |
recipient.signed | recipient | One recipient finished. Others may still be pending. |
document.completed | document | Everyone signed and the PDF is sealed. This is the one most integrations act on. |
document.declined | recipient | A recipient declined, which ends the document. |
document.expired | document | It passed its expiry date unsigned. |
document.cancelled | document | It 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.
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"]
}'{
"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_..."
}What a delivery looks like
{
"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.
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.
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);
});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.
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:
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.