> This page is for API.

> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.spreadspace.app/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.spreadspace.app/_mcp/server.

# Webhooks

A webhook endpoint is a URL you own and register once. When a document
finishes, a package completes, or an analyst finalizes a spread, SpreadSpace
posts a small signed JSON event to it. Events carry identifiers and state, not
figures: they tell you what changed and what to fetch.

## Register an endpoint

A key holding the **`webhooks:write`** scope registers an endpoint with
**Create webhook** (`POST /api/webhooks`, in the [API reference](/api)). The
body takes the destination `url`, an optional `description`, and
`subscribed_events`, the list of types this endpoint wants.

Each entry is an event name from the [table below](#events), or `*`. `*`
subscribes to every type, **including ones added later**, so a receiver on `*`
must treat a `type` it does not recognise as opaque and ignore it rather than
fail on it.

An endpoint's subscription is whatever it was created or last updated with. An
event type added to the platform later never joins an existing endpoint on its
own (only `*` follows new types), so to change what an endpoint receives,
**update it** with `PATCH /api/webhooks/{id}` and a new `subscribed_events`
rather than registering again. Registering the same URL a second time makes a
second endpoint, with its own secret and its own deliveries, and the two run
side by side; the list read shows every endpoint the workspace holds.

The response carries `signing_secret`, a `whsec_…` string returned exactly
once and never re-emitted. Capture it at creation; if you lose it, rotate for a
new one. Afterwards a `webhooks:read` key can list endpoints and their
deliveries, and `webhooks:write` covers update, rotate, delete and replay.

## Verify a signature

Every delivery carries a `SpreadSpace-Signature` header:

```
SpreadSpace-Signature: t=1700000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`t` is seconds since the Unix epoch. `v1` is the lowercase hex HMAC-SHA256 of
the string `{t}.{raw body}`, keyed with that endpoint's signing secret. Sign
over the **exact bytes you received**. A re-serialized JSON object will not
match.

1. Parse `t` and `v1` out of the header.
2. Recompute the HMAC over `{t}.{raw body}` with your secret.
3. Compare with a constant-time comparison, never `==` on the hex strings.
4. Reject a `t` more than five minutes from your own clock, in either
   direction.

Check the HMAC **before** the freshness window: that way a forged body is
refused as a bad signature rather than as a stale one.

Rotation creates a new secret and returns it once, alongside
`previous_secret_revokes_at`, which is 24 hours out. Verify against both
secrets during that window and accept a delivery that satisfies either. Each individual
delivery is signed with exactly one secret, pinned when it was enqueued, so a
retry that began before the rotation keeps signing with the older one until it
is done.

The SDKs verify and parse in one call, and raise rather than return a value you
might forget to check:

**`TypeScript`**

```ts title="TypeScript"
import { SpreadSpace, verifyAndParseWebhook, WebhookSignatureError } from '@spreadspace/sdk';

const client = new SpreadSpace(); // reads SPREADSPACE_API_KEY

try {
  const event = verifyAndParseWebhook(rawBody, signatureHeader, 'whsec_...');
  if (event.type === 'spread.finalized') {
    // Read the snapshot this event names, by id. Not "the latest final",
    // which can be a newer one under a retry or a replay.
    const snapshot = await client.loans.attributes.retrieve(
      event.data.loan_id, event.data.snapshot_id);
  }
} catch (err) {
  if (err instanceof WebhookSignatureError) {
    // reject with 400
  }
}
```

**`Python`**

```python title="Python"
from spreadspace import SpreadSpace, verify_and_parse_webhook, WebhookSignatureError

client = SpreadSpace()  # reads SPREADSPACE_API_KEY

try:
    event = verify_and_parse_webhook(raw_body, signature_header, "whsec_...")
    if event.type == "spread.finalized":
        # The snapshot this event names, by id, not "the latest final".
        snapshot = client.loans.attributes.get_snapshot(
            event.data["loan_id"], event.data["snapshot_id"])
except WebhookSignatureError:
    ...  # reject with 400
```

**`C#`**

```csharp title="C#"
using SpreadSpace;

using var client = new SpreadSpaceClient(); // reads SPREADSPACE_API_KEY

try
{
    var ev = WebhooksResource.VerifyAndParse(rawBody, signatureHeader, "whsec_...");
    if (ev.Type == "spread.finalized")
    {
        // The snapshot this event names, by id, not "the latest final".
        var p = ev.AsSpreadFinalized();
        var snapshot = await client.Loans.Attributes.GetSnapshotAsync(p.LoanId, p.SnapshotId);
    }
}
catch (WebhookSignatureException) { /* reject with 400 */ }
```

## Receive events with the SDK

Each SDK builds a **receiver** that does the whole loop for you: it verifies the
signature, drops duplicates, dispatches to a per-event handler, and hands back
the status and body to answer with.

**`TypeScript`**

```ts title="TypeScript"
const receiver = client.webhooks.receiver({
  secret: [process.env.SPREADSPACE_WEBHOOK_SECRET!, process.env.SPREADSPACE_WEBHOOK_SECRET_PREVIOUS ?? ''].filter(Boolean),
  onSpreadFinalized: async ({ event, attributes }) => {
    // attributes = the snapshot this event names, read for you by id (GET /api/loans/{loan_id}/attributes/{snapshot_id})
    await db.saveFinalizedFigures(event.data.loan_id, attributes);
  },
  onSpreadReopened: async ({ event }) => {
    // the analyst is revising again: the snapshot stands, but mark your copy stale
    await db.markFinalizedFiguresStale(event.data.snapshot_id, event.data.reopened_at);
  },
});

// Express
app.post('/webhooks/spreadspace', express.raw({ type: 'application/json' }), async (req, res) => {
  const r = await receiver.handle({ rawBody: req.body, headers: req.headers });
  res.status(r.status).send(r.body);
});

// Hono:     const r = await receiver.handle({ rawBody: await c.req.text(), headers: c.req.raw.headers });
// Next.js:  const r = await receiver.handle({ rawBody: await req.text(), headers: req.headers });
```

**`Python`**

```python title="Python"
import os

receiver = client.webhooks.receiver(
    secret=[os.environ["SPREADSPACE_WEBHOOK_SECRET"]],
    on_spread_finalized=lambda event, attributes: save_finalized_figures(event.data["loan_id"], attributes),
    on_spread_reopened=lambda event: mark_finalized_figures_stale(event.data["snapshot_id"], event.data["reopened_at"]),
)

# FastAPI
@app.post("/webhooks/spreadspace")
async def spreadspace_webhook(request):
    body = await request.body()
    receipt = receiver.handle(body, request.headers)
    return Response(receipt.body, status_code=receipt.status)

# Flask: receipt = receiver.handle(request.get_data(), request.headers)
```

**`C#`**

```csharp title="C#"
var receiver = client.Webhooks.CreateReceiver(new WebhookReceiverOptions {
    Secrets = new[] { secret },
    OnSpreadFinalized = async ctx => await SaveFinalizedFiguresAsync(ctx.Payload.LoanId, ctx.Attributes),
    OnSpreadReopened = async ctx => await MarkFinalizedFiguresStaleAsync(ctx.Payload.SnapshotId, ctx.Payload.ReopenedAt),
});

// ASP.NET minimal API
app.MapPost("/webhooks/spreadspace", async (HttpRequest request, CancellationToken ct) =>
{
    var raw = await new StreamReader(request.Body).ReadToEndAsync(ct);
    var headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());
    var receipt = await receiver.HandleAsync(raw, headers, ct);
    return Results.Content(receipt.Body, "application/json", statusCode: receipt.StatusCode);
});
```

Hand the receiver the **raw bytes**. A framework that parses JSON before your
handler sees it has already changed the bytes the signature covers, so
verification will fail on a body that was never tampered with.

The receiver answers on your behalf, and each answer means something to the
[retry schedule](#delivery):

| Situation                                     | Status | Effect                                                         |
| --------------------------------------------- | ------ | -------------------------------------------------------------- |
| Signature bad or outside the freshness window | `400`  | Refused; no handler runs.                                      |
| An event `id` already seen                    | `200`  | Your handler does not run again.                               |
| No handler registered for that type           | `200`  | Accepted and dropped.                                          |
| Your handler throws                           | `500`  | SpreadSpace retries, and the `id` is **not** recorded as seen. |
| Your handler returns                          | `200`  | The `id` is recorded as seen only now.                         |

Recording the id only after your handler succeeds is what makes a retry after a
crash do the work rather than skip it.

On `spread.finalized` the receiver reads the snapshot the event names before
calling your handler. The read is
`GET /api/loans/{loan_id}/attributes/{snapshot_id}`, by id, never the loan's
latest. The receiver passes that snapshot alongside the event as `attributes`,
so the common case needs no second call. Under a retry or a replay it is still
the snapshot that event announced, not whatever the loan holds now.

| Option       | TypeScript         | Python              | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| ------------ | ------------------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Secret       | `secret`           | `secret`            | One secret, or several. Pass the current **and** the previous secret during the 24-hour rotation window.                                                                                                                                                                                                                                                                                                                                                                                   |
| Freshness    | `toleranceSeconds` | `tolerance_seconds` | How far `t` may sit from your clock, either direction. Default `300`.                                                                                                                                                                                                                                                                                                                                                                                                                      |
| Prefetch     | `fetchAttributes`  | `fetch_attributes`  | Read the snapshot the event names on `spread.finalized`. Default on; set it off to handle the event without the extra read.                                                                                                                                                                                                                                                                                                                                                                |
| Dedupe store | `dedupe`           | `dedupe`            | A `has(id)` / `add(id)` pair. The default is a bounded in-memory set that lives on the receiver and covers one process, so **build the receiver once per process, not per request**. A receiver rebuilt inside each request starts empty and never sees a duplicate. If your signing secret is loaded from storage rather than the environment, load it at startup and rebuild the receiver when you rotate. A deployment running several instances must supply a shared store either way. |
| Handlers     | `on<EventName>`    | `on_*`              | One per event type, plus `onUnknown` / `on_unknown` for a type this version of the SDK does not know.                                                                                                                                                                                                                                                                                                                                                                                      |

C# takes the same options as properties on `WebhookReceiverOptions`, and
`HandleAsync(rawBody, headers, cancellationToken)` returns a `WebhookReceipt`
carrying `StatusCode` and `Body`.

## The envelope

Every event body has the same six fields; only `data` changes shape.

| Field       | Type      | Description                                                                                           |
| ----------- | --------- | ----------------------------------------------------------------------------------------------------- |
| `id`        | `string`  | `evt_` followed by 24 lowercase hex characters. Also sent as the `SpreadSpace-Event-Id` header.       |
| `type`      | `string`  | The event name, one of the types below.                                                               |
| `created`   | `integer` | Seconds since the Unix epoch, server-side. Seconds, not milliseconds.                                 |
| `tenant_id` | `string`  | The workspace the event belongs to. Reject a delivery whose tenant is not the one you registered for. |
| `livemode`  | `boolean` | `true` for live activity, `false` for test mode.                                                      |
| `data`      | `object`  | The event's own payload, per the tables below.                                                        |

```json
{
  "id": "evt_4b8c1d2e3f405162738495a6",
  "type": "spread.finalized",
  "created": 1700000000,
  "tenant_id": "org_01HQ3M4N5P6Q7R8S9T0V1W2X",
  "livemode": true,
  "data": {
    "loan_id": "9f2c41ab7e6d40128b35c7d1",
    "borrower_id": "3ac81be40d5f47a2b9e6108c",
    "spread_board_id": "b7d2e91f4a6c48159c03fe27",
    "snapshot_id": "5e1a9c73b8d24f60a91d2b48",
    "machine_snapshot_id": "c40b16f89e2d47a3850cf19b",
    "version": 7,
    "finalized_at": "2026-08-17T18:42:11.284016+00:00"
  }
}
```

A field with no value is present as `null`, and every key is always present,
so a receiver can bind the shape once. The one exception is `message` on
`document.failed`: it is present only when there is a sentence you can show
the uploader, so bind it as optional.

## Events

There are seventeen event types. The tables below are generated from the same
contract the API reference renders, so a payload record that gains a field
gains a row here.

The two spread events are a pair. `spread.finalized` says the figures froze
and names the snapshot to read; `spread.reopened` says that same snapshot is
still on record but the analyst is revising the spread again. Mark your copy
stale, and expect a new `spread.finalized` with a higher version. Neither
carries a figure.

The export events and the memo-render events come in matching triples:
`export.succeeded`, `export.failed` and `export.cancelled` for an
[export](/api/exports), and `memo_render.succeeded`, `memo_render.failed` and
`memo_render.cancelled` for a [credit memo](/api/credit-memos) render. They
are terminal, so exactly one of the three fires per export or per render, and
each carries identifiers and the timestamp it reached that state. On the
failed pair, `reason` carries the operation's error code. None of the six
carries a download link: fetch the export or the rendered PDF yourself when
one arrives.

The two credit memo events are a pair in the same way as the spread events.
`memo.finalized` says an analyst finalized the memo and its final PDF is ready
to pull from `GET /api/loans/{loan_id}/memos/{memo_id}/pdf`; `memo.reopened` says
that same PDF is still on record but the analyst is editing the memo again.
Mark your copy stale, and expect a new `memo.finalized` when they finish. Both
carry the memo, loan, borrower and render ids, and neither carries the memo
itself. On `memo.reopened`, `render_id` is null when the memo has no final PDF
to name. Both also carry `spread_snapshots`, the spread figures the memo was
approved on: one entry per entity its figure exhibits cite, each the `final`
attribute snapshot's `snapshot_id`, its `version` and the `entity` (`key` and
`name`), and an empty list when the memo has none. They are pointers, not
figures: read the snapshot by id. See [Credit memos](/api/credit-memos).

`extraction.mapped` fires once for every mapping of a profit and loss
statement or a balance sheet onto the chart of accounts: the statement's first
mapping, and each one made again after its extraction, the workspace's chart
of accounts, caption rules or export map, or the mapping logic changed, or the
statement was corrected in the workspace. `reason` says which: `first`,
`extraction`, `chart`, `logic` or `correction`. `statement` says which
statement was mapped, `profit_and_loss` or `balance_sheet`, and so which read
returns the mapping. It is a pointer in the same way as the spread events: the
document, its borrower and loan, the `mapping_id`, `status` and
`mapping_state`, and the versions the mapping was made under, never a figure.
Read the mapping from
`GET /api/borrowers/{borrower_id}/extractions/{doc_id}/profit-loss-mapping`
for a profit and loss statement, or from
`GET /api/borrowers/{borrower_id}/extractions/{doc_id}/balance-sheet-mapping`
for a balance sheet, and let it replace the copy you hold. It is sent to a
workspace whose plan includes statement mapping. See
[Statement mapping](/api/statement-mapping).

On `document.failed`, `reason` is `unextractable` when the document could not
be extracted. When there is a sentence you can show the uploader, `message`
carries it, for example for a bank statement that did not include all of its
pages.

A document resolved as a duplicate carries its duplicate resolution in
`reason` instead, for example `content_hash_duplicate`, `exact_duplicate` or
`subset_superseded`. A duplicate resolution is not an extraction error; the
content is already on the loan under the original document. Any other
`reason` is a short code or message.

| Event                   | When it fires                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `document.failed`       | Fires when a document is rejected, hits an unrecoverable extraction error, or is resolved as a duplicate of one already on the deal. `reason` carries the short code or message. `message`, when present, is a sentence you can show the uploader.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `document.processed`    | Fires once per document, after its extraction has been stored. Carries routing identifiers only, so read the extracted figures back through the API by `document_id`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `embed.session.created` | Fires once when a signed iframe URL is exchanged for an embed session. `session_id` is the id `DELETE /api/embed/sessions/{session_id}` takes and `handle_id` is the handle your server recorded at the mint. The payload carries ids and times, never a token.                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `embed.session.revoked` | Fires once when an embed session is revoked, by its own `DELETE` or through its handle's. The token stops working at once. `handle_id` is null for a session minted directly.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `export.cancelled`      | Fires once when an export reaches cancelled, either straight from queued or at the document boundary where the worker acknowledged the request.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `export.failed`         | Fires once when an export ends in failure and no bundle will be produced. `reason` is the export's error code.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `export.succeeded`      | Fires once when an export's bundle is ready to download. Fetch the export to get a fresh download link; the event never carries one.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `extraction.mapped`     | Fires once for every mapping of a profit and loss statement or a balance sheet onto the chart of accounts: the statement's first mapping, and each one made again after its extraction, the workspace's chart of accounts, caption rules or export map, or the mapping logic changed, or the statement was corrected in the workspace. The payload carries ids and versions, not figures. Fetch the mapping from the read `statement` names: `GET /api/borrowers/{borrower_id}/extractions/{doc_id}/profit-loss-mapping` for a profit and loss statement, `GET /api/borrowers/{borrower_id}/extractions/{doc_id}/balance-sheet-mapping` for a balance sheet. Sent to workspaces whose plan includes the Statement mapping feature. |
| `extraction.ready`      | Fires once per document, the moment its extraction is queryable in the shape you should build on, and the usual cue to pull line items. For a bank statement it is held back until counterparty enrichment lands, or until the grace window elapses without it (`enrichment` says which).                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `extraction.updated`    | Fires when a stored extraction is rewritten in place after `extraction.ready` already fired for that document: what you fetched is stale. Re-fetch and compare `extraction_version` against `previous_extraction_version`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `job.completed`         | Terminal event for a document package: fires exactly once, when every document in it has reached a terminal state, success or failure.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `loan.classified`       | Fires once per document package (job), after every document in the package has been classified.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `memo.finalized`        | Fires once per finalize: an analyst finished a credit memo, it was locked, and its final PDF was made. The payload carries ids, not the memo. Pull the PDF from `GET /api/loans/{loan_id}/memos/{memo_id}/pdf`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `memo.reopened`         | Fires once per reopen: an analyst reopened a finalized credit memo to edit it. The final PDF it points at is kept and unchanged, but the memo is under revision until the next `memo.finalized`, so mark your copy stale. The payload carries ids, not the memo.                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `memo_render.cancelled` | Fires once when a queued credit memo render is cancelled before it starts.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `memo_render.failed`    | Fires once when a credit memo's PDF render ends in failure and no PDF was produced. `reason` is the render's error code.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `memo_render.succeeded` | Fires once when a credit memo's PDF render finishes. Pull the PDF through the memo's own download endpoint; the event never carries a link.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `spread.finalized`      | Fires once per finalize: an analyst finalized a spread, the `final` and `machine` attribute snapshots were created, and the board was locked. The payload carries ids, not figures. Fetch the figures from `GET /api/loans/{loan_id}/attributes/{snapshot_id}`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `spread.reopened`       | Fires once per reopen: an analyst reopened a finalized spread to edit it. The `final` snapshot it points at is kept and unchanged, but the figures are under revision until the next `spread.finalized`, so mark your copy stale. The payload carries ids, not figures.                                                                                                                                                                                                                                                                                                                                                                                                                                                            |

**`document.failed`**

| Field         | Type                 | Description                                                                                                                                                  |
| ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `document_id` | `string`             | The extracted document.                                                                                                                                      |
| `job_id`      | `string`             | The document package (job) the document was uploaded in.                                                                                                     |
| `borrower_id` | `string`             | The borrower that owns the loan.                                                                                                                             |
| `loan_id`     | `string`             | The loan the document belongs to.                                                                                                                            |
| `reason`      | `string`             | `unextractable` when the document could not be extracted, whatever the cause; `message` then carries a sentence you can show the uploader when there is one. |
| `message`     | `string, nullable`   | A sentence you can show the uploader, present only when the failure has one, for example a bank statement that did not include all of its pages.             |
| `failed_at`   | `string (date-time)` | Server-side wall-clock time the failure was recorded.                                                                                                        |

**`document.processed`**

| Field                | Type                 | Description                                                                                              |
| -------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
| `document_id`        | `string`             | The extracted document, a 24-character identifier.                                                       |
| `job_id`             | `string`             | The document package (job) the document was uploaded in.                                                 |
| `borrower_id`        | `string`             | The borrower that owns the loan.                                                                         |
| `loan_id`            | `string`             | The loan the document belongs to.                                                                        |
| `document_type`      | `string`             | What the document was classified as, for example `bank_statement`, `w2` or `1120s`.                      |
| `processed_at`       | `string (date-time)` | Server-side wall-clock time the extraction finished and was persisted.                                   |
| `extraction_id`      | `string, nullable`   | The extraction record to fetch.                                                                          |
| `extraction_version` | `integer, nullable`  | The revision of the stored extraction at the moment this event fired, 1 for a freshly ingested document. |

**`embed.session.created`**

| Field              | Type                 | Description                                                                                    |
| ------------------ | -------------------- | ---------------------------------------------------------------------------------------------- |
| `session_id`       | `string`             | The embed session the exchange minted: the id `DELETE /api/embed/sessions/{session_id}` takes. |
| `handle_id`        | `string`             | The iframe handle your server recorded at the mint.                                            |
| `loan_id`          | `string`             | The loan the session is bound to.                                                              |
| `borrower_id`      | `string`             | The borrower that owns the loan.                                                               |
| `external_user_id` | `string, nullable`   | The `external_user_id` the mint named; null when it named none.                                |
| `expires_at`       | `string (date-time)` | When the session's token stops working on its own.                                             |
| `created_at`       | `string (date-time)` | Server-side wall-clock time of the exchange.                                                   |

**`embed.session.revoked`**

| Field              | Type                 | Description                                                                           |
| ------------------ | -------------------- | ------------------------------------------------------------------------------------- |
| `session_id`       | `string`             | The embed session that was revoked.                                                   |
| `handle_id`        | `string, nullable`   | The iframe handle the session was exchanged from; null for a session minted directly. |
| `loan_id`          | `string`             | The loan the session was bound to.                                                    |
| `borrower_id`      | `string`             | The borrower that owns the loan.                                                      |
| `external_user_id` | `string, nullable`   | The `external_user_id` the mint named; null when it named none.                       |
| `revoked_at`       | `string (date-time)` | Server-side wall-clock time of the revoke.                                            |

**`export.cancelled`**

| Field          | Type                 | Description                                               |
| -------------- | -------------------- | --------------------------------------------------------- |
| `export_id`    | `string`             | The export that was cancelled.                            |
| `borrower_id`  | `string`             | The borrower the requested documents belong to.           |
| `loan_id`      | `string`             | The loan the requested documents sit on.                  |
| `format`       | `string`             | The bundle format the export was created with.            |
| `cancelled_at` | `string (date-time)` | Server-side wall-clock time the export reached cancelled. |

**`export.failed`**

| Field         | Type                 | Description                                     |
| ------------- | -------------------- | ----------------------------------------------- |
| `export_id`   | `string`             | The export that failed.                         |
| `borrower_id` | `string`             | The borrower the requested documents belong to. |
| `loan_id`     | `string`             | The loan the requested documents sit on.        |
| `format`      | `string`             | The bundle format the export was created with.  |
| `reason`      | `string`             | The export's error code.                        |
| `failed_at`   | `string (date-time)` | Server-side wall-clock time the export failed.  |

**`export.succeeded`**

| Field            | Type                 | Description                                       |
| ---------------- | -------------------- | ------------------------------------------------- |
| `export_id`      | `string`             | The export that finished.                         |
| `borrower_id`    | `string`             | The borrower the exported documents belong to.    |
| `loan_id`        | `string`             | The loan the exported documents sit on.           |
| `format`         | `string`             | The bundle format the export was created with.    |
| `document_count` | `integer`            | How many documents landed in the bundle.          |
| `succeeded_at`   | `string (date-time)` | Server-side wall-clock time the export succeeded. |

**`extraction.mapped`**

| Field                    | Type                 | Description                                                                                                                                                                         |
| ------------------------ | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `document_id`            | `string`             | The document that was mapped.                                                                                                                                                       |
| `statement`              | `string`             | The statement that was mapped: `profit_and_loss`, whose mapping the profit and loss mapping read returns, or `balance_sheet`, whose mapping the balance sheet mapping read returns. |
| `borrower_id`            | `string`             | The borrower the document belongs to.                                                                                                                                               |
| `loan_id`                | `string, nullable`   | The loan the document belongs to.                                                                                                                                                   |
| `extraction_version`     | `integer`            | The revision of the stored extraction the mapping was made from.                                                                                                                    |
| `mapping_id`             | `string`             | The mapping this event announces, the `mapping_id` the mapping read returns.                                                                                                        |
| `status`                 | `string`             | `ready` when the statement was mapped, or `unmappable` when it was read and could not be mapped.                                                                                    |
| `mapping_state`          | `string, nullable`   | `mapped` when the statement's lines are filed on the chart of accounts, or `unfooted` when they are returned as printed because they do not walk to the statement's bottom line.    |
| `engine_version`         | `string`             | The version of the mapping logic that made the mapping.                                                                                                                             |
| `chart_version`          | `string, nullable`   | The version of the platform chart of accounts the mapping's accounts come from.                                                                                                     |
| `chart_document_version` | `integer`            | The version of the workspace's chart of accounts the mapping was made under, the `version` that `GET /api/chart-of-accounts` returns.                                               |
| `chart_rules_version`    | `integer`            | The version of the workspace's caption rules the mapping was made under, the `rules_version` that `GET /api/chart-of-accounts` returns.                                             |
| `reason`                 | `string`             | Why the mapping was made: `first` for the document's first mapping, otherwise what changed since its previous one.                                                                  |
| `mapped_at`              | `string (date-time)` | When the mapping was made, the `created_at` the mapping read returns.                                                                                                               |

**`extraction.ready`**

| Field                | Type                 | Description                                                                                                                                                                                                                                                                                                                                                                                   |
| -------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `extraction_id`      | `string`             | The extraction record to fetch.                                                                                                                                                                                                                                                                                                                                                               |
| `document_id`        | `string`             | The extracted document.                                                                                                                                                                                                                                                                                                                                                                       |
| `job_id`             | `string`             | The document package (job) the document was uploaded in.                                                                                                                                                                                                                                                                                                                                      |
| `borrower_id`        | `string`             | The borrower that owns the loan.                                                                                                                                                                                                                                                                                                                                                              |
| `loan_id`            | `string`             | The loan the document belongs to.                                                                                                                                                                                                                                                                                                                                                             |
| `document_type`      | `string`             | What the document was classified as, using the same values `document.processed` reports.                                                                                                                                                                                                                                                                                                      |
| `extraction_version` | `integer, nullable`  | The revision of the stored extraction this event announces, 1 as ingested and incremented on every later rewrite.                                                                                                                                                                                                                                                                             |
| `enrichment`         | `string`             | Enrichment state of the extraction this event announces: `complete` (the enrichment is already in what you can fetch now), `not_applicable` (no enrichment applies to this document, so it is final), or `timed_out` (enrichment was expected but did not land within the grace window, so you are reading the un-enriched extraction and an `extraction.updated` follows if it lands later). |
| `ready_at`           | `string (date-time)` | Server-side wall-clock time the extraction was made queryable.                                                                                                                                                                                                                                                                                                                                |

**`extraction.updated`**

| Field                         | Type                 | Description                                                                                                                  |
| ----------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `extraction_id`               | `string`             | The extraction record to fetch.                                                                                              |
| `document_id`                 | `string`             | The extracted document.                                                                                                      |
| `job_id`                      | `string`             | The document package (job) the document was uploaded in.                                                                     |
| `borrower_id`                 | `string`             | The borrower that owns the loan.                                                                                             |
| `loan_id`                     | `string`             | The loan the document belongs to.                                                                                            |
| `document_type`               | `string`             | What the document was classified as, using the same values the sibling events report.                                        |
| `extraction_version`          | `integer`            | The revision the stored extraction is at now, which is what a fresh fetch returns.                                           |
| `previous_extraction_version` | `integer`            | The revision that was current before this rewrite (the one `ready` may have announced).                                      |
| `reason`                      | `string`             | Why the extraction changed: `enrichment` when an enrichment landed, or `rewrite` for any other change to the stored payload. |
| `updated_at`                  | `string (date-time)` | Server-side wall-clock time the rewrite was persisted.                                                                       |

**`job.completed`**

| Field                      | Type                 | Description                                                     |
| -------------------------- | -------------------- | --------------------------------------------------------------- |
| `job_id`                   | `string`             | The document package (job) that finished.                       |
| `borrower_id`              | `string`             | The borrower that owns the loan.                                |
| `loan_id`                  | `string`             | The loan the package was uploaded against.                      |
| `document_count_total`     | `integer`            | Total documents in the package.                                 |
| `document_count_succeeded` | `integer`            | Documents that reached a terminal success state.                |
| `document_count_failed`    | `integer`            | Documents that terminated in failure.                           |
| `completed_at`             | `string (date-time)` | Server-side wall-clock time the job entered its terminal state. |

**`loan.classified`**

| Field            | Type                 | Description                                           |
| ---------------- | -------------------- | ----------------------------------------------------- |
| `loan_id`        | `string`             | The loan whose documents were classified.             |
| `borrower_id`    | `string`             | The borrower that owns the loan.                      |
| `job_id`         | `string`             | The document package (job) this event is for.         |
| `document_count` | `integer`            | Number of documents classified for this package.      |
| `classified_at`  | `string (date-time)` | Server-side wall-clock time classification completed. |

**`memo.finalized`**

| Field              | Type                 | Description                                                                                                                                                    |
| ------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `memo_id`          | `string`             | The memo that was finalized.                                                                                                                                   |
| `loan_id`          | `string`             | The loan the memo is saved on.                                                                                                                                 |
| `borrower_id`      | `string`             | The borrower that owns the loan.                                                                                                                               |
| `render_id`        | `string`             | The render that made the final PDF, named by the render id this API serves everywhere else.                                                                    |
| `finalized_at`     | `string (date-time)` | When the memo was finalized.                                                                                                                                   |
| `spread_snapshots` | `array`              | The spread figures the memo was approved on: one entry per entity its figure exhibits cite, naming the `final` attribute snapshot, its version and the entity. |

**`memo.reopened`**

| Field              | Type                 | Description                                                                                                      |
| ------------------ | -------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `memo_id`          | `string`             | The memo that was reopened.                                                                                      |
| `loan_id`          | `string`             | The loan the memo is saved on.                                                                                   |
| `borrower_id`      | `string`             | The borrower that owns the loan.                                                                                 |
| `render_id`        | `string, nullable`   | The render behind the final PDF that was in force when the memo was reopened.                                    |
| `reopened_at`      | `string (date-time)` | Server-side wall-clock time of the reopen.                                                                       |
| `spread_snapshots` | `array`              | The spread figures the memo was approved on when it was reopened, the same entries its `memo.finalized` carried. |

**`memo_render.cancelled`**

| Field          | Type                 | Description                                               |
| -------------- | -------------------- | --------------------------------------------------------- |
| `render_id`    | `string`             | The render that was cancelled.                            |
| `memo_id`      | `string`             | The saved memo the render was for.                        |
| `loan_id`      | `string`             | The loan the memo sits on.                                |
| `cancelled_at` | `string (date-time)` | Server-side wall-clock time the render reached cancelled. |

**`memo_render.failed`**

| Field       | Type                 | Description                                    |
| ----------- | -------------------- | ---------------------------------------------- |
| `render_id` | `string`             | The render that failed.                        |
| `memo_id`   | `string`             | The saved memo the render was for.             |
| `loan_id`   | `string`             | The loan the memo sits on.                     |
| `reason`    | `string`             | The render's error code.                       |
| `failed_at` | `string (date-time)` | Server-side wall-clock time the render failed. |

**`memo_render.succeeded`**

| Field          | Type                 | Description                                       |
| -------------- | -------------------- | ------------------------------------------------- |
| `render_id`    | `string`             | The render that finished.                         |
| `memo_id`      | `string`             | The saved memo that was rendered.                 |
| `loan_id`      | `string`             | The loan the memo sits on.                        |
| `succeeded_at` | `string (date-time)` | Server-side wall-clock time the render succeeded. |

**`spread.finalized`**

| Field                 | Type                 | Description                                                                                                  |
| --------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ |
| `loan_id`             | `string`             | The loan whose spread was finalized.                                                                         |
| `borrower_id`         | `string`             | The borrower that owns the loan.                                                                             |
| `spread_board_id`     | `string`             | The saved spread board that was finalized.                                                                   |
| `snapshot_id`         | `string`             | The `final` snapshot this finalize created.                                                                  |
| `machine_snapshot_id` | `string`             | The `machine` snapshot created with it: the same loan built from the documents alone, with no analyst input. |
| `version`             | `integer`            | The final snapshot's per-loan version number.                                                                |
| `finalized_at`        | `string (date-time)` | Server-side wall-clock time of the finalize.                                                                 |

**`spread.reopened`**

| Field             | Type                 | Description                                                         |
| ----------------- | -------------------- | ------------------------------------------------------------------- |
| `loan_id`         | `string`             | The loan whose spread was reopened.                                 |
| `borrower_id`     | `string`             | The borrower that owns the loan.                                    |
| `spread_board_id` | `string`             | The saved spread board that was reopened.                           |
| `snapshot_id`     | `string`             | The `final` snapshot that was in force when the board was reopened. |
| `version`         | `integer`            | That snapshot's per-loan version number.                            |
| `reopened_at`     | `string (date-time)` | Server-side wall-clock time of the reopen.                          |

## Delivery

Delivery is **at-least-once**. The same event can arrive more than once, either
as a retry after a slow response on your side or as a duplicate you should
absorb. Dedupe on the envelope `id`, which is also the `SpreadSpace-Event-Id`
header: it is stable across every retry and every replay of that event, and is
shared by all endpoints one event fans out to. Record it, and make a second
arrival a no-op.

A delivery is queued the moment the event is recorded and sent typically within
seconds; allow up to a minute. It is then attempted up to **12 times** within a
**24-hour** horizon, with each attempt allowed **5 seconds** to answer. Retries
fall at fixed offsets from the moment the delivery was queued: 5 seconds, 30
seconds, 2 minutes, 10 minutes, 30 minutes, then 1, 2, 4, 8, 16 and 24 hours.
A `429` or `503` that carries `Retry-After` is retried no sooner than it asks;
a `Retry-After` past the horizon ends the delivery. A delivery that runs out of
attempts is `dead`; [replay](#replay) it once your endpoint is back.

| Your response                 | What happens                                                         |
| ----------------------------- | -------------------------------------------------------------------- |
| Any `2xx`                     | Delivered. No further attempts.                                      |
| Any `3xx`                     | Retried. Redirects are not followed ([Destinations](#destinations)). |
| `429`                         | Retried.                                                             |
| Any other `4xx`               | Stopped. No further attempts for that delivery.                      |
| `5xx`, timeout, network error | Retried.                                                             |

Answer fast and do the work afterwards: acknowledge with a `2xx` as soon as
you have durably recorded the event, then process it out of band. A handler
that finishes its work before answering risks the 5-second cutoff and an
avoidable duplicate. The SDK receiver above
([Receive events with the SDK](#receive-events-with-the-sdk)) implements the
dedupe-and-answer half of this for you.

**Delivery order is not guaranteed.** Two events emitted in a known order can
arrive in the other one, and a retry can arrive after a later event. Reconcile
rather than assume: `created` gives the server-side ordering, and for
extractions `extraction_version` says which revision a payload describes.
`extraction.updated` also carries `previous_extraction_version`, so you can
tell a step forward from a duplicate.

What each event means is fixed even though delivery order is not:
`document.processed` then `extraction.ready` are each emitted once per
document, `extraction.updated` only after a ready has already fired for that
document, `job.completed` once per package, and `spread.finalized` is a
pointer. It names the snapshots and never carries the figures. The export and
render events are terminal in the same way: one of `export.succeeded`,
`export.failed` or `export.cancelled` per export, one of the three
`memo_render.*` events per render, and each of them names the resource to read
rather than carrying its file.

**List deliveries** (`GET /api/webhooks/{id}/deliveries`) shows what happened
to each one. A delivery is `pending` while attempts remain, `delivered` once a
`2xx` came back, and `dead` when it stopped: attempts exhausted, the horizon
passed, a `4xx` that is not `429`, an attempt that came due while the endpoint
was paused, or the endpoint deleted. Filter by `status`, `event_type` or
`loan_id`. Every row carries the `loan_id` its event was about, so "what did
you send for this loan" is one call when a single deal's figures are missing.

Each row carries two identifiers, and they are not the same thing. `id` is that
delivery attempt's own id, the one to hand to a replay. `event_ref` is the
event id as delivered: the envelope's `id` and the `SpreadSpace-Event-Id`
header. Join on `event_ref` to line this list up against your own receiver
logs.

## Replay

**Replay delivery** (`POST /api/webhooks/{id}/deliveries/{delivery_id}/replay`)
schedules a fresh delivery of an event you already have a record of. It creates
a new delivery with its own id, carrying the **same event `id`**, signed with
the endpoint's current secret.

Because the event id is unchanged, a receiver that deduplicates properly will
recognise the replay and ignore it. Replay is therefore for deliveries that
never arrived or were never acknowledged. It is not a way to re-run a handler
that already succeeded. To reprocess an event on purpose, clear your record of that
`id` first.

## Destinations

A destination must be a public `https` URL. Private and internal addresses are
refused when the endpoint is registered, and checked again at connect time, so
an address that resolves inward later is refused then too. Redirects are not
followed: point the endpoint at the URL that will handle the request.

The rule is the same for a test-mode key as for a live one, so to exercise a
receiver running on your own machine, expose it through a tunnel (`cloudflared`,
`ngrok`) and register the tunnel's `https` URL.

Every delivery arrives with `User-Agent: SpreadSpace-Webhooks/1`.

## Testing

An upload through an `ss_test_` key runs in the sandbox, and the four
document events it raises (`document.processed`, `extraction.ready`,
`loan.classified`, `job.completed`) are delivered to your **live** workspace:
every endpoint registered there and subscribed to those types receives them,
each with `livemode: false` and a `tenant_id` naming the live workspace.
Register that endpoint from the dashboard (**Settings → Live API**, the
**Webhooks** tab) or with a live key. There is no test-only endpoint: an
endpoint receives the live and the sandbox deliveries for every type it
subscribes to, so branch on `livemode` to route sandbox deliveries to a
staging handler. The envelope, the signature and the retry behaviour are
identical either way.

A registration made with a test key lands in the sandbox workspace, not the
live one. The test key's list read shows only those endpoints, and they receive
only what is raised inside the sandbox workspace. Uploads raise nothing there,
so a test-key registration never receives the upload events above. It does
receive the events of work the test key starts in the sandbox itself: an
export or a memo render started with the test key raises its terminal
`export.*` or `memo_render.*` event there, with `livemode: false`, and a
spread finalized or reopened in the sandbox raises `spread.finalized` or
`spread.reopened` there.

Replay works in whichever workspace holds the endpoint. To replay a sandbox
upload event, list the live endpoint's deliveries with a live key or from the
dashboard, then replay the delivery as often as you need, with your dedupe
record cleared between runs.