Payments: merchants of record, webhooks and subscriptions

Who really sells when a customer pays - a merchant of record or you - how hosted checkout keeps card data out of the app, how to trust a webhook with HMAC signatures, timestamps, idempotency, event ordering and optimistic concurrency, how a subscription moves through trial, active, past due, canceled and refunded, why entitlements are data, and how to test all of it with a stand-in provider.

In short

Selling software online is four jobs: taking the money, paying the tax on it in every country, keeping each subscription in step, and deciding what a customer may use. Where payments are switched on, {{brand}} gives the first two to Paddle, a merchant of record, and opens Paddle's hosted checkout, so no card number reaches its servers. Paddle reports every change through signed webhooks. {{brand}} checks each one and applies it once, in the order it happened. The result is kept as entitlements: plans, features and limits stored as data.

After this lesson you can Choose between a merchant of record and a payment processor. Verify a webhook and make its handler safe against repeats, reordering and races. Design a subscription state machine with trials, grace periods and refunds. Test the whole payment flow without a real payment account.

Words you need

Who does what when a customer pays

A card payment passes through more hands than it seems. The issuing bank gave the customer the card; the acquiring bank receives money for the seller; the card network (Visa, Mastercard) connects the two; a processor speaks to the network for you. A merchant of record sits on top of all of this and also becomes the legal seller.

Tax is the hidden cost A subscription sold to a customer in Germany carries German VAT; in Texas, Texas sales tax; in Australia, GST. Each has its own rules, rates and filing calendar. For a small team, registering and filing in dozens of places often costs more than the difference in fees - that is the whole case for a merchant of record.

Hosted checkout: why card data never touches the app

If card numbers pass through your servers, your whole system falls under PCI DSS - audits, scans, network rules. If the card is typed into a page or frame the provider serves, the number goes straight from the browser to the provider. You only ever hold references: a transaction id, a customer id, a subscription id. That normally keeps you in the smallest self-assessment, called SAQ A.

Never trust what travelled through the browser Anything the browser carried - the items, the quantity, an account id in the order's custom data, a success redirect - can be changed by the person using it. Decide the plan, seats and account on the server, and attach the account reference in a form the buyer cannot read or alter. A reference that fails its check names nobody.

A third-party script can read everything on the page it runs in. So give the provider's script one page of its own with a Content Security Policy that names only the provider's hosts, and keep every other page strict. Leave that page with a full page load.

Trusting a webhook

A webhook endpoint must accept requests from the internet without a sign-in, so the request itself has to prove where it came from. Every serious provider signs each delivery with an HMAC made from a secret only you and the provider know. The receiver repeats the calculation over the raw bytes it received and compares the two.

Sign the bytes, not the JSON Many frameworks parse the body before your code runs. Parsing and writing it out again can change a space, the order of keys or an escaped character - and the signature no longer matches. Read the raw body first, verify it, and only then parse. Compare with a constant-time function, so the time taken leaks nothing about how many bytes matched.

From at least once to exactly once: idempotency, ordering and concurrency

Providers promise at least once delivery: when they are not sure you got an event, they send it again. They do not promise order: a payment may arrive before the subscription it belongs to. And two copies may arrive at the same moment on two servers. Three techniques turn this into an effect that happens exactly once, in the right order.

The event's record and the change it causes are written in one transaction. If anything fails, neither is kept and the provider sends the event again; if both are kept, every later copy finds the record and stops. Recording the id in a separate step - before or after - leaves a gap in which an event is lost or applied twice.

Each subscription remembers when its last applied event happened. Older news is recorded - so it is never asked about again - and changes nothing. Without this rule the 3rd delivery would quietly undo the cancellation. One exception is deliberate: money. A payment always counts for the period it paid for, whatever arrived before it. A payment that arrives before the subscription it belongs to is parked and attached when the subscription appears.

A lock per subscription (for example a PostgreSQL advisory lock, which works across servers) makes events take turns. The row version catches the writers the lock does not cover, such as a person canceling.

PostgreSQL has a hidden column, xmin, that changes on every update. EF Core can use it as the row version, so you do not need a column of your own.

On a conflict the ORM throws a concurrency exception. Forget what you read, read again, decide again, and retry a few times.

Ask the provider when in doubt Webhooks can be lost for days. Before an irreversible step - taking a plan away for an unpaid bill, or deciding what a late payment means - {{brand}} reads the subscription back from the provider's API and applies the answer like any other event. A lost 'subscription created' does not have to lose the plan either: a system that can identify the account from every later event can start the subscription from the next healthy one.

Answer within seconds, finish the rest later

