Skip to content
UdriaUdria

Library · How we built it

218 Requirements: Building Production Stripe Billing in the Open

A progress report with the real counter, four bugs that only appear under concurrency, and the parts that are not built yet.

By Roman GrigorievUpdated 24 September 202616 min read

Written during the build, and kept. This started as a progress report published while the counter below still read 205 and the release gate was red. Production Stripe Billing shipped on 22 September 2026 and is on sale now. I have updated the numbers and the status, and left the engineering exactly as it was — the interesting part was never the score.

I published the counter because the most common reaction to a $199 specification package is a reasonable one: my agent could write this in an afternoon. The honest answer is that your agent can write the happy path in an afternoon. What follows is what the rest of it looks like.

The counter

Production Stripe Billing 1.0.0 — released

P0 requirements: 218
  DONE     217
  PARTIAL    1
  TODO       0
  BLOCKED    0

RELEASE GATE: PASS

The four blockers that kept it red are gone: both reference repositories were chosen, frozen and installed into; the Stripe sandbox tier has run; the terms went through legal review and are in force; and the version is 1.0.0.

One requirement is still PARTIAL, and it is expected to stay that way. REQ-SEC-002 asks whether your handlers call canManageBilling on every billing mutation path. That is a property of code this package never sees. No test here can prove it, agent/REVIEW.md says so plainly, and marking it done would be the exact dishonesty the gate exists to prevent.

The gate prints all of that on every run and exits non-zero when it is not true. It is designed so that an unfinished capability cannot look finished — including to me.

What a "requirement" means here

Not a task. Not a user story. A requirement is one behaviour that must be true, with a traceability row naming the file that implements it, the test that proves it, and how it was verified:

requirement_id, priority, implementation_path, documentation_path,
test_id, test_tier, verification_method, status, last_verified_commit

There are three verification methods: automated, manual_gate, inspection. A requirement marked automated and DONE with no test named is a release-gate failure. That rule exists because "done" is the easiest word in software to say and the hardest to check.

218 of those cover: repository preflight, host capability probing, the plan catalogue, Stripe Customer mapping, monthly and annual subscriptions, card-upfront trials, Checkout, flexible billing mode, the local projection, entitlements, outbound idempotency, the webhook inbox, durable sync jobs, a scheduled worker, signature and replay protection, current-state reconciliation, immediate upgrades, SCA recovery, scheduled downgrades, interval changes, proration preview, failed-payment access states, cancellation, reactivation, terminal cancellation, the Customer Portal, refund and dispute observability, dead-letter alerting, tenant authorisation, redirect safety, row-level security, billing UI states, four tiers of tests, Stripe tooling, agent safety and rollback, two reference installs, secure delivery, and the update mechanism.

Stripe provides none of that. Stripe provides payments, and it provides them extremely well. Everything above is the seam between Stripe and one particular application — which is exactly the part Stripe cannot know about your domain model.

Why the webhook endpoint does almost nothing

Stripe does not guarantee the order events arrive in. A subscription updated twice in the same second can deliver those two events in either order, and a retry can deliver a three-day-old event after a fresh one.

The tempting design is to claim the event and apply the change in one transaction. It looks atomic and it removes a moving part. It is also wrong in three compounding ways: Stripe asks endpoints to return 2xx quickly, before complex work; applying the change sometimes requires reading current Stripe state, and a database transaction must not stay open across a network call; and it makes arrival order load-bearing, which means inventing a universal rule for deciding which snapshot is newer. Every such rule is wrong in some case involving a retry, a backdated event, or two objects touched in the same second.

So the endpoint does exactly three things. It verifies the signature against the raw body. In one short transaction it records the event and enqueues a durable job. Then it returns 2xx.

A background worker later retrieves current Stripe state and projects that. Events become change signals rather than sources of truth, and the ordering problem stops existing rather than being solved. A newer webhook arriving mid-sync simply asks for another sync.

That decision costs a page of prose and buys the removal of an entire category of bug. It also produces one of the more useful lines in the specification, which is about the dedupe pattern almost everyone writes first:

insert into billing_events (id, ...) on conflict (id) do nothing
-- zero rows means we have seen this event; acknowledge 200 and stop

