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

  1. Your app calls app/api/checkout/route.ts to create a Stripe Checkout Session and includes metadata.slug so you know which product policy to generate.
  2. The customer is redirected to Stripe Checkout and pays there; no license server keys are exposed to the browser.
  3. Stripe marks the session complete and sends checkout.session.completed to your Vercel webhook endpoint.
  4. Your webhook verifies the Stripe signature, checks event type and payment status, and rejects anything that does not pass validation.
  5. For valid events, your webhook calls POST /generate with the server API key and Idempotency-Key: event.id so Stripe retries cannot create duplicate licenses.
  6. Simple License Server returns a license key; your app stores Stripe-to-license mapping, sends delivery email or portal update, and then returns 2xx to Stripe.

1) Set environment variables

LICENSE_SERVER_SERVER_API_KEY should be a generated server key created via POST /management/api-keys.

bash
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=a94f2c8b5e11d67e8a03c91fd78122e3f6acbb16de2202aa0f814cc2f71d10be

2) 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.

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

typescript
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

bash
stripe listen --forward-to localhost:3000/api/webhooks/stripe

stripe trigger checkout.session.completed

6) Verify your /generate contract

Your intermediary should send this shape to Simple License Server (server key required on /generate).

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

Back to docs overview