Skip to main content
ASoc
Tutorial

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.

The ASoc Team9 min read

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 checkoutThe after-payment checkout
RunsIn the buyer's browserOn your server, called by the payment provider
Optimised forConversion: fewer fields, clear totalsCorrectness: never lose a paid order, never grant twice
Fails asAbandoned cartsCharged customers with no access
Trusted?No, anyone can fake a success pageOnly if the signature verifies
Where in our reposrc/components/molecules/BuyButton.tsxsrc/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:

StepCheckIf it failsStatus
1HMAC-SHA256 over the raw body matches X-SignatureZero writes, generic message401
2Body parses as JSONNothing recorded400
3meta, data.attributes and an order id are presentNot our event202
4store_id equals ours, by exact string comparisonAnother store's event202
5Not a test_mode order in productionTest order, ignored202
6Event is order_created or order_refundedUnknown event, ignored202
7Variant id maps to a tierUnconfigured product202
8Atomic order and slot creationError logged500

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

SymptomLikely causeFix
Paid order missing from the databaseHandler throws on a bad user_id and 500-loopsValidate the id; fall back to an unattached order
Webhook retries forever for another store's eventsReturning 500 for events that are not yoursReturn 202 for foreign store ids
Two grants for one purchaseNo uniqueness on the provider's order idMake the database insert idempotent with on conflict
Welcome email sometimes never arrivesUnawaited promise frozen after the responseSchedule it with after()
Test orders grant production accesstest_mode not checkedIgnore test mode when NODE_ENV is production
Refund leaves a download workingRevoke and mark-refunded as separate writesOne transaction, not gated on current status
Valid events return 401Body parsed before the signature was verifiedVerify 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.

Keep reading

Tutorial12 min read

Automated Product Screenshots with Playwright, 111 Templates Deep

The capture is four lines; deciding what is in frame is the work. Nav-driven page discovery, the is-this-the-product check, and the proxy bug that blanks every Chromium request.

Read more
Tutorial10 min read

A Podcast Landing Page Is a Registry, a Feed, and One CSP Line

Episodes belong in a typed registry, the feed is the actual product, and the player embed renders blank unless your Content-Security-Policy names its host.

Read more
Tutorial13 min read

Programmatic SEO in Next.js: Derive the Pages, Don't List Them

A hand-maintained slug list left 24 of our products on no category page at all, and nothing failed. Here is the derived hub-and-spoke build that fixed it.

Read more