Example
Stripe example: checkout -> Vercel webhook -> license generation
This guide shows one reliable production pattern: Stripe handles checkout, a Vercel serverless route verifies webhook signatures, and your backend bridge calls POST /generate on Simple License Server.
What happens step by step
Think of the flow as two lanes: the browser lane (customer checkout) and the trusted backend lane (webhook + license generation).
- Your app calls
app/api/checkout/route.tsto create a Stripe Checkout Session and includesmetadata.slugso you know which product policy to generate. - The customer is redirected to Stripe Checkout and pays there; no license server keys are exposed to the browser.
- Stripe marks the session complete and sends
checkout.session.completedto your Vercel webhook endpoint. - Your webhook verifies the Stripe signature, checks event type and payment status, and rejects anything that does not pass validation.
- For valid events, your webhook calls
POST /generatewith the server API key andIdempotency-Key: event.idso Stripe retries cannot create duplicate licenses. - Simple License Server returns a license key; your app stores Stripe-to-license mapping, sends delivery email or portal update, and then returns
2xxto Stripe.
1) Set environment variables
LICENSE_SERVER_SERVER_API_KEY should be a generated server key created via POST /management/api-keys.
NEXT_PUBLIC_APP_URL=https://your-app.example.com
STRIPE_SECRET_KEY=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_PRICE_ID=price_123
LICENSE_SERVER_URL=https://licenses.example.com
LICENSE_SERVER_SERVER_API_KEY=a94f2c8b5e11d67e8a03c91fd78122e3f6acbb16de2202aa0f814cc2f71d10be2) Create a Checkout Session route in your app
File: app/api/checkout/route.ts
The critical detail is setting metadata.slug, because your webhook uses this to call POST /generate.
import Stripe from "stripe"
export const runtime = "nodejs"
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
const appUrl = process.env.NEXT_PUBLIC_APP_URL
const priceId = process.env.STRIPE_PRICE_ID
if (!stripeSecretKey || !appUrl || !priceId) {
throw new Error("Missing STRIPE_SECRET_KEY, NEXT_PUBLIC_APP_URL, or STRIPE_PRICE_ID")
}
const stripe = new Stripe(stripeSecretKey)
export async function POST() {
const slug = "default"
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: priceId, quantity: 1 }],
success_url: appUrl + "/success?session_id={CHECKOUT_SESSION_ID}",
cancel_url: appUrl + "/pricing",
metadata: { slug },
})
return Response.json({ checkout_url: session.url })
}3) Add the Stripe webhook intermediary route
File: app/api/webhooks/stripe/route.ts
This route verifies Stripe signatures and uses Idempotency-Key: event.id to make webhook retries safe.
import Stripe from "stripe"
export const runtime = "nodejs"
const stripeSecretKey = process.env.STRIPE_SECRET_KEY
const stripeWebhookSecret = process.env.STRIPE_WEBHOOK_SECRET
const licenseServerUrl = process.env.LICENSE_SERVER_URL
const licenseServerApiKey = process.env.LICENSE_SERVER_SERVER_API_KEY
if (!stripeSecretKey || !stripeWebhookSecret || !licenseServerUrl || !licenseServerApiKey) {
throw new Error("Missing one or more required environment variables")
}
const stripe = new Stripe(stripeSecretKey)
async function generateLicense(input: {
slug: string
eventId: string
email: string | null
customerId: string | null
checkoutSessionId: string
}) {
const response = await fetch(licenseServerUrl + "/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + licenseServerApiKey,
"Idempotency-Key": input.eventId,
},
body: JSON.stringify({
slug: input.slug,
metadata: {
provider: "stripe",
provider_event_id: input.eventId,
provider_customer_id: input.customerId,
provider_checkout_session_id: input.checkoutSessionId,
email: input.email,
},
}),
})
if (!response.ok) {
const details = await response.text()
throw new Error("generate failed (" + response.status + "): " + details)
}
return (await response.json()) as { license_key: string }
}
export async function POST(req: Request) {
const signature = req.headers.get("stripe-signature")
if (!signature) return new Response("missing stripe-signature", { status: 400 })
const rawBody = await req.text()
let stripeEvent: Stripe.Event
try {
stripeEvent = stripe.webhooks.constructEvent(rawBody, signature, stripeWebhookSecret)
} catch {
return new Response("invalid signature", { status: 400 })
}
if (stripeEvent.type !== "checkout.session.completed") {
return new Response("ignored", { status: 200 })
}
const session = stripeEvent.data.object as Stripe.Checkout.Session
if (session.payment_status !== "paid") {
return new Response("payment not completed", { status: 200 })
}
const slug = session.metadata?.slug?.trim()
if (!slug) return new Response("missing slug metadata", { status: 400 })
const customerId =
typeof session.customer === "string"
? session.customer
: session.customer?.id || null
try {
const generated = await generateLicense({
slug,
eventId: stripeEvent.id,
email: session.customer_details?.email || null,
customerId,
checkoutSessionId: session.id,
})
// Persist your own support mapping table.
// await db.licenseMap.upsert({ stripeSessionId: session.id, licenseKey: generated.license_key, ... })
return Response.json({ ok: true, license_key: generated.license_key })
} catch (error) {
console.error("license generation failed", error)
return new Response("generate failed", { status: 500 })
}
}4) Configure Stripe webhook destination
- Endpoint URL:
https://your-app.example.com/api/webhooks/stripe - Event to subscribe:
checkout.session.completed - Copy the endpoint signing secret into
STRIPE_WEBHOOK_SECRET
5) Test locally with Stripe CLI
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed6) Verify your /generate contract
Your intermediary should send this shape to Simple License Server (server key required on /generate).
curl -sS https://licenses.example.com/generate \
-H "Authorization: Bearer a94f2c8b5e11d67e8a03c91fd78122e3f6acbb16de2202aa0f814cc2f71d10be" \
-H "Idempotency-Key: evt_123" \
-H "Content-Type: application/json" \
-d '{
"slug": "default",
"metadata": {
"provider": "stripe",
"provider_event_id": "evt_123",
"provider_checkout_session_id": "cs_test_123",
"email": "user@example.com"
}
}'Production notes
- Keep Stripe and license server keys only in server-side environment storage.
- Return non-2xx on generation failures so Stripe retries the event.
- Persist your own mapping table for support and key re-delivery workflows.
- Do not generate licenses from success-page redirects alone.