🆕 Webhook
The production-grade way to learn about extraction events. We POST a signed JSON payload to a URL you control.
Configure your endpoint
- Open the API page in the dashboard.
- Under Webhook Settings, click Create a new endpoint.
- Paste the URL we should POST to (must be
https://— except for localhost during testing). - Copy the signing secret (starts with
whsec_). You'll see it exactly once — store it as a server-side env var.
Request format
Every event delivery is a POST with:
Content-Type: application/jsonwebhook-id— opaque unique ID per event (use for idempotency)webhook-timestamp— Unix seconds at send timewebhook-signature—v1,<base64-of-HMAC-SHA256>(may contain multiple space-separated signatures during key rotation)
Body shape:
{
"type": "extraction.completed",
"timestamp": "2026-05-24T10:00:00Z",
"data": {
"extraction_id": "ext_01HQX...",
"batch_id": "btc_01HQX...",
"status": "processed",
"files": [
{ "id": "file_01HQX...", "status": "processed" }
]
}
}Verify the signature
We follow the Standard Webhooks spec — the same scheme used by Stripe, Dodo Payments, Svix. Signed payload format:
{webhook-id}.{webhook-timestamp}.{raw-body}HMAC-SHA256 over that string with your whsec_ key (base64-decoded
after stripping the whsec_ prefix), then base64-encode the digest.
Node.js / TypeScript
import crypto from "node:crypto";
export function verifyWebhook(rawBody: string, headers: Headers, secret: string): boolean {
const id = headers.get("webhook-id");
const ts = headers.get("webhook-timestamp");
const sig = headers.get("webhook-signature");
if (!id || !ts || !sig) return false;
// Reject replays older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const keyB64 = secret.startsWith("whsec_") ? secret.slice(6) : secret;
const keyBytes = Buffer.from(keyB64, "base64");
const expected = crypto
.createHmac("sha256", keyBytes)
.update(`${id}.${ts}.${rawBody}`)
.digest("base64");
// sig is "v1,<base64> v1,<base64>" — pass if any matches
return sig.split(" ").some((s) => {
const [v, val] = s.split(",", 2);
return v === "v1" && val && val.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(val), Buffer.from(expected));
});
}Python
import hmac, hashlib, base64, time
def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> bool:
id_ = headers.get("webhook-id")
ts = headers.get("webhook-timestamp")
sig = headers.get("webhook-signature")
if not (id_ and ts and sig): return False
if abs(time.time() - int(ts)) > 300: return False
key_b64 = secret[6:] if secret.startswith("whsec_") else secret
key = base64.b64decode(key_b64)
expected = base64.b64encode(
hmac.new(key, f"{id_}.{ts}.".encode() + raw_body, hashlib.sha256).digest()
).decode()
for part in sig.split(" "):
v, _, val = part.partition(",")
if v == "v1" and hmac.compare_digest(val, expected): return True
return FalseIdempotency
We may retry on transient failures, so the same webhook-id can be
delivered more than once. Always:
- Look up the
webhook-idin your own database. - If you've already processed it, return
200and stop. - Otherwise process and record.
Retries
If your endpoint returns a 5xx or times out (10s), we retry with
exponential backoff for up to 3 days. After that, the event is
dropped — but you can always re-fetch results via the polling
endpoints.
Your endpoint should:
- Return
2xxquickly (offload heavy work to a queue). - Be idempotent.
- Verify the signature before doing anything with the payload.
Testing your endpoint
Inside the dashboard, click Send test event next to your webhook
row. We POST a signed endpoint.test payload and show you the HTTP
status + latency we got back — useful for confirming signature
verification works before any real events fire.