# Webhooks

Signed HTTP delivery for every event in your program, with verification, retries and partner postbacks.

AffiliateRail sends a signed HTTP `POST` to your endpoint when something happens in your program: a
partner is approved, a referral converts, a commission is earned, a payout settles or fails. Partners
get the same mechanism for their own events from the portal (**postbacks**). Everything below applies
to both.

The machine-readable version of this page, with the same examples, is published at
`/webhooks/llms.txt` on the app.

## Setting up an endpoint

**Merchant webhooks**: **Settings**, **Webhooks**, **Add endpoint**. Tick the event types you want (nothing
ticked means everything), and copy the signing secret when it's shown. It's shown exactly once; if
you lose it, rotate it.

**Partner postbacks**: in the partner portal, **Settings**, **Postbacks**. Partners can subscribe to the
referral, commission and payout families for their own activity only. The request, the signature
and the retry schedule are identical to merchant webhooks.

Endpoint URLs must be public `https://` (or `http://`) addresses. URLs that point at private,
loopback, link-local or internal hostnames are refused when saved and again at send time, and
redirects are never followed.

Signing secrets are shown once, and are encrypted at rest within moments of being created or rotated.

## The request

```
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: AffiliateRail-Webhooks/1.0
Rail-Signature: t=1756728000,v1=5f1c6c...e2a9
Rail-Event: commission.created
Rail-Event-Id: evt_4Kp2Qw9eRt7YuIoP1aSdFg
Rail-Delivery-Id: whd_7HgFdSaQwErTyUiOpLkJh
Rail-Attempt: 1
Rail-Request-Id: req_0123456789abcdef01234567
webhook-id: evt_4Kp2Qw9eRt7YuIoP1aSdFg
webhook-timestamp: 1756728000
webhook-signature: v1,g0hM9SsE...JEE=

{"id":"evt_4Kp2Qw9eRt7YuIoP1aSdFg","type":"commission.created","created_at":"2026-09-01T12:00:00.000Z","data":{...},"request":{"id":"req_0123456789abcdef01234567"}}
```

| Header | Meaning |
| --- | --- |
| `Rail-Signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>`. See [Verifying the signature](#verifying-the-signature). |
| `Rail-Event` | The event type, same as `type` in the body. Handy for routing before you parse. |
| `Rail-Event-Id` | The event id, same as `id` in the body. The same event keeps the same id across retries and replays. |
| `Rail-Delivery-Id` | This particular delivery. A replay gets a new delivery id with the old event id. |
| `Rail-Attempt` | 1 for the first try, up to 8. |
| `Rail-Request-Id` | The id of the API call, dashboard action or scheduled job run that caused the event: `req_...`, a uuid, or `run_...`. Quote it when you contact support. Missing when none of those caused it. |
| `webhook-id`, `webhook-timestamp`, `webhook-signature` | The same delivery in [Standard Webhooks](https://github.com/standard-webhooks/standard-webhooks) form, so any off-the-shelf library can verify it. See below. |

The body has these keys:

| Key | Type | Meaning |
| --- | --- | --- |
| `id` | string | Event id, `evt_...`. Test events from the dashboard start with `evt_test_`. |
| `type` | string | One of the types in the [catalogue](#event-catalogue). |
| `created_at` | string | ISO 8601, UTC, when the event happened. |
| `data` | object | The event's fields. Shapes are per type, below. |
| `request` | object | `{ "id": "..." }`, the same id as the `Rail-Request-Id` header: `req_...` or a uuid for an API call or dashboard action, `run_...` for a scheduled job run. `id` is `null` when none of those caused the event. Missing on events from before 15 September 2026 and on test events. |

Conventions inside `data`: ids are prefixed strings (`part_`, `sale_`, `com_`, `pyt_`...); money is an
integer in minor units in a `*_minor` field (4999 is 49.99) with a `currency` field beside it; times
are ISO 8601 UTC; `partner_id` is on every event that belongs to a partner.

## Verifying the signature

Every delivery is signed with the endpoint's secret. Verify before you trust anything in the body.

1. Read the `Rail-Signature` header and split it on commas. Take the `t` value (a unix timestamp in
   seconds) and every `v1` value (there is normally one).
2. Build the signed string: the timestamp, a literal `.`, and the **raw request body** exactly as
   received. Do not re-serialise the JSON; parsers reorder keys.
3. Compute `HMAC-SHA256(secret, signed_string)` and hex-encode it. The key is the whole secret as
   shown in the dashboard, `whsec_` prefix included.
4. Compare it to each `v1` value with a constant-time comparison. Any match is valid.
5. Reject if `t` is more than five minutes from your clock. That closes replay of a captured request.

### Node.js

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyRailSignature(secret, header, rawBody, toleranceSeconds = 300) {
  const parts = header.split(",").map((p) => p.trim());
  const t = parts.find((p) => p.startsWith("t="))?.slice(2);
  const sigs = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
  if (!t || !/^\d+$/.test(t) || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return sigs.some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s, "hex"), Buffer.from(expected, "hex")));
}

// Express: keep the raw body. express.json() would re-serialise it.
app.post("/webhooks/affiliaterail", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifyRailSignature(process.env.RAIL_WEBHOOK_SECRET, req.get("Rail-Signature") ?? "", req.body.toString("utf8"))) {
    return res.status(400).send("bad signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  // handle event.type / event.data, then answer quickly
  res.sendStatus(200);
});
```

### Python

```python
import hmac, hashlib, time

def verify_rail_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = [p.strip() for p in header.split(",")]
    t = next((p[2:] for p in parts if p.startswith("t=")), None)
    sigs = [p[3:] for p in parts if p.startswith("v1=")]
    if t is None or not t.isdigit() or not sigs:
        return False
    if abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in sigs)

