← All writing

How do you charge for your app without building billing?

The checkout is the easy half. The whole flow, why the webhook is the source of truth, and four failures that let people use what they never paid for.

How do you charge for your app without building a billing system?

Use a payment provider's hosted checkout, and treat their webhook as the only source of truth about what someone has paid for. That is the whole architecture. The mistake that costs people real money is thinking the checkout is the feature, when the checkout is perhaps a fifth of it. The other four fifths is keeping your idea of who has paid in sync with reality, forever, without anyone at a keyboard.

Here is the complete flow and the four ways it breaks.

The flow, end to end

  1. Someone clicks "Subscribe".
  2. Your server creates a checkout session with the provider and redirects them to it.
  3. They pay on the provider's page. Their card details never touch your servers, which is the single biggest reason to do it this way.
  4. The provider redirects them back to a success page.
  5. Separately, the provider sends your server a webhook saying the subscription started.
  6. Your server updates its records when the webhook arrives, not when the redirect lands.
  7. Every time someone tries to use a paid feature, you check those records.

Steps 5 and 6 are the ones people collapse into step 4, and that is where the trouble starts.

Why the webhook is the source of truth

The redirect is a courtesy. The webhook is the fact.

Consider what happens when the redirect does not arrive. The customer pays and closes the tab. Their phone loses signal at the moment of return. The browser crashes. A corporate proxy eats it.

In every one of those cases the money has moved and the customer has nothing, because your only record of the payment was a page they never loaded. They will write to support, correctly annoyed, and you will fix it by hand.

The webhook has none of those failure modes. It is a server-to-server call from the payment provider, retried on failure until it succeeds. Build on it, and use the success page for nothing more than saying thank you.

The four failures

1. Not verifying the signature

Your webhook endpoint is a public URL that writes to your database. Without signature verification, anyone who finds it can post a message saying "this account has an active subscription", and your app will believe it.

There is no error state for this. It works exactly as designed, for everyone, including people who have not paid. Every provider signs its webhooks and gives you a secret to check against. Use it, and reject anything that fails.

2. Not handling the same event twice

Payment providers retry. If your endpoint is slow, or returns an error, or the network hiccups, the same event arrives again. This is not an edge case, it is the documented design, and it happens most during outages when retries pile up.

So handling an event twice has to do exactly what handling it once did. Record the event id and skip ones you have seen, or write the update so that applying it repeatedly changes nothing after the first time. Either works. Neither is optional.

3. Storing "paid" as a boolean

account.paid = true seems reasonable on the day of purchase and is wrong by the end of the month.

Subscriptions change state on their own: they renew, they fail to renew, they get cancelled at the end of a period, they get refunded, they get disputed. What you need to store is the current state and the date it runs until, and then answer "may this account use the paid feature?" by consulting both.

The concrete version: store the status and the period end, and let the webhook keep them current. Do not compute access once and cache it in a boolean nobody updates.

4. Checking entitlement in the interface only

Hiding the button is not access control. If the only thing stopping an unpaid account from using a paid feature is that the link is not rendered, then the feature is available to anyone who types the address.

The check belongs on the server, on the request path, in one place that every paid feature shares. One line in a controller, not a condition scattered through your screens.

Subscriptions or one-time payments?

Briefly, because it changes what you build.

One-time is simpler: it either happened or it did not, and there is no ongoing state to track.

Subscriptions are more work because of everything in failure three, but they are what most products need, and the machinery you build is the same machinery you would need for renewals anyway.

Usage-based is a different problem entirely, involving metering and reporting, and it is worth being sure you need it before taking it on.

Charging inside a mobile app

If your product has an iPhone or Android app and you sell digital goods, both stores require you to use their in-app purchase system, and both take a cut of up to 30%.

This is not a technical detail, it is the largest line item in what it costs to run an app. It also means a second purchase path with its own receipts and its own webhooks, arriving at the same question: is this account currently entitled?

The way to keep that manageable is to make entitlement a single concept in your codebase that both paths write to, rather than two parallel systems that disagree. There is more on that in getting your web app into the app stores.

How to test any of this

The thing that makes billing miserable to work on is that exercising it usually means real money, or a test-mode account and a lot of clicking.

The alternative is a seam: a real client when credentials are present, and a deterministic local stand-in when they are not. The same code path, a different implementation behind it. That means the entire purchase flow runs in process on your laptop, and your test suite can exercise subscribe, cancel, and expire without touching the network.

It is also what lets a new developer, or a coding agent, run and modify the billing flow on the first day without anyone handing them a key.

The checklist

  • Hosted checkout, so card details never reach your servers.
  • The webhook is the source of truth. The redirect is a thank-you page.
  • Verify signatures. Reject anything unsigned.
  • Handle duplicate delivery. The same event twice must change nothing the second time.
  • Store status and period end, not a boolean.
  • Check entitlement on the server, in one shared place.
  • Have a local stand-in so the flow is testable without money.

This is one of the four things worth not building yourself, because a webhook that skips signature verification looks exactly like one that does not.

Keep reading