# Webhooks

> Receive document and collection events on your server, verify their signature, and handle retries.

Section: Guides
Source: https://statementbear.com/docs/webhooks
Product: StatementBear statement parsing API, $0.08 per page, 200 pages free.

---

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](https://statementbear.com/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

| Type | Sent when | `data.object` |
| --- | --- | --- |
| `document.succeeded` | A read finished. Async, synchronous and collection uploads alike. | The [document](https://statementbear.com/docs/api/document-object), complete. |
| `document.failed` | A 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.submitted` | An applicant pressed the button saying they have sent everything. | The [collection](https://statementbear.com/docs/api/collections). |
| `test` | You 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:

```http
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",
      ...
    }
  }
}
```

| Field | Meaning |
| --- | --- |
| `id` | `evt_` and 24 hex characters. The same on every retry of one event. De-duplicate on it. |
| `object` | Always `event`. |
| `type` | One of the event types above. |
| `created` | When the event happened. Unix seconds. |
| `livemode` | `false` on a test endpoint. |
| `data.object` | The 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.

Node.js:

```javascript
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);
});
```

Python:

```python
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

SECRET = os.environ["STATEMENTBEAR_WEBHOOK_SECRET"].encode()  # whsec_...
app = Flask(__name__)

@app.post("/webhooks/statementbear")
def statementbear_webhook():
    header = request.headers.get("StatementBear-Signature", "")
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    body = request.get_data()  # the raw bytes, before any JSON parsing

    expected = hmac.new(SECRET, parts.get("t", "").encode() + b"." + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(400)
    if abs(time.time() - int(parts["t"])) > 300:
        abort(400)

    handle_event(json.loads(body))
    return "", 200
```

Ruby:

```ruby
require "sinatra"
require "openssl"
require "json"

SECRET = ENV.fetch("STATEMENTBEAR_WEBHOOK_SECRET") # whsec_...

post "/webhooks/statementbear" do
  body = request.body.read # the raw bytes, before any JSON parsing
  parts = request.env["HTTP_STATEMENTBEAR_SIGNATURE"].to_s.split(",").map { |p| p.split("=", 2) }.to_h

  expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{parts['t']}.#{body}")
  halt 400 unless parts["v1"] && Rack::Utils.secure_compare(expected, parts["v1"])
  halt 400 if (Time.now.to_i - parts["t"].to_i).abs > 300

  handle_event(JSON.parse(body))
  status 200
end
```

PHP:

```php
<?php
$secret = getenv('STATEMENTBEAR_WEBHOOK_SECRET'); // whsec_...
$body   = file_get_contents('php://input');       // the raw bytes

$parts = [];
foreach (explode(',', $_SERVER['HTTP_STATEMENTBEAR_SIGNATURE'] ?? '') as $part) {
    [$k, $v] = array_pad(explode('=', $part, 2), 2, '');
    $parts[$k] = $v;
}

$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $body, $secret);
if (!hash_equals($expected, $parts['v1'] ?? '') || abs(time() - (int) ($parts['t'] ?? 0)) > 300) {
    http_response_code(400);
    exit;
}

$event = json_decode($body, true);
http_response_code(200);
handle_event($event);
```

> **Verify before you trust it**
>
> Your endpoint is a public URL. Anyone can post to it. Treat an event whose signature does not match as if it never arrived.

## 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](https://statementbear.com/docs/api/documents).

---

All documentation: https://statementbear.com/docs/llms.txt
Questions: support@statementbear.com