# Flask
@app.post("/webhooks/affiliaterail")
def rail_webhook():
    if not verify_rail_signature(os.environ["RAIL_WEBHOOK_SECRET"], request.headers.get("Rail-Signature", ""), request.get_data()):
        abort(400)
    event = request.get_json()
    return "", 200
```

### PHP

```php
function verifyRailSignature(string $secret, string $header, string $rawBody, int $tolerance = 300): bool
{
    $t = null;
    $sigs = [];
    foreach (explode(',', $header) as $part) {
        $part = trim($part);
        if (str_starts_with($part, 't=')) { $t = substr($part, 2); }
        if (str_starts_with($part, 'v1=')) { $sigs[] = substr($part, 3); }
    }
    if ($t === null || !ctype_digit($t) || $sigs === []) { return false; }
    if (abs(time() - (int) $t) > $tolerance) { return false; }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($sigs as $sig) {
        if (hash_equals($expected, $sig)) { return true; }
    }
    return false;
}

$raw = file_get_contents('php://input');
if (!verifyRailSignature(getenv('RAIL_WEBHOOK_SECRET'), $_SERVER['HTTP_RAIL_SIGNATURE'] ?? '', $raw)) {
    http_response_code(400);
    exit;
}
$event = json_decode($raw, true);
http_response_code(200);
```

### Verifying with a Standard Webhooks library

Every delivery also carries the three Standard Webhooks headers, permanently, signed with the
same secret. If your stack already has a Standard Webhooks library, point it at the request and
you're done. There's nothing to install on our side because the spec is a header format.

- `webhook-id` is the event id. It stays the same across retries and replays of one event, which
  is exactly what the library's de-duplication expects.
- `webhook-timestamp` is the same unix seconds as `t=` in `Rail-Signature`.
- `webhook-signature` is `v1,<base64>` where the base64 is
  `HMAC-SHA256(key, "{id}.{timestamp}.{raw body}")`. During a rotation's grace day there are two
  entries, space separated, new secret first.

The key differs from the hand-rolled path above on purpose: Standard Webhooks libraries strip the
`whsec_` prefix and base64-decode the rest before signing, so we sign that way for these three
headers. `Rail-Signature` keeps using the whole secret string, unchanged, forever. One secret,
two recipes, and both are replay-bounded by the timestamp.

One footnote for old endpoints: a secret created before 1 September 2026 can contain `-` or `_`
characters that a strict base64 decoder rejects. If your library refuses the secret, rotate it
once and the new secret decodes everywhere.

### Rotating a secret

Rotating gives you a new secret and keeps the old one signing alongside it for 24 hours. During
that day every delivery's `Rail-Signature` carries two `v1` entries, the new secret's first; the
verifier above accepts any one of them, so a handler still holding the old secret keeps working
while you swap. After the day, deliveries carry one signature and the old secret verifies
nothing. Rotating again inside the day replaces the old secret: at most two are ever live.

## Responding, retries and failures

- Answer with any **2xx** within **10 seconds** to acknowledge. Do the work after you respond if it
  is slow; a timeout counts as a failure.
- Anything else (a 3xx, 4xx or 5xx, a timeout, a refused connection, a name that stops resolving) is
  a failed attempt. Redirects aren't followed.
- Failed attempts retry on this schedule, measured from the previous attempt:

  | Attempt | Waits |
  | --- | --- |
  | 2 | 1 minute |
  | 3 | 5 minutes |
  | 4 | 30 minutes |
  | 5 | 2 hours |
  | 6 | 12 hours |
  | 7 | 24 hours |
  | 8 | 24 hours |

  These waits are minimums. The delivery sweep runs twice an hour, at one and thirty-one minutes
  past, so a wait shorter than that lands on the next sweep: the first retry arrives within half an
  hour of the failure, and the rest land within half an hour of the time in the table.

  After the eighth failure the delivery is marked **failed** and stays in the log. That's about 63
  hours of retries, which covers a weekend.
- The delivery log (**Settings**, **Webhooks**, **Delivery log**) shows every attempt's status code and
  response body, and the exact request body. Test events appear there too.

### When we turn an endpoint off

If the last 20 events we sent to an endpoint all failed every retry, within 30 days, and those failures span at least five days, we turn the endpoint off and email the account owner. Turning it back on, or rotating the endpoint's secret, starts the count again. Failed deliveries stay in the log, so you can replay them once the endpoint works.

## Replay

From the log you can replay one delivery, replay everything that failed since a point in time, or
replay everything in a time window of up to 30 days. A replay creates a new delivery with the
original payload and the original event id (so `webhook-id` is unchanged and your de-duplication
drops the copy if you already handled it); the old entry stays in the log unchanged.

The same three affordances exist over the API: `GET /v1/webhook_endpoints/{id}/deliveries` reads
the log, `POST /v1/webhook_endpoints/{id}/ping` sends a test event now, and
`POST /v1/webhook_deliveries/{id}/replay` queues a replay.

## Delivery guarantees

- **At least once.** A delivery is retried until you acknowledge it or the schedule runs out, and a
  replay can send it again on purpose. If a worker dies between your 200 and our bookkeeping, you may
  see the same event twice. De-duplicate on `id`.
- **In order, per endpoint, as far as the network allows.** Events are emitted in a strict sequence
  (a `sale.created` always precedes the `commission.created` it produced) and each endpoint is
  delivered one request at a time in that sequence. Retries move a failed event later, so don't
  assume your endpoint saw everything before a given event; use `created_at` and the ids inside
  `data` to reconcile.
- **Plan for arrival order, not emission order.** Every payload carries `created_at` and the ids
  of the objects it describes, so a consumer can always drop state that is older than what it
  already holds. That, plus de-duplicating on `id`, is the whole recipe for a correct handler.
- **Events are written in the same database transaction as the change they describe.** There is no
  event without the change and no change without the event.

## Test events

**Settings**, **Webhooks**, **Send a test** sends one catalogue example, signed with the endpoint's secret,
and shows you the status code your endpoint returned. The event id starts with `evt_test_` so your
handler can tell it apart. Test deliveries aren't retried.

## Partner postbacks

Partners can register their own URLs in the portal. They receive only events whose `partner_id` is
theirs, only from the `referral.*`, `commission.*` and `payout.*` families, and never a customer's
email address or other identity. Everything else, including the signature and the header names, is
identical to merchant webhooks, so the same verifier works for both.

## Event catalogue

Every event type, with the `data` shape it carries. Event names follow the vocabulary most widely
used in affiliate software, so handlers you already have for another tool keep working when you point
them here.

### Partners

#### `affiliate.created`

A partner record exists: signed up through the portal, invited, created in the dashboard or imported.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate.created",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "email": "alice@example.com",
    "handle": "alice",
    "status": "pending",
    "group_id": null
  }
}
```

