I ship a lot of small products. Browser extensions, little SaaS tools, one game. Most of them live on their own subdomain and do exactly one job.
For a long time the worst part of starting a new one wasn't the product. It was billing. Bank verification, ID verification, waiting for approval, recreating the same plans, wiring the same webhooks, testing the same four subscription states. Every single time. I got good at it the way you get good at anything you resent.
So I stopped doing it per product and did it once for all of them. Here's the shape that fell out, including the part I had wrong for months.
The thing I had wrong
Paddle has a list of approved domains. My assumption was that every site taking money had to be on that list, which meant a review round per subdomain, forever.
That's not what the list gates. Approved domains gate the Paddle.js checkout overlay running in a browser. That's it. The server side doesn't care:
- webhook signature verification: not domain gated
- creating a customer portal session with the API key: not domain gated
- your own internal endpoints receiving forwarded events: obviously not domain gated
Exactly one thing in the whole flow has to happen on an approved domain, and it's the moment the overlay opens. Everything else can live wherever you want.
Once I saw that, the design was basically forced.
The shape
One payment account. One approved domain, the apex. One webhook endpoint, on that apex, for the entire family:
Paddle ──webhook──> apex.example.com/api/webhook/paddle
│
├─ verify signature
├─ read custom_data.site
└─ route:
own event → handle locally
other site → forward raw event to that site
unknown → 200 and drop it
Two rules make this hold up, and both are about what the shared piece refuses to know.
The dispatcher does not know a single price ID. It verifies the signature, reads one field, and forwards the raw snake_case event onward. Mapping a price to a plan, granting credits, writing to a subscription table: all of that lives in whichever site owns the event. The dispatcher stays a router. If I ever have to open it to add a product, I've broken it.
An unknown site gets a 200, not an error. This one I learned the annoying way. Return a 500 for an event you can't route and the provider retries it, on a backoff that outlives your patience. Acknowledge and drop.
Each satellite exposes one internal endpoint that takes { env, event } behind a shared bearer secret compared with timingSafeEqual. It does not re-verify the Paddle signature, because the apex already did, and it never receives webhooks directly. Old direct routes are still there as stubs that return 410, so a stale config in some dashboard fails loudly instead of half-working.
Checkout, on a domain Paddle never approved
The satellite can't open the overlay on its own domain. So it doesn't. It hands checkout off to the apex and gets out of the way.
The satellite signs a short-lived token and redirects:
type HandoffPayload = {
site: string // which satellite this came from
userId: string // that satellite's user id, not a shared one
email: string
items: { priceId: string; qty: number }[]
env: "sandbox" | "production"
successUrl: string // where to land after paying
exp: number // <= 5 minutes out
nonce: string
}
base64url(JSON) + "." + base64url(HMAC-SHA256), then a 302 to the apex. The apex verifies, opens the overlay with exactly what's in the token, and the user pays on an approved domain without noticing the trip.
Four things in there are load-bearing, and three are the kind you only add after imagining how you'd attack it yourself:
-
expunder five minutes plus anonce. I don't store nonces server side. Real replay protection for money is webhook idempotency keyed on event id, and that already exists downstream. The nonce just keeps two tokens from being byte-identical. -
successUrlis checked against a host allowlist. Sign a redirect target and forget to validate it and you've built an open redirect with a signature on it, which is worse than a plain one because it looks trustworthy. The host has to end in a domain I own. -
envtravels inside the token. The apex picks its sandbox or production client token from what the token says, not from whatever the apex happens to be running. Otherwise a sandbox test eventually opens a production overlay and you find out about it from a real charge. -
timingSafeEqual, not===. Cheap, and there's no reason not to.
Subscription management works the same way. The satellite looks up the customer and subscription ids it already has, signs a different payload, posts it to the apex, and the apex (the only place holding the API key) creates the hosted portal session and returns the URL. The key has never been anywhere else, so there is one secret to rotate instead of one per site.
The part I like: the apex proxy is completely generic. It doesn't know what any satellite sells. Adding a new one means copying two small endpoints into the satellite and adding a line to a registry. No business logic anywhere near the shared code.
What it costs
This isn't free, and the price isn't technical.
The merchant of record is the parent account for every product. The checkout form, the invoice and the receipt all carry the parent's name, and there is no white-labeling that away. So a satellite can't present itself as an independent company. It has to be honest about being one product from a group, and the handoff page has to read as "one secure checkout for all of these", because that is what it is.
If your plan is for each product to look like a separate business, stop here. This design won't do it.
The other cost is smaller and dumber. Two of my satellites have env selector variables that are spelled differently, because I wrote them weeks apart. Nothing detects that. It's just sitting there, waiting to confuse me at a bad time.
The part that generalizes
The provider specifics fade. What held up is the constraint I put on the shared component: it is not allowed to know anything product-specific. No price ids, no plan names, no per-site branches. The second the shared piece needs an if for one product, it stops being infrastructure and becomes a thing you maintain per product, which is the exact problem you built it to avoid.
Also: read what a vendor's restriction actually restricts. I spent months routing around a domain review that only ever applied to one line of browser code.
Written with AI assistance for the English drafting. The architecture, the decisions and the mistakes are mine.