Test and live modes
Unchurn supports both Stripe test mode and live mode. The two are kept apart end-to-end. A test-mode token can never trigger a live-mode change, and the other way around.
How modes are bound
Mode is a claim inside the signed token your server mints. When you call createUnchurnHandler, the token your resolveUser returns includes mode: 'test' | 'live'. The widget passes the token to Unchurn’s backend, which:
- Checks the HMAC signature.
- Reads the
modeclaim. - Reads the subscription from Stripe with the matching mode’s API key for your merchant — your test secret for
test, your live secret forlive. - Runs the whole session against that mode’s Stripe environment.
Because the mode is signed, the browser can’t change it. A customer cannot escalate from test to live by editing a request.
Token prefixes
Tokens carry their mode in the prefix so you can debug at a glance:
| Mode | Prefix example |
|---|---|
test | unch_test_a1b2c3... |
live | unch_live_a1b2c3... |
This mirrors Stripe’s sk_test_ / sk_live_ convention for the same reason: a leaked token in your logs is identifiable without decoding.
Configuring mode
Set the mode when you build the token in resolveUser:
// app/api/unchurn/token/route.ts
import { createUnchurnHandler } from '@unchurn.dev/widget/nextjs'
export const POST = createUnchurnHandler({
secret: process.env.UNCHURN_SECRET!,
merchantId: process.env.UNCHURN_MERCHANT_ID!,
resolveUser: async (req) => {
const user = await getCurrentUser(req)
if (!user) return null
return {
subscriptionId: user.stripeSubscriptionId,
mode: process.env.NODE_ENV === 'production' ? 'live' : 'test',
}
},
})The default if you skip mode is live. Be explicit when in doubt.
Why mixing is impossible
The rule that prevents mixing is enforced server-side, not in the widget:
- Stripe Connect connects test and live separately. Unchurn stores the connected-account ID (
acct_*) for each mode in two distinct columns. Connecting test mode does not connect live mode, and the other way around. We never store your Stripe API keys. - Subscriptions are mode-scoped on Stripe’s side. A subscription created in test mode is invisible to the live API and the other way around, even though the ID format (
sub_…) looks the same. Unchurn reads subs against the matching mode and shows a clear error if your token’s mode doesn’t match the request mode. - Stripe webhooks land on mode-specific endpoints. Test events hit
/api/webhooks/stripe/test, live events hit/api/webhooks/stripe/live. Each endpoint is signed with its own webhook signing secret.
What test mode does and doesn’t do
Test mode does:
- Run the full cancel flow UI against real Stripe test-mode subscriptions
- Make real Stripe changes against your test environment (cancel scheduling, pause, etc.)
- Record sessions in your dashboard with a
mode: 'test'flag - Fire webhooks to your test webhook endpoint
Test mode does not:
- Touch any production Stripe data
- Count toward your subscription’s session quota
- Enforce the one-extension-per-customer cap (so you can re-test trial extension against the same test customer)
If a test-mode session somehow tries a live-mode change (say, a token claim is tampered with along the way), the backend rejects the change before it touches Stripe. The session is recorded with a clear error state.
Recommended workflow
- Local development: always
mode: 'test'. Use a Stripe test customer with a test subscription. - Staging / preview: also
mode: 'test', against the same test merchant or a separate test-only merchant if you want isolation. - Production:
mode: 'live'. Use a real Stripe customer’s live subscription.
The mode is set by your server’s environment. If you’re tempted to add a query parameter to switch modes from the browser, don’t — that defeats the whole isolation guarantee.