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

  1. In the dashboard, open Developer tools → Webhooks and choose Add webhook.
  2. Give it a name, your endpoint URL (a public https address, for example https://app.northwind.example/webhooks/affiliatops) and the events you want.
  3. 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.
  4. Send test delivers a signed webhook.test event and shows the HTTP result, so you can check your endpoint before real events arrive.

Headers and body

Every delivery carries these headers:

HeaderValue
Affiliatops-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>" keyed with your signing secret
Affiliatops-Eventthe event type, for example commission.created
Affiliatops-Deliveryid of this delivery; retries keep it, a manual Resend gets a new one
Content-Typeapplication/json
User-AgentAffiliatops-Webhooks/1.0
example request
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.0

The 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.

body
{
  "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:

Node.js
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.

EventWhen it is sent
affiliate.createdA partner joins your program
affiliate.approvedYou approve a pending partner
affiliate.rejectedYou reject a pending partner
referral.submittedA partner submits a lead
referral.approvedA lead is approved
referral.rejectedA lead is rejected
conversion.createdA sale or sign-up is recorded for a partner
commission.createdA commission is created (held until the hold period ends)
commission.approvedA commission is approved and added to the partner balance
commission.reversedA commission is cancelled, clawed back or reduced after a refund
commission.paidA commission is included in a payout
payout.createdA payout is created for a partner
payout.completedA payout is marked as paid
payout.failedA 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

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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

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"
  }
}

commission.approved

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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

data
{
  "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.

shell
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.

signup
{
  "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
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.

payment_succeeded
{
  "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
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

typeWhat it records
signupAn account was created. Send referral_code or coupon_code to attribute it.
trial_startedA trial began (send subscription with trial_ends_at).
subscription_createdA subscription started. Send subscription (id, status, plan, quantity).
subscription_updatedA plan, quantity or status change. Send subscription.
subscription_canceledA subscription ended (subscription_cancelled is also accepted).
payment_succeededA charge succeeded. payment.amount or payment.amount_cents is required. Creates the commission.
payment_failedA charge failed (send payment and subscription.id).
refundA 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 through refund.payment_id instead.
  • 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, amount or amount_cents, currency (3-letter ISO, e.g. USD).
  • refund — also nested: id, payment_id, amount or amount_cents, reason. A top-level amount is ignored.
  • referral_code and coupon_code (alias code) 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 — results is always included: an array with one entry per event sent. Sending a single event (not wrapped in { "events": [...] }) also adds a result field, a shortcut for results[0] — a batch request never gets one. status is processed, duplicate (same id seen before; nothing changed) or ignored (no partner was attributed to this customer — normal, not an error).
  • 400 — nothing was processed; errors lists 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.
200
{
  "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:

refund
{
  "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
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