Paddle counts a delivery that is not answered within five seconds as failed, and sends it again. Sending emails or calling the provider back inside the handler is how a slow mail server turns into duplicate deliveries. The fix is to split the work into a durable half and a follow-up half - the idea behind the transactional outbox.

The options in the market

What {{brand}} chose, and why

{{brand}} is a small company selling to customers in many countries, so the deciding cost was tax, not the card fee. A merchant of record makes VAT, GST and US sales tax Paddle's job, and brings the subscription machinery - card retries, proration, invoices, a customer portal - that would otherwise be months of work.

The trade-off accepted A higher fee per transaction, a provider that must approve the business before it can sell, less control over the checkout, and an API without idempotency keys. The last one shaped the code: a call that creates something is never sent twice on a doubt, and every operation reads first, so a request repeated after a timeout finds its work already done.

Another choice is right elsewhere. A company selling mainly in one country, with an accountant who already files its taxes, saves money with Stripe. A very large business with a finance team often runs a processor such as Adyen and a tax engine itself. Because the provider sits behind one contract, that change would be a new adapter and a setting - and leaving a merchant of record makes the business responsible for tax again, which is a business decision, not a code change.

Subscriptions as a state machine

A subscription is a small state machine: a few states, and events that move it between them. Writing it down as one keeps billing bugs rare. The question every screen and check asks is simple - does this subscription grant its plan right now? - and it is answered by one rule, in one place.

Read the state, not the event's name A subscription event usually carries the whole subscription as it stands now. Work your state out from its status and any scheduled change - active with a cancellation scheduled means canceled at the period's end - rather than from the event's name. Then a lost or reordered event cannot leave you in a state the provider never had.

Money going back to the customer is a second kind of event. Every billing system has to choose what each one does to access.

Plan for payments you cannot honor Some payments cannot be honored, such as a duplicate subscription. A sound design ends the subscription and refunds the payment, remembers that it still has to do so, and keeps retrying until the provider confirms. A provider outage then never leaves money held for nothing.

Per-seat billing and proration

On a per-seat plan the provider keeps a quantity: the number of seats the organization must pay for, counted by the server. A change in the middle of a period is prorated: you pay only for the days left. Two design rules follow.

Entitlements as data

The worst billing code is a check like plan == "pro" scattered through a product: every new plan or price change becomes a deploy, and every place that was forgotten is a bug. The alternative is entitlements as data: plans, the features each includes and the limits on each metric are rows in the database. Code asks only two questions - is this feature included, and is there room under this limit - and never names a plan.

Plans the provider never sees Because entitlements are rows, a plan does not need a payment at all. A trial is a subscription row with an end date; an enterprise contract or a private installation gets its plan assigned by hand or by its license. The provider only ever answers one question: which price did this customer pay for?

Money in code: decimals, minor units and tax

Never hold money in a floating-point number: 0.1 + 0.2 is not 0.3 in binary floating point, and the error grows with every sum. Use a decimal type (C# decimal, PostgreSQL numeric) or whole numbers of the smallest unit. Paddle sends amounts as text holding whole minor units - "1000" is 10.00 USD - and some currencies, such as the Japanese yen, have no minor unit at all.

Calling the provider: retries without double charges

Calls go the other way too: the server creates customers and transactions, changes quantities, cancels and refunds. A network timeout leaves you not knowing whether the provider did the work. Retrying blindly can charge twice; not retrying can lose the change.

Where a provider offers no idempotency key, design every operation to read first: before refunding, list the payment's refunds; before canceling, read whether a cancellation is already scheduled. An operation sent twice then finds its work done. Two more habits: check the shape of every id before putting it into a URL, and give the client one fixed base address with redirects off, so the API key is never sent anywhere else.

Testing payments with a stand-in provider

You cannot run a test suite against real cards, and a sandbox is slow, shared and needs an account. {{brand}}'s billing was built and tested first against stand-ins, with no payment account; a real sandbox came after. The layers below each catch a different kind of bug.

A fake must fail like the real thing A stand-in that accepts everything proves nothing. Make it refuse what the provider refuses - changing a canceled subscription, a second refund while one waits for approval, a second customer for one email address - and let a test make it slow or unreachable. Most billing bugs hide in those paths.

Keep the stand-in away from real customers Never let a fake provider, or the provider's sandbox keys, run where real customers pay. Make the application refuse to start with them in production.

Try it yourself: verify and deduplicate a webhook

With Node.js and curl you can build the receiving end of a payment provider in a few minutes, and watch each defence work: the signature, the timestamp and the event id.

Common mistakes

Interview questions

Related