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. |
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 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. |
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.
- Read the
Rail-Signatureheader and split it on commas. Take thetvalue (a unix timestamp in seconds) and everyv1value (there is normally one). - 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. - Compute
HMAC-SHA256(secret, signed_string)and hex-encode it. The key is the whole secret as shown in the dashboard,whsec_prefix included. - Compare it to each
v1value with a constant-time comparison. Any match is valid. - Reject if
tis more than five minutes from your clock. That closes replay of a captured request.
Node.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
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 "", 200PHP
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-idis 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-timestampis the same unix seconds ast=inRail-Signature.webhook-signatureisv1,<base64>where the base64 isHMAC-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.createdalways precedes thecommission.createdit 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; usecreated_atand the ids insidedatato reconcile. - Plan for arrival order, not emission order. Every payload carries
created_atand 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 onid, 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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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"
}
}