Skip to Content
Troubleshooting

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.

401 from the token endpoint or widget shows “session error”

What you see. Either the widget never opens (the browser console shows a 401 from /api/unchurn/token), or the widget opens and immediately shows a session-error screen.

Causes, in order of likelihood:

  • Your resolveUser callback returned null because the customer isn’t signed in or your auth helper didn’t identify them.
  • UNCHURN_SECRET doesn’t match the value in your dashboard (often because you rotated the secret recently and didn’t redeploy).
  • The mode baked into the token doesn’t match the mode the widget is running in.
  • The token expired before the customer clicked (max TTL is 600 s).

Fix. Check the customer is signed in before they reach the cancel button — log what your resolveUser returns. Confirm UNCHURN_SECRET matches the dashboard. Confirm the mode in your token route matches your widget’s mode. If the token expired, the widget surfaces an expired-state screen — the customer must click again to trigger a fresh token request from your endpoint. If you’ve just rotated the secret, redeploy the server route so the new value is in use.

”stripe_not_connected”

What you see. The first attempt to open the cancel flow 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 or forbids the widget’s injected stylesheet. This applies to both direct CDN installs and npm SDK installs, because the npm loader also loads https://cdn.unchurn.dev/widget.js.

Fix. Add https://cdn.unchurn.dev to script-src. You may also need a style allowance for the widget stylesheet, such as 'unsafe-inline' in style-src, or a stricter hash/nonce policy if your CSP requires it.

”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.

”Discount screen shows the dashboard default, not an intelligent pick”

What you see. Intelligent discount is enabled, but the discount offer shown matches your configured default percent rather than an LTV-adjusted pick.

Cause. The adaptive engine times out after 1,500 ms or returns nothing it can use. The engine always falls back to the rules-seeded offer so the customer never waits.

Fix. This is by design — the customer is never blocked on the engine. If the adaptive pick is consistently absent, confirm the subscription signals (tenure, MRR) are present on the session and that your configured max percent gives the engine room to pick a meaningful value above the minimum 5%.

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.