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

# Webhooks

> Receive real-time events, verify signatures, and understand delivery retries

Affixo can POST an event to your server whenever something happens in your
program — a new affiliate, a conversion, a confirmed commission, a paid payout.
Create endpoints from the dashboard (**Webhooks**) or via
[`POST /v1/webhooks`](/api-reference); each endpoint carries its own signing
secret, returned **once** at creation.

## Events

| Event                  | Fires when                                          |
| ---------------------- | --------------------------------------------------- |
| `affiliate.created`    | An affiliate record is created                      |
| `conversion.created`   | A conversion (lead or sale) is recorded             |
| `conversion.reversed`  | A conversion is refunded, reversed, or charged back |
| `commission.confirmed` | A commission becomes payable                        |
| `commission.reversed`  | A commission is clawed back                         |
| `payout.paid`          | A payout is marked paid                             |
| `fulfilment.pending`   | A non-cash reward is earned and awaits fulfilment   |
| `fulfilment.completed` | A non-cash reward is fulfilled                      |
| `fulfilment.cancelled` | A non-cash reward fulfilment is cancelled           |

Subscribe to specific events or `["*"]` for everything (including any events
added in the future). Unknown event names are rejected at creation time.

The dashboard's **Send test** button delivers a `ping` event. It bypasses your
subscription filter — accept `ping` in your handler even if you validate the
event name against the list above.

## Delivery format

Each delivery is a `POST` with a JSON body:

```json theme={null}
{
  "id": "5b9f…",          // delivery id — use for idempotency
  "event": "commission.confirmed",
  "data": { … }            // the event object
}
```

Headers:

| Header               | Meaning                                                     |
| -------------------- | ----------------------------------------------------------- |
| `X-Affixo-Event`     | The event name                                              |
| `X-Affixo-Delivery`  | Unique delivery id (retries reuse it — deduplicate on this) |
| `X-Affixo-Timestamp` | Unix seconds when the delivery was signed                   |
| `X-Affixo-Signature` | `sha256=<hex HMAC>` — see below                             |

Your endpoint must be **https** on a public host and should respond with a 2xx
within 10 seconds. Redirects are treated as failures — respond at the
subscribed URL directly.

## Verifying signatures

The signature is an HMAC-SHA256 over `"{timestamp}.{raw_body}"` using your
endpoint's secret, hex-encoded, sent as `X-Affixo-Signature: sha256=<hex>`.
Always verify against the **raw** request body (before any JSON parsing).

```js Node.js / Express theme={null}
import crypto from "node:crypto";

app.post("/hooks/affixo", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.header("X-Affixo-Timestamp");
  const sig = req.header("X-Affixo-Signature"); // "sha256=abc123…"

  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.AFFIXO_WEBHOOK_SECRET)
    .update(`${ts}.${req.body.toString()}`)
    .digest("hex");

  const ok =
    sig &&
    sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return res.status(401).send("bad signature");

  // Optional replay guard: reject signatures older than ~5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    return res.status(401).send("stale timestamp");
  }

  const { id, event, data } = JSON.parse(req.body.toString());
  // … handle, deduplicating on `id` …
  res.sendStatus(200);
});
```

<Warning>
  Rotating an endpoint's secret (dashboard → Rotate) takes effect on the next
  delivery — deliveries are signed with the secret read at send time, so update
  your server first, then rotate.
</Warning>

## Retries

Failed deliveries (non-2xx, timeout, or redirect) are retried with exponential
backoff — `4^attempt` seconds, capped at 8 hours — until the attempt limit is
reached, after which the delivery is marked failed. Because retries can arrive
minutes to hours later, make your handler idempotent on `X-Affixo-Delivery`.

Deliveries already queued keep their signing secret and URL resolution at send
time, so a rotated secret or edited URL applies to every future attempt.
