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

# Architecture

> From a 2xx API response to a signed POST: the route and deliver queue stages, the webhook_dispatch ledger and the subscriber-gated publishing rule.

## Components

| Component                                                    | Path (`backend/horizon-api`)                              |
| ------------------------------------------------------------ | --------------------------------------------------------- |
| Publisher hooked into every `ok()` response                  | `lib/webhook/outbound/webhook-out-route.publisher.ts`     |
| Active-subscriber cache (30 s TTL)                           | `lib/webhook/outbound/webhook-subscription-cache.ts`      |
| Route stage consumer (`webhook-out-route`)                   | `lib/webhook/outbound/webhook-out-route.handler.ts`       |
| Owning-account resolver (`self` / `direct` / `via` / `none`) | `lib/webhook/outbound/webhook-account-resolver.ts`        |
| Deliver stage consumer (`webhook-out-deliver`)               | `lib/webhook/outbound/webhook-out-deliver.handler.ts`     |
| Signature helper                                             | `lib/webhook/outbound/webhook-signature.ts`               |
| Callback URL rules (HTTPS, denylist)                         | `lib/webhook/outbound/webhook-callback-url.ts`            |
| Queue registrations and retry policy                         | `lib/webhook/outbound/webhook-out-queue-registrations.ts` |
| Generated event catalogue                                    | `lib/webhook/catalogue/webhook-event-catalogue.data.ts`   |

## How a delivery flows

```mermaid theme={null}
flowchart LR
    subgraph api [horizon-api request]
        route["Mutating v3 route<br/>2xx response"]
        publisher["publishApiTransactionEvent()"]
        cache["Active-subscriber cache"]
    end
    subgraph queue [Queue · lane webhook-out]
        routeTopic["webhook-out-route"]
        deliverTopic["webhook-out-deliver"]
    end
    subgraph ledger [PostgreSQL]
        dispatch[("webhook_dispatch")]
    end
    subscriber["Subscriber endpoint<br/>HTTPS POST"]

    route --> publisher
    publisher -- "internal in cache?" --> cache
    cache -- yes --> routeTopic
    routeTopic --> resolve["Resolve account · match webhooks"]
    resolve -- "one row per subscriber" --> dispatch
    resolve --> deliverTopic
    deliverTopic --> deliver["Sign · POST · record outcome"]
    deliver --> subscriber
    deliver --> dispatch
```

<Steps>
  <Step title="Publish (inside the request)">
    Every route that goes through the shared `handler()`/`ok()` wrapper calls
    `publishApiTransactionEvent` after a `2xx`. The publisher derives the
    catalogue internal from the method and route, then checks the in-memory
    set of internals that currently have at least one **active** subscriber.
    If nobody listens, **nothing is enqueued** — the queue tables stay empty
    for the thousands of mutations no one subscribed to. Failures here are
    logged and never surface to the API caller.
  </Step>

  <Step title="Route (webhook-out-route)">
    The consumer loads the catalogue row, resolves the owning account of the
    mutated resource (see below), and selects the matching webhooks: global
    ones always match; account-scoped ones match only when the resolved
    account equals the webhook's `account`. One `webhook_dispatch` row is
    inserted per match with `status = pending`, then one
    `webhook-out-deliver` message is sent per row.
  </Step>

  <Step title="Deliver (webhook-out-deliver)">
    The consumer builds the thin body, signs it with the subscriber secret,
    `POST`s it with a 10 s timeout and `redirect: "error"`, and records the
    outcome (`success`, `error` with `next_retry_at`, or `exhausted`) on the
    ledger row. Retryable failures are rescheduled on the custom backoff
    schedule; see [Retries and failures](/webhooks/retries-and-failures).
  </Step>
</Steps>

## Owning-account resolution

Account-scoped subscriptions need to know *which* account a mutated row
belongs to. The catalogue records one of four strategies per event:

| Kind     | Meaning                                                 | Example                                                                                       |
| -------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `self`   | The resource **is** the account                         | `account.created` → `data.document_id`                                                        |
| `direct` | The table has an `account` column                       | `payment-debt.created` → `debt.account`                                                       |
| `via`    | The table references another row that carries `account` | `subscription.history.created` → `subscription_history.subscription` → `subscription.account` |
| `none`   | Not account-scoped; only global subscribers match       | `general.address.updated`, every `*.purged`                                                   |

The resolver reads the row once, on the route stage; deliveries never touch
the source table again.

## Lanes and schedule

Both topics run on the dedicated `webhook-out` lane (`vercel.json` →
`app/api/queue/consumers/webhook-out/route.ts`) so a burst of deliveries
cannot starve business jobs, and business backlogs cannot delay a
subscriber's notification. The deliver topic uses a **custom retry schedule**
instead of the platform default — the subscriber-facing backoff is part of
the public contract.

## Feature flag

Publishing is gated by `ENABLE_API_EVENT_QUEUE`. While it is `false` the API
behaves exactly as before: no catalogue lookup, no cache, no queue traffic.
Operators flip it per environment after the catalogue is synced and the first
subscriber has been tested; see [Operations](/webhooks/operations).

<Warning>
  The body deliberately carries identifiers only. Never rely on a webhook to
  transport authoritative data or personal information; read the resource
  through the API with the subscriber's own credentials.
</Warning>
