Payments and Billing Architecture: Your Database Is Not the Source of Truth
In a subscription product, the payment provider knows what actually happened. Your job is to stay reconciled with it through webhooks, retries, and a ledger.
Adding payments to a product looks like a small feature. A checkout page, a button, a provider’s library, and money starts arriving. That first version works, and it hides the part that causes the trouble: the product now has two systems that both believe they know who has paid, and they will disagree.
Billing architecture is mostly about managing that disagreement. The payment provider holds the facts about charges, refunds, and subscriptions. Your application holds a copy of those facts it uses to decide what each customer can do. Keeping the copy honest is the whole job.
The provider is the source of truth
Money moves in the provider’s system. A card is charged there, a refund is issued there, a subscription renews or fails there. Your database does not decide any of that; it learns about it.
The mistake is treating your own record as authoritative and the provider as a messenger. When something goes wrong, the question “what actually happened” has one reliable answer, and it lives with the provider. A design that assumes otherwise ends up with customers who paid but have no access, or access without payment, and no clean way to tell which.
So the architecture starts from a principle: your records mirror the provider’s, and any conflict is resolved in the provider’s favour.
Webhooks carry the facts
Payment events happen asynchronously. A renewal runs overnight, a bank declines a charge a day later, a dispute opens weeks afterward. None of this passes through your checkout page, so none of it can be learned from the user’s session.
Providers report these through webhooks, and that makes the webhook handler the most important code in the billing system. It is where a successful payment becomes an active subscription, a failed one starts a grace period, and a cancellation ends access.
Because it carries so much weight, it has to be built carefully. The reliability issues are exactly those in webhooks vs polling: events can arrive late, twice, or out of order, and a missed one is silent. The remedy is the same too, a periodic reconciliation against the provider that finds anything a webhook missed.
Make every handler safe to repeat
Duplicate delivery is routine, so each handler must tolerate it. Granting a month of access twice, or sending a receipt twice, are the typical failures, and in billing they cost real money and trust.
The technique is the one described in idempotent automation. Record each event’s unique identifier, enforce uniqueness with a database constraint so concurrent deliveries cannot both succeed, and write the handler so that running it again changes nothing. Apply the same care to the calls you make out to the provider: use idempotency keys on any request that creates a charge or a customer, so a retry after a timeout cannot bill someone twice.
Keep a ledger, not just a status
A common shortcut is a single field on the customer: active, past due, cancelled. It is easy to read and nearly impossible to audit. When someone asks why a customer was charged, or why access was removed on a particular day, a status field has no answer.
An append-only ledger of billing events does. Each charge, refund, plan change, and credit is a row that is never edited, and the current state is derived from them. The cost is a little more design. The benefit is that every question about the past can be answered, discrepancies with the provider can be found by comparison, and mistakes can be corrected by adding a reversing entry instead of rewriting history.
The same discipline matters for audit and compliance, and it matters more as the product grows. It is far easier to build the ledger early than to reconstruct one from a status field and a pile of emails.
Entitlements are separate from payments
What a customer can do in the product should be driven by what they are entitled to, and entitlements should be derived from billing state, not stored as an independent flag that someone flips by hand.
Separating the two makes the awkward cases manageable. A grace period after a failed payment, a free extension granted by support, a plan that includes some features and not others, a customer in the middle of an upgrade: each is a rule about entitlements, expressed in one place, instead of scattered conditions through the code.
Where the product serves multiple customers, this also ties into the tenancy decisions in multi-tenant SaaS architecture: billing attaches to the account, and the roles within an account decide who can see or change it.
Plan for the failure paths first
The happy path is a card that works every time. Real billing is mostly the other cases: expired cards, insufficient funds, authentication challenges, disputes, refunds, partial refunds, currency differences, and tax. Each needs a defined behaviour, because the default is to do nothing and let the customer find out.
Decide in advance how long a failed payment is retried, what the customer sees, when access is limited, and how they recover. These are product decisions as much as technical ones, and they directly affect revenue, since a large share of cancellations in subscription products comes from payments that simply failed.
When to keep it simple
An early product with one plan and a handful of customers does not need a ledger and a full reconciliation job on day one. A provider’s hosted checkout and customer portal can carry a surprising amount of load, and using them keeps sensitive payment data out of your system entirely.
The line to hold is this: even the simple version should treat the provider as the source of truth, handle webhooks idempotently, and reconcile periodically. The rest can grow as the pricing and the customer count do.
We build the billing and account layers products depend on. See how we work on applications, or read about choosing a tech stack for an MVP.