Trace a transient failure through it. The first delivery inserts the row. The business effect then fails — a pool timeout, a failover, a deadlock — so the endpoint correctly returns non-2xx and Stripe retries. The retry inserts, conflicts, gets zero rows, concludes "duplicate", and returns 200. Stripe is satisfied and stops retrying. The event has been processed by nobody. A subscription is never activated, a customer never receives what they paid for, and nothing errors anywhere, because every individual component did what it was told.

The fix is not a second flag. It is writing the receipt and the work in the same transaction, so that "we have seen this" and "work exists" cannot diverge.

Four bugs that only exist when two people click at once

The most useful thing I can show you is not the architecture. It is the specific mistakes that were sitting in code I had written carefully, reviewed, and covered with a passing test suite.

All four were found in a single afternoon, by pointing the tests at a real Postgres server instead of an in-process one.

1. A transaction that cannot report its own failure

The rule is that one billing principal may have at most one blocking subscription-lifecycle operation at a time — you cannot start a cancellation while an upgrade is still resolving. It is enforced by a partial unique index, so the database is the arbiter rather than application logic.

The admission code caught the unique violation and then asked the database which operation was blocking:

try {
  await tx.query(`insert into billing_operations ...`);
} catch (err) {
  if (!isUniqueViolation(err)) throw err;
  const blocking = await findBlockingOperation(tx, principalId);
  return { admitted: false, blocking };
}

That cannot work. In PostgreSQL a constraint violation aborts the entire transaction. The recovery query runs against a transaction that is already dead, and the caller gets an unhandled error instead of a clean "another change is in progress".

Single-threaded, the conflict never fires. The catch block never executes. Every test passes. The bug is invisible until two real requests race — which, for a billing endpoint, means it is invisible until it matters.

The fix is to stop using exceptions for a condition that is not exceptional:

insert into billing_operations (...) values (...)
on conflict do nothing
returning *

Zero rows back, then work out which constraint it was, with the transaction still usable.

2. Asking the wrong question about whether a row was inserted

The sync queue coalesces signals: if a webhook arrives for an account that already has work queued, it folds onto the existing job rather than creating a second one. The statement does both in one shot, and reports which happened:

insert into billing_sync_jobs (...) values (...)
on conflict (billing_principal_id) where status in ('pending','processing','retryable')
do update set resync_requested = ...
returning id, (xmax = 0) as created

xmax = 0 is a widely repeated trick for distinguishing an insert from an update in an upsert. It is also unreliable in exactly this case. ON CONFLICT uses speculative insertion, which borrows xmax as its token, so a genuinely inserted row can come back reporting itself as a coalesce.

The question was always simpler than the trick. We supplied the id; the statement returns an id. If they match, we inserted:

return { jobId: row.id, created: row.id === input.jobId };

3. The worker that wakes up and overwrites its replacement

A background worker claims an account's sync job under a five-minute lease. If the process stalls — a slow Stripe call, a container paused, a deploy — the lease expires and another worker takes over.

The subtlety is that the first worker is not dead. It is holding a snapshot of Stripe state from before the stall, and when it comes back it will try to write it. Serialisation does not help: by the time it wakes, it no longer holds anything.

Every claim and every reclaim increments a lease_epoch. The worker carries the epoch it was given, and the transaction that commits the projection re-checks it immediately before writing:

select id from billing_sync_jobs
where id = $1 and status = 'processing'
  and lease_epoch = $2 and locked_until > $3
for update

If the epoch has moved, the write is void. The stale worker commits nothing.

Then a second-order problem: a fenced write has no symptom. Nothing errors. The only visible effect is that state converges slightly later than it should, which nobody notices. So fenced writes are counted, and an operator is told when the count crosses a threshold — because the real meaning of routine fencing is that five minutes is too short for that installation's actual latency, and there is no other way to learn it.

4. Test isolation that was not isolating anything

This one was mine, in the harness rather than the product, and it is the one I would most like other people to avoid.

Each test run created a randomly named schema and pinned it with the search_path connection parameter, so runs could not interfere with each other. Standard approach. It is silently ignored by the provider I was using:

current_schema: public | search_path: "$user", public

Every run had been sharing one set of tables. The drop schema at the end cleaned up nothing. Two tests then failed on rows inherited from an earlier run, and I spent a while carefully explaining behaviour that was an artefact of my own test setup.

