The 4 Stripe webhook events that matter for affiliate tracking
Stripe affiliate tracking is mostly a webhook problem.
The click cookie gets the referral code onto the visitor. client_reference_id or metadata gets the code onto Checkout. After that, webhooks decide whether your ledger is trustworthy.
For a subscription SaaS affiliate program, four events matter most:
checkout.session.completedinvoice.paidcharge.refundedcustomer.subscription.deleted
You can support more events later. Start here and make these idempotent before you ship.
The rule before the events: idempotency first
Stripe can deliver the same webhook event more than once. It can also deliver events in an order that is not the order your happy-path diagram expects. Your handler needs to survive both.
The simplest pattern is a webhook_events table:
stripe_event_idwith a unique constraint- event type
- raw payload
- processed timestamp
- error field
When a webhook arrives, verify the Stripe signature, parse the event, and insert the event ID before doing side effects. If the insert fails because the event ID already exists, return 200 and stop.
Stripe's webhook docs are clear that retries happen. Treat duplicate delivery as normal infrastructure behavior, not an edge case.
Pseudo-flow:
async function handleStripeEvent(event) {
const inserted = await insertWebhookEvent(event.id, event.type, event);
if (!inserted) {
return { ok: true, duplicate: true };
}
await processEvent(event);
await markWebhookProcessed(event.id);
return { ok: true };
}
This table is not analytics. It is a lock and an audit record.
1. checkout.session.completed
Use checkout.session.completed to connect the Stripe customer to the affiliate referral.
At checkout creation time, your app should pass the referral code into Stripe. The usual fields are:
client_reference_idmetadata
Stripe's Checkout Session API supports both. For affiliate tracking, using both is a reasonable belt-and-suspenders pattern:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
client_reference_id: referralCode,
metadata: {
affiliate_code: referralCode,
affiliate_link_id: linkId,
},
line_items,
success_url,
cancel_url,
});
When checkout.session.completed arrives, read the referral code from client_reference_id first, then metadata as fallback. Resolve it to a membership or affiliate record. Then create or update a referral row with:
- Stripe customer ID
- affiliate membership ID
- optional link ID
- customer email hash if you store one
- status such as
trialorcustomer - first seen timestamp
Do not create a commission here just because checkout completed. For subscriptions, the money event is the invoice. Checkout completion tells you who the customer should be attached to.
Also enforce the duplicate-customer rule here: one referral per Stripe customer ID. If the customer is already mapped to an affiliate, do not overwrite it with a later checkout unless a human intentionally changes it.
2. invoice.paid
Use invoice.paid to create commissions.
This is the recurring revenue event. Every paid subscription invoice can become a commission if:
- the Stripe customer maps to a referral
- the program pays recurring commission
- the invoice is inside the commission window
- the invoice has not already produced a commission
- the customer is not a self-referral
The idempotency rule should be stronger than event ID here. Store stripe_invoice_id on the commission and make it unique per source. That way, even if two different event paths try to create commission for the same invoice, the database stops the duplicate.
Commission creation should calculate:
- commission amount
- currency
- source invoice ID
- hold until date
- status
pending - affiliate membership
- referral
The hold date should be now + program.hold_days. In the Ambassly spec, the default is 30 days. Many SaaS programs use 30 to 45 days so the hold period is at least as long as the refund window.
Write an audit event after creating the commission. Future you will need it.
3. charge.refunded
Use charge.refunded to claw back commissions.
Refunds are not rare enough to handle manually forever. If a paid invoice produced a commission and the underlying charge is refunded, the commission ledger needs to change.
There are two cases.
If the commission is pending or approved, move it to clawed_back and write an audit event with the Stripe charge or refund reference.
If the commission is already paid, do not mutate history to pretend it was never paid. Create a negative adjustment commission or ledger entry that nets against the affiliate's next payout.
That distinction matters. A ledger should tell the truth:
- the invoice was paid
- the commission was created
- the commission was paid
- the charge was refunded
- a negative adjustment was created
If you collapse those into one current status, you will not be able to answer affiliate questions cleanly.
4. customer.subscription.deleted
Use customer.subscription.deleted to stop future recurring commission and update referral state.
This event usually means the subscription ended. It does not necessarily mean a past invoice should be clawed back. Cancellation and refund are different events.
On subscription deletion:
- mark the referral or subscription mapping inactive
- stop expecting future invoice commissions
- record the churn timestamp
- leave already earned commissions alone unless a refund event says otherwise
This event is also useful for commission windows. If your program pays recurring commission for 12 months, the customer may churn before the window ends. No invoice, no commission. If they reactivate later, your terms should decide whether the original affiliate resumes earning.
Event ordering
Your code should not assume checkout.session.completed always arrives before invoice.paid.
Usually it will. Eventually it will not.
There are two practical fixes:
- make
invoice.paidable to look up the Checkout Session, subscription, customer, or metadata if the referral row is missing - store unresolved invoice events for retry or reconciliation
The Ambassly spec includes a reconciliation route that re-lists recent invoices and repairs gaps. That is the right posture. Webhooks are the live path. Reconciliation is the repair path.
Self-referral and fast-conversion checks
Two lightweight fraud checks belong in this flow.
First, self-referral detection. If the affiliate email matches the customer email, auto-reject the commission and write an audit event. You can add domain and payout-email checks later.
Second, fast-conversion flagging. The spec calls for flagging conversions less than 60 seconds after click. That should usually be a review flag, not automatic rejection. A last-click before checkout can be legitimate, but it is worth surfacing.
Minimal schema
You can build the first version with these tables:
- referrals
- commissions
- commission_events
- webhook_events
The important constraints are:
- unique
stripe_event_idinwebhook_events - unique
stripe_customer_idin referrals, scoped to the company - unique
stripe_invoice_idfor commission source - append-only
commission_events
Those constraints do more to protect your program than a complicated webhook handler without database guarantees.
The boring version is the correct version
A reliable affiliate webhook system is not clever. It is a small set of events, processed idempotently, with database constraints and a ledger trail.
Handle checkout completion to attach the customer. Handle paid invoices to create pending commissions. Handle refunds to claw back. Handle subscription deletion to stop future expectations.
Everything else is refinement. If those four events are wrong, the affiliate portal will eventually show numbers nobody can defend.