Documentation menu

Webhooks

Every event in the trail, delivered to an HTTPS endpoint you control, signed with a secret you were handed once.

Events

EventWhen
signing.createdA document was prepared.
signing.sentIt went out, with one link per signer.
signing.deliveredThe message reached the provider.
signing.viewedA signer opened it. The first view only — a trail that grows on every refresh buries the events that matter.
signing.consentedA signer agreed to do business electronically. Always before the signature.
signing.signedA signer signed.
signing.completedEveryone who had to sign has. This is the one most integrations want.
signing.declinedA signer said no, with a reason.
signing.voidedWithdrawn, with a reason.
signing.expiredThe link ran out.
signing.remindedA reminder went out, with a fresh link.
packet.readyEvery required document about one subject is complete.
packet.blockedSomething in a packet was declined.
billing.quota_approachingFour fifths of the month's included agreements are used, on a plan that stops at the line. Once a period.
billing.quota_exhaustedThey are all used, and sends are refused with a 402 until the plan changes or the period turns. Once a period.
signing.pingA test delivery you asked for.

Subscribe to "*" for all of them, or name the ones you want. An event that is not on this list is refused when you add the endpoint, rather than silently never arriving.

The payload

Enough to act on without a second call — and never the token. A webhook body lands in somebody's logs.

{
  "event": "signing.completed",
  "occurred_at": "2026-10-03T14:22:09Z",
  "document": {
    "id": "…", "title": "Rental agreement — Dana Reyes", "status": "completed",
    "body_sha256": "…", "completed_at": "2026-10-03T14:22:09Z",
    "metadata": { "external_ref": "RES-88213" }
  },
  "signer": { "email": "dana@example.com", "full_name": "Dana Reyes", "status": "signed" }
}

Retries

A failed delivery is retried on a widening ladder — 1 minute, 5, 30, 2 hours, 12, then daily — six times by default. After that the delivery is dead and stays readable, so you can see what was missed rather than guessing.

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 recognise a repeat.

Verifying a delivery

Three headers come with every delivery.

HeaderWhat
SignSealer-Event-IdThe delivery's id. The same on every retry, so it is what you deduplicate on.
SignSealer-TimestampUnix seconds when it was signed. Refuse anything more than five minutes from your clock, either way.
SignSealer-Signaturev1=<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/node";

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.

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 25 agreements a month.

Get an API key