Your prototype works. That is not the same as safe to sell.
You built the whole checkout in test mode, because that is the sane way to build it: test cards, fake money, real webhooks. It worked, access turned on, and you flipped the store to live. Lemon Squeezy and Stripe keep the two worlds apart by default — a test event fires only to your test webhook configuration, signed with your test secret — so this should be a non-issue. It becomes one when you misconfigure that boundary, and there are two ways to do it. One of them quietly gives your product away.
The launch check is concrete:
In your live app, no test-mode payment and no test-mode event produces a real entitlement — and a public test card is declined at checkout.
Two ways the wall between test and live gets a hole
You shipped with test keys. This is the one that costs you directly, and an outsider can trigger it. If production is still pointed at your test API keys, your live checkout is really running in test mode — so it accepts 4242 4242 4242 4242, the public test card printed in every payment doc on the internet. Anyone who finds the page "buys" the product with a card that costs nothing, and no real money ever arrives. The fix is boring and non-negotiable: production runs on the live API keys and verifies against the live webhook signing secret, injected from server-only config — never the test ones you built with.
You cross-wired the webhook. LS and Stripe deliver a test event only to a test webhook configuration. The hole opens when you register your production URL as a webhook under test mode too, and then either share one signing secret across both modes or skip signature verification. Now a test-mode event can reach your live entitlement logic, and if nothing checks the mode, it grants real access — a free product for a test order.
The backstop for that second case is one line: the handler reads the mode flag and refuses a test event before it grants. In a real payload that flag is Lemon Squeezy's meta.test_mode / data.attributes.test_mode, or Stripe's event.livemode === false; the example below uses a normalized order.test_mode.
// fixed: a live app refuses test-mode events (reject unless explicitly live)
if (order.test_mode !== false) {
return { status: 200, result: "ignored-test-event" }; // ack, but grant nothing
}
Two things about that check. First, if your live endpoint verifies the live signing secret, a test event — signed with the test secret — fails signature and is rejected before this code runs. The mode check only bites when the secret is shared across modes or verification is missing, which is exactly the misconfiguration it defends against. Second, the cleaner design removes the need for a backstop at all: separate webhook endpoints, separate secrets, and separate entitlement storage per mode, so a test event physically cannot write a live grant. Reject-unless-explicitly-live (above) also fails safe if the flag is ever absent.
Reproduce it with a test
You do not need to break anything — you use test mode on purpose:
- Confirm production is on live keys. In your live checkout, try the public test card
4242 4242 4242 4242. A correctly-keyed live store declines it. If it succeeds, your "live" store is still in test mode: real customers can't actually pay, and anyone can take the product for free. - Confirm a test event can't grant live. Point your app at its live configuration and deliver a test-mode event to the live handler (Lemon Squeezy's Simulate Webhook Events, or a Stripe test event). Note the setup: to even reach the live handler, that URL has to be registered as a test webhook — if it isn't, the event never arrives, which is itself a pass. If it does arrive, a correct handler either rejects it at signature verification (the live secret won't validate a test-signed event) or hits the mode check and returns 200 while granting nothing. Both are passes; a granted entitlement is the only failure.
- Positive control. A real, live paid event still grants access. If neither test nor live grants, you have not proved the boundary — you have just broken granting.
Assert the entitlement state after each step, not just the HTTP status. Rejecting or ignoring a test event is correct; granting on it is the bug.
What a passing test proves — and does not
A green test proves that your live checkout declines a public test card and that the specific test event you delivered granted nothing. It is not a security certification.
It does not prove every entitlement path checks the mode (an admin grant, a manual comp, a second webhook), that your keys are rotated and scoped per mode, or that a future refactor won't share a secret across modes. It does not cover the reverse migration risk — replaying old test data into a live database during a cutover.
The goal is not to call the billing "secure." It is to hold one falsifiable line:
Production runs on live keys and the live signing secret; a public test card is declined; and no test-mode event produces a live entitlement.
Re-run it after any change to keys, webhook configuration, or the entitlement path. Building in test mode is exactly how it should be done. Making sure test mode can't hand out the real thing — or quietly collect nothing from real customers — is the part that lets you charge for it.
See the same red/green testing pattern in the reference repo
Run the broader launch-readiness checklist
Sources: Lemon Squeezy test mode and Stripe test and live modes.
Test this on your own app before you sell it.