Webhooks
Every event in the trail, delivered to an HTTPS endpoint you control, signed with a secret you were handed once.
Watch it done: Webhooks that you can debug (1:44)
Events
| Event | When |
|---|---|
| signing.created | A document was prepared. |
| signing.sent | It went out, with one link per signer. |
| signing.delivered | The message carrying a signer's link reached them, and detail.channel says how. email: our email service was told by the server that receives their mail that it took the message. sms, only when the business turns it on (below): the phone network said the text reached the phone. Neither means anybody read it — signing.viewed says that. Once per signer per channel, for the first such message (the invitation, a reminder or a party's link), and only while the document waits for them. |
| signing.viewed | A signer opened it. The first view only — a trail that grows on every refresh buries the events that matter. |
| signing.verified | A signer confirmed the one-time code sent to the address or number their link went to. detail says the channel (email or sms) and where it went, masked. |
| signing.location_shared | A signer shared where they were, when the business asks for it. detail carries lat, lon and accuracy_m. Sent only to an endpoint that names it, never to "*". |
| signing.location_declined | A signer was asked where they were and said no, or their device could not tell. No position. |
| signing.id_captured | A photo of the signer's ID, or of the signer, was kept. detail says which (id or selfie) and carries its SHA-256, never the image. |
| signing.photo_supplied | A photograph answered a question. detail names the field, and the person a per-child field is about, and carries the photo's SHA-256, never the image. |
| signing.link_shown | A fresh link was shown rather than sent: at the gate, put in a frame, or handed over by a party's booker. |
| signing.consented | A signer agreed to do business electronically. Always before the signature. |
| signing.signed | A signer signed. |
| signing.completed | Everyone who had to sign has. This is the one most integrations want. |
| signing.declined | A signer said no, with a reason. |
| signing.voided | Withdrawn, with a reason. |
| signing.expired | The link ran out. |
| signing.reminded | A reminder went out, with a fresh link. |
| packet.ready | Every required document about one subject is complete. |
| packet.blocked | Something in a packet was declined. |
| billing.quota_approaching | Four fifths of the month's included agreements are used, on a plan that stops at the line. Once a period. |
| billing.quota_exhausted | They are all used, and sends are refused with a 402 until the plan changes or the period turns. Once a period. |
| texting.number_updated | The business's application for its own texting number moved a step: sent to the carriers, a change asked for, verified, paused, released. One event per step, so keep the last one; GET /v1/account says the same under texting.application. |
| signing.ping | A test delivery you asked for. |
Subscribe to "*" for all of them but one, or name the ones you
want. An event that is not on this list is refused when you add the endpoint
or change its events (PATCH /v1/webhooks/{id}), with
422 and a hint that lists every one, rather than silently never
arriving. A change that sends no events is not asked about the
ones already stored; a name stored before changes were checked (3 October) is
listed in the endpoint's unknown_events, and marked on its page in
the dashboard. A business has at most twenty endpoints.
A texted link reaching the phone. signing.delivered
is sent for the email that carried a signer's link. A business may also have it
sent when the link went by text and the phone network says the text reached the
phone: Tell my webhooks when a texted link reaches the phone, under
Integrations, Webhooks in the dashboard, which only an owner or an admin of the
business can turn on, and which is off until they do. Then the event says
detail.channel sms, once per signer, beside the email's
email: two facts, two deliveries. A carrier's "delivered" means the
phone network accepted the text for the handset, not that anybody read it. Every
change to the setting is on the business's audit trail. It is not in the API.
A signer's position goes only where it is named.
signing.location_shared carries the latitude and longitude a signer
shared, and a webhook goes to whatever address was typed into a form, so it is
sent only to an endpoint that lists signing.location_shared by name;
"*" is not sent it, and no event reaches "*" with a
position in its detail. An endpoint on "*" since
before 3 October was sent it until then, and a replay of one of those is
refused. The network region on a signature (detail.client.geo, a
country, region and city read from an address) is not a position and is not
held back.
The payload
Enough to act on without a second call — and never the token. A webhook body lands in somebody's logs.
tenant_id is the business the event belongs to. It is there so one
endpoint can serve many businesses and route on the body alone: the document id
cannot do that job, because looking it up needs you to already know which
business to look in.
{
"event": "signing.completed",
"tenant_id": "8f14e45f-ceea-467a-9f6b-1c2d3e4f5a6b",
"occurred_at": "2026-10-03T14:22:09Z",
"document": {
"id": "…", "title": "Rental agreement — Dana Reyes", "status": "completed",
"workflow_id": null, "template_id": "…",
"body_sha256": "…", "completed_at": "2026-10-03T14:22:09Z", "expires_at": null,
"metadata": { "external_ref": "RES-88213", "custom": { "unit": "River Bend" } }
},
"signer": {
"id": "…", "email": "dana@example.signsealer.com", "phone": null, "full_name": "Dana Reyes",
"role": "signer", "sign_order": 1, "status": "signed",
"signed_at": "2026-10-03T14:22:09Z", "declined_at": null, "decline_reason": null
},
"signature_method": "typed",
"detail": null
}
Every event, by its shape
| Events | Carries |
|---|---|
| signing.* | document as above, and signer when the event is one person's (null for sent, completed, voided, expired). signature_method on signed; detail is what the trail recorded beside the event, when anything. document.metadata carries your external_ref and, under custom, the metadata you gave when you made it. |
| packet.ready · packet.blocked | packet: id, code, required, completed; subject: id, kind, provider, external_id, label, starts_on. |
| billing.quota_approaching · billing.quota_exhausted | allowance: included, used, remaining, state, reason, balance_cents, prepaid. |
| texting.number_updated | number: status, kind (toll_free, local or sole_proprietor), phone once it is the business's (verified or paused; null before), area_code, reason when the carriers asked for a change or it is held up (null otherwise), and brought, true for a number the business already had. |
| signing.ping | A test sent with Send a test on the endpoint's page in the portal (sixty an hour per endpoint; there is no API route for it yet): nothing to act on, and proof the endpoint and its secret work. |
Every body has event, tenant_id and occurred_at.
Fields are added over time and never removed or renamed inside v1, so
ignore a key you do not know rather than refusing the body
(Changelog and versioning).
We keep each delivery's body, for the deliveries list and for a replay, and
not for ever. Once the document is redacted, or the business's message
retention has passed (thirty days; see Retention and redaction) for a delivery that
is finished, the people are taken out of it: the title reads
Redacted document, the signer's name, address, number and reason
for declining go, and so does external_ref. What happened, to which
document, and when, stays.
What counts as delivered
- A
2xxwithin ten seconds. Anything else is a failure and goes on the retry ladder below: another status, a timeout, a refused connection. - A redirect is a failure, not a hop. We deliver to the address you registered and nowhere it forwards to — a redirect is how a delivery would be walked to an address nobody checked.
- HTTPS only, and never to a private address. The endpoint must resolve to a public address at the moment we call it.
- Answer first, work after. Record the event and answer
200; do the slow part afterwards. A receiver that does the work inside the ten seconds is one that times out on a busy day and is sent the event again.
Retries
A delivery is tried six times by default: the first, then again 1 minute, 5,
30, 2 hours and 12 hours after each failure (daily after that, for a business
that allows more tries). After the last the delivery is dead and
stays readable, so you can see what was missed rather than guessing. A try our
own worker stopped in the middle of counts as one of the six, so a delivery
that keeps stopping the worker ends dead, with a sentence saying why, rather
than being tried for ever.
Twenty consecutive failures across deliveries pause the endpoint. Pausing stops us calling it; it does not throw your events away. They keep being written down and wait — up to ten thousand of them — and turning the endpoint back on forgives the failure count and sends what is waiting. The answer to that call tells you how many were released.
Past ten thousand held deliveries we stop keeping them, and count what we
did not keep. That number is on the endpoint's page in the portal and in
held_dropped on the endpoint, and it does not reset: it is the
record of what the outage cost, so you know a gap exists rather than finding
out later. The documents themselves are unaffected and readable through the
API — it is the notifications about them that were missed.
About ten thousand, rather than exactly: two events arriving at once can
each find room, so the queue can pass the mark by a few. It is a safety valve
on a queue nobody is draining, not a number to count on. While an endpoint is
at that mark, sending a test or replaying a delivery answers 422
and says why, instead of reporting a delivery that was not made — resume the
endpoint and both work again.
Deliveries are at-least-once. A network can drop our side of a successful call, so make your handler idempotent — the event carries enough to recognize a repeat.
Verifying a delivery
Three headers come with every delivery.
| Header | What |
|---|---|
| SignSealer-Event-Id | The delivery's id. The same on every retry, so it is what you deduplicate on. |
| SignSealer-Timestamp | Unix seconds when it was signed. Refuse anything more than five minutes from your clock, either way. |
| SignSealer-Signature | v1=<hex>: HMAC-SHA256 of timestamp.body with your secret. During a rotation it carries two, comma-separated; either verifies. |
The bytes that are signed are the exact bytes that are sent, so verify the raw body before parsing it. Parsing and re-serialising checks your own formatter rather than ours, and two JSON serialisers disagree about key order and unicode escapes eventually — the day they do, every delivery fails at once.
The SDK does this for you, and it is the recommended way:
import { verifyWebhookRequest } from "@signsealer/sdk";
app.post("/hooks/signsealer",
express.raw({ type: "application/json" }), // the bytes, not the object
async (req, res) => {
const { ok, reason, eventId } = await verifyWebhookRequest({
secret: process.env.SIGNSEALER_WEBHOOK_SECRET,
body: req.body.toString("utf8"),
headers: req.headers,
});
if (!ok) return res.status(400).send(reason);
if (await alreadyHandled(eventId)) return res.sendStatus(200); // at-least-once
// ... act on it, then record eventId
res.sendStatus(200);
});
The event id is stable across retries — a delivery attempted six times carries the same id all six — which is what makes it the thing to deduplicate on.
What it is not is authenticated. The signature covers the timestamp and the
body; the id travels beside them in a header, outside the signature. So somebody
holding one valid delivery can send those same signed bytes again inside the
tolerance window with a different id on them, and a receiver keyed on the id
will treat the replay as new. We say so rather than implying otherwise: if a
replay inside a five-minute window would be harmful to your system, check the
event's own contents against your records before acting. Bringing the id inside
the signature is a change we will make under a v2= scheme.
Or deduplicate on a SHA-256 of the raw body, which is what our WordPress plugin does. That is covered by the signature, and it is the same on every retry, because a delivery's body is built once and sent unchanged each time. The one thing it cannot tell apart is a replay somebody asked for (below), which sends the same body again under a new event id — so a receiver keyed on the body treats that as a repeat too.
It works in Node, in an edge runtime and in the browser, because it is built
on WebCrypto rather than node:crypto — which is why it is
awaited.
If you are not using the SDK
Verify in constant time, and mind the three things that are easy to miss: the timestamp is inside the MAC, so check the window in both directions; a rotation sends two signatures and either must verify; and compare every part rather than stopping at the first that matches.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, body, headers) {
const sig = headers["signsealer-signature"];
const ts = Number(headers["signsealer-timestamp"]);
if (!sig || !Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > 300) return false; // both directions
const expected = Buffer.from(
createHmac("sha256", secret).update(ts + "." + body).digest("hex"), "hex");
let matched = false;
for (const part of sig.split(",")) {
const given = /^v1=([0-9a-f]{64})$/.exec(part.trim())?.[1];
if (given === undefined) return false; // no junk in the header
const b = Buffer.from(given, "hex");
// Every part, not the first that matches: the time taken then says
// nothing about which one was right.
if (b.length === expected.length && timingSafeEqual(expected, b)) matched = true;
}
return matched;
}
Rotating the secret
POST /v1/webhooks/{id}/rotate, or the button on the endpoint's page,
returns a new secret once. For the next 24 hours every delivery is signed with
both secrets — the header carries two v1= values, the new one first —
so you can switch your receiver at your own pace without dropping a delivery.
Then the old secret is forgotten. Both checks above handle the pair, and it is
the part a hand-written one usually gets wrong: a receiver that only looks at
the first signature works perfectly until the first rotation and then refuses
every delivery.
Sending a delivery again
GET /v1/webhooks/{id}/deliveries lists the last fifty. A delivery
that was delivered, failed or died can be queued again with
POST /v1/webhooks/deliveries/{id}/replay, or "Send again" beside it in
the portal: a new delivery with the same event and the same body, with
replay_of naming the original. It gets a new event id, on purpose —
a receiver that already acted on the original can tell this repeat was asked for
by a person. One replay of a delivery may be in the queue at a time, and a replay
waits like any other if the endpoint is off or paused.
Ready to build? An API key takes a minute in the portal, and the free plan covers the first 10 agreements a month.
Get an API key