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
- Merchant of record The company that legally sells to your customer. It charges the card, adds and pays the right VAT or sales tax, issues the invoice and handles refunds - Paddle, Lemon Squeezy.
- Payment processor Moves the money from the card to your bank, but you stay the seller and own tax, invoices and disputes - Stripe, Adyen, Braintree.
- Hosted checkout A payment page or frame served by the provider, so the card is typed into their page, never into yours.
- PCI DSS The card industry's security standard. Anyone who stores, processes or sends card numbers must meet it. A hosted checkout keeps card data off your servers and normally limits you to the smallest self-assessment.
- Webhook An HTTP POST the provider sends to your server when something happens: a subscription started, a payment failed, a refund was approved.
- HMAC signature A keyed hash of the message made with a shared secret. A match proves the sender knows the secret and that not one byte changed.
- Replay attack Sending a real, correctly signed message again later. A signed timestamp and a record of seen event ids stop it.
- Idempotency Doing the same thing twice has the same effect as once. Webhooks arrive at least once, so their handlers must be idempotent.
- Optimistic concurrency Read a row with its version, write only if the version is unchanged, otherwise read again and retry - no lock held while deciding.
- At-least-once delivery The provider sends an event again whenever it is unsure you got it. So you will see duplicates and a different order, and the handler must cope with both.
- Proration Charging or crediting part of a period when something changes in the middle of it: a seat added with 12 of 30 days left costs 12/30 of a month.
- Entitlement What a customer may use right now - features and limits - worked out from their plan and stored as data, not written into code.
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.
- 1. Checkout The buyer types the card into the provider's page or frame. Your page never sees the number.
- 2. Authorization The processor asks the card network, which asks the issuing bank: is the card valid, and is the money there? A yes reserves the amount.
- 3. Strong authentication In Europe and many other places the bank may ask for a second step - 3-D Secure, a code or an app prompt - before it says yes.
- 4. Capture and settlement The reserved amount is taken, and a few days later it is settled to the acquiring bank, minus fees.
- 5. Tax and invoice Someone must add the right tax for the buyer's country, issue a valid invoice and file the tax with that country. With a merchant of record, that someone is the provider.
- 6. Payout The provider pays you what is left, on a schedule. With a merchant of record you are paid as its supplier, not as the 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.
- The server makes the order The server - never the browser - creates a transaction at the provider: which price, how many seats, which customer, and a reference back to the buyer's account.
- The browser opens the provider's frame The page loads the provider's script and opens the checkout for that transaction id. The card is typed into the provider's frame.
- The provider charges Authorization, strong authentication when the bank asks for it, capture.
- The provider tells the server A signed webhook - subscription created, payment completed - reaches an endpoint nobody signs in to.
- The app catches up The page says thank you, then asks its own server every few seconds until the webhook's effect shows. The browser's success message never grants anything.
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.
- 1. Cap and read the raw body Cap the size and read the raw body as bytes, before any JSON parsing. The signature was made over exactly these bytes.
- 2. Verify the signature Recompute HMAC-SHA256 over the timestamp and the body with the secret, and compare in constant time. Accept any of several signatures while a secret is being replaced.
- 3. Check the timestamp Refuse a delivery whose signed timestamp is far from now - minutes, not hours. A captured request cannot be replayed later.
- 4. Apply once, in order, in one transaction Inside one transaction, look up the event id. Already recorded? Answer 200 and stop. Otherwise apply only news newer than what was applied, and write the change and the event record together. The unique index, not the lookup, is what stops two copies at once.
- 5. Answer fast Answer 2xx within seconds. Slow work, such as emails and calls back to the provider, comes after the answer.
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.
- 1. Server A reads It reads the row and sees version 7.
- 2. Server B reads It reads the same row a moment later and also sees version 7.
- 3. A writes Its update says where version = 7, changes the row, and the version becomes 8.
- 4. B is refused Its update says where version = 7, changes 0 rows, and is refused. Without this check it would silently undo A's change - a lost update.
- 5. B tries again It forgets what it read, reads again (version 8), decides again on fresh data, and writes.
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.
- Record In one transaction: the change to the subscription, the event's record, and a mark for every job the event leaves - for example a row for an email to send or a refund to make.
- Answer Answer 200 as soon as that commits. If it failed, answer an error: nothing was kept, so the retry is safe.
- Follow up Try the jobs straight away, for a short while. When they take longer, the answer goes out first and they run on.
- Sweep A background pass finds every job still open - the server stopped, the provider was down - and finishes it. Each job is marked done once, so nothing is sent twice.
The options in the market
- Paddle A merchant of record for software, with subscriptions built in.; Calculates, collects and files sales tax and VAT worldwide; Invoices, card retries, proration and a customer portal included; A full sandbox with its own keys and test cards; A plain REST API - no SDK needed; Higher fee: about 5% plus 50 cents a transaction; Sells only after it has reviewed the business and its website; Less control over the checkout's look and flow; No documented idempotency key, so retries need care
- Lemon Squeezy A merchant of record aimed at small creators; part of Stripe since 2024.; The same tax relief as Paddle; Very quick to start; Fees close to Paddle's; A smaller platform whose future is tied to its owner's plans
- Stripe Billing with Stripe Tax The best-known processor, with billing and tax calculation as add-ons.; Lower fees in most markets; The best API and documentation, with an idempotency key on every write; Every payment method and full control of the checkout; You are the seller: register, file and pay tax in each country past its threshold; You answer disputes and chargebacks yourself; Billing and tax are extra fees on top
- Your own billing on a processor Charge cards through Adyen or Braintree and write subscriptions yourself.; Full control; The lowest fees at very large volume; Months of work: card retries, proration, invoices, tax; Every bug is a billing bug
- Trust the browser's return The page reports success after paying.; Simplest to build; Anyone with developer tools can fake it; Lost when the tab is closed
- Poll the provider's API Ask every few minutes how each subscription stands.; Never misses a change; No public endpoint; Slow to notice; Many calls and rate limits, growing with every customer
- Webhooks only Trust the provider's signed pushes and nothing else.; Instant; One call per change; Deliveries are lost, repeated and reordered; One missed event can leave a wrong state for good
- Webhooks, plus reading back when in doubt Apply signed webhooks; ask the API before an irreversible step.; Fast in the common case; Correct when a delivery is lost; Only a few extra calls; Two ways in to keep consistent - so a read-back is turned into an event and applied the same way
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.
- Tax and invoices handled worldwide for one percentage fee, instead of registrations and filings in many countries.
- No card data, ever: Paddle's checkout frame takes the card, so the app holds only Paddle's ids.
- One contract, many adapters: the product talks to one billing interface, so a provider can be replaced or run beside another during a move.
- REST, no SDK: the integration calls the provider's documented API directly.
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.
- Trial When the founder qualifies, the new organization's trial starts - a row with an end date, nothing at the provider. A personal workspace never gets one.
- Checkout Someone who manages billing buys the plan; the webhook moves the row to Active with the end of the paid period.
- Renewal Each period a payment event moves the paid-through date forward.
- Card declined The renewal fails: Past due. The provider retries the card, the plan holds through the grace, and one email asks to fix the payment.
- Recovered or let go A retry succeeds: Active again. Or the grace runs out - and before letting go, the app asks the provider whether it was paid after all.
- Falls back A personal workspace returns to the free plan; an organization to the free plan or to read-only. Nothing is deleted - reading, exporting and deleting stay open.
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.
- Charge an added seat now, not on the next bill: a charge waiting for the next bill is lost when the customer cancels first.
- Ask the provider for the amount instead of calculating it yourself. It prorates to the minute and adds tax, so the figure you show in advance is an estimate before tax.
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.
- Find the governing subscription The workspace's subscription that grants its plan now - by the one state rule - else the default free plan.
- Read the plan's rows Its features and limits come from the database, with a short cache on the feature list. A billing event applies on the next request, and a plan edit shortly after.
- Check the feature Not included? The request is refused with a clear reason - or, for a plan that lapsed, only reading, exporting and deleting stay open.
- Check the limit Count what the workspace holds or used, compare it with the limit, and refuse with how far over it would go.
- Plan names in code Quick for the first plan; A deploy for every price or limit change; Checks drift apart across the code
- Features in the provider's product metadata One place to edit, beside the price; Every check depends on the provider being reachable, or on a copy kept in step; Changing provider means moving the catalog
- Plans, features and limits as rows in your database Edited in an admin console; the provider only says which price was bought.; Prices and limits change without a deploy; The same with any provider - or with none, for plans given by hand; Fast, local checks; You keep a mapping from the provider's price ids to your plans
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.
- Store amounts before tax, with the currency and the provider's fee beside them: revenue, refunds and credits are all worked out before tax. The invoice is the provider's record of the tax.
- Keep the currency with every amount. A payment in another currency than the plan's is recorded, but never mixed into the plan's price.
- Round once, at the end, and say how - half away from zero, to the cent.
- Use the provider's own figures for what will be charged: its preview of the next bill includes tax and every seat; the catalog price times seats does not.
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.
- Seed A plan catalog and a workspace.
- Start a checkout Through the real endpoint; the fake API records the transaction it was asked to create.
- Build the event Load the documented 'subscription created' payload and give it the account reference of that transaction.
- Sign and deliver three times Sign the bytes exactly as the provider does and post them to the webhook endpoint - three times.
- Assert once One subscription, active; one event record; and a second checkout for the same workspace refused.
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.
- Make a secret Any long random string - for example from openssl rand -hex 32.
- Run a tiny receiver The server below verifies the signature over the raw bytes, refuses old timestamps and remembers event ids.
- Send a signed event Sign 'timestamp:body' with openssl and post it with curl. The answer: applied.
- Send it again The same bytes and the same signature. The answer: already seen - still 200.
- Tamper and replay Change one character of the body, or sign with a timestamp an hour old. Both are refused with 400.
Common mistakes
- Granting access on the browser's redirect: a success page can be faked or never reached. Only a verified webhook - or the provider's API - changes a plan.
- Verifying a re-serialized body: read the raw bytes before any JSON parsing, or valid deliveries fail and someone is tempted to switch the check off.
- Marking an event processed in a separate step: record the event id in the same transaction as its effect, or a crash in between loses it or applies it twice.
- Applying events in arrival order: compare the time each event happened with the last one applied; an old 'active' must never undo a newer cancellation.
- Floats for money: use decimals or whole minor units, keep the currency beside every amount, and remember the zero-decimal currencies.
- Retrying a create call after a timeout: a second transaction or refund is a second charge. Retry reads and state-setting writes; for creates, read first or use an idempotency key.
Interview questions
- What is the difference between a merchant of record and a payment processor? A processor only moves the money; you stay the legal seller and own tax, invoices and disputes. A merchant of record resells your product, so it also calculates, collects and files VAT and sales tax worldwide - for a higher fee.
- Why use a hosted checkout? The card is typed into the provider's page or frame and goes straight to the provider, so your servers never touch card data. That keeps you in the smallest PCI DSS self-assessment and removes a whole class of breaches.
- How do you verify a webhook? Read the raw body with a size limit. Recompute the HMAC over the signed timestamp and the body with the shared secret, and compare in constant time. Refuse a timestamp far from now. Parse the JSON only after it verified.
- Webhooks can arrive twice. How do you make the handler idempotent? Store the provider's event id under a unique index, in the same transaction as the change the event makes. A repeat - or a racing copy that loses the insert - finds the record and answers 200 without changing anything.
- How do you handle events that arrive out of order? Keep the time of the last applied event on the subscription, and record older events as stale without applying them. Make deliberate exceptions where they matter, such as a payment always counting for the period it paid for.
- Two servers process two events of the same subscription at once. What goes wrong, and how do you prevent it? A lost update: both read, both write, and one undoes the other. Make one subscription's events take turns with a lock that works across servers, and give the row a version so any stale write is refused and retried on fresh data.
- A call to the provider timed out. Do you retry? Reads and writes that set a state, such as a quantity, are safe to repeat. A call that creates something is not, because a second one is a second charge. For creates, use an idempotency key, or read first so the repeat finds the work already done.
- Design a subscription state machine. Use trialing, active, past due, canceled and expired. One rule says whether each grants the plan: until the trial ends, while active, through a grace period when past due, and until the paid period ends when canceled. Expired never grants. Work the state out from the provider's whole subscription, not from event names.
- Why must money never be stored in a float, and what do you use instead? Binary floating point cannot represent values like 0.1 exactly, so errors grow with every sum. Use a decimal type or whole minor units such as cents. Keep the currency beside every amount, and remember currencies with no minor unit, such as the yen.
- How do you test payments without a real account? Unit-test the signature with a fixed clock. Replay the provider's documented payloads. Put an in-memory fake behind the HTTP client that refuses what the real API refuses. Run race tests on a real database. Keep the sandbox and its test cards for the last check.
Related
- /help/technology/access
- /help/technology/data
- /help/technology/testing