📄DocParse Docs

🆕 Webhook

The production-grade way to learn about extraction events. We POST a signed JSON payload to a URL you control.

Configure your endpoint

  1. Open the API page in the dashboard.
  2. Under Webhook Settings, click Create a new endpoint.
  3. Paste the URL we should POST to (must be https:// — except for localhost during testing).
  4. 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/json
  • webhook-id — opaque unique ID per event (use for idempotency)
  • webhook-timestamp — Unix seconds at send time
  • webhook-signaturev1,<base64-of-HMAC-SHA256> (may contain multiple space-separated signatures during key rotation)

Body shape:

json
{
  "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:

plaintext
{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

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

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 False

Idempotency

We may retry on transient failures, so the same webhook-id can be delivered more than once. Always:

  1. Look up the webhook-id in your own database.
  2. If you've already processed it, return 200 and stop.
  3. 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 2xx quickly (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.