Skip to Content
ConceptsTest and live modes

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:

  1. Checks the HMAC signature.
  2. Reads the mode claim.
  3. Reads the subscription from Stripe with the matching mode’s API key for your merchant — your test secret for test, your live secret for live.
  4. 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:

ModePrefix example
testunch_test_a1b2c3...
liveunch_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.

  1. Local development: always mode: 'test'. Use a Stripe test customer with a test subscription.
  2. Staging / preview: also mode: 'test', against the same test merchant or a separate test-only merchant if you want isolation.
  3. 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.

Last updated on