Isolation is now by truncation, which is cruder and cannot silently not work — and it is verified immediately afterwards rather than assumed:

if (Number(leftover) !== 0) {
  throw new Error("Test database is not clean after truncate. Refusing to run.");
}

That is the general lesson, and it is worth more than the specific fix: a safety mechanism that can fail silently is not a safety mechanism. It is a comment with a database connection.

Tests that refuse to pass

Five tests prove the concurrency invariants — two simultaneous requests, one winner. They need two real database connections. An in-process engine has one, so it would run both halves in sequence and report a pass for a property that was never exercised.

They skip instead:

Tests  76 passed | 5 skipped

A skip is visible. A false green is not. The release gate treats the whole suite as unproven until a real server has run them, and refuses to ship on that basis.

The same rule applies inside the host-contract suite. One check asks whether select ... for update actually blocks a competing transaction. On a single connection it cannot be answered, so it reports:

SKIP  Row locks serialise concurrent writers
      No concurrent-capable connection was provided. Not asserted — an
      in-process engine would run both halves in sequence and report a
      pass for a property never exercised

Twelve of those checks run against whatever the host actually implements, and they are deliberately hostile. Calling a function and watching it return only proves the function exists. Each check tries to make the contract do the wrong thing and requires a refusal:

  • canManageBilling must refuse a foreign principal, not merely grant the right one — a predicate that returns true for everything passes the naive test.
  • transaction must actually roll back, which a transaction-mode pooler handing consecutive statements to different backends does not.
  • The scheduler must report a proven interval; "unproven" fails rather than being taken on trust.
  • The logger must not emit a field named like a secret.
  • The domain must not import the Stripe SDK — scanned, not asserted.

And seven further tests bind a deliberately broken host and assert the checks catch it. A conformance suite that has only ever seen a correct host cannot tell you whether it would catch an incorrect one.

The decisions nobody wants to make twice

Some of the 218 are not code at all. They are decisions that are cheap to make once, carefully, and expensive to make badly under time pressure.

A cancelled subscription is not always "no access". A customer who switches to your free plan and a customer who churns both end up canceled in Stripe. The distinction has to survive on a persisted termination_reason, because the pending_* fields that described the transition are cleared exactly when it completes. Rely on those and your projection resolves a free-plan customer to ended the moment it becomes correct to call them free.

An unrecognised price is neither a yes nor a no. An operator changes something in the Stripe Dashboard and the subscription's price no longer maps to any configured plan. Granting entitlements from it is privilege escalation. Revoking access from a customer who is demonstrably paying is destructive deprovisioning. The rule is to preserve the last safe configuration, flag the account for review, and block further changes — and to fall back to restricted only when there is no safe prior plan to preserve.

A quote is not permission to charge an arbitrary later state. Stripe will tell you exactly what an upgrade costs. The failure is quoting one number and charging another, and there are two independent ways to get there.

The first is parameter drift. Every input that affects the amount — the items, the proration behaviour, the proration date, the billing-cycle anchor, the billing mode — must be identical between the preview call and the update that follows it. A monthly-to-annual change resets the anchor; a preview that omits the anchor computes a different scenario from the one the customer will be charged for. The specification requires both payloads to be derived from one internal structure, so divergence is structurally impossible rather than forbidden by a rule somebody has to remember.

The second is time. A quote shown at 23:58 and confirmed at 00:01 may have crossed a renewal boundary, and the stored proration date is now outside the current period. Sending it anyway produces a provider validation error, which must never reach the customer as a raw exception. The correct behaviour is to notice before calling Stripe, produce a fresh quote, and require fresh confirmation — applying nothing in that request.

Neither of those is difficult once stated. Both are easy to miss, and the symptom of missing them is a customer charged an amount they never agreed to.

Review is not an access state. Those are separate dimensions. Collapsing them produces either a hybrid state that deprovisions paying customers, or a flag nobody acts on.

Whoever may manage billing may not declare what Stripe did. When an operation cannot be resolved automatically, someone has to establish whether money actually moved. That is a different authority from "may change this organisation's plan". Reusing the tenant's permission for it is a release-gate failure, and the conformance suite fails a host that wires the same function to both.

