Webhooks
Get notified the moment a document reaches succeeded, partial, needs_review or failed.
Events
document.succeededdocument.partialdocument.needs_reviewdocument.failed
Register an endpoint with POST /v1/webhooks (a write-scoped API key is required; see Authentication) or from the dashboard's Webhooks page (organization owners and admins only; other members can see that a webhook exists but not create, delete, or test-send it), or per-document with webhook_url on the upload call. See "Per-document override" below for how that one differs. A production endpoint must be https, and an endpoint that resolves to a private, loopback, link-local, or cloud metadata address is rejected, both at registration time and again on every delivery attempt (so a target that's later repointed at a private address stops being delivered to). Removing a member from your organization automatically disables every webhook they created.
Payload and signature
Every delivery carries an X-Webhook-Signature header: t=<unix_ts>,v1=<hex hmac>, where the hmac is HMAC_SHA256(secret, t + "." + raw_body). Verify the signature before trusting the body, and reject a timestamp more than 5 minutes old or in the future (the replay window).
POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-Webhook-Signature: t=1758912000,v1=5f2b1c9e...a41d
X-Webhook-Event-Id: whd_9f2a1c8b4e
{
"event_id": "whd_9f2a1c8b4e",
"event": "document.partial",
"document_id": "doc_9f2a1c",
"status": "partial",
"created_at": "2026-09-25T21:04:11.000Z"
}Verifying a delivery
Compute the HMAC over the raw request body, not a re-serialized copy of the parsed JSON, since re-serialization can change whitespace and byte-for-byte break the signature.
import crypto from 'node:crypto';
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1 ?? '', 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: read the RAW body (not the parsed JSON) for this route.
app.post('/webhooks/inbound', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verify(req.body.toString('utf8'), req.header('X-Webhook-Signature'), process.env.WEBHOOK_SECRET);
if (!ok) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// event.event_id (== the X-Webhook-Event-Id header) is stable per
// delivery -- check it against processed-event storage and skip a
// duplicate before doing anything with event.event/document_id/status.
if (alreadyProcessed(event.event_id)) return res.sendStatus(200);
res.sendStatus(200);
});Retries
A delivery that doesn't get a 2xx response is retried up to 5 times with backoff (1 minute, 5 minutes, 30 minutes, 2 hours, then 12 hours). Respond 2xx as soon as you've durably queued the event; do the actual processing afterward if it's slow. The dashboard's delivery log shows every attempt, its response code, and a manual test-send button per webhook.
Deduplicating with the event id
Every delivery also carries an X-Webhook-Event-Id header (and the same value as event_id in the body), a stable identifier for that one delivery, the same idea as Stripe's own event ids. It stays the same across every retry attempt of the same delivery, and is unique per real event. Store it against processed events and skip anything you've already seen: this protects your integration against a receiver-side double-process from a retried attempt your first response to didn't actually fail (a dropped response after you'd already durably queued the event, for example), the same defensive pattern any serious webhook consumer already uses for any provider.
Stripe billing deliveries
Payment disputes suspend the subscription, Catch-Up year, or unused pages bought by that charge. A won dispute restores the saved access with its original expiration, unless the payment was refunded. Lost disputes remain suspended. Independent AppSumo codes keep their access.
POST /app/api/billing/webhook requires theStripe-Signature header signed by Stripe for the exact raw body. Missing signatures return 400 MISSING_SIGNATURE. Invalid signatures return 400 INVALID_SIGNATURE. Both use the standard JSON error response.
Billing refund events
Billing receives Stripe events at /app/api/billing/webhook, authenticated with Stripe-Signature. It handles charge.refunded, refund.updated and refund.failed. Refund IDs and statuses are saved. Catch-Up access and unused page credits are revoked only when a refund succeeds. Pending or failed refunds retain access. The dashboard response uses refunded: false until success; auto: truemeans an automatic request was submitted. A failed or canceled Autopilot refund returns auto: false and a manual-check message, alerts support, and releases the pending download pause.
Operator key migration
Internal maintenance at POST /internal/maintenance/reencrypt requires an operator token and a configured encryption key. Each response includes webhooks_skipped, grants_skipped, and org_callback_secrets_skipped for ciphertext failures encountered in that call. Transient failures are counted in failed_transient and stay eligible for retry. Least-attempted rows run first so a failing row cannot block healthy rows. A missing encryption key rejects the request without quarantining rows. Undecryptable rows are recorded for operator repair and excluded from later batches. Remaining flags exclude those rows, so operators must inspect the recorded failures before retiring the old key.
Per-document override (webhook_url on upload)
Dashboard uploads with webhook_url require completed authenticator setup for password accounts. An unprotected upload receives 403 TWO_FACTOR_SETUP_REQUIRED before storage or processing, so it cannot create a callback secret or send a callback. Uploads without a callback remain available for the free first look.
Passing webhook_url directly on POST /v1/documents (or the equivalent app upload) sends that one document's terminal-status event straight to a URL without registering it as a standing subscription. It goes through the exact same delivery mechanism as a registered webhook above, claimed and delivered, retried on the same backoff schedule if it fails, backfilled if a crash drops the first attempt, and visible in the same delivery log, so it's exactly as durable as a registered webhook, not a lesser best-effort notification.
It's signed the same way, with the same X-Webhook-Signature header and verification steps above, but with a different secret: one callback signing secret per organization, not per webhook, since a one-off override URL has nowhere in the upload call to register its own. Create, view (masked prefix only), and rotate it from Settings > Webhooks; it's created automatically the first time any document's override is ever delivered if you haven't created one yourself first. Rotating invalidates the signature on any override delivery still retrying with the old secret. Use the fresh secret it shows you (once) to verify from then on.
The URL itself is validated identically to a registered webhook's (https only, private/internal addresses rejected, re-checked on every delivery attempt).
Stripe billing event delivery uses a separate signed endpoint at /app/api/billing/webhook. An unfinished event returns 503 WEBHOOK_BUSY with a Retry-After header. Stripe should retry that delivery. A completed event returns 200.