A working prototype feels like 90 percent of the work. It is closer to 20. The remaining 80 percent is the part no demo shows, and it is the part that decides whether a stranger can pay you without you watching.
Why the gap is invisible while you build
When you build with an AI coding agent, the feature code is the fast part. You describe a feature, the agent produces it, you run it, it works on your machine. That loop is so quick it reshapes your sense of progress. You feel close to done because you can see the thing working.
But "working on your machine" is a demo. A demo does not have to handle the webhook that arrives twice. A demo does not need a deploy that keeps existing users online. A demo does not refund anyone. The gap is everything the demo never had to do, and it is the work that decides whether a stranger can use what you built without you watching.
What lives in the gap
After shipping several products this way, here is the list that actually separates a prototype from something that takes payments on a Sunday you did not plan to be working.
Retries and idempotency. Stripe, your email provider, and half your integrations retry failed requests. The first retry is not a failure. It is the normal behavior of a distributed system. If your webhook handler runs twice on the same event and grants access twice, or charges twice, or sends twice, you have a bug that only appears in production, where retries are routine. Idempotency is the fix, and it is not optional when money is involved.
Here is the pattern in practice, using a Stripe checkout webhook:
func handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
event, err := webhook.ConstructEvent(body, r.Header.Get("Stripe-Signature"), secret)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
// Check for duplicate before doing anything.
processed, err := db.EventExists(event.ID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if processed {
w.WriteHeader(http.StatusOK)
return
}
// Handle the event type.
switch event.Type {
case "checkout.session.completed":
var session stripe.CheckoutSession
json.Unmarshal(event.Data.Raw, &session)
db.GrantAccess(session.ClientReferenceID)
}
// Mark processed last. On crash, retry replays safely.
db.MarkEventProcessed(event.ID)
w.WriteHeader(http.StatusOK)
}
The order matters: check first, do the work, mark processed last. If the process crashes after granting access but before marking, the retry checks, finds no mark, runs again, and grants access twice. Wrap the two writes in a transaction to prevent this. The pattern is the same in any language: the event ID is the deduplication key, and you must treat every webhook as callable more than once.
Deploys that do not break the present. A deploy that drops in-flight requests, serves a half-migrated schema, or kicks every logged-in user out is a prototype-grade deploy. Production deploys need a story for migrations, for old code reading new data, and for the user mid-request. Most of this is not code. It is order of operations, and the testing you do before the deploy is the difference between a quiet rollout and a support inbox that fills while you are away from the keyboard.
Access and refunds from day one. A prototype grants access by hand. A product needs the access flow, the refund flow, and the cancellation flow designed before launch, not after the first support email. Refunds in particular are a feature you build once and hope to rarely run. The day you need it and it is missing, you have an angry customer and no clean way to reverse the charge.
Failure modes with a home. A declined card, a webhook that arrives twice, a rate-limited third-party API, a form submitted with missing required fields. Each one either recovers on its own or shows the user a clear next step. A 500 page is not a failure mode with a home. It is a confession that you stopped thinking about that path.
Observability before you need it. You will not know a webhook is failing until a customer tells you, unless something is watching. One error tracker, one uptime check, one log you actually read. You do not need a dashboard with eight panels. You need to know, within minutes, that something is wrong, and you need a place to look when it is.
Why AI widens the gap instead of closing it
This is the counterintuitive part. AI coding agents make the prototype faster, which means they make the gap a larger fraction of your total time. A prototype that used to take two weeks now takes an afternoon. The gap has not shrunk. It still takes the same hours to make a webhook idempotent, to design a refund flow, to wire an error tracker. So the gap goes from being 50 percent of the work to being 90 percent of the work, and it feels worse because the first 10 percent was so fast.
The agent also cannot close the gap for you, because the gap is judgment, not code generation. The agent can write an idempotency check once you tell it the event ID is the deduplication key. It cannot decide for you that you need one. That decision is the work, and it is the part the demos skip.
The launch test
Here is the test to run before calling something shipped. If any of these is missing, you have a demo, not a product.
A stranger can pay without you intervening. The full purchase path runs end to end: pick a plan, enter a card, complete, get access. Test it with a fresh browser, no saved login, a real test card. If the path needs you to send a setup email, it is not done.
Failure modes have a home. A declined card, an expired session, a rate-limited API. Every failure path either recovers on its own or shows the user a clear next step. No bare 500 pages, no silent failures.
You can deploy without breaking the people already here. A deploy that drops sessions, loses the database connection, or serves a half-migrated schema is not ready. Know how migrations run, whether old code can read new data, and what happens to the user mid-request.
A user who is stuck can get unstuck. A password reset that works. A receipt email that arrives. A contact path that reaches you. Support is not a feature you add later. It is the difference between a customer who stays and one who chargebacks.
You know when it breaks before the user does. One error tracker, one uptime check, one log you read. Within minutes, not hours, you know something is wrong and you have a place to look.
Why this is hard to see
The checklist is boring, which is why it gets skipped. It is also invisible to the part of your brain that got you here. Building features feels like progress; each one is a thing you can show. Hardening does not show. Nobody retweets "I made my webhook idempotent." But the webhook is the thing that double-charges a customer on a Tuesday you did not plan to be working, and the feature is not.
There is a second reason the gap hides: AI assistance makes the feature part fast while the gap stays the same size. The visible part is done, so the rest feels small. It is not small. It is the part that decides whether the product survives contact with strangers.
One thing you can do today
If you are in the middle of building something, add idempotency to your webhook handler. Check the incoming event ID against a set you have already processed, and skip duplicates. It is a small change that prevents a class of bugs you do not want to discover in production.
The database table backing it needs only two columns: event_id TEXT PRIMARY KEY and created_at. One indexed lookup per webhook call, and you prevent double-charge and double-access bugs that are painful to unwind.
A path through the gap
The gap is not a wall. It is a list, and the list is finite. Work it row by row, and the prototype becomes a product. For the full path from first commit to a production launch that survives contact with strangers, The Handbook covers it end to end, with every step verified against real deployments.
Field reports
Log in to submit a field report.
Loading reports…