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

# Webhook use cases

> Four patterns — reacting fast without trusting the event, reconciling what was missed, routing across services, and surviving a rotation.

## 1. React fast, trust the resource

The pattern that makes single-attempt delivery safe: treat the event as a signal to look, never as the fact itself.

```ts theme={null}
const handlers = {
  'fiat.withdrawal.completed': async (data) => {
    const transfer = await sakawave.get(`/v1/transfers/${data.relatedOperation.id}`);
    if (transfer.status !== 'completed') return;      // event was stale; resource wins
    await markPaid(transfer.externalReference, transfer);
  },

  'bills_payment.completed': async (data) => {
    const payment = await sakawave.get(`/v1/bill-payments/${data.relatedOperation.id}`);
    if (payment.tokenData) await deliverToken(payment.tokenData);
  },

  'account.updated': async (data) => {
    // Resource events already carry the object, so no fetch is needed —
    // but read it if the decision is expensive to get wrong.
    if (data.transferReady) await publishFundingInstructions(data.id);
  },
};
```

Two rules in there:

* **Financial events** carry a ledger transaction. The thing you care about is usually in `relatedOperation`, and you should fetch the real resource before acting on money.
* **Resource events** carry the object itself, identical to a `GET`. Acting on the payload directly is fine for cheap decisions.

## 2. Reconcile what never arrived

Delivery is one attempt. Design as if some events simply never arrive, because some will not.

```ts theme={null}
// Every five minutes. Cheap, and the only thing that makes the system correct.
async function sweep() {
  const pending = await sakawave.get('/v1/transfers?status=pending&pageSize=100');
  for (const transfer of pending.items) {
    if (olderThan(transfer.createdAt, minutes(30))) await reconcile(transfer);
  }

  const bills = await sakawave.get('/v1/bill-payments?status=pending&pageSize=100');
  for (const bill of bills.items) {
    if (olderThan(bill.createdAt, minutes(15))) await reconcile(bill);
  }
}
```

Sweep against the resource lists, not against your own record of what you expect — the point is to find what you do not know about.

Anything that happened while your endpoint was disabled is **not** recoverable by resend: those events were never staged. Only reconciliation finds them.

## 3. Route one endpoint across seven services

You get every event on one URL. Route on prefix, not on an exhaustive list.

```ts theme={null}
function route(type: string, data: unknown) {
  if (type.startsWith('fiat.')) return money.handle(type, data);
  if (type.startsWith('bills_payment.')) return bills.handle(type, data);
  if (type.startsWith('card.transaction.')) return cardSpend.handle(type, data);
  if (type.startsWith('card.')) return cards.handle(type, data);
  if (type.startsWith('account.')) return accounts.handle(type, data);
  if (type.startsWith('customer.')) return customers.handle(type, data);
  if (type === 'endpoint.test') return ok();
  return ok();          // unknown types are normal, not errors
}
```

Note the ordering: `card.transaction.*` must be tested before `card.*`, or card payments will be routed to the card lifecycle handler.

The final `return ok()` matters. New event types are added over time, and a receiver that throws on an unrecognised type turns a harmless addition into an outage.

## 4. Survive a secret rotation

There is no overlap window — the moment a new secret is issued, deliveries are signed with it. Deploy first, rotate second.

```ts theme={null}
// Step 1: deploy a receiver that accepts either secret.
const candidates = [process.env.SAKAWAVE_WEBHOOK_SECRET, process.env.SAKAWAVE_WEBHOOK_SECRET_NEXT]
  .filter(Boolean);

const verified = candidates.some((secret) => matches(secret, id, timestamp, rawBody, signature));

// Step 2: rotate in the dashboard.
// Step 3: promote NEXT to the primary and drop the old one.
```

Changing the endpoint **URL** does not rotate the secret, so a host migration needs no key handling at all.

## 5. Make deduplication real

The id is a header, and deduplication has to be atomic with recording receipt — not a check followed by an insert.

```sql theme={null}
CREATE TABLE webhook_events (
  id            text PRIMARY KEY,          -- Sakawave-Webhook-Id
  event         text NOT NULL,
  received_at   timestamptz NOT NULL DEFAULT now(),
  processed_at  timestamptz,
  body_sha256   text NOT NULL
);
```

```ts theme={null}
// The insert is the lock. A duplicate loses the race and returns 2xx.
const { rowCount } = await db.query(
  'INSERT INTO webhook_events (id, event, body_sha256) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
  [id, type, sha256(rawBody)],
);
if (rowCount === 0) return respond204();     // already seen
```

Store the body hash too. A resend reuses the id and payload, so a differing hash under the same id is worth an alert.

## What to watch for

| Symptom | Likely cause |
| - | - |
| Every signature fails | A JSON body parser ran before the raw-body route |
| Signatures fail intermittently | A proxy re-encoding the body, or a stale secret after rotation |
| Work happens twice | Deduplicating on `data.id` instead of the header id |
| Events stop entirely | The endpoint was disabled, or its hostname now resolves privately |
| Nothing arrives in sandbox | Sandbox and live are configured separately |
| Handler throws on a new type | No default branch in the router |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.