Guides

Webhooks

Register an HTTPS endpoint and we will POST a signed callback the moment a document moves — no polling.

Registering an endpoint

Add endpoints in Settings → Webhooks, available on the Crew plan and above. Choose which events it should receive; you can register up to ten endpoints per account.

  • The URL must be public HTTPS. Private, loopback, and cloud-metadata addresses are refused at registration and re-checked before every delivery.
  • The signing secret (whsec_…) is shown once, when you create the endpoint. Store it somewhere you can reach from your handler.
  • There is no API for managing endpoints — that is deliberate, since an API key that could redirect your own webhooks would be a nasty thing to leak.

Events

EventFires when
document.sentThe document was sent and signature requests went out.
document.viewedA recipient opened the document for the first time.
document.signedA recipient signed. On a multi-party document this fires once per signer.
document.completedEvery signer has signed. This is the one most integrations act on.
document.declinedA recipient refused to sign. The document is finished.
document.voidedThe document was cancelled by its sender.

Every event carries the same payload shape — the full document — so one handler can serve all of them.

The payload

json
{
  "id": "3d9a17f4-8c2b-4e51-9f77-0b6ae2c81d40",
  "event": "document.completed",
  "created_at": "2026-08-04T10:41:07.912Z",
  "data": {
    "document": {
      "object": "document",
      "id": "9f1c7d2e-4b3a-4c88-9e21-7a5b0c4d8e13",
      "type": "contract",
      "status": "completed",
      "title": "Framing Agreement",
      "recipient_count": 1,
      "sent_at": "2026-08-04T10:22:44.031Z",
      "completed_at": "2026-08-04T10:41:07.001Z",
      "created_at": "2026-08-04T10:22:31.482Z",
      "updated_at": "2026-08-04T10:41:07.001Z"
    }
  }
}
FieldWhat it is
idThe event id, and your deduplication key. Stable across retries and manual redeliveries.
eventWhich event this is. Also in the X-XOsign-Event header.
created_atWhen the event was recorded, not when it was delivered.
data.documentThe document, in exactly the same shape GET /documents/{id} returns.

Request headers

HeaderValue
Content-Typeapplication/json
User-AgentXOsign-Webhooks/1.0
X-XOsign-EventThe event type, e.g. document.completed
X-XOsign-Signaturet=<unix seconds>,v1=<hex digest>

Verifying the signature

Your endpoint is a public URL, so anyone can POST to it. Verify every request before you act on it.

text
signature = HMAC_SHA256(secret, "{timestamp}.{raw_request_body}")
header    = X-XOsign-Signature: t={timestamp},v1={signature_as_lowercase_hex}

The timestamp is signed along with the body, so a captured request cannot be replayed with a fresh timestamp, and an old capture fails your freshness check.

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

/**
 * @param rawBody the EXACT bytes we sent — not a re-serialized object.
 * @param header  the X-XOsign-Signature header value.
 * @param secret  the endpoint's whsec_… secret.
 */
export function verifyXOsignWebhook(rawBody, header, secret) {
  const parts = Object.fromEntries(
    String(header ?? "").split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
    }),
  );
  const { t, v1 } = parts;
  if (!t || !v1) return false;

  // Reject old captures. The timestamp is inside the signed string, so it
  // cannot be edited without breaking the signature.
  if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(v1, "hex");
  const b = Buffer.from(expected, "hex");
  // Constant-time — a plain === leaks how much of the digest matched.
  return a.length === b.length && timingSafeEqual(a, b);
}

Sign the raw bytes, not a re-serialized object

The signature covers the exact body we sent. If your framework parses the JSON and you re-serialize it to verify, key order and whitespace will differ and every signature will fail. In Express that means express.raw(), not express.json(). This is the single most common webhook integration bug.

Compare in constant time

Use timingSafeEqual or hmac.compare_digest, not ===. A plain comparison returns early on the first differing byte, which leaks how much of the digest was correct.

Responding

  • Return any 2xx to acknowledge. We do not read the body.
  • Answer within 10 seconds. Acknowledge first and do the work afterwards — a slow handler is indistinguishable from a broken one and will be retried.
  • Any 4xx or 5xx is a failure and will be retried.

Redirects are not followed

A 3xx is treated as a permanent misconfiguration: we do not follow it, and the delivery is dead-lettered immediately rather than retried. Register the final URL. This is an anti-SSRF measure — a redirect could otherwise be used to point deliveries at an internal address.

Retries

A failed delivery is retried on a widening schedule, and each attempt carries a fresh signature and timestamp.

AttemptNext try
After the 1st failure1 minute
After the 2nd5 minutes
After the 3rd30 minutes
After the 4th2 hours
After the 5th12 hours
After the 6thGiven up — the delivery is marked dead

After 6 attempts — roughly fifteen hours — the delivery is marked dead and we stop. Recent deliveries and their outcomes are visible in Settings, where you can also redeliver.

Delivery is at-least-once — dedupe on id

If your endpoint succeeds but the response never reaches us, we will retry and you will see the same event twice. The id is stable across retries and redeliveries, so record processed ids and ignore repeats. Ordering is not guaranteed either: under retries a document.completed can arrive before a document.signed. Treat each event as a statement about the document it carries rather than as a step in a sequence.

If a webhook never arrives

  • Check the endpoint is enabled and subscribed to that event in Settings.
  • Check the deliveries list — a dead delivery records the status code and the first part of your response body.
  • A 3xx or a non-public URL is dead-lettered without retrying; both show up in that list.
  • Webhooks are a notification channel, not a system of record. If one matters, reconcile periodically with GET /documents?status=completed rather than assuming every event arrived.
Webhooks · XOsign API docs