Skip to Content
WidgetServer integration

Server integration

Mint signed tokens from your server so the widget can open a session. @unchurn.dev/widget/server works in every JavaScript runtime — Next.js (App and Pages routers), Remix, SvelteKit, Hono, Express, Fastify, Koa, Cloudflare Workers, Vercel Edge, AWS Lambda, Bun, and Deno.

Two ways to integrate, both producing interchangeable tokens:

  • createUnchurnHandler — drop-in (Request) => Promise<Response> for any Fetch-API runtime (Next App Router, Remix, SvelteKit, Hono, Workers, Edge, Bun, Deno).
  • mintUnchurnToken — pure function for any Node-http runtime where you don’t get a Web Request (Next Pages Router, Express, Fastify, Koa, Lambda).

Prerequisites

  • @unchurn.dev/widget@latest installed.
  • UNCHURN_SECRET and UNCHURN_MERCHANT_ID set as server-only environment variables. Never prefix with NEXT_PUBLIC_ or anything else that ships to the browser.

The mode in your signed token (test vs live) tells Unchurn which Stripe environment to use. Derive it from NODE_ENV so preview deployments use test mode and production uses live.

Fetch-API frameworks

Use createUnchurnHandler. It returns a handler that reads the auth, mints the token, and writes the JSON response. You only write resolveUser.

Next.js App Router

// app/api/unchurn/token/route.ts import { createUnchurnHandler } from '@unchurn.dev/widget/server' import { getCurrentUser } from '@/lib/auth' 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?.stripeSubscriptionId) return null return { subscriptionId: user.stripeSubscriptionId, mode: process.env.NODE_ENV === 'production' ? 'live' : 'test', } }, })

Remix

// app/routes/api.unchurn.token.ts import { createUnchurnHandler } from '@unchurn.dev/widget/server' import { getCurrentUser } from '~/lib/auth' const handler = createUnchurnHandler({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, resolveUser: async (req) => { const user = await getCurrentUser(req) if (!user?.stripeSubscriptionId) return null return { subscriptionId: user.stripeSubscriptionId } }, }) export const action = ({ request }: { request: Request }) => handler(request)

SvelteKit

// src/routes/api/unchurn/token/+server.ts import { createUnchurnHandler } from '@unchurn.dev/widget/server' import { UNCHURN_SECRET, UNCHURN_MERCHANT_ID } from '$env/static/private' import { getCurrentUser } from '$lib/server/auth' const handler = createUnchurnHandler({ secret: UNCHURN_SECRET, merchantId: UNCHURN_MERCHANT_ID, resolveUser: async (req) => { const user = await getCurrentUser(req) if (!user?.stripeSubscriptionId) return null return { subscriptionId: user.stripeSubscriptionId } }, }) export const POST = ({ request }) => handler(request)

Hono

import { Hono } from 'hono' import { createUnchurnHandler } from '@unchurn.dev/widget/server' const handler = createUnchurnHandler({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, resolveUser: async (req) => { const user = await getCurrentUser(req) if (!user?.stripeSubscriptionId) return null return { subscriptionId: user.stripeSubscriptionId } }, }) const app = new Hono() app.post('/api/unchurn/token', (c) => handler(c.req.raw)) export default app

Cloudflare Workers / Vercel Edge / Bun / Deno

Any runtime that delivers a Web Request to your function works the same way:

import { createUnchurnHandler } from '@unchurn.dev/widget/server' const handler = createUnchurnHandler({ secret: globalThis.UNCHURN_SECRET, merchantId: globalThis.UNCHURN_MERCHANT_ID, resolveUser: async (req) => { const user = await getCurrentUser(req) if (!user?.stripeSubscriptionId) return null return { subscriptionId: user.stripeSubscriptionId } }, }) export default { fetch: handler }

Node-http frameworks

Use mintUnchurnToken. You write the thin handler — read auth, mint, return JSON.

Next.js Pages Router

// pages/api/unchurn/token.ts import type { NextApiRequest, NextApiResponse } from 'next' import { mintUnchurnToken } from '@unchurn.dev/widget/server' import { getCurrentUser } from '@/lib/auth' export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== 'POST') { res.setHeader('Allow', 'POST') return res.status(405).end() } try { const user = await getCurrentUser(req) if (!user?.stripeSubscriptionId) return res.status(401).end() const token = mintUnchurnToken({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, subscriptionId: user.stripeSubscriptionId, mode: process.env.NODE_ENV === 'production' ? 'live' : 'test', }) res.json(token) } catch (err) { console.error('[unchurn] token mint failed:', err) res.status(500).end() } }

Express

