DocsGuides

Webhooks

Register an HTTPS endpoint in the console and every finished document, and every completed collection, is posted to it as a signed event.

Add an endpoint#

  1. Open the Webhooks page of the console in the mode the endpoint is for. A live endpoint receives events from live keys only, a test endpoint from test keys only.
  2. Add your URL. It must be https://.
  3. Choose its events, or keep all of them.
  4. Reveal its signing secret, which starts whsec_, and copy it into your secret store.
  5. Press Send test event to check your handler.

An account can hold up to 5 endpoints. Each has its own secret. Rotating it in the console replaces it at once, so deploy the new one straight away. Removing an endpoint stops its pending deliveries. The Webhooks page lists the last 50 deliveries and their status.

Events#

TypeSent whendata.object
document.succeededA read finished. Async, synchronous and collection uploads alike.The document, complete.
document.failedA read failed after the document was created: every async failure, and a synchronous read that failed partway.The document, with status: "failed" and error.
collection.submittedAn applicant pressed the button saying they have sent everything.The collection.
testYou pressed Send test event in the console.{ "message": "A test event from the StatementBear console." }

A request refused before a document exists, such as a 415 or a 402 on the POST, sends no event. The error is in the response.

The payload#

A delivery
POST /webhooks/statementbear HTTP/1.1
Content-Type: application/json
StatementBear-Signature: t=1785372131,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{
  "id": "evt_0e4c7b91a2d35f86c1b0a9e4",
  "object": "event",
  "type": "document.succeeded",
  "created": 1785372131,
  "livemode": true,
  "data": {
    "object": {
      "id": "doc_8c1d40e97a2b5f3e6d0c9a71",
      "object": "document",
      "status": "succeeded",
      ...
    }
  }
}
FieldMeaning
idevt_ and 24 hex characters. The same on every retry of one event. De-duplicate on it.
objectAlways event.
typeOne of the event types above.
createdWhen the event happened. Unix seconds.
livemodefalse on a test endpoint.
data.objectThe document or collection, as GET would return it at that moment.

Verify the signature#

Every delivery carries StatementBear-Signature: t=<unix seconds>,v1=<hex>. To check it:

  1. Read the raw request body, before any JSON parsing.
  2. Join t, a full stop and the raw body: <t>.<body>.
  3. Compute HMAC-SHA256 of that string, keyed with the endpoint's whole secret, whsec_ included, as lowercase hex.
  4. Compare it with v1 in constant time.
  5. Reject the event when t is more than five minutes from your clock.
import crypto from "node:crypto";
import express from "express";

const secret = process.env.STATEMENTBEAR_WEBHOOK_SECRET; // whsec_...
const app = express();

// The signature covers the exact bytes sent, so read the raw body.
app.post("/webhooks/statementbear", express.raw({ type: "application/json" }), (req, res) => {
  const parts = Object.fromEntries(
    (req.get("StatementBear-Signature") ?? "").split(",").map((p) => p.split("=")),
  );
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${req.body}`)
    .digest("hex");

  const valid =
    typeof parts.v1 === "string" &&
    parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  if (!valid || !fresh) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  res.sendStatus(200); // Acknowledge, then do the work.
  handleEvent(event);
});

Respond#

Answer with any 2xx within 10 seconds. Acknowledge first and do the work afterwards, on a queue. Any other status, a timeout or a connection error counts as a failure and is retried.

Retries#

A failed delivery is retried up to 8 times over about three days, after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 24 hours and 24 hours. Then the delivery is marked failed.

  • Every retry sends the same body and the same event id. t and the signature are fresh on each attempt.
  • Events can arrive more than once and out of order. De-duplicate on id, and fetch GET /api/v1/documents/{id} when you need the current state.
  • A test event is sent once and never retried.

Without webhooks#

Poll GET /api/v1/documents/{id}, or list recent documents with GET /api/v1/documents?status=succeeded. See Documents.

Something wrong or missing on this page? [email protected]