Error codes
HTTP error codes returned by the token signing handler and the widget session API.
Token signing handler errors
createUnchurnHandler returns these HTTP responses. Source: packages/widget/src/server.ts.
| Status | Condition | Body |
|---|---|---|
200 | Token minted successfully | UnchurnTokenResponse JSON |
401 | resolveUser returned null (unauthenticated or no subscription) | empty |
405 | Request method is not POST | empty, Allow: POST header |
500 | resolveUser threw an exception | empty |
500 | resolveUser returned a non-object or invalid shape | empty |
500 | Token mint failed (e.g. subscriptionId contains illegal characters) | empty |
All error bodies are empty — no information is leaked to the browser. Errors are logged server-side.
resolveUser shape validation:
If resolveUser returns a value where:
subscriptionIdis not a non-empty string → 500modeis not'test','live', orundefined→ 500subscriptionIdcontains characters outside[A-Za-z0-9_-]→ 500 (detected at mint time byassertValidIdentifier)
Widget session API errors
The Unchurn backend returns structured errors for widget requests.
Error response shape
export const apiErrorResponseSchema = z.object({
error: z.object({
code: apiErrorCodeSchema,
message: z.string(),
docs: z.string().url().optional(),
example_file: z.string().optional(),
offer_kind: offerKindSchema.optional(),
}),
});| Field | Type | Description |
|---|---|---|
error.code | ApiErrorCode | Machine-readable code (see table below) |
error.message | string | Human-readable description |
error.docs | string (URL) | Optional documentation link |
error.example_file | string | Optional suggested file path for the fix |
error.offer_kind | 'plan_switch' | 'trial_extension' | 'pause' | 'discount' | Which retention offer the rejection refers to (only on policy-rejection errors) |
Error codes
| Code | HTTP status | When it fires | What to do |
|---|---|---|---|
bad_request | 400 | Malformed request body | Fix the widget integration |
unauthorized | 401 | Auth rejection not covered by a more specific code | Check message; confirm UNCHURN_SECRET matches the dashboard |
auth_token_required | 401 | require_signed_widget=true but no authToken was supplied | Supply a signed widget token |
auth_token_invalid | 401 | Token present but signature, payload, or mode is wrong | Regenerate token server-side |
auth_token_expired | 401 | Token present but exp is in the past (TTL 600 s) | Regenerate; useUnchurn retries automatically |
signing_not_configured | 401 | Token required or supplied but merchant has no widget_signing_secret | Set a signing secret in the dashboard |
forbidden | 403 | Policy rejection not covered by a more specific code | — |
direct_cancel_disabled | 403 | Cancel Now clicked but direct_cancel_access was false at session creation | — |
not_found | 404 | Session or resource not found | — |
conflict | 409 | Conflict not covered by a more specific code | — |
stripe_not_connected | 409 | Stripe Connect not yet completed for this merchant | Complete the Stripe Connect OAuth flow |
session_already_completed | 409 | /complete called twice for the same terminal outcome; Stripe side-effect already committed | Widget dismisses quietly — idempotent |
refresh_required | 409 | Stale flow-config or session snapshot | Widget refreshes the session and retries |
pause_not_eligible_interval | 409 | Pause offer unavailable — subscription is not billed monthly | — |
unsupported_plan_type | 422 | Subscription price shape is outside the support matrix (metered, tiered, one-time) | See supported subscriptions |
rate_limited | 429 | Too many requests | Back off and retry |
stripe_error | 502 | Stripe API returned an error | Check Stripe status; retry |
manual_cancellation_unavailable | 503 | Transient infra or Stripe failure on the manual-cancellation request | Retry |
operation_in_progress | 503 | Concurrent mutation already claimed the session | Widget retries shortly |
internal | 500 | Unexpected server error | Contact support |
SDK error state
useUnchurn exposes an error: Error | null field. createUnchurn().open() and window.unchurn.open({ tokenEndpoint }) reject their returned promises for the same token/runtime failures.
- Token endpoint returned a non-2xx status:
Error('Token endpoint returned HTTP <status>') - Token endpoint returned a malformed response body: a Zod validation error describing which field was wrong
In React, these surface as the hook’s error field so the merchant can show a degraded UI instead of crashing. In non-React code, catch the returned promise.
Cross-links
- Widget API —
createUnchurnHandler— handler that emits 401/405/500 - Widget API — loader API, React components, and hook error behavior
- Supported subscriptions — eligibility constraints that surface as 409 / 422