Skip to content

All writing

The webhook that never fired: shipping payments for a real business as a solo student

6 min read

Intro

On May 23, 2025 I made the first commit to the website for CYD Soccer Academy, a youth soccer academy in Calgary. I've been its only engineer since: registration, payments, texts and email all run through code I wrote.

Seventeen months and 1,156 commits later, I'm still the only engineer on it. This post isn't really about the stack. It's about what changes when the code you write takes money from real families, and there's nobody else on the team to catch your mistakes.

What the business needed

The job sounds simple. A parent picks a program, a gym and a schedule, then pays in full or in monthly instalments. They get a confirmation email and a text, and their kid shows up on the right roster. Staff need to see who paid and who's coming.

Today that covers about 300 active families across five gyms in Calgary. Registrations land in Google Sheets, a primary and a backup, because that's where the staff already work. In November 2025 I moved registrations off Supabase and went Sheets-only. Every paid signup runs through one pipeline: sheet rows, the owner's sale notice, the welcome email, the confirmation text and the roster add. When a family pays in person, the admin page runs exactly the same steps, so they end up looking the same as someone who paid online.

Most of what I learned came from the money part.

Lesson 1: A webhook that isn't there doesn't complain

An instalment plan is a Stripe subscription, and Stripe will bill a subscription forever unless something tells it to stop. My webhook did that: on every paid invoice it counted the payments and stopped the plan at the limit. The logic was right. But the endpoint was never registered in Stripe, so it never ran. Nothing errored, because nothing happened.

The first sign showed up in May 2026. Families who had already paid kept getting the "your spot isn't reserved" text sequence. I fixed that by making the texting job ask Stripe directly who had paid, instead of waiting for a webhook. But I didn't follow the thread to the other thing that webhook was supposed to do. In July 2026, five families were charged past the end of their plans. The academy's owner handled those five with the families directly, and the fix I shipped afterwards is built so it can never touch a plan that was already running.

The fix was a nightly job, at 3 AM Calgary time, that asks Stripe which plans have taken every payment they were sold for and stops them. A few choices I'm glad I made:

  • It shipped in report-only mode: it emails the list and changes nothing until someone arms it.

  • It stops a plan at the end of the period, not right away, so nobody loses time they already paid for.

  • It never touches plans that were already running before it existed. The owner was settling those family by family, and a test checks that the job refuses to touch them even when it's armed.

The idea I took away: a system that waits to be pushed fails silently. One that pulls fails loudly.

Lesson 2: "Exactly once" needs a lock, not a hope

Duplicates followed me the whole way. In August 2025 a double-click could submit a registration twice. In September, the form's autosave wrote a new sheet row for every step, and cleaning up one export took it from more than 160 rows down to 24 unique entries.

The expensive one came in July 2026, with the Spain trip. A parent paid a deposit, then the confirmation page created their instalment subscription. I was counting on Stripe's idempotency key to stop that from happening twice. Those keys expire after 24 hours. A parent who reopened their confirmation page days later got a second subscription, and two families were double-billed. Now the session id is stored with the sheet row, and before creating a plan the code checks whether any subscription, canceled ones included, already carries that deposit's id.

In October 2026 I built one shared "record this paid checkout once" claim that the registration save and the newer checkouts all go through. My first version wrote a unique value into the payment's Stripe metadata and read it back. In a test that fired ten saves in parallel, all ten won, because every request read the value as empty before any write landed. The fix, 32 minutes later, made the write itself the lock:

ts
// Fixed idempotency key, per-call nonce in the body. A rival request sends
// the same key with a different body, so Stripe rejects it.
const nonce = `${new Date().toISOString()}|${via}|${Math.random().toString(36).slice(2, 10)}`;
try {
  await writeFlag(nonce, `cyd-claim:${objectId}:${key}:${generation}`);
} catch (err) {
  if (lostTheRace(err)) return { status: 'already' };
  throw err;
}
if ((await readMetadata())[key] !== nonce) return { status: 'already' };

Stripe takes the first request under that key. Everyone else gets an idempotency error, which the code reads as "already done."

Lesson 3: The browser doesn't get to set the price

On October 17, 2026 I sat down and reviewed every path money takes through the app. What I found:

  • The route that saves a registration would take one without proof of payment. Now it needs a real, paid Stripe Checkout session from the site, paid with the same email, saved once per child. The plan, price and gym come from that session, not from the form.

  • The checkout route took the price from the page and only checked that it was in a sane range. Now the server prices the plan with the same function the payment page uses, and a 313-case test checks that the two always agree.

  • An old payment route charged whatever amount it was sent. Nothing called it anymore, so I deleted it.

  • Public endpoints let anyone add rows to the sheets, including the analytics tab behind the revenue dashboard. They're gone.

The same day I added signature checks to the Twilio webhooks (any request without a valid signature gets a 403) and escaping for anything a parent types before it reaches a spreadsheet cell. The rule I follow now: anything the browser sends is a suggestion.

Lesson 4: Send the alert first

When a parent texts the academy back, a webhook files the message into its conversation thread and alerts the phone that answers parents. In August 2026 I found that the alert went out last. Filing a message from an unknown number could take up to 50 API calls in a row, on an account with 567 threads, which was long enough to hit the webhook timeout, and the alert died with it. Two of the four replies that week never reached anyone. Now the alert goes out first, on its own, and the filing is best-effort after it.

The flip side is noise. The nightly plan report only emails when there's something to act on, something broke, or the job is armed, because a silent "all clear" every night trains people to ignore it. The owner's failure texts from checkout now fire only on real Stripe outages, not on bad requests.

What I'd do differently

  • Register and test the webhook on day one, then keep checking that events actually arrive. "No events in a week" should be an alert.

  • Price on the server from the first commit. The October payments fix ended up as one commit touching 33 files.

  • Review security on a schedule. My big review came 17 months in. About 937 of my 1,156 commits were co-written with an AI coding assistant. That's how one person ships this much, but it also means a lot of code got merged with no second reviewer. Regular reviews are the cheapest fix for that.

  • Treat the spreadsheet as a view, not the database. Sheets is where the staff live, and that was the right call for them. But a sheet has no unique constraint, so every "only once" rule had to be built by hand in code. Next time the records go in a real database and the sheet reads from it.

Final thoughts

None of these bugs were clever. They were a webhook nobody registered, a key that expired, a lock that wasn't a lock, and an alert sent in the wrong order. What they had in common is that none of them made any noise. Owning a system like this mostly means building the thing that makes noise when something goes wrong.