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
| Event | Fires when |
|---|---|
document.sent | The document was sent and signature requests went out. |
document.viewed | A recipient opened the document for the first time. |
document.signed | A recipient signed. On a multi-party document this fires once per signer. |
document.completed | Every signer has signed. This is the one most integrations act on. |
document.declined | A recipient refused to sign. The document is finished. |
document.voided | The 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
{
"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"
}
}
}| Field | What it is |
|---|---|
id | The event id, and your deduplication key. Stable across retries and manual redeliveries. |
event | Which event this is. Also in the X-XOsign-Event header. |
created_at | When the event was recorded, not when it was delivered. |
data.document | The document, in exactly the same shape GET /documents/{id} returns. |
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | XOsign-Webhooks/1.0 |
X-XOsign-Event | The event type, e.g. document.completed |
X-XOsign-Signature | t=<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.
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);
}import hashlib, hmac, time
TOLERANCE_SECONDS = 300
def verify_xosign_webhook(raw_body: bytes, header: str, secret: str) -> bool:
"""raw_body must be the EXACT bytes received, not a re-serialized dict."""
try:
parts = dict(kv.split("=", 1) for kv in (header or "").split(","))
t, v1 = parts["t"].strip(), parts["v1"].strip()
except (ValueError, KeyError):
return False
# Reject old captures. The timestamp is inside the signed string.
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
f"{t}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
# Constant-time — a plain == leaks how much of the digest matched.
return hmac.compare_digest(v1, expected)import express from "express";
const app = express();
app.post(
"/webhooks/xosign",
// express.raw is essential: express.json() would parse and discard the
// exact bytes, and a re-serialized body will not match the signature.
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyXOsignWebhook(
req.body.toString("utf8"),
req.get("X-XOsign-Signature"),
process.env.XOSIGN_WEBHOOK_SECRET,
);
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// Acknowledge FIRST, work afterwards. We time out after 10 seconds and
// will retry — slow handlers cause duplicate deliveries.
res.status(200).end();
// event.id is stable across retries and redeliveries: dedupe on it.
void handleEvent(event);
},
);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
2xxto 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
4xxor5xxis 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.
| Attempt | Next try |
|---|---|
| After the 1st failure | 1 minute |
| After the 2nd | 5 minutes |
| After the 3rd | 30 minutes |
| After the 4th | 2 hours |
| After the 5th | 12 hours |
| After the 6th | Given 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
3xxor 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=completedrather than assuming every event arrived.