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 WebRequest(Next Pages Router, Express, Fastify, Koa, Lambda).
Prerequisites
@unchurn.dev/widget@latestinstalled.UNCHURN_SECRETandUNCHURN_MERCHANT_IDset as server-only environment variables. Never prefix withNEXT_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 appCloudflare 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 routerFastify
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 toUNCHURN_SECRET. - Wrong API for the runtime. Use
mintUnchurnTokenin Node-http frameworks (Pages Router, Express, Fastify, Koa, Lambda). UsecreateUnchurnHandleronly where the framework hands you a WebRequest. Casting a NodereqtoRequestwill crash at runtime. - Route handler not exported as
POST. App Router enforces named HTTP-method exports.export defaultorexport const handlerwon’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, allowscript-src https://cdn.unchurn.devand inline styles (the widget injects scoped styles into the page). - Tailwind/shadcn HSL triples. If your app uses
--primary: 255 82% 63%(three numbers, nohsl()wrapper), passcolorPrimary: 'hsl(var(--primary))', notcolorPrimary: 'var(--primary)'.
Where to go next
- Auth integrations —
resolveUserexamples for Clerk, NextAuth, Supabase, custom headers. - Appearance — match the widget to your brand.
- Test and live modes — Stripe environments and the test badge.
- Configuration — open options, copy overrides, callbacks.
- Token format — wire format and verification rules.