> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dolfinai.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed events at your own endpoint instead of polling

Dolfin sends a signed HTTP `POST` to a URL you control whenever something happens to a bill,
invoice, credit note, or match group. Instead of polling `GET /v1/bills/{id}` in a loop waiting
for extraction to finish, you get told the moment it does.

Most events carry the **full entity snapshot** — the same shape the corresponding `GET` endpoint
returns — so you usually don't need a follow-up call. A few send a compact reference instead and
expect you to fetch the detail; see [what each event sends](#what-each-event-sends).

## How it works

<Steps>
  <Step title="Create a subscription">
    In the [Dolfin portal](https://portal.dolfinai.co), add your HTTPS endpoint and pick the
    events you want. Copy the signing secret — it's shown once.
  </Step>

  <Step title="Receive the POST">
    Dolfin delivers each matching event to your URL with a `Dolfin-Signature` header.
  </Step>

  <Step title="Verify the signature">
    Recompute the HMAC over the raw request body and compare. Reject anything that doesn't match.
  </Step>

  <Step title="Return 2xx">
    Any `2xx` acknowledges the event. Anything else — or no response within 10 seconds — is
    retried with backoff.
  </Step>
</Steps>

## Create a subscription

Subscriptions are managed in the [Dolfin portal](https://portal.dolfinai.co) under **Webhooks**.
Create one with:

* **URL** — must be `https://` and publicly reachable. Private, loopback, link-local, and cloud
  metadata addresses are rejected.
* **Event types** — one or more from the [catalog](#event-catalog). Subscribe only to what you
  handle; you can change the selection later.

Dolfin generates a signing secret (`whsec_…`) and shows it **once**, at creation. Store it
somewhere your webhook handler can read it. If you lose it or need to cycle it, rotate the secret
from the portal — the previous one stops working immediately.

<Note>
  **Subscriptions are client-wide.** One subscription covers every organisation under your client,
  so you don't need one per organisation. Each event carries `organisationId` — route on that
  field. You can create up to **10** subscriptions per client, which is usually more than enough:
  use separate ones to split traffic across services, not across organisations.
</Note>

<Warning>
  **There's no backfill.** A new subscription only receives events that fire *after* it's created.
  To seed your initial state, page through `GET /v1/bills` and `GET /v1/invoices` once, then let
  webhooks keep you current.
</Warning>

## The event envelope

Every delivery has the same outer shape. Only `data` changes between event types.

```json theme={null}
{
  "id": "01HXYZ8QK4M2N7P9R3T5V6W8XA",
  "type": "bill.pending_review",
  "occurredAt": "2026-07-31T09:14:22Z",
  "organisationId": "9a658587-fe02-402e-b1ac-bfaf53274ef8",
  "actor": {
    "id": null,
    "type": "system"
  },
  "data": {
    "bill": {
      "id": "b1234567-abcd-ef01-2345-6789abcdef01",
      "state": "PendingReview",
      "supplierName": "Acme Supplies Ltd",
      "invoiceNumber": "ACM-2025-0142",
      "currency": "GBP",
      "totalAmount": "576.00",
      "confidence": 0.94
    }
  }
}
```

| Field            | Description                                                                                                                                                                  |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`             | Unique event ID, stable across retries. **De-duplicate on this.**                                                                                                            |
| `type`           | The event type, e.g. `bill.pending_review`.                                                                                                                                  |
| `occurredAt`     | When the event happened (UTC).                                                                                                                                               |
| `organisationId` | The organisation the event belongs to.                                                                                                                                       |
| `actor`          | Who triggered it: `type` is `user`, `api_key`, or `system`. `id` is null for `system`.                                                                                       |
| `data`           | The payload. For bill events, `data.bill` is exactly what `GET /v1/bills/{id}` returns — but this varies by event, so check [what each event sends](#what-each-event-sends). |

Two headers are also set, so you can route or de-duplicate without parsing the body:
`Dolfin-Event-Id` and `Dolfin-Event-Type`.

## Example: react to bill extraction

This is the flow that most often replaces polling. Uploading a bill returns immediately — the
document is read asynchronously — so you need to know when the extracted fields are ready.

Subscribe to these four events:

| Event                     | What it means                                          | What to do                                                                                            |
| ------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `bill.created`            | Upload accepted, document queued for reading.          | Usually nothing — the bill has no extracted fields yet.                                               |
| `bill.pending_review`     | Extraction finished. Fields are populated.             | Show the bill for review; correct anything wrong with `PATCH /v1/bills/{id}`.                         |
| `bill.ocr_failed`         | The document couldn't be read.                         | Retry with `POST /v1/bills/{id}/retry-ocr`, or ask the user for a better scan.                        |
| `bill.duplicate_detected` | The bill looks like a re-send of one you already have. | Compare against `duplicateOfBillId`; confirm with `POST /v1/bills/{id}/dismiss-duplicate` or void it. |

Upload the bill as usual:

```bash theme={null}
curl -X POST https://api.dolfinai.co/v1/bills \
  -H "x-dolfin-api-key: dol_live_abc123" \
  -H "x-dolfin-organisation-id: 9a658587-fe02-402e-b1ac-bfaf53274ef8" \
  -F "file=@./acme-invoice-2025-01.pdf"
```

The response comes back straight away with the bill in `Extracting`. Rather than polling it, wait
for the webhook:

```javascript theme={null}
app.post('/webhooks/dolfin', async (req, res) => {
  const event = req.body; // already signature-verified — see below

  switch (event.type) {
    case 'bill.pending_review': {
      const bill = event.data.bill;
      // Extraction is done: supplierName, invoiceNumber, totalAmount, lineItems are populated.
      await queueForReview(bill);
      break;
    }

    case 'bill.ocr_failed':
      await flagUnreadableDocument(event.data.bill.id);
      break;

    case 'bill.duplicate_detected':
      // matchBasis is "SupplierId" (strong) or "SupplierName" (weaker — check carefully).
      await flagPossibleDuplicate(
        event.data.bill.id,
        event.data.duplicateOfBillId,
        event.data.matchBasis,
      );
      break;
  }

  res.sendStatus(200);
});
```

A bill that finished extraction carries a `confidence` score between 0 and 1, so you can route
low-confidence documents to a human and let clean ones flow through.

<Note>
  **Extraction methods.** Dolfin can read bill documents with either OCR or LLM vision extraction.
  Which one your account uses is configured per client — [contact us](mailto:hello@dolfinai.co)
  to switch. Either way the events, states, and extracted fields are identical, so nothing in your
  integration changes.
</Note>

<Note>
  The event is named `bill.ocr_failed` while the bill state is `ExtractionFailed`. The event name
  is kept as-is so existing integrations don't break — treat it as "the document couldn't be
  read", whichever extraction method your account uses.
</Note>

## Verify the signature

**Always verify before trusting a payload.** Your endpoint is public, so anyone could post to it.

Each request carries:

```
Dolfin-Signature: t=1753952062,v1=5257a869e7ecebeda32affa62cdca3fa793363fb1d5cf7a1e4b0dbc1a1b0a5f5
```

`t` is the Unix timestamp of the delivery attempt and `v1` is a hex HMAC-SHA256. To verify:

1. Concatenate `t`, a literal `.`, and the **raw** request body.
2. Compute HMAC-SHA256 over that string, keyed with your signing secret.
3. Compare against `v1` in constant time.
4. Reject deliveries whose `t` is outside your tolerance (5 minutes is a good default) to block
   replays.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'node:crypto';
  import express from 'express';

  const app = express();
  const SECRET = process.env.DOLFIN_WEBHOOK_SECRET; // whsec_...
  const TOLERANCE_SECONDS = 300;

  // The raw body is required — a re-serialized object will not match the signature.
  app.post(
    '/webhooks/dolfin',
    express.raw({ type: 'application/json' }),
    (req, res) => {
      const header = req.get('Dolfin-Signature') ?? '';
      const parts = Object.fromEntries(
        header.split(',').map(p => p.split('=').map(s => s.trim())),
      );
      const { t, v1 } = parts;

      if (!t || !v1) return res.sendStatus(400);

      if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) {
        return res.sendStatus(400);
      }

      const expected = crypto
        .createHmac('sha256', SECRET)
        .update(`${t}.${req.body.toString('utf8')}`)
        .digest('hex');

      const ok =
        expected.length === v1.length &&
        crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));

      if (!ok) return res.sendStatus(401);

      const event = JSON.parse(req.body.toString('utf8'));
      handleEvent(event); // your logic
      res.sendStatus(200);
    },
  );
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import os
  import time
  from flask import Flask, request, abort

  app = Flask(__name__)
  SECRET = os.environ["DOLFIN_WEBHOOK_SECRET"]  # whsec_...
  TOLERANCE_SECONDS = 300


  @app.post("/webhooks/dolfin")
  def dolfin_webhook():
      header = request.headers.get("Dolfin-Signature", "")
      parts = dict(
          p.strip().split("=", 1) for p in header.split(",") if "=" in p
      )
      timestamp, signature = parts.get("t"), parts.get("v1")

      if not timestamp or not signature:
          abort(400)

      if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
          abort(400)

      # Use the raw body — re-serializing the parsed JSON will not match.
      raw = request.get_data()
      expected = hmac.new(
          SECRET.encode("utf-8"),
          f"{timestamp}.".encode("utf-8") + raw,
          hashlib.sha256,
      ).hexdigest()

      if not hmac.compare_digest(expected, signature):
          abort(401)

      handle_event(request.get_json())  # your logic
      return "", 200
  ```
</CodeGroup>

<Warning>
  Sign the **raw bytes** you received. Parsing the JSON and re-serializing it changes whitespace
  and key order, and the signature will never match. This is the most common integration bug.
</Warning>

## Responding, retries, and delivery

Return any `2xx` to acknowledge. Do it **quickly** — Dolfin gives up on a delivery attempt after
10 seconds. If your processing is slow, enqueue the event and return `200` immediately.

A non-`2xx` response, a timeout, or a connection error schedules a retry:

| Attempt | Delay after previous |
| ------- | -------------------- |
| 2       | 30 seconds           |
| 3       | 2 minutes            |
| 4       | 10 minutes           |
| 5       | 30 minutes           |
| 6       | 1 hour               |
| 7       | 3 hours              |
| 8       | 6 hours              |
| 9       | 12 hours             |
| 10      | 24 hours             |

After 10 failed attempts the delivery is marked failed and no longer retried automatically. You
can inspect deliveries and replay a failed one from the portal.

A few guarantees worth designing around:

* **At-least-once delivery.** A successful delivery can still be re-sent — for example if your
  `200` was lost in transit. The event `id` is stable across every attempt, so record processed
  IDs and ignore repeats. **Make your handler idempotent.**
* **No ordering guarantee.** Events aren't guaranteed to arrive in the order they occurred. Don't
  infer state from arrival order — trust the entity snapshot in `data`, or re-fetch the entity.
* **Auto-disable.** After 50 consecutive delivery failures, a subscription is switched off to stop
  hammering a dead endpoint. Re-enable it in the portal once you've fixed things; that resets the
  failure count.

## Testing locally

Your endpoint has to be reachable over HTTPS, so `localhost` won't work directly. Expose your
local server with a tunnel:

```bash theme={null}
# with cloudflared
cloudflared tunnel --url http://localhost:3000

# or with ngrok
ngrok http 3000
```

Create a subscription pointing at the tunnel's HTTPS URL, then upload a bill to trigger real
events. The portal's delivery list shows each attempt with its status, response code, and payload,
and lets you replay a failed delivery while you iterate.

## What each event sends

`data` is not the same shape for every event. Most give you the whole entity; a couple give you
just enough to go and fetch it. Each event's reference page shows its exact payload.

**Full entity** — no follow-up call needed. The nested object is identical to what the matching
`GET` endpoint returns.

| Events                        | `data` contains                                                            |
| ----------------------------- | -------------------------------------------------------------------------- |
| `bill.*`                      | `bill` — the full [`BillResponse`](/api-reference/endpoint/bills/get-bill) |
| `invoice.*`                   | `invoice` — the full invoice                                               |
| `credit_note.issued`          | `creditNote`                                                               |
| `supplier_credit_note.issued` | `supplierCreditNote`                                                       |

**Full entity plus context** — the entity, plus the specifics of what just happened.

| Event                                 | `data` contains                                             |
| ------------------------------------- | ----------------------------------------------------------- |
| `bill.duplicate_detected`             | `bill`, `duplicateOfBillId`, `matchBasis`                   |
| `supplier_credit.applied`             | `supplierCreditNote`, `billId`, `amount`, `currency`        |
| `supplier_credit.reversed`            | `supplierCreditNote`, `billId`, `amount`, `currency`        |
| `recurring_invoice.generation_failed` | `schedule`, `occurrenceNumber`, `errorCode`, `errorMessage` |

**Summary or reference only** — fetch the detail yourself if you need it.

| Event                                | `data` contains                                  | To get the full picture                        |
| ------------------------------------ | ------------------------------------------------ | ---------------------------------------------- |
| `match_group.*`                      | `matchGroup` — only `id`, `billId`, and `status` | `GET /v1/match-groups/{id}`                    |
| `company_bill.awaiting_organisation` | `billId` only                                    | `GET /v1/companies/{companyId}/bills/{billId}` |

## Event catalog

### Bills

| Event                                                                        | Fires when                                                           |
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| [`bill.created`](/api-reference/webhooks/bill-created)                       | A bill was created.                                                  |
| [`bill.updated`](/api-reference/webhooks/bill-updated)                       | A bill's fields were corrected during review (no state change).      |
| [`bill.pending_review`](/api-reference/webhooks/bill-pending-review)         | Extraction finished; the bill is ready for review.                   |
| [`bill.ocr_failed`](/api-reference/webhooks/bill-ocr-failed)                 | The document couldn't be read; it can be retried.                    |
| [`bill.duplicate_detected`](/api-reference/webhooks/bill-duplicate-detected) | The bill appears to duplicate an earlier one from the same supplier. |
| [`bill.needs_approval`](/api-reference/webhooks/bill-needs-approval)         | A bill needs approval.                                               |
| [`bill.approved`](/api-reference/webhooks/bill-approved)                     | A bill was approved.                                                 |
| [`bill.rejected`](/api-reference/webhooks/bill-rejected)                     | A bill was rejected.                                                 |
| [`bill.scheduled`](/api-reference/webhooks/bill-scheduled)                   | A bill was scheduled for payment.                                    |
| [`bill.paid`](/api-reference/webhooks/bill-paid)                             | A bill was paid (terminal).                                          |
| [`bill.voided`](/api-reference/webhooks/bill-voided)                         | A bill was voided.                                                   |
| [`bill.credited`](/api-reference/webhooks/bill-credited)                     | A supplier credit note was applied against a bill.                   |

### Invoices

| Event                                                                                                | Fires when                                                           |
| ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| [`invoice.created`](/api-reference/webhooks/invoice-created)                                         | An invoice was created (draft).                                      |
| [`invoice.updated`](/api-reference/webhooks/invoice-updated)                                         | An invoice's fields were corrected while in draft (no state change). |
| [`invoice.sent`](/api-reference/webhooks/invoice-sent)                                               | An invoice was sent.                                                 |
| [`invoice.partially_paid`](/api-reference/webhooks/invoice-partially-paid)                           | An invoice was partially paid.                                       |
| [`invoice.paid`](/api-reference/webhooks/invoice-paid)                                               | An invoice was paid in full (terminal).                              |
| [`invoice.voided`](/api-reference/webhooks/invoice-voided)                                           | An invoice was voided (terminal).                                    |
| [`invoice.credited`](/api-reference/webhooks/invoice-credited)                                       | An invoice was credited.                                             |
| [`credit_note.issued`](/api-reference/webhooks/credit-note-issued)                                   | A credit note was issued.                                            |
| [`recurring_invoice.generation_failed`](/api-reference/webhooks/recurring-invoice-generation-failed) | A recurring-invoice occurrence failed to generate.                   |

### Three-way matching

| Event                                                                  | Fires when                                        |
| ---------------------------------------------------------------------- | ------------------------------------------------- |
| [`match_group.created`](/api-reference/webhooks/match-group-created)   | A match group was created.                        |
| [`match_group.variance`](/api-reference/webhooks/match-group-variance) | A match group has a variance requiring attention. |
| [`match_group.resolved`](/api-reference/webhooks/match-group-resolved) | A match group was resolved.                       |

See the [three-way matching guide](/guides/three-way-matching) for what these mean in context.

### Supplier credits

| Event                                                                                | Fires when                         |
| ------------------------------------------------------------------------------------ | ---------------------------------- |
| [`supplier_credit_note.issued`](/api-reference/webhooks/supplier-credit-note-issued) | A supplier credit note was issued. |
| [`supplier_credit.applied`](/api-reference/webhooks/supplier-credit-applied)         | A supplier credit was applied.     |
| [`supplier_credit.reversed`](/api-reference/webhooks/supplier-credit-reversed)       | A supplier credit was reversed.    |

See the [supplier credit notes guide](/guides/supplier-credit-notes) for the full flow.

### Companies

| Event                                                                                              | Fires when                                                                                  |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| [`company_bill.awaiting_organisation`](/api-reference/webhooks/company-bill-awaiting-organisation) | A bill received against a company needs someone to choose which organisation it belongs to. |

## Next steps

<CardGroup cols={2}>
  <Card title="Quick Start - AP" icon="rocket" href="/guides/quick-start-ap">
    Upload a bill and drive it through review and approval.
  </Card>

  <Card title="Three-way Matching" icon="code" href="/guides/three-way-matching">
    Match purchase orders to bills and gate approval on variances.
  </Card>
</CardGroup>