#### `affiliate.updated`

A partner's profile, group or status changed. `status` carries the new value and `reason` the note if one was given.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate.updated",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "status": "declined",
    "reason": "Outside our market"
  }
}
```

#### `affiliate.confirmed`

A partner was approved and is active. For programs with an application step this follows `application.approved`.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate.confirmed",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "email": "alice@example.com",
    "handle": "alice",
    "group_id": "grp_2PqRsTuVwXyZ0123456789"
  }
}
```

#### `affiliate.deleted`

A partner was deleted. Their links stop attributing and their open commissions are voided separately.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate.deleted",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A"
  }
}
```

### Applications

#### `application.submitted`

A prospective partner answered your application questions. Review it in the dashboard or approve over the API.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "application.submitted",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "application_id": "app_4RfVbGtYhNmJuKiLoPqWe",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "email": "alice@example.com",
    "handle": "alice",
    "answers": {
      "website": "https://alice.example",
      "audience": "Indie SaaS founders"
    }
  }
}
```

#### `application.approved`

An application was approved. `affiliate.confirmed` follows in the same moment.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "application.approved",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "application_id": "app_4RfVbGtYhNmJuKiLoPqWe",
    "email": "alice@example.com",
    "handle": "alice",
    "reviewed_by": "usr_8UjMkIoLpNbVcXzAsDfGh",
    "reason": null
  }
}
```

#### `application.rejected`

An application was rejected, with the reason the reviewer gave.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "application.rejected",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "application_id": "app_4RfVbGtYhNmJuKiLoPqWe",
    "email": "alice@example.com",
    "handle": "alice",
    "reviewed_by": "usr_8UjMkIoLpNbVcXzAsDfGh",
    "reason": "No relevant audience"
  }
}
```

