Troubleshooting and FAQ
Common errors and what to check first. Each entry covers what you see, why it happens, and the fix.
For the full list of error codes the API returns, see the error codes reference.
”Token endpoint returned HTTP 401”
What you see. Browser console shows the error from useUnchurn. The widget never opens.
Cause. Your /api/unchurn/token route returned a 401. The most common reason is that your resolveUser callback returned null — the user is not signed in, or your auth helper failed to identify them.
Fix. Check that the customer is signed in before they reach the cancel button. In your route handler, log what getCurrentUser(req) returns when the request arrives. If you have not wired auth at all yet, the Quickstart shows the minimum.
Token endpoint returns 401 / widget shows “session error”
What you see. The widget opens then immediately shows the “session error” gate. The API response carries code: 'unauthorized'.
Cause. All token-related auth failures (missing token in locked mode, invalid HMAC signature, expired expMs, mismatched mode/merchantId/subscriptionId) collapse to unauthorized with a descriptive message. Common causes: UNCHURN_SECRET doesn’t match your dashboard, you rotated the secret recently, or the mode baked into the token doesn’t match the request mode.
Fix. Confirm UNCHURN_SECRET matches the dashboard. Confirm mode in your token endpoint matches the mode the widget is running in. If you rotated the secret, every token signed with the old one is now invalid — the widget recovers on the next fetch via useUnchurn. For expired tokens specifically, the hook auto-refreshes on the next show() call; manual refresh() only matters if you want to force a fresh token earlier.
”stripe_not_connected”
What you see. First call to showCancelFlow returns 409 with this code. The widget shows the session-error gate.
Cause. Your Stripe Connect handshake has not completed for the mode the widget is running in. Common case: you connected test mode in the dashboard, ran the widget in test mode against your real subscription, and then flipped to live without connecting live mode first.
Fix. Open your dashboard, switch to the mode you are missing, and click Connect Stripe. See Connect Stripe.
Widget shows “subscription not eligible”
What you see. The cancel flow opens and immediately shows the cancel confirmation — none of your configured offers render.
Cause. The subscription failed every offer’s eligibility check. The most common reasons are: the subscription is on an annual plan (pause and plan-switch are monthly-only at launch), the subscription is past-due, or the subscription has multiple line items.
Fix. This is by design — Unchurn refuses to mutate subscription shapes where an automated change could mis-bill the customer. The customer can still cancel. Check the Supported subscriptions reference page for the full list of rules. If the subscription should be eligible and is not, look at the session row in your Cancellations view — it carries the offer evaluations and the reason each offer was blocked.
”Live mode not working but test mode does”
What you see. Test sessions complete fine. Live sessions return stripe_not_connected, or the widget refuses to mint a live token.
Cause. Live and test connections are separate. Connecting test mode does not connect live mode. Tokens are also mode-locked — a unch_live_… token cannot trigger a test-mode session and a unch_test_… token cannot trigger a live-mode session.
Fix. Connect live mode from the dashboard. In your token endpoint, make sure mode is set to 'live' for live customers and 'test' only for development. Mixing them is the most common live-launch bug we see.
CORS errors on the token endpoint
What you see. The browser blocks the POST to /api/unchurn/token with a CORS error before the request reaches your server.
Cause. Your token endpoint and the page that calls useUnchurn are on different origins. Browsers refuse cross-origin POSTs from JavaScript unless your server returns the right CORS headers.
Fix. The simplest fix is to colocate the token endpoint with the page that opens the widget — both on app.example.com, both relative paths. If you must run them on different origins, your token endpoint needs to return Access-Control-Allow-Origin (set to your widget origin, not *) and respond to the preflight OPTIONS request. The widget itself talks to the Unchurn backend on app.unchurn.dev; that endpoint already returns the right CORS headers.
”Cancel button blocked by Content Security Policy”
What you see. Browser console shows a CSP violation. The widget bundle fails to load or fails to render.
Cause. Your CSP forbids loading scripts from cdn.unchurn.dev (vanilla install) or forbids inline styles (the widget injects scoped CSS variables for theming).
Fix. Add https://cdn.unchurn.dev to your script-src. If you use the npm package the script-src restriction does not apply, but you may still need 'unsafe-inline' in style-src for the theming layer, or move to a hash-based allowance.
”Customer cancelled but Stripe still shows them active”
What you see. Customer reports they cancelled. Stripe dashboard shows the subscription as active, no cancel_at_period_end.
Cause. The session ended in manual_cancellation_requested, not canceled. Unchurn does not call Stripe for subscription shapes that are unsafe for an automated mutation — multi-item, schedule-attached, paused-at-Stripe, past-due. The customer’s intent is captured. The Stripe write is up to you.
Fix. Check the Cancellations view in your dashboard for the session. Manual requests show up there with the reason the automated path was blocked. Complete the cancellation in Stripe (or your billing tool) within your own legal SLA. See Compliance for the full picture.
”Sessions counted but no Stripe mutation happened”
What you see. Your dashboard shows the session as saved (offer accepted) but Stripe shows no coupon, no pause, no plan change.
Cause. A successful 200 from /complete means Stripe recorded the mutation. If Stripe later rejects the change asynchronously — for example, a coupon that conflicts with an existing discount — the session row is not retroactively updated in MVP.
Fix. Cross-reference the session ID with the Stripe events log for the timestamp. If Stripe never recorded the mutation despite Unchurn returning success, that is a bug — file it against app.unchurn.dev/support with the session ID and the Stripe account.
”Discount didn’t apply to the customer’s invoice”
What you see. Customer accepted the discount, the success screen confirms it, but the next invoice shows the full amount.
Cause. Discount application is based on Stripe’s subscriptions.update({ discounts }) call. The most common reasons it does not appear on the invoice: the discount was applied after the invoice for the next period had already been generated, or another coupon on the customer takes precedence, or the price the discount targets has been changed since.
Fix. Check the Stripe customer page — the discount should be attached to the subscription. If it is attached but not applied to the invoice, the next invoice cycle will pick it up. If the customer’s next-invoice-now matters (for example, they accepted the discount on the day of renewal), you may need to manually credit the invoice in Stripe.
”session_already_completed”
What you see. A 409 with this code on a /complete call.
Cause. A previous /complete for the same session already committed. This is the idempotent-replay path — usually a network retry or a double-click on the customer’s confirm button. The Stripe mutation already landed.
Fix. Treat as success. The widget handles this automatically — it fires your onComplete callback once and dismisses. Only worry about it if you see it from your own server-side code.
”Sessions counted but the agent never picked an offer”
What you see. The discount screen shows the rules-seeded offer (your dashboard default), not an agent-picked one.
Cause. The agentic offer service either timed out, returned a value outside your bounds, or is not enabled for this merchant. The widget always falls back to the rules-seeded offer so the customer never sees a spinner.
Fix. This is the design — never block the customer on the agent. If you want every session to use the agent pick, check the agent service status in your dashboard and confirm your offer bounds are not too tight.
When all else fails
- Capture the session ID from the browser network tab.
- Cross-reference with the Cancellations view in your dashboard.
- Send the session ID and the customer-visible symptom to Unchurn support.
The session row records every check that ran, every signal it picked, and every Stripe call it made — that is usually enough to identify the cause without a long debugging round trip.