import express from 'express' import { mintUnchurnToken } from '@unchurn.dev/widget/server' const router = express.Router() router.post('/api/unchurn/token', async (req, res, next) => { try { const user = req.session?.user if (!user?.stripeSubscriptionId) return res.status(401).end() const token = mintUnchurnToken({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, subscriptionId: user.stripeSubscriptionId, mode: process.env.NODE_ENV === 'production' ? 'live' : 'test', }) res.json(token) } catch (err) { next(err) // Express 4 swallows async throws; route them to error middleware. } }) export default router

Fastify

import Fastify from 'fastify' import { mintUnchurnToken } from '@unchurn.dev/widget/server' import { getCurrentUser } from './lib/auth' const app = Fastify() app.post('/api/unchurn/token', async (req, reply) => { try { const user = await getCurrentUser(req) if (!user?.stripeSubscriptionId) return reply.code(401).send() return mintUnchurnToken({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, subscriptionId: user.stripeSubscriptionId, }) } catch (err) { // Fastify's default error serializer echoes `error.message` in the // 500 response body. `mintUnchurnToken` throws with merchant-side // field details — log them, but return an empty 500 to the caller. req.log.error(err, '[unchurn] token mint failed') return reply.code(500).send() } })

Koa

import Router from '@koa/router' import { mintUnchurnToken } from '@unchurn.dev/widget/server' const router = new Router() router.post('/api/unchurn/token', async (ctx) => { const user = ctx.state.user if (!user?.stripeSubscriptionId) { ctx.status = 401 return } ctx.body = mintUnchurnToken({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, subscriptionId: user.stripeSubscriptionId, }) })

AWS Lambda (API Gateway)

import type { APIGatewayProxyHandlerV2 } from 'aws-lambda' import { mintUnchurnToken } from '@unchurn.dev/widget/server' export const handler: APIGatewayProxyHandlerV2 = async (event) => { if (event.requestContext.http.method !== 'POST') { return { statusCode: 405, headers: { Allow: 'POST' } } } const user = await getCurrentUser(event) if (!user?.stripeSubscriptionId) return { statusCode: 401 } const token = mintUnchurnToken({ secret: process.env.UNCHURN_SECRET!, merchantId: process.env.UNCHURN_MERCHANT_ID!, subscriptionId: user.stripeSubscriptionId, }) return { statusCode: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify(token), } }

What resolveUser is responsible for

resolveUser is the auth boundary. It authenticates the request and returns the Stripe subscription that belongs to the signed-in customer. Return null to reject — createUnchurnHandler responds with 401. Throw an error and the handler returns 500; the error message is logged server-side and never reaches the client. See Auth integrations for Clerk, NextAuth, Supabase, and custom-auth examples.

Successful responses carry { authToken, expiresAt, merchantId, subscriptionId, mode }. The token is HMAC-signed over merchantId:subscriptionId:mode:expMs; the browser cannot mint or mutate one. See Token format for the wire format.

Set environment variables per environment

On Vercel, set UNCHURN_SECRET and UNCHURN_MERCHANT_ID in Project Settings → Environment Variables with separate values per scope (Development, Preview, Production). The NODE_ENV check in resolveUser then takes care of the mode automatically — preview deployments get unch_test_ tokens, production gets unch_live_.

Middleware compatibility (Next.js)

If you protect routes with next/server middleware, make sure your matcher does NOT include /api/unchurn/token. The handler enforces auth itself via resolveUser; running it through a middleware that blocks unauthenticated requests would prevent the cancel-flow widget from ever fetching a token.

The simplest pattern is to scope the matcher to authenticated pages only — e.g. matcher: ['/dashboard/:path*'] — leaving /api/unchurn/token outside.

Common pitfalls

  • Secret exposed to the browser. Anything without NEXT_PUBLIC_ stays server-side. Never add the prefix to UNCHURN_SECRET.
  • Wrong API for the runtime. Use mintUnchurnToken in Node-http frameworks (Pages Router, Express, Fastify, Koa, Lambda). Use createUnchurnHandler only where the framework hands you a Web Request. Casting a Node req to Request will crash at runtime.
  • Route handler not exported as POST. App Router enforces named HTTP-method exports. export default or export const handler won’t match POST requests.
  • CSP blocks the runtime. The package loads the hosted widget from cdn.unchurn.dev. If you set a Content Security Policy, allow script-src https://cdn.unchurn.dev and inline styles (the widget injects scoped styles into the page).
  • Tailwind/shadcn HSL triples. If your app uses --primary: 255 82% 63% (three numbers, no hsl() wrapper), pass colorPrimary: 'hsl(var(--primary))', not colorPrimary: 'var(--primary)'.

Where to go next