Verifying dOCR webhooks with X-docr-Signature (HMAC-SHA256)

Every dOCR webhook is signed with an HMAC-SHA256 signature in the X-docr-Signature header. Here's how to verify it before you trust a payload.

By The dOCR team | June 17, 2026

When an extraction finishes, dOCR notifies your endpoint with a webhook — extraction.completed on success, or extraction.failed if something went wrong. Because anyone who learns your webhook URL could POST to it, you should verify that each delivery actually came from dOCR before acting on it. That’s what the X-docr-Signature header is for.

The signature header

Every delivery includes a header that looks like this:

X-docr-Signature: t=1718600000,v1=5257a869e7…

It has two parts:

  • t — the Unix timestamp when dOCR signed the payload.
  • v1 — the HMAC-SHA256 signature of the signed payload, hex-encoded.

The signed payload is the timestamp and the raw request body joined by a period: t + "." + rawBody. The signature is computed with your webhook secret as the HMAC key.

Verifying a delivery

Recompute the signature on your side and compare it to v1 using a constant-time comparison. Here it is in Node:

import crypto from "node:crypto";

function verifyDocrSignature(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => kv.split("="))
  );
  const signedPayload = `${parts.t}.${rawBody}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Two things matter here:

  1. Use the raw request body. Sign the exact bytes you received, before any JSON parsing or re-serialization. Re-encoding the body will change the signature and break verification.
  2. Compare in constant time. Use crypto.timingSafeEqual rather than === to avoid leaking information through timing.

Reject stale deliveries

Once the signature checks out, also confirm the timestamp is recent — for example, within five minutes of now. Rejecting old t values protects you against replayed deliveries:

const ageSeconds = Math.abs(Date.now() / 1000 - Number(parts.t));
if (ageSeconds > 300) throw new Error("stale webhook");

Retries

If your endpoint doesn’t return a 2xx, dOCR retries the delivery. Make your handler idempotent — key on the extraction id from the payload so a retried extraction.completed doesn’t process the same job twice.

Verify the signature, check the timestamp, and acknowledge with a 2xx once you’ve safely enqueued the work. That’s the whole contract.

The dOCR team

dOCR, Inc.