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](/blog/what-it-costs-to-start). 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](/blog/your-app-in-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](/blog/what-your-agent-should-not-build), because a webhook that skips signature
verification looks exactly like one that does not.