### Links

#### `affiliate_link.created`

A referral link was created for a partner, by them or by you.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate_link.created",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "link_id": "lnk_7HgFdSaQwErTyUiOpLkJh",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "url": "https://acme.com/?ref=alice",
    "param": "ref",
    "value": "alice",
    "destination_url": "https://acme.com/"
  }
}
```

#### `affiliate_link.updated`

A link's destination, label or short slug changed. Old links keep working; this is the new shape.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate_link.updated",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "link_id": "lnk_7HgFdSaQwErTyUiOpLkJh",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "url": "https://acme.com/pricing?ref=alice",
    "destination_url": "https://acme.com/pricing"
  }
}
```

#### `affiliate_link.deleted`

A link was deleted. Clicks on it no longer attribute.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate_link.deleted",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "link_id": "lnk_7HgFdSaQwErTyUiOpLkJh",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A"
  }
}
```

### Coupons

#### `affiliate_coupon.created`

A coupon code was attached to a partner. The code itself is created in your payment processor and read by us.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate_coupon.created",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "coupon_id": "cpn_3KlMnOpQrStUvWxYz01234",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "code": "ALICE20",
    "active": true
  }
}
```

#### `affiliate_coupon.updated`

A coupon's partner or code mapping changed.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate_coupon.updated",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "coupon_id": "cpn_3KlMnOpQrStUvWxYz01234",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "code": "ALICE25",
    "active": true
  }
}
```

#### `affiliate_coupon.activated`

A coupon was switched on: purchases using it attribute to the partner again.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate_coupon.activated",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "coupon_id": "cpn_3KlMnOpQrStUvWxYz01234",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "code": "ALICE20",
    "active": true
  }
}
```

