One year to ship it, one day to find out it didn't work

Published: August 31, 2026 7 minutes read

📚 Want the Complete Framework?

Download the free AWS Cost Optimization Playbook: 21-page strategic guide with 5-pillar framework and 90-day action plan to reclaim the 32% cloud waste.

Get Free Playbook →

The first commit on SkillPilot is dated August 7th, 2025. The first real sale — an actual card, an actual customer, an actual euro — is dated August 30th, 2026. Just over a year between the two.

I bring up the dates because the honest version of this story doesn’t fit the “I built this in a weekend” format that gets shared around. It took a year, on purpose, because most of that year wasn’t spent writing lesson content — it was spent on decisions that are expensive to get wrong and boring to talk about: what the checkout has to guarantee before it’s allowed to touch a database, how a repository should be shaped so two apps can share a design system without becoming one app, what “access” actually means when money and enrollment are two different tables that can disagree.

Three of those decisions held up. One of them didn’t, and I only found out by using my own product for the first time.

Decision one: access comes from one table, and payment is not that table

The platform sells fixed-price course packs. The obvious design — and the one I nearly shipped — is: a payment succeeds, so grant access. One table, one write, done.

I split it into two instead. purchases is the accounting record: proof that money changed hands, kept because tax law requires it, and never consulted to decide whether someone can read a lesson. enrollments is the only thing checkAccess() reads. The two are written by the same webhook handler, in the same request, purchases first and enrollments second — and that order is the whole design.

Here’s why the order matters. If the webhook writes purchases and then dies before it reaches enrollments, the customer has paid and has no access. That’s a bad state, but it’s a recoverable one: the accounting record exists, so a reconciliation job — or a human looking at the table — can find it and fix it. If I’d written it the other way around, the failure mode would be a customer who has access and no payment record, which is a worse problem wearing a better disguise: nothing looks broken until someone asks for a refund on a purchase that was never recorded.

The same split pays for itself on the way out. A refund revokes access by deleting the enrollment row, not by editing the purchase. The purchase stays exactly as it was, marked refunded — because it happened, and pretending otherwise would be worse bookkeeping than admitting a refund occurred.

None of this is a novel idea. It’s the standard shape for any system where “did you pay” and “can you access this” need to be allowed to disagree temporarily without the disagreement being a security hole. What I’d flag for anyone building this for the first time: the decision that matters isn’t “should these be two tables” — that part is obvious once you say it out loud. It’s which one goes first, and that only becomes obvious once you’ve asked, out loud, “what happens if this write never arrives?”

Decision two: the redirect the customer sees is not the source of truth

Stripe Checkout, after a successful payment, sends the browser back to a return URL on my domain. It would be trivial to grant access right there — the customer landed on /learn/{pack}?comprado=1, so they must have paid.

They must not have. That URL is guessable, bookmarkable, and shareable. The only event I trust is the webhook — a server-to-server call, signed, that Stripe sends independently of whatever the browser does next. If the customer closes the tab the instant the payment completes, before the redirect even loads, the webhook still arrives and access is still granted. If someone tries to type that return URL from a purchase that never happened, nothing happens, because the redirect grants nothing — it just tells the browser what to display while it waits for the webhook that already fired, or is about to.

This is the textbook failure mode of every payment integration built by someone doing it for the first time, mentioned in Stripe’s own documentation, and I still nearly reached for the shortcut because it’s the first thing that works in local testing — where the webhook and the redirect arrive close enough together that the bug is invisible until it’s in production, under real network latency, with a customer who has a slow connection or a fast trigger finger on the back button.

Decision three: one repository, on purpose, and what it actually saves

SkillPilot is two Next.js applications on two Cloudflare Workers — a public marketing site and a private courses platform — sharing one design system and one content schema. The easy call is two repositories: cleaner boundaries, independent deploys, no risk of one app’s build breaking the other’s.

I went with one repository instead, and the reason isn’t “monorepos are better” as a general claim — it’s a specific bet about what this project would need to share. A shared component library that both apps render from the same source is worth nothing if the two apps can drift out of sync about which version they’re using; a shared content schema is worth nothing if a change to what a “lesson” looks like has to be manually propagated between two repos and can silently diverge. The cost of a monorepo is real — deploys get more careful, because a change to the shared package now has to go out to both apps, not one — but it’s a cost I can see and plan around. The cost of two repos slowly disagreeing about what their shared pieces mean is one I wouldn’t see until it broke something.

The trade-off I’d name for anyone considering this: a monorepo is the right call when the shared pieces are load-bearing and change together. It’s the wrong call when you’re sharing code because it’s convenient today and you haven’t yet decided whether the two things are actually one product. I was sure enough of the first case here to accept slower, more careful deploys in exchange.

The one I got wrong, and only found out by paying myself

All three of those decisions were correct on the day I made them and are still correct today. None of them are why nobody could buy anything for the first few days the pricing pages were indexed.

The actual reason: a Content Security Policy directive I wrote myself, form-action 'self', with a comment next to it explaining why it was safe — the checkout isn’t a form submitted to Stripe, the server responds with a redirect, and form-action doesn’t govern that. I was confident enough in that reasoning to write it down. Chrome disagreed. Firefox didn’t. The two browsers don’t agree on whether that directive reaches past a redirect into the navigation it causes, and the CSP spec doesn’t clearly settle it either. I develop in Chrome.

Here’s what makes this worth writing about rather than just fixing quietly: every automated signal I had said the system was correct. Close to two thousand unit tests, green. Type checking, clean across every package. A CI pipeline that ran on every push and never once failed. The server-side code that builds the Stripe session was right, fully covered, and returned the right redirect every time. What failed was a browser’s decision about whether to follow that redirect — a behavior that lives entirely in the seam between two components that were each, individually, doing exactly what their tests said they’d do.

I found it by buying something. Not a test transaction against a sandbox key — a real card, real Stripe live mode, the actual button a real customer would click. Until I did that, on August 30th, I had a fully tested product that had never been used, and those turned out to be very different claims.

What I’d take from this into the next project

Not “write more tests” — the tests were already right, about the things they could see. The lesson is narrower and less comfortable: correctness is a claim about components, and a product is a claim about the seams between them. A server that’s right and a browser that’s right can still produce a system that’s wrong, and nothing short of actually using it — with real money, in the real browser, all the way through — will tell you that.

Twelve months bought three decisions I’d make the same way again. It didn’t buy immunity from the fourth thing, the one that only shows up when a real person tries to do the real thing. I don’t think more planning would have caught it faster. I think using the product sooner would have.

I’m building this at SkillPilot, a small platform for engineers studying toward infrastructure and security certifications. The architecture above is what’s running it today.

Carlos INFANTES - The Wise CTO

About Carlos INFANTES

25 years building infrastructure at scale - from Amazon to the United Nations (193 countries) to helping Series A-B startups scale. Now providing CTO mentoring at €120/hour to bring enterprise-grade cloud expertise to growth-stage companies.