# Webhooks

Five mandate events, written to an outbox in the same transaction as the consumption and delivered every minute, signed over the exact bytes sent.

## Events

| Event | Fires when | What to do |
| --- | --- | --- |
| mandate.granted | A mandate is issued | Show the person what her agent may now do |
| mandate.consumed | A consumption is committed | Reconcile against your own action reference |
| mandate.at_limit | A consumption used the last of the period | Warn, or offer a step-up |
| mandate.revoked | A mandate is revoked | Stop the agent immediately |
| mandate.expired | expires_at passes | Ask for a new mandate before acting again |

Hosted sessions emit no webhooks. There is no `session.completed` and no `session.expired`.

## Delivery

The event is written to an outbox in the **same transaction** as the consumption, and `mandate-outbox-worker` delivers it every minute.

The design answers the obvious question: a webhook sent inside the transaction would hold the ceiling lock hostage to a slow client, and a send before commit would announce something that never happened. Delivery is at-least-once — deduplicate on the event id.

## Signature

```
signature = "sha256=" + HMAC_SHA256(webhook_secret, `${unix_seconds}.${raw_body}`)
```

| Header | Value |
| --- | --- |
| x-sidaxis-signature | sha256=… |
| x-sidaxis-timestamp | Unix seconds; reject outside ±300 s |
| x-sidaxis-event-id | Deduplicate on this |
| x-sieve-signature, x-sieve-timestamp, x-sieve-event-id | The legacy triple, with identical values |

> Verify against the raw body, in constant time. Re-serialising the JSON changes the bytes and breaks the check.

## Retries

30 s → 2 m → 10 m → 1 h → 6 h, then parked.

A webhook with no configured secret is not delivered unsigned — it fails explicitly.
