If you run an affiliate program on Stripe subscriptions, you pay commission when an invoice gets paid. The part that quietly breaks is what happens when a renewal that already earned someone a commission stops being paid.
Most implementations reverse on the wrong event. I had the shape of this wrong myself until I sat down with the retry docs and read what actually fires.
The event everyone reverses on is the wrong one
invoice.payment_failed looks like the obvious trigger. Stripe describes it as: “Occurs whenever an invoice payment attempt fails, due to either a declined payment, including soft decline, or to the lack of a stored payment method.”
Attempt is the load-bearing word. It fires once per attempt, and the Smart Retries docs say “The recommended default setting is 8 tries within 2 weeks.” So a single doomed invoice can fire that event eight times. If your handler reverses a commission each time, you have clawed back the same commission eight times.
The second problem is worse, because it is silent. Most failed attempts are temporary. The card works on attempt three, the invoice gets paid, and you have already reversed a commission that was genuinely owed. Nobody files a bug for this. Your affiliate just quietly earns less than they should.
The dunning window is an ambiguity window
For up to two weeks on default settings, you do not know whether that commission is owed. That is not a problem to engineer around, it is just true, and the design should say so.
Give commission rows a third state. Something like pending_recovery, entered on the first failure, exited on either invoice.paid or a terminal outcome. During that window you do not pay it out and you do not reverse it. Holding is the honest answer, and it is also the one that stops you paying out money you may need to chase back.
Which terminal event, and the trap that bit me
When retries run out, the docs are clear that Stripe stops: “After the final payment attempt, we make no further payment attempts.”
What happens to the subscription depends on a setting the merchant chose, and there are three options:
Setting Result Cancel the subscription “changes to acanceled state after the maximum number of days defined in the retry schedule”
Mark the subscription as unpaid
“changes to an unpaid state … Invoices continue to be generated and stay in a draft state”
Leave the subscription overdue
“remains in a past_due state … Invoices continue to be generated and charge customer based on retry settings”
So the natural move is to reverse on a terminal invoice event, and the two candidates are invoice.marked_uncollectible (“Occurs whenever an invoice is marked uncollectible”) and invoice.voided (“Occurs whenever an invoice is voided”).
Here is the trap. uncollectible is a manual status. The invoice workflow docs describe it only as something a person does: “you can update the status of the invoice to be uncollectible“, offered either as a Dashboard action or an API call. The status itself means “The customer is unlikely to pay the invoice. Normally, you treat it as bad debt in your accounting process.” There is no documented automatic transition from open or past_due into it.
Which means: if the merchant is on “leave the subscription overdue”, nothing marks that invoice uncollectible unless a human does it as a bookkeeping chore. invoice.marked_uncollectible may never fire at all. Your commission sits in pending_recovery forever, against revenue that is never arriving.
Worth separating void here too, because people reach for it as a synonym and it is not one. Voiding “is conceptually similar to deleting or cancelling it. However, voiding an invoice maintains a paper trail”, and “Voided invoices are treated as zero-value for reporting purposes, and aren’t payable.” Uncollectible is bad debt you still recognise. Void is an invoice that stops existing for reporting. They are different accounting statements and they deserve different commission handling.
What to do instead
Do not hang reversal on a single invoice event.
- Listen for
customer.subscription.deletedandcustomer.subscription.updatedas well, since two of the three end states are expressed on the subscription rather than the invoice. - Add a time-based sweep. The retry window is knowable: it is whatever Smart Retry policy the account is on, and the options are 1 week, 2 weeks, 3 weeks, 1 month or 2 months. If a commission has sat in
pending_recoverypast that window plus a margin, resolve it yourself instead of waiting for an event that is not coming. - Record which rule resolved it. When an affiliate asks why a commission vanished, “the invoice was marked uncollectible on this date” and “our sweep timed it out after 21 days” are very different answers, and only one of them is defensible.
The one decline that is not a retry problem
A decline with authentication_required is not a timing failure. The card is fine and the issuer wants the customer back on-session to complete 3DS. No retry schedule fixes that, so a commission stuck behind one is waiting on a human, not a timer. Treat it differently in the sweep: it should trigger an email to the customer, not a longer wait.
Measuring what this costs you
Stripe has a page for this that is easy to miss: dashboard.stripe.com/revenue_recovery, which gives “key performance indicators (KPIs), trends, and reports to help you understand how failed subscription payments are affecting your business.”
Two caveats before you trust the number. It covers the current and previous month only, so any 90 day question still needs an export. And the scope is narrower than it looks: “Data in the revenue recovery overview represents recurring subscription payments only and excludes the first invoice payment following a trial.” If most of your failures are trial conversions, they are not in there.
When you do export, count invoices and not charges. Eight failed charge rows can be one uncollected invoice. Sum the charges and you will overstate what failed renewals cost you by several times, and if you are using that number to size how much commission you need to claw back, you will size it several times too big.
The shape of it
Pay on a settled fact. Hold on an ambiguous one. Reverse on a terminal one, and then go and check that your terminal one actually fires for the merchant configuration you are running.
Every Stripe quote above is from the Billing and API docs, checked on 2026-07-31: billing/revenue-recovery/smart-retries, billing/invoices/workflow, billing/revenue-recovery/recovery-analytics and api/events/types.
답글 남기기