The short version
- What we built:
ELN Billing, a platform that consolidates billing for multiple in-house SaaS products into one place. It wraps Stripe and handles subscriptions, coupons, one-time charges, referral rewards, and refunds through a single API family. When a payment or cancellation happens, it notifies each service through signed webhooks (automated server-to-server notifications). - Why we built it: payment code is heavy. One existing site alone had more than 30 files related to subscriptions. Copying that into every new SaaS is not realistic.
- The key point: for a consolidation service, deciding what not to own matters more than what to own. ELN Billing owns only the source of truth for "is this user subscribed"; the interpretation — "what does the subscription allow" — stays in each service. Six months of development, 172 commits, 17 spec documents; a pre-GA version is live in production today.
The full story (about a 14-minute read)
Last time I wrote about our notification platform. This is the same series: features every SaaS needs, moved out of each product and into a platform. This time, billing.
Thirty files is not something you copy
It started with counting. On one existing site, the subscription-related code alone exceeded 30 files: Stripe integration, contract state management, coupons, refund operations. Duplicating that for every new SaaS is not just a typing problem. Refund handling and billing disputes scatter across sites, and users face a different billing screen for every product.
The model we aimed for is the App Store's: payments in one place, features in each app. The platform owns payment and contract management; each service decides what the contract enables.
What ELN Billing does
From a consuming SaaS, the platform looks like this.
- Subscriptions, coupons, and one-time charges through one API family. Services carry no payment screens and no Stripe integration of their own. Need a checkout page? Send a plan ID and return URLs, get a Stripe-hosted checkout URL back. The public API for registered services spans 22 endpoints: subscription lookup and cancellation, coupon validation, credit purchase/consumption/balance, referral codes and conversion tracking, refunds.
- API keys are per service. The key identifies the calling service, and every response is automatically scoped to that service's data. Another service's contracts are invisible even with a valid key.
- Payment events arrive as signed webhooks. Fourteen event types — subscription created/updated/canceled, payment failed, credit balance low, and more — with tamper detection.
- A self-service portal ships with the platform. Users check their subscription, cancel, and apply coupons in a screen the platform owns, localized in six languages (Japanese, English, Chinese, Korean, Thai, Spanish). Services do not build their own cancellation screens.
- Operators see all revenue in one console. Services, plans, coupons, subscriptions, credits, referrals, and MRR/ARR analytics. The operator console is physically separated from the user-facing portal — different domain, different deployment.
- Services register their own plans. Each service creates and updates its plans with its own API key; plans are never created from the platform console. Plan revisions are versioned — a new plan ID chained to its predecessor, never an in-place overwrite.
The most important decision — do not own "what the plan allows"
Mid-development, one service asked us to store and enforce plan-level feature limits (say, a minimum check interval) inside the billing platform. It sounds natural — it's the billing platform, why not keep the plan-to-features table there?
I did not implement it. The moment the platform understands what a value means, it carries that service's domain knowledge. The next service brings a different limit concept, and the platform grows again. Billing sits where breakage stops payments for every service; letting per-service specifications snowball there is a future I wanted to avoid.
Instead, plans carry a free-form field that each service writes in its own vocabulary — a monitoring product writes "50 monitors max, 60-second minimum interval"; an AI workspace writes "top-up rate and unlimited artifacts." The meaning, the values, and the enforcement belong to each service; the platform distributes the bytes without interpreting them.
The boundary is also documented as prohibitions: "do not hardcode prices in individual sites," "do not define permissions or feature limits in the billing platform," "never identify plans by name-string matching — always by plan ID." Up to "is the user subscribed" is the platform; from "so what can they do" is the service. That single line is why consolidating billing did not turn the platform into a warehouse of everyone's feature specs.
Not owning authentication either
The other thing we refused to own is authentication. A billing platform knows who subscribes to what, so building its own login is tempting. We delegated it to our identity platform (ELN ID): user IDs are unified across all services, and billing only consumes the authentication result.
Saying "delegation made it easy" would be inaccurate, so let me add the cost: every new environment we spin up needs its return URL registered on the identity platform's side. Delegation removes implementation, but adds coupling to operate.
One Stripe account for everything
The Stripe topology was a compared decision too. One account per service defeats the point of consolidation — rejected. Stripe Connect changes the fee structure and complicates operations — rejected. What we chose: one Stripe account for all services, with the service ID, plan ID, and user ID burned into each payment's metadata. One Stripe dashboard shows all revenue, and every payment routes mechanically by its metadata.
Duplicate event processing is handled platform-side as well: every Stripe event ID is stored with a seven-day expiry, and re-delivered events are skipped safely. Payment events must be assumed to occasionally arrive twice.
Webhook craftsmanship — sign, retry, never drag payments down
Notifications to services are the accident-prone boundary, so the spec is nailed down tightly.
- The signature formula is written in the spec as one line. The signature covers the timestamp and body joined with a period, and three headers carry signature, timestamp, and event type. Why so explicit? A story below.
- Retries at 1 s → 5 s → 15 s, three attempts max. Only receiver-side transient failures (5xx) are retried; receiver bugs (4xx) are not. Each attempt times out at ten seconds.
- Notification failure never blocks payment processing. Even if every delivery fails, Stripe gets its acknowledgment. A notification hiccup must not push the payment itself into a redelivery loop.
"The webhooks aren't arriving" — a five-battle war
The most instructive stretch of this project was April 2026: five consecutive battles with webhooks that would not arrive.
Battle one: signature mismatch. The sender signed the body alone; the receiver expected timestamp-plus-body. Every delivery was rejected as tampered. Root cause: the spec never stated the formula, so both sides invented one. We wrote the formula into the spec and added a contract test that the two sides match.
Battle two: three layers of missing configuration. Signatures fixed, still nothing. After three wrong hypotheses, the real answer was a chain: Stripe had zero webhook endpoints registered; the hosting environment lacked the verification secret; and the receiver URL in the database still said http://localhost:3000. Any one of them silences the whole path. We banned localhost-type URLs at the API, wrote a script that verifies all three layers in one command, and made a production-setup checklist.
Battle three: fire-and-forget. One case remained. The send call was written fire-and-forget, and the serverless runtime froze the environment the moment the handler returned — cutting off in-flight sends so the SaaS never received a single request. We awaited every send and added a regression test asserting "notification resolved before handler return." Fire-and-forget breaks on serverless.
Battle four: the receiver. The last missing delivery: the receiving service lacked its secret — and its build pipeline had been broken for two days, so adding the secret changed nothing. That week produced our two standing rules: "read the receiver's response body first" and "a live end-to-end check outranks every hypothesis." A script that actually pushes a delivery through is now a mandatory deployment gate.
Guess, fix, miss — four times. The one lesson: for anything that crosses a boundary, do not claim it is fixed until you have seen both ends for real.
The discipline of money-handling code
Billing has no "mostly correct." In September 2026 we swept the payment paths end to end: 16 defects fixed, all seven payment routes verified live. Two representative fixes:
- Exactly-once referral rewards. Reward granting could double-pay under concurrency — and, worse, permanently lose a reward on a transient database error, because the "granted" flag was set before the grant and failures were swallowed. We rebuilt it on conditional writes (claim exactly one grant slot first) and verified the concurrency with integration tests against a real local database.
- Coupon service boundaries. One service's coupons validated successfully against another service's checkout — a missed application of the "scope everything by API key" rule. Boundary checks are an every-endpoint discipline; one gap is an incident.
Behind the scenes — a working whole on day one, 32,000 lines in six months
The first commit is March 1, 2026. That same day, 19 commits laid down the working skeleton: identity integration, database, Stripe, checkout, webhook processing, subscription management, operator console, revenue analytics, signed notifications, and deployment. Building the entire working shape on day one and then fixing what real usage reveals — that rhythm fits AI-driven development well.
Six months later: 172 commits, 233 TypeScript files, about 32,000 lines, 17 spec documents, 258 tests (including integration tests against a real local database) plus four browser-driven end-to-end specs. Billing earns deliberately thicker tests than our other products.
It lives in its own repository. We considered co-locating with the corporate site or the shared monorepo, and rejected both: "billing stops every SaaS when it breaks; it does not belong anywhere it can be dragged down by unrelated changes." Note the contrast with our notification platform, which chose the shared monorepo — the same word "platform" lands in different homes depending on its blast radius.
Where it stands
A pre-GA version (v0.9) went to production in September 2026, with the first SaaS running real billing through it and a second mid-integration; six services are registered to onboard, and the GA (v1.0) scope is fixed at eight features. The user portal and the operator console run separately, on separate domains and deployments.
Lessons you can take with you
- Decide what a consolidation service will not own, first. Keep the source of truth in the platform and the interpretation in the consumers, and the platform stays out of everyone's change stream. Write the prohibitions down.
- Anything that stops everything when it breaks (payments) deserves an isolated home, with dependencies pointing one way only: service → platform.
- Sign and auto-retry webhooks from day one — and put the signature formula itself in the spec. Two sides inventing formulas independently halts every delivery.
- Debug boundary failures by looking at both ends, not by hypothesis. Configuration fails as a chain, not a point — make one command verify every layer, and gate deployments on it.
- Guarantee "exactly once" for money movements with conditional writes, and prove it with concurrency tests against a real database.



Top comments (0)