Online Store Checkout: The Half That Runs After the Card Clears
An online store checkout is a form plus a webhook pipeline. The 8 checks our 287-line handler runs in order, and the six cases that would lose a paid order if skipped.
An online store checkout is two programs, and most guides describe only the first. The visible one is the form: cart review, address, payment method, confirm. The invisible one runs after the card clears and decides whether the buyer actually gets what they paid for. When a store loses money to checkout bugs, it is almost always the second program.
For a store selling downloads, the second program is a signed webhook handler. Ours is 287 lines in src/lib/lemonsqueezy/webhook.ts, covered by 23 unit tests, and every branch in it exists because an order can arrive in a state you did not plan for. This post walks those branches in the order the code runs them.
The two halves of a checkout
| The visible checkout | The after-payment checkout | |
|---|---|---|
| Runs | In the buyer's browser | On your server, called by the payment provider |
| Optimised for | Conversion: fewer fields, clear totals | Correctness: never lose a paid order, never grant twice |
| Fails as | Abandoned carts | Charged customers with no access |
| Trusted? | No, anyone can fake a success page | Only if the signature verifies |
| Where in our repo | src/components/molecules/BuyButton.tsx | src/lib/lemonsqueezy/webhook.ts |
Everything in the visible half can be handed to a hosted checkout. Nothing in the second half can, because only your database knows what "granted" means. The LemonSqueezy checkout walkthrough shows the happy path; this post is about the order that is not on it.
The pipeline, in order
processWebhook is deliberately framework-agnostic: it takes the raw body, the signature header and a narrow database interface, and returns a status and a body. That is what makes it testable without a live database or a Request. Each step can end the run:
| Step | Check | If it fails | Status |
|---|---|---|---|
| 1 | HMAC-SHA256 over the raw body matches X-Signature | Zero writes, generic message | 401 |
| 2 | Body parses as JSON | Nothing recorded | 400 |
| 3 | meta, data.attributes and an order id are present | Not our event | 202 |
| 4 | store_id equals ours, by exact string comparison | Another store's event | 202 |
| 5 | Not a test_mode order in production | Test order, ignored | 202 |
| 6 | Event is order_created or order_refunded | Unknown event, ignored | 202 |
| 7 | Variant id maps to a tier | Unconfigured product | 202 |
| 8 | Atomic order and slot creation | Error logged | 500 |
The status codes are not decoration. A 202 tells the provider the delivery was received and nothing more should happen, so it stops retrying. A 500 asks for a retry. Using 202 for "valid but not for us" and 500 only for "our database failed" is what keeps a foreign store's events from retrying forever while still recovering from a real outage.
Case 1: the user id the buyer can edit
We attach the buyer's id to the checkout URL so the webhook can connect a payment to an account. That id travels in a query string, which means the buyer can edit it. The obvious implementation passes it straight to the insert, and that is the trap the handler's own comments name.
A malformed value throws a Postgres cast error. The handler would return 500, the provider would retry, and the retry would throw the identical error. A paid order is never recorded, not even as unattached, so the fallback that would have saved it never runs.
The shipped version only trusts a well-formed UUID:
let userId = rawUserId && UUID_RE.test(rawUserId) ? rawUserId : null;
A value that fails the pattern becomes null: the order is stored unattached, a structured alert fires, and the response is 200. The test is named for the failure it prevents: non-UUID custom_data.user_id → stored unattached, no throw, 200, triggers the alert.
Case 2: a valid id that points at nobody
A well-formed UUID can still belong to no account. The foreign key on the orders table rejects it inside the database function, nothing commits, and the same retry loop appears. The handler catches SQLSTATE 23503 and retries once without the id:
try {
({ created } = await deps.db.createOrderWithSlots(
{ ...orderInput, userId },
slotKinds,
));
} catch (error) {
if (userId !== null && isForeignKeyViolation(error)) {
userId = null;
({ created } = await deps.db.createOrderWithSlots(
{ ...orderInput, userId },
slotKinds,
));
} else {
throw error;
}
}
The principle for both cases: a paid order must always be recorded, even badly attached. An unattached order is a ticket for a human to resolve. A dropped order is money taken with no record. The two failure modes are not remotely equal.
Case 3: the same order twice
Providers redeliver. Idempotency here is not an application-level lookup; it is the database function's own on conflict (ls_order_id) do nothing, which returns created: false for a repeat. The handler treats that flag as the single authority:
// created === false means ls_order_id already existed (duplicate/retry) —
// idempotent no-op, never re-grant slots, never re-alert, never re-email.
if (created) {
/* alert if unattached, otherwise schedule the welcome email */
}
Order creation and slot creation happen in one Postgres transaction (create_order_with_slots in supabase/migrations/0003_commerce_rpcs.sql), so a crash between "insert order" and "insert slots" cannot leave an order that grants nothing.
Case 4: the refund that arrives first
Refunds can arrive before the order they refund, or for an order you never recorded. The refund function returns false when it finds no matching order, and the handler raises an alert while still answering 200. Answering 500 would make the provider retry an event that cannot succeed until a human reconciles it.
The refund itself revokes every non-revoked slot and marks the order refunded in one transaction, and it deliberately does not gate on the order's current status:
update public.entitlement_slots set status = 'revoked'
where order_id = v_order_id and status <> 'revoked';
update public.orders set status = 'refunded' where id = v_order_id;
A refund replayed against an already-refunded order therefore still re-revokes any slot left active by an earlier partial failure. One test pins exactly that: refund recovers a slot left 'active' despite the order already being 'refunded'.
Case 5: the email that never sends
The purchase confirmation is sent after the order commits, never inside the entitlement transaction, and never awaited, because a slow mail provider must not delay the 200. The tempting version is a fire-and-forget promise. On a serverless host that promise has no guaranteed lifetime once the response is sent, so the invocation can be frozen before the mail call finishes and the email silently never leaves.
The route hands it to after(), which keeps the invocation alive without blocking the response:
sendPurchaseWelcome: (email, tier) =>
after(() => sendPurchaseWelcome(email, tier)),
Three of the 23 tests exist only to keep this from breaking the sale: one for a rejecting sender, one for a sender that throws synchronously, and one for no sender configured at all. None may change the 200.
Case 6: test orders in production
Providers have a test mode with real-looking payloads. Step 5 ignores test_mode: true when NODE_ENV is production, and processes it normally everywhere else, so you can exercise the whole flow in development without a test order granting real access in production. Both directions have a test.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Paid order missing from the database | Handler throws on a bad user_id and 500-loops | Validate the id; fall back to an unattached order |
| Webhook retries forever for another store's events | Returning 500 for events that are not yours | Return 202 for foreign store ids |
| Two grants for one purchase | No uniqueness on the provider's order id | Make the database insert idempotent with on conflict |
| Welcome email sometimes never arrives | Unawaited promise frozen after the response | Schedule it with after() |
| Test orders grant production access | test_mode not checked | Ignore test mode when NODE_ENV is production |
| Refund leaves a download working | Revoke and mark-refunded as separate writes | One transaction, not gated on current status |
| Valid events return 401 | Body parsed before the signature was verified | Verify over the raw text first |
Frequently asked questions
What should an online store checkout do after payment? Verify the provider's signed notification, record the order exactly once, grant access in the same transaction, and only then send email. The redirect back to your site is a convenience for the buyer and proves nothing.
Should the checkout page itself be custom? Usually not. Own the data you pass in and the states of the button that leads there; custom checkout covers where that line sits. The after-payment code is where custom work pays back.
How do I handle refunds in a digital store? Revoke access and mark the order refunded in one transaction, and make replays safe. The policy side, including what to promise buyers, is in the digital product refund policy.
Do I need a database to run a checkout? Not to take payment. You need one to grant anything afterwards, because a webhook has to write somewhere. The process flow for an ecommerce site maps where that write sits in the larger chain.
Start from a finished front end
Everything above sits behind a page that has to sell the product first. ASoc Cover is an insurance marketing site built around instant quotes and fast claims, ASoc Echo an AI chatbot landing page with a live widget preview, and ASoc Edge an applied-AI agency site, each a typed, statically rendered Next.js project.
Browse the full set of Next.js landing page templates, or the Tailwind landing page templates if you would rather bring your own framework.
