DEV Community

Mihir kanzariya
Mihir kanzariya

Posted on

Clawing back affiliate commissions when a Stripe payment is refunded or disputed

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.refunded when you refund all or part of a payment.
  • charge.dispute.created when 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
});
Enter fullscreen mode Exit fullscreen mode

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',
  });
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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)