Blog ·
Stripe for hosting: when a payment is yours
Succeeded is not settled, a dispute is not a refund, and dunning is a dashboard setting. What building recurring hosting billing on Stripe actually involves.
By Provibr
Billing for hosting is not e-commerce. A shop takes money once and ships a box; you take money every month and keep a machine running in between. That difference shows up in one place, and it is the place every integration gets wrong first: deciding when a payment is real enough to keep delivering.
This is what we ran into building recurring billing on Stripe. None of it is exotic, and most of it is invisible until the month a payment reverses.
"Succeeded" is not "settled"
A successful checkout is not money. Card and iDEAL are close enough to instant that you can pretend otherwise. SEPA direct debit is not, and neither is a bank transfer.
A SEPA payment sits in processing after checkout, with nothing received, for days. It usually settles. Sometimes it does not. If your integration treats the checkout as the moment of truth, you have provisioned a server on the strength of an intention.
So the state model is not a boolean. We ended up with six phases, and exactly one of them means "deliver":
| Phase | What it means | Deliver? |
|---|---|---|
| pending | Started, nothing received yet | No |
| settled | Money received and ours | Yes |
| failed | Not going to happen | No |
| disputed | The customer is contesting it | No, but it can come back |
| reversed | Charged back or refunded | No, permanently |
| unknown | We could not determine it | No |
Making "deliverable" mean precisely and only settled costs you nothing to write and buys you the property that no other part of the codebase gets to have an opinion about it. Everywhere else asks one question.
Event order is not guaranteed, so make the illegal transitions illegal
Webhooks arrive out of order. Not often, but they do, and the failure is silent: a stale event overwrites a newer state, and a customer's service either stops for no reason or keeps running after a chargeback.
The cheap defence is to write down which transitions are impossible and refuse them. Settled cannot go back to pending, settled cannot become failed, pending cannot jump straight to disputed, and nothing ever leaves reversed or failed. A late event that would cause one of those is dropped rather than applied.
The second defence is architectural, and it is the decision we would keep in any payment integration.
The webhook is an accelerator, not a source of truth. Ours verifies the signature, deduplicates, and pushes one organisation identifier onto a queue. That is all it does. A poller then fetches the current state from Stripe before anything changes. The consequence is worth the plumbing: a missed webhook is never a missed payment, it is a slower payment. Delivery does not depend on a delivery mechanism you do not control.
Signature verification has three details that are easy to get subtly wrong:
- Compare in constant time, but check the lengths first, because the constant-time comparison throws on mismatched lengths rather than returning false.
- Accept multiple signatures in the header. That is how secret rotation works, and an implementation that reads only the first one breaks on the day you rotate.
- Reject a non-hexadecimal signature before parsing it as hex, because hex parsing truncates silently at the first invalid character and a two-character signature will happily compare against nothing.
We also deduplicate by claiming the event ID before publishing the nudge. That has a known downside: claim, then fail to publish, and the hint is lost. It is acceptable precisely because the poll is the source of truth, which is a good illustration of one architectural decision paying for a compromise elsewhere.
Dunning is a dashboard setting, not an API parameter
If a monthly payment fails, something has to retry it and something has to decide when the service stops. We wrote that logic first and then deleted most of it.
Stripe's Smart Retries picks retry timing from its own data, and what a subscription becomes after every retry has failed is configured in the dashboard. It is an account-wide setting rather than something you pass per subscription, and test mode and live mode are configured separately, which is a good way to be surprised in production after a green test run.
That leaves your side with one job: translate the subscription status into what the customer is allowed to use.
| Subscription status | What we grant | Why |
|---|---|---|
| active, trialing | Full access | |
| past_due | Grace: a banner, nothing stopped | Stripe is still retrying |
| unpaid | Suspended | Retries are exhausted |
| canceled | Suspended | |
| incomplete | Full access, no restriction | The first payment never succeeded |
| paused | Full access |
The past_due row is status-driven rather than clock-driven, and that is the simplification worth taking. Once the processor owns the retry schedule, "how many days has this been failing" stops being your question. Asking it anyway means running a second timer against a schedule you cannot see.
The incomplete row is the one that bites. It means a subscription whose very first payment never went through, which is not the same thing as a paying customer who stopped paying. Treating them alike suspends a running customer retroactively on the strength of an event about a subscription that never started.
And suspension is a pause, never a deletion. Coming back is an action performed by a human. Nothing in a billing state machine should be able to destroy a customer's data, however confident it is.
The bug that gave the money back and kept the service running
Here is the failure that taught us the most, and it had nothing to do with the payment provider.
A chargeback correctly set the customer's licence state to blocked. Within five minutes it was back to active. The customer had their money and their servers.
The cause was that a background job recalculating a different thing, resource overage, issued an UPDATE on the same column every five minutes without a WHERE clause. Three separate pieces of code composed the same field, each correct on its own, and the one that ran most often won.
The fix was structural rather than a patch: one function composes that column, taking three floors as input, one from billing, one from overage, one from reversals, and nothing else writes it. The general rule is worth stating in the abstract, because it applies far outside billing. If more than one writer computes the same column, that column is not a decision. It is a race, and it will resolve in favour of whichever job has the shortest interval.
A dispute is not a refund, and a partial refund is not half a delivery
These are two distinctions that hosting billing needs and that a generic payments guide will not make for you.
An open dispute is not a loss. It can be won. The payment stops counting as delivered, but that is a statement about the payment and not about the licence: an open dispute maps to grace, so the customer keeps their service and you get a notification. Suspending someone who is going to win their dispute is a support conversation you do not want. A final chargeback is a loss, and that one maps to suspension.
A partial refund is the awkward one. It is tempting to treat it as a partial delivery, and there is no such thing. A subscription month is delivered or it is not. So a partial refund drops the payment out of settled entirely, and delivery stops. That is the honest reading, even though it feels harsh, because the alternative is inventing a fraction of a service that your product cannot actually provide.
If you push credit notes into an accounting system, one more warning. In ours a credit note is not a separate resource: it is an invoice with negative amounts, and the duplicate call copies the reference field from the original. That reference is exactly the key an idempotency check searches on, so a naive implementation finds the original on the next run, concludes nothing has been credited, and issues a second credit note. Money back twice. Every generated document needs its own anchor, derived from the event that caused it.
What a proforma is here, and why the counter has two prefixes
A proforma is the document the platform itself produces, with its own number, and it is what the customer sees in the panel. The legal invoice is created in the accounting system with its own series. Without that accounting system connected, the proforma is the only document there is, which is why it has to be numbered properly rather than treated as a receipt.
Two details that came out of doing it:
The number pads to four digits as a minimum, not a fixed width. Document ten thousand gets five digits. Truncating to keep the format tidy would give two documents the same number, and a unique index will let you find that out at the worst possible moment.
One counter, two prefixes. When the prefix changed, the sequence continued rather than restarting. Two counters would have produced a new document whose number differs from an old one only in a two-letter prefix, which a database sees as unique and a human reading a bank statement does not.
And a top-up of prepaid credit carries no VAT on its proforma, because a top-up is a prepayment rather than a service. The VAT belongs on the invoice for what the credit is eventually spent on. Getting that backwards means charging tax twice on the same money.
What we could measure, and what we could not
The single most useful thing in all of this is the test clock. A monthly billing cycle can be driven end to end in minutes: create a SEPA payment, confirm that processing activates nothing at all, advance the clock past the settlement window, watch it settle, and check that activation happens exactly once and is idempotent under a second run. Without one, the monthly cycle is the one thing you cannot rehearse, and the first real run of it happens in production.
Two honest limits on all of the above. This has been measured against Stripe's sandbox and a real webhook delivered over the network, not against a large live customer base. And a lesson that generalises past payments: a feature flag on a worker and a button does not gate a database schema. A deploy once shipped code that read columns whose migration had not run, and an unrelated administration page returned a server error for everybody, because the flag was off but the query was not.
This came out of building the billing side of Provibr, where hosting providers manage their own infrastructure through one panel and the agent connects outbound only.