#### `affiliate_coupon.deactivated`

A coupon was switched off: purchases using it no longer attribute.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate_coupon.deactivated",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "coupon_id": "cpn_3KlMnOpQrStUvWxYz01234",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "code": "ALICE20",
    "active": false
  }
}
```

#### `affiliate_coupon.deleted`

A coupon mapping was removed.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "affiliate_coupon.deleted",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "coupon_id": "cpn_3KlMnOpQrStUvWxYz01234",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A"
  }
}
```

### Referrals

#### `referral.created`

A new visitor arrived through a partner's link. A referral is a visit identity, not a person; repeat visits by the same browser attach to the existing referral and do not fire again. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "referral.created",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "referral_id": "ref_8QwErTyUiOpAsDfGhJkLz",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "link_id": "lnk_7HgFdSaQwErTyUiOpLkJh",
    "landing_url": "https://acme.com/?ref=alice",
    "tracked_by": "link",
    "expires_at": "2026-10-20T10:00:00.000Z"
  }
}
```

#### `referral.lead`

The visitor signed up. A customer record now exists against the referral. Partner postbacks receive this event without the email field. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "referral.lead",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "referral_id": "ref_8QwErTyUiOpAsDfGhJkLz",
    "customer_id": "cus_9aBcDeFgHiJkLmNoPqRsT",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "email": "buyer@example.com",
    "tracked_by": "link"
  }
}
```

#### `referral.converted`

The referred customer made their first payment. Fires once per referral, alongside the first `sale.created`. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "referral.converted",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "referral_id": "ref_8QwErTyUiOpAsDfGhJkLz",
    "customer_id": "cus_9aBcDeFgHiJkLmNoPqRsT",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "sale_id": "sale_5ZxCvBnMaSdFgHjKlQwEr"
  }
}
```

#### `referral.deleted`

A referral was deleted, usually because it was flagged as self-referral or fraud. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "referral.deleted",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "referral_id": "ref_8QwErTyUiOpAsDfGhJkLz",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "reason": "self_referral"
  }
}
```

### Sales

#### `sale.created`

A charge attributed to a partner was recorded. `amount_minor` is the charge in integer minor units (4999 = 49.99); `is_first_sale` distinguishes a new customer from a renewal.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "sale.created",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "sale_id": "sale_5ZxCvBnMaSdFgHjKlQwEr",
    "customer_id": "cus_9aBcDeFgHiJkLmNoPqRsT",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 4999,
    "currency": "USD",
    "is_first_sale": true,
    "external_charge_id": "ch_3PqRsTuVwXyZ",
    "occurred_at": "2026-09-01T00:00:00.000Z"
  }
}
```

#### `sale.updated`

A sale's amount or attribution was corrected after the fact. Commissions are recalculated and emit their own events.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "sale.updated",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "sale_id": "sale_5ZxCvBnMaSdFgHjKlQwEr",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 3999,
    "currency": "USD",
    "previous_amount_minor": 4999
  }
}
```

#### `sale.refunded`

The charge was refunded. Unpaid commissions on it are voided (`commission.voided`); an already-paid one gets a negative clawback `commission.created` instead.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "sale.refunded",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "sale_id": "sale_5ZxCvBnMaSdFgHjKlQwEr",
    "customer_id": "cus_9aBcDeFgHiJkLmNoPqRsT",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 4999,
    "currency": "USD",
    "refunded_at": "2026-09-03T00:00:00.000Z"
  }
}
```

#### `sale.deleted`

A sale was deleted outright, for instance when an import is rolled back.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "sale.deleted",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "sale_id": "sale_5ZxCvBnMaSdFgHjKlQwEr",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A"
  }
}
```

### Commissions

#### `commission.created`

A commission was earned. `status` starts at `pending` (inside the holding period) or `due`; `mature_at` is when it becomes payable. A clawback after a refund of a paid commission arrives here with `kind: "clawback"` and a negative amount. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "commission.created",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "commission_id": "com_6AsDfGhJkLzXcVbNmQwEr",
    "sale_id": "sale_5ZxCvBnMaSdFgHjKlQwEr",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 1000,
    "currency": "USD",
    "status": "pending",
    "rule_id": "flw_1QaZxSwEdCvFrTgBnHyUj",
    "mature_at": "2026-09-15T00:00:00.000Z"
  }
}
```

#### `commission.updated`

A commission changed status or amount: it matured to `due`, was approved or rejected manually, or was edited. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "commission.updated",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "commission_id": "com_6AsDfGhJkLzXcVbNmQwEr",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 1000,
    "currency": "USD",
    "status": "due",
    "previous_status": "approved"
  }
}
```

#### `commission.paid`

The commission left in a payout that settled. `payout_id` links it to the batch. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "commission.paid",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "commission_id": "com_6AsDfGhJkLzXcVbNmQwEr",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "payout_id": "pyt_2WsXcDeRfVtGbYhNuJmIk",
    "amount_minor": 1000,
    "currency": "USD",
    "paid_at": "2026-10-15T09:00:00.000Z"
  }
}
```

#### `commission.voided`

An unpaid commission was cancelled, almost always because its sale was refunded. `reason` says why. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "commission.voided",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "commission_id": "com_6AsDfGhJkLzXcVbNmQwEr",
    "sale_id": "sale_5ZxCvBnMaSdFgHjKlQwEr",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 1000,
    "currency": "USD",
    "reason": "sale_refunded"
  }
}
```

#### `commission.deleted`

A commission was deleted, for instance when an import is rolled back. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "commission.deleted",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "commission_id": "com_6AsDfGhJkLzXcVbNmQwEr",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A"
  }
}
```

### Payouts

#### `payout.created`

A payout for a partner was generated in a batch. Nothing has been sent yet. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "payout.created",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "payout_id": "pyt_2WsXcDeRfVtGbYhNuJmIk",
    "batch_id": "batch_9OkMijNuhBygVtfCrdXes",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 12500,
    "currency": "USD",
    "status": "pending",
    "due_at": "2026-10-15T00:00:00.000Z"
  }
}
```

#### `payout.updated`

A payout changed status outside the paid and failed cases, for instance when it was picked up for processing. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "payout.updated",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "payout_id": "pyt_2WsXcDeRfVtGbYhNuJmIk",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "status": "processing",
    "previous_status": "pending"
  }
}
```

#### `payout.due`

Money is owed right now. This is the event to notify on if you pay by hand: the batch is ready and above the minimum. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "payout.due",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "payout_id": "pyt_2WsXcDeRfVtGbYhNuJmIk",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 12500,
    "currency": "USD",
    "due_at": "2026-10-15T00:00:00.000Z"
  }
}
```

#### `payout.paid`

The rail confirmed settlement, or you marked the payout paid by hand. Only a terminal status from the rail counts; a 200 on submit never does. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "payout.paid",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "payout_id": "pyt_2WsXcDeRfVtGbYhNuJmIk",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 12500,
    "currency": "USD",
    "method": "paypal",
    "external_id": "PAYOUT-ITEM-ID",
    "paid_at": "2026-10-15T09:00:00.000Z"
  }
}
```

#### `payout.failed`

The rail rejected or returned the payout. Rails fail asynchronously and often; treat this as first-class and act on `error_code`. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "payout.failed",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "payout_id": "pyt_2WsXcDeRfVtGbYhNuJmIk",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 12500,
    "currency": "USD",
    "method": "paypal",
    "error_code": "RECEIVER_UNREGISTERED",
    "error_message": "Receiver is unregistered"
  }
}
```

#### `payout.not_eligible`

A partner had money due but could not be paid: no payout method, a missing tax form, or a balance under the minimum. `reason` is machine-readable; nothing is silently dropped. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "payout.not_eligible",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "payout_id": "pyt_2WsXcDeRfVtGbYhNuJmIk",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "amount_minor": 12500,
    "currency": "USD",
    "reason": "no_payout_method",
    "reason_text": "No payout method on file"
  }
}
```

#### `payout.deleted`

A pending payout was deleted; its commissions returned to `due` for the next batch. Available to partner postbacks.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "payout.deleted",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "payout_id": "pyt_2WsXcDeRfVtGbYhNuJmIk",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A"
  }
}
```

### Risk

#### `risk_flag.created`

A monitoring rule raised a flag on a partner or customer. `evidence` is the rule's working; a human resolves the flag in the dashboard.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "risk_flag.created",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "risk_flag_id": "rsk_5TgBnHyUjMkIoLpQaZwSx",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "customer_id": "cus_9aBcDeFgHiJkLmNoPqRsT",
    "kind": "same_ip_cluster",
    "severity": "high",
    "evidence": {
      "ip_hash": "a1b2c3",
      "signups_last_24h": 14
    }
  }
}
```

#### `risk_flag.resolved`

A person closed a risk flag. `status` says which way it went, and `note` is their own words about what they found, which is required before a flag can be closed.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "risk_flag.resolved",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "risk_flag_id": "rsk_5TgBnHyUjMkIoLpQaZwSx",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "customer_id": "cus_9aBcDeFgHiJkLmNoPqRsT",
    "kind": "same_ip_cluster",
    "severity": "high",
    "status": "resolved",
    "note": "Same office, confirmed with the partner",
    "resolved_by": "usr_6HjKlMnOpQrStUvWxYz012"
  }
}
```

### Customers

#### `customer.enrolled_as_partner`

A paying customer became a partner on their first sale, because the program's customer auto-enrol switch is on. `partner_id` is the new partner; `customer_id` the buyer it was made from. An `affiliate.created` for the same partner precedes it.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "customer.enrolled_as_partner",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "customer_id": "cus_9aBcDeFgHiJkLmNoPqRsT",
    "partner_id": "part_4Jk2mN8pQrS7tUvWxYz01A",
    "handle": "alice",
    "email": "alice@example.com",
    "group_id": "grp_2PqRsTuVwXyZ0123456789",
    "source": "customer"
  }
}
```

### Commission rules

#### `rule.updated`

A commission rule was edited. `before` and `after` hold only the four fields that decide what a partner is paid, so the change a subscriber cares about is the change they can see. A rate edit applies to every payment from now on, renewals for partners recruited under the old rate included, so this is the event to listen on if you tell partners about rate changes.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "rule.updated",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "rule_id": "flw_7WxYz0123456789AbCdEf",
    "name": "Standard 20%",
    "before": {
      "rate_type": "percent",
      "rate_value": 2000,
      "priority": 100,
      "status": "active"
    },
    "after": {
      "rate_type": "percent",
      "rate_value": 1500,
      "priority": 100,
      "status": "active"
    }
  }
}
```

#### `rule.deleted`

A commission rule was removed. Worth acting on: if it was the last rule that applied to a partner, their next renewal produces no commission at all, and nothing else would tell you.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "rule.deleted",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "rule_id": "flw_7WxYz0123456789AbCdEf",
    "name": "Standard 20%"
  }
}
```

#### `rule.status_changed`

A commission rule was switched on or off. `status` is the new value. Deactivating the last applicable rule has the same effect as deleting it: the next renewal earns nothing.

```json
{
  "id": "evt_4Kp2Qw9eRt7YuIoP1aSdFg",
  "type": "rule.status_changed",
  "created_at": "2026-09-01T12:00:00.000Z",
  "data": {
    "rule_id": "flw_7WxYz0123456789AbCdEf",
    "name": "Standard 20%",
    "status": "inactive"
  }
}
```