That last one has a corollary worth stating plainly. The function that resolves a stuck operation takes no force flag. It takes evidence, and it maps that evidence to exactly three outcomes: proven applied, proven expired, proven not applied. Ambiguous evidence leaves the operation stuck. The failure mode being designed against is an operator under pressure clearing a blocker to unstick a customer — and the blocker exists precisely because nobody yet knows whether that customer was charged.

Absence of evidence is not evidence of failure. That sentence is in the specification because it is the thing a tired person gets wrong at 11pm.

What the tests found when they finally ran against Stripe

Tiers 2 and 3 — the ones that need a real Stripe sandbox — were written weeks before there was a key to run them with. They skipped, visibly, with a marker test asserting the tier was unproven. A false green is worse than a stated gap.

The first real run failed. Three product bugs, none of which any amount of offline testing could have found, because the offline tests asserted payload shapes against an API that does not accept them:

  • billing_mode was sent on both the preview and the apply. Stripe takes it only at creation: the preview replies that the subscription's own mode will be used, the update replies that the parameter is unknown.
  • proration_date was sent alongside billing_cycle_anchor=now, which Stripe rejects — resetting the anchor is the proration instant. The path that does this is monthly-to-annual, the most likely upgrade a customer ever makes.
  • The test fixture built subscriptions in a different billing mode from the one the domain builds, by hand-writing a constant that already existed. A test failed against its own fixture.

Then a fourth, in the suite rather than the product: acceptance tests used fixed Stripe idempotency keys. Those are scoped to the endpoint they are first used against, so the suite passed once on a fresh account and failed on every run afterwards. That is the shape of failure that teaches people to re-run a suite instead of reading it.

I am listing them because this is the argument for the tier existing. Four defects, found in one afternoon, by tests that had been sitting there unable to prove anything.

What two reference installs caught

The specification asks for two, and it is specific about why: the second must differ on named host contracts, because two similar repositories prove nothing.

The pair is cal.com and rallly, frozen at a commit each. The install is not a simulation — it applies their own migration files, 595 and 154 of them, producing their real schemas, then applies the billing schema on top and runs the conformance suite against the result.

Two differences justified the second install on their own.

Identifier type. cal.com's ids are integers. rallly's are text. A binding that quietly assumed integers would have passed every check against the primary and failed against the secondary. That is exactly the class of assumption one reference install cannot catch.

Where the authority lives. In cal.com, owning a team is a membership role, so reading the membership table answers the whole question. In rallly the owner is a column on the space and holds no membership row at all. A binding that read only the membership table would have refused the one person who certainly may spend the money — and would have looked correct against the primary.

What is not built yet

Being specific about this matters more than being encouraging about it.

Eleven requirements remain, and none of them is a hard engineering problem. Seven are commercial terms that clear when the sale opens. Five are the delivery pipeline, which is written and waiting on a storage bucket. One is an inspection that happens during a real installation rather than in a test.

Also not in v1, and named so nobody buys expecting them: seat enforcement, no-card trials, Stripe Tax, coupons and promotion codes, non-card recurring methods, multi-currency, usage-based billing, Stripe Connect.

That list is generated from the same file that generates the manifest and feeds the product page, and a test fails if a public page claims something that is not in it. It failed for months, because the site promised seat accounting in eight places across four pages. It passes now. The claims were corrected, not the test.

So what is the $199 for

Not the code. Your agent writes the code, in your repository, in your conventions.

It is for the part that does not come from generating code quickly: knowing that a webhook endpoint which treats "seen" as "processed" will silently drop a subscription activation. Knowing that a worker's expired lease does not make it harmless. Knowing that a preview which omits the billing-cycle anchor computes a different scenario from the one that will be charged. Knowing that an unrecognised price is neither a grant nor a revoke.

Each of those is one requirement out of 218. Individually, each is a paragraph. Collectively they are the difference between billing that works and billing that works until two customers click at the same moment.

The work is real, it is measurable, and I would rather show you a counter that moves than a finished-looking page that has never had two connections pointed at it.

The first version of this article said 79. It was written when the sandbox tiers could not run, before anything had been installed into somebody else's repository, and before a public page had been checked against what the package actually ships. Every one of those gaps closed by finding something wrong.

I will publish the counter again when it reads 218.

Sources

Was this useful?

Related reading