Webhooks and server events quickstart
Two ways to connect Affiliatops to your app: receive signed events when partners, sales, commissions and payouts change, and send subscription billing events from your server so commissions are created and reversed automatically. The examples use a $49 plan billed in USD.
Receive outbound webhooks
Affiliatops sends a POST with a JSON body to your endpoint for each event you subscribe to. Endpoints are managed in the dashboard: one URL per webhook, each with its own signing secret.
Add an endpoint
- In the dashboard, open Developer tools → Webhooks and choose Add webhook.
- Give it a name, your endpoint URL (a public https address, for example
https://app.northwind.example/webhooks/affiliatops) and the events you want. - Copy the signing secret (
whsec_…). It is shown when the webhook is created and any time with Reveal. New secret replaces it — requests signed with the old one stop verifying on your side. - Send test delivers a signed
webhook.testevent and shows the HTTP result, so you can check your endpoint before real events arrive.
Headers and body
Every delivery carries these headers:
| Header | Value |
|---|---|
| Affiliatops-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" keyed with your signing secret |
| Affiliatops-Event | the event type, for example commission.created |
| Affiliatops-Delivery | id of this delivery; retries keep it, a manual Resend gets a new one |
| Content-Type | application/json |
| User-Agent | Affiliatops-Webhooks/1.0 |
POST /webhooks/affiliatops HTTP/1.1
Host: app.northwind.example
Affiliatops-Signature: t=1791299047,v1=33bdbfae61413f5893f6c801fc01b0b90fe1712c57716a62594b66994c191895
Affiliatops-Event: commission.created
Affiliatops-Delivery: cm7b6404d338213e4d6369d6
Content-Type: application/json
User-Agent: Affiliatops-Webhooks/1.0The body is an envelope with the event id, type, timestamp, merchant id and a data object. Use the envelope id to ignore duplicates: a Resend repeats it.
{
"id": "evt_846e9ea3998a3ee46ac518ba",
"type": "commission.created",
"created_at": "2026-10-06T15:04:07.512Z",
"merchant_id": "mer_northwind_001",
"data": {
"commission": {
"id": "com_northwind_1042",
"status": "PENDING",
"amount_cents": 980,
"currency": "USD",
"rate": 20,
"affiliate_id": "aff_marcus_018",
"conversion_id": "cnv_northwind_1042",
"payout_id": null,
"matures_at": "2026-11-05T15:04:07.512Z",
"approved_at": null,
"paid_at": null,
"created_at": "2026-10-06T15:04:07.512Z"
},
"conversion": {
"id": "cnv_northwind_1042",
"type": "PURCHASE",
"amount_cents": 4900,
"currency": "USD",
"status": "PENDING",
"affiliate_id": "aff_marcus_018",
"referral_id": null,
"external_id": "ORD-8841",
"source": "tracker",
"created_at": "2026-10-06T15:04:07.512Z"
}
}
}Verify the signature
Compute the HMAC over the raw body, before parsing it: hash <t>.<raw body> with your signing secret, then compare the hex digest with the header's v1 value in constant time. Reject requests whose timestamp is more than five minutes from your clock. The same snippet is shown in the dashboard:
import crypto from 'node:crypto';
// rawBody: the request body exactly as received (a string, before JSON.parse)
export function verifyAffiliatops(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('=', 2)),
);
const timestamp = Number(parts.t);
// Reject old requests (replays): allow 5 minutes of clock drift
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const given = Buffer.from(parts.v1 || '', 'hex');
return given.length === 32 && crypto.timingSafeEqual(given, Buffer.from(expected, 'hex'));
}
// Express: app.post('/affiliatops', express.text({ type: '*/*' }), (req, res) => {
// if (!verifyAffiliatops(req.body, req.get('Affiliatops-Signature'), process.env.AFFILIATOPS_WEBHOOK_SECRET)) {
// return res.sendStatus(400);
// }
// const event = JSON.parse(req.body);
// res.sendStatus(200);
// });Events and example payloads
Every event below is available to subscribe to; the dashboard lists them with the same descriptions.
| Event | When it is sent |
|---|---|
| affiliate.created | A partner joins your program |
| affiliate.approved | You approve a pending partner |
| affiliate.rejected | You reject a pending partner |
| referral.submitted | A partner submits a lead |
| referral.approved | A lead is approved |
| referral.rejected | A lead is rejected |
| conversion.created | A sale or sign-up is recorded for a partner |
| commission.created | A commission is created (held until the hold period ends) |
| commission.approved | A commission is approved and added to the partner balance |
| commission.reversed | A commission is cancelled, clawed back or reduced after a refund |
| commission.paid | A commission is included in a payout |
| payout.created | A payout is created for a partner |
| payout.completed | A payout is marked as paid |
| payout.failed | A payout is marked as failed |
Example data per event. Money is an integer in minor units (USD cents): the $49 payment earned a $9.80 commission at 20%. The full body wraps this in the envelope shown above.
affiliate.created
{
"affiliate": {
"id": "aff_marcus_018",
"user_id": "usr_marcus_018",
"referral_code": "MARCUS-4F2A",
"name": "Marcus Reed",
"email": "[email protected]",
"status": "PENDING",
"created_at": "2026-09-28T11:02:44.000Z"
},
"source": "join_link"
}affiliate.approved
{
"affiliate": {
"id": "aff_marcus_018",
"user_id": "usr_marcus_018",
"referral_code": "MARCUS-4F2A",
"name": "Marcus Reed",
"email": "[email protected]",
"status": "ACTIVE",
"created_at": "2026-09-28T11:02:44.000Z"
},
"previous_status": "PENDING"
}affiliate.rejected
{
"affiliate": {
"id": "aff_marcus_018",
"user_id": "usr_marcus_018",
"referral_code": "MARCUS-4F2A",
"name": "Marcus Reed",
"email": "[email protected]",
"status": "INACTIVE",
"created_at": "2026-09-28T11:02:44.000Z"
},
"previous_status": "PENDING"
}referral.submitted
{
"referral": {
"id": "ref_lumenops_04",
"affiliate_id": "aff_marcus_018",
"lead_name": "Priya Nair",
"lead_email": "[email protected]",
"status": "PENDING",
"reviewed_at": null,
"created_at": "2026-10-02T09:14:20.000Z"
}
}referral.approved
{
"referral": {
"id": "ref_lumenops_04",
"affiliate_id": "aff_marcus_018",
"lead_name": "Priya Nair",
"lead_email": "[email protected]",
"status": "APPROVED",
"reviewed_at": "2026-10-03T08:30:00.000Z",
"created_at": "2026-10-02T09:14:20.000Z"
}
}referral.rejected
{
"referral": {
"id": "ref_lumenops_04",
"affiliate_id": "aff_marcus_018",
"lead_name": "Priya Nair",
"lead_email": "[email protected]",
"status": "REJECTED",
"reviewed_at": "2026-10-03T08:31:00.000Z",
"created_at": "2026-10-02T09:14:20.000Z"
}
}conversion.created
{
"conversion": {
"id": "cnv_northwind_1042",
"type": "PURCHASE",
"amount_cents": 4900,
"currency": "USD",
"status": "PENDING",
"affiliate_id": "aff_marcus_018",
"referral_id": null,
"external_id": "ORD-8841",
"source": "tracker",
"created_at": "2026-10-06T15:04:07.512Z"
},
"commission": {
"id": "com_northwind_1042",
"status": "PENDING",
"amount_cents": 980,
"currency": "USD",
"rate": 20,
"affiliate_id": "aff_marcus_018",
"conversion_id": "cnv_northwind_1042",
"payout_id": null,
"matures_at": "2026-11-05T15:04:07.512Z",
"approved_at": null,
"paid_at": null,
"created_at": "2026-10-06T15:04:07.512Z"
}
}commission.created
{
"commission": {
"id": "com_northwind_1042",
"status": "PENDING",
"amount_cents": 980,
"currency": "USD",
"rate": 20,
"affiliate_id": "aff_marcus_018",
"conversion_id": "cnv_northwind_1042",
"payout_id": null,
"matures_at": "2026-11-05T15:04:07.512Z",
"approved_at": null,
"paid_at": null,
"created_at": "2026-10-06T15:04:07.512Z"
},
"conversion": {
"id": "cnv_northwind_1042",
"type": "PURCHASE",
"amount_cents": 4900,
"currency": "USD",
"status": "PENDING",
"affiliate_id": "aff_marcus_018",
"referral_id": null,
"external_id": "ORD-8841",
"source": "tracker",
"created_at": "2026-10-06T15:04:07.512Z"
}
}commission.approved
{
"commission": {
"id": "com_northwind_1042",
"status": "APPROVED",
"amount_cents": 980,
"currency": "USD",
"rate": 20,
"affiliate_id": "aff_marcus_018",
"conversion_id": "cnv_northwind_1042",
"payout_id": null,
"matures_at": "2026-11-05T15:04:07.512Z",
"approved_at": "2026-11-05T04:00:12.000Z",
"paid_at": null,
"created_at": "2026-10-06T15:04:07.512Z"
},
"approval": {
"reason": "hold_period_ended"
}
}commission.reversed
{
"commission": {
"id": "com_northwind_1042",
"status": "PENDING",
"amount_cents": 780,
"currency": "USD",
"rate": 20,
"affiliate_id": "aff_marcus_018",
"conversion_id": "cnv_northwind_1042",
"payout_id": null,
"matures_at": "2026-11-05T15:04:07.512Z",
"approved_at": null,
"paid_at": null,
"created_at": "2026-10-06T15:04:07.512Z"
},
"reversal": {
"action": "reduce",
"reversed_cents": 200,
"refund_cents": 1000,
"reason": "Customer refund",
"refund_id": "re_northwind_0007",
"conversion_id": "cnv_northwind_1042",
"fully_refunded": false,
"source": "refund_webhook"
}
}commission.paid
{
"commission": {
"id": "com_northwind_1077",
"status": "PAID",
"amount_cents": 980,
"currency": "USD",
"rate": 20,
"affiliate_id": "aff_marcus_018",
"conversion_id": "cnv_northwind_1077",
"payout_id": "po_northwind_301",
"matures_at": "2026-10-20T10:12:00.000Z",
"approved_at": "2026-10-20T04:00:00.000Z",
"paid_at": "2026-11-10T09:00:00.000Z",
"created_at": "2026-09-20T10:12:00.000Z"
},
"payout": {
"id": "po_northwind_301",
"affiliate_id": "aff_marcus_018",
"amount_cents": 2940,
"deduction_cents": 0,
"currency": "USD",
"commission_count": 3,
"status": "PENDING",
"method": "Bank Transfer",
"processed_at": null,
"created_at": "2026-11-10T09:00:00.000Z"
}
}payout.created
{
"payout": {
"id": "po_northwind_301",
"affiliate_id": "aff_marcus_018",
"amount_cents": 2940,
"deduction_cents": 0,
"currency": "USD",
"commission_count": 3,
"status": "PENDING",
"method": "Bank Transfer",
"processed_at": null,
"created_at": "2026-11-10T09:00:00.000Z"
},
"commission_ids": [
"com_northwind_1077",
"com_northwind_1091",
"com_northwind_1103"
]
}payout.completed
{
"payout": {
"id": "po_northwind_301",
"affiliate_id": "aff_marcus_018",
"amount_cents": 2940,
"deduction_cents": 0,
"currency": "USD",
"commission_count": 3,
"status": "COMPLETED",
"method": "Bank Transfer",
"processed_at": "2026-11-12T16:20:00.000Z",
"created_at": "2026-11-10T09:00:00.000Z"
}
}payout.failed
{
"payout": {
"id": "po_northwind_301",
"affiliate_id": "aff_marcus_018",
"amount_cents": 2940,
"deduction_cents": 0,
"currency": "USD",
"commission_count": 3,
"status": "FAILED",
"method": "Bank Transfer",
"processed_at": null,
"created_at": "2026-11-10T09:00:00.000Z"
}
}Retries and the delivery log
- Any 2xx response within 10 seconds counts as delivered. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours — six attempts in total. Redirects are not followed.
- After 24 failed attempts in a row the endpoint is turned off; switching it back on in the dashboard resets the counter.
- Each endpoint's latest 50 deliveries are listed with status, HTTP code and a response snippet. Resend queues the same payload again (same event id, new delivery id).
- Deliveries run concurrently and retries can arrive late, so don't rely on the order events arrive in. De-duplicate on the envelope id instead.
Send server events
POST /api/saas/events takes the events your product generates — account created, subscription changes, charges, refunds — and records them against the partner who referred the customer. Call it from your server, never from browser code.
Every event needs a stable id: it is this endpoint's idempotency key, and a retry with the same id changes nothing. Set it explicitly — without one, only payment_succeeded (falls back to payment.id) and refund (falls back to refund.id) are de-duplicated; every other event type is re-applied on retry.
Credentials
Send the key as Authorization: Bearer <key> (or X-API-Key: <key>). The key is either a merchant API key with write scope (aft_…, created under Admin → API keys) or a store integration secret (sk_store_…). Store public keys (pk_…) are rejected.
export AFFILIATOPS_API_URL="https://affiliatops.com"
export AFFILIATOPS_API_KEY="aft_..." # or sk_store_...Sign-up and first payment
When an account is created, send the referral code your sign-up page captured (or the coupon code the customer typed). Set id to a value from your own system: a retry with the same id returns status: "duplicate" and changes nothing.
{
"id": "evt_signup_acct_1042",
"type": "signup",
"occurred_at": "2026-09-28T11:02:44.000Z",
"referral_code": "MARCUS-4F2A",
"customer": {
"id": "acct_1042",
"email": "[email protected]",
"name": "Dana Whitfield"
}
}curl -X POST "$AFFILIATOPS_API_URL/api/saas/events" \
-H "Authorization: Bearer $AFFILIATOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"evt_signup_acct_1042","type":"signup","occurred_at":"2026-09-28T11:02:44.000Z","referral_code":"MARCUS-4F2A","customer":{"id":"acct_1042","email":"[email protected]","name":"Dana Whitfield"}}'When the first charge succeeds, send payment_succeeded. The payment amount is required: amount takes major units (49); amount_cents takes integer minor units (4900). When both are present, amount_cents wins. This example's top-level id and its payment.id below are two different strings on purpose: those fields are independent, so nothing requires them to match. Reusing one string for both, as agent.md's recipe does, works too.
{
"id": "evt_payment_inv_northwind_2048",
"type": "payment_succeeded",
"occurred_at": "2026-10-06T15:04:07.512Z",
"customer": {
"id": "acct_1042",
"email": "[email protected]"
},
"subscription": {
"id": "sub_northwind_77"
},
"payment": {
"id": "inv_northwind_2048",
"amount_cents": 4900,
"currency": "USD"
}
}curl -X POST "$AFFILIATOPS_API_URL/api/saas/events" \
-H "Authorization: Bearer $AFFILIATOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"evt_payment_inv_northwind_2048","type":"payment_succeeded","occurred_at":"2026-10-06T15:04:07.512Z","customer":{"id":"acct_1042","email":"[email protected]"},"subscription":{"id":"sub_northwind_77"},"payment":{"id":"inv_northwind_2048","amount_cents":4900,"currency":"USD"}}'Event types
| type | What it records |
|---|---|
| signup | An account was created. Send referral_code or coupon_code to attribute it. |
| trial_started | A trial began (send subscription with trial_ends_at). |
| subscription_created | A subscription started. Send subscription (id, status, plan, quantity). |
| subscription_updated | A plan, quantity or status change. Send subscription. |
| subscription_canceled | A subscription ended (subscription_cancelled is also accepted). |
| payment_succeeded | A charge succeeded. payment.amount or payment.amount_cents is required. Creates the commission. |
| payment_failed | A charge failed (send payment and subscription.id). |
| refund | A payment was refunded; reverses the commission it created (see below). |
Fields
customer—id(your account id),email,name,billing_id(your provider's customer id). At least one identifier is required; a refund can identify the customer throughrefund.payment_idinstead.subscription—id,status,plan(id,name,amount_cents,currency,interval,interval_count),quantity,amount/amount_cents(the total per interval),trial_ends_at, period dates.payment—id,amountoramount_cents,currency(3-letter ISO, e.g. USD).refund— also nested:id,payment_id,amountoramount_cents,reason. A top-level amount is ignored.referral_codeandcoupon_code(aliascode) attribute the customer. Codes are trimmed, case-insensitive and scoped to your business.occurred_at— when the event happened (ISO-8601 or Unix seconds); defaults to when we receive it.
Batch, responses and errors
Send one event, or up to 100 as { "events": [ … ] }; a batch is processed in order, so one request can create a subscription and then pay it.
- 200 —
resultsis always included: an array with one entry per event sent. Sending a single event (not wrapped in{ "events": [...] }) also adds aresultfield, a shortcut forresults[0]— a batch request never gets one.statusisprocessed,duplicate(same id seen before; nothing changed) orignored(no partner was attributed to this customer — normal, not an error). - 400 — nothing was processed;
errorslists the problems per event index. - 401 — missing or invalid key, or a public key was used.
- 500 — some events failed. The response says which; retry them with the same ids.
{
"success": true,
"result": {
"index": 0,
"status": "processed",
"eventId": "cm3x9f2q1000x3o5p8qv1a0nt",
"customerId": "cus_northwind_1042",
"subscriptionId": null,
"affiliateId": "aff_marcus_018",
"commissionIds": [
"com_northwind_1042"
]
}
}eventId is Affiliatops' own record id for this event, never an echo of the id you sent — keep using your own id for retries and correlation. subscriptionId is null here because this exact example never sent a subscription_created or subscription_updated event first: payment_succeeded's own subscription.id only matches an existing subscription, it never creates one. Send a subscription event when you want subscription-level fields (plan, status, MRR) tracked.
Refunds
Send a refund event with refund.payment_id set to the payment.id you sent with the original payment:
{
"id": "evt_refund_re_northwind_0007",
"type": "refund",
"occurred_at": "2026-10-20T09:41:02.000Z",
"refund": {
"id": "re_northwind_0007",
"payment_id": "inv_northwind_2048",
"amount_cents": 1000,
"reason": "Customer refund"
}
}curl -X POST "$AFFILIATOPS_API_URL/api/saas/events" \
-H "Authorization: Bearer $AFFILIATOPS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"evt_refund_re_northwind_0007","type":"refund","occurred_at":"2026-10-20T09:41:02.000Z","refund":{"id":"re_northwind_0007","payment_id":"inv_northwind_2048","amount_cents":1000,"reason":"Customer refund"}}'Put the amount inside refund: amount_cents in minor units ($10 is 1000) or amount in major units (10). Omit both for a full refund of what is left.
The refund reverses the commission the payment created: a pending or approved commission is reduced or cancelled (an approved one comes off the partner balance), and the reversed part of a paid commission is deducted from the partner's next payout. Each reversal also sends a commission.reversed webhook, and the refund reduces the customer's recorded revenue.
Partial refunds are computed cumulatively, so several refunds never reverse more than the original commission. Keep refund.id and the event id stable, and a replayed refund changes nothing. If there is nothing left to refund — or the payment is not found and no amount was sent — the event is acknowledged with status: "ignored": check refund.payment_id. If the payment is not found but an amount was sent, the refund is recorded against the customer without reversing any commission. When the program has refund clawbacks turned off, refunds never reverse commissions.
Next steps
- Every field of the events endpoint: POST /api/saas/events in the API reference.
- A single Markdown file for coding agents: /docs/agent.md.
- Selling one-off orders instead of subscriptions? Use the order receivers instead: POST /api/webhook/conversion and POST /api/webhook/refund.