Affiliate programs are easy on the happy path. Someone clicks a link, subscribes, you owe a commission. The part that quietly loses you money is what happens next: the customer refunds in week two, or files a dispute in month two, and you already paid the affiliate for a sale that no longer exists.
Here is how to handle the reversal cleanly on Stripe.
The two events you actually care about
Stripe fires a webhook for both cases:
-
charge.refundedwhen you refund all or part of a payment. -
charge.dispute.createdwhen the cardholder files a chargeback.
Both mean the revenue you attributed to an affiliate is gone, fully or partially, so the commission tied to it has to be reversed.
Match the reversal back to the commission
The trick is being able to go from a Stripe charge back to the commission you recorded. Store that link at the moment you create the commission, keyed on the charge or invoice id:
// when you record a commission on a successful payment
await db.commissions.insert({
affiliate_id: affiliateId,
stripe_charge_id: charge.id,
stripe_invoice_id: invoice.id,
amount_cents: Math.round(charge.amount * commissionRate),
status: 'pending', // not yet paid out
});
Now the refund handler can find the exact row:
// webhook: charge.refunded
const charge = event.data.object;
const commission = await db.commissions.findOne({
stripe_charge_id: charge.id,
});
if (!commission) return; // not an affiliate sale, ignore
// partial refunds are common, so reverse proportionally
const refundedRatio = charge.amount_refunded / charge.amount;
const clawback = Math.round(commission.amount_cents * refundedRatio);
if (commission.status === 'pending') {
// not paid yet: just reduce or void it
await db.commissions.update(commission.id, {
amount_cents: commission.amount_cents - clawback,
status: refundedRatio === 1 ? 'voided' : 'pending',
});
} else {
// already paid: carry a negative adjustment into the next payout
await db.commissions.insert({
affiliate_id: commission.affiliate_id,
stripe_charge_id: charge.id,
amount_cents: -clawback,
status: 'adjustment',
});
}
The branch that matters is pending vs already-paid. If you have not paid the affiliate yet, you can shrink or void the commission. If you already paid it, you cannot un-send the money, so you carry a negative adjustment into their next payout.
Disputes need a different default
A charge.dispute.created is not final the way a refund is. You might win it. So do not void the commission on the dispute event, freeze it:
// webhook: charge.dispute.created
await db.commissions.update(
{ stripe_charge_id: event.data.object.charge },
{ status: 'held' } // exclude from payouts until resolved
);
Then act on charge.dispute.closed and read event.data.object.status: if it is lost, reverse it like a refund; if won, move it back to pending.
Make the handler idempotent
Stripe retries webhooks, and it will happily deliver the same charge.refunded twice. If your clawback is not idempotent you will double-reverse and underpay the affiliate. Guard on the event id before you touch a commission:
const seen = await db.processed_events.findOne({ id: event.id });
if (seen) return; // already handled
await db.processed_events.insert({ id: event.id });
Why this matters more than it looks
Skip the reversal and your affiliate payouts slowly drift above your actual revenue. Refund rates of 5 to 10% are normal for SaaS, so a program paying 30% quietly overpays a few percent of revenue every month, and you only catch it when you reconcile by hand.
Getting the refund and dispute path right from day one is the difference between an affiliate program that pays for itself and one that leaks.
(For context, I work on Referralful, which handles this reconciliation automatically on top of Stripe. The logic above is the same whether you build it or buy it.)
Top comments (0)