Stripe Webhooks in TanStack Start: A Step-by-Step Setup Guide

By Aziz Ali
stripetanstack-startwebhooksdrizzle-ormtypescript

I've wired Stripe webhooks more times than I care to admit. The first time took me a full weekend. The second time, half a day. By the third, I had a template. Here's exactly what that looks like in TanStack Start — signature verification, Drizzle ORM updates, idempotency, and local testing included.

Why Stripe Webhooks Are Different from Regular API Calls

Webhooks aren't requests you make — they're requests Stripe makes to you. That changes everything:

  • They arrive at any time, not in response to user actions
  • You must verify they came from Stripe (fake webhooks are a real attack vector)
  • Your endpoint must respond within 30 seconds or Stripe retries
  • You need to handle duplicates — Stripe guarantees at least once delivery

Most tutorials cover the happy path and call it a day. We're going deeper.

Creating the Webhook Endpoint in TanStack Start

TanStack Start uses file-based routing for both pages and API routes. Create your webhook handler like this:

// app/api/stripe/webhook.ts
import { createAPIRoute } from '@tanstack/start'
import Stripe from 'stripe'
import { db } from '~/server/db'
import { subscriptions } from '~/server/schema'
import { eq } from 'drizzle-orm'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-01-27.acacia',
})

export const POST = createAPIRoute(async ({ request }) => {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error('Webhook signature verification failed:', err)
    return new Response('Webhook Error', { status: 400 })
  }

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const sub = event.data.object as Stripe.Subscription
      await handleSubscriptionChange(sub)
      break
    }
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription
      await handleSubscriptionDeleted(sub)
      break
    }
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.CheckoutSession
      await handleCheckoutComplete(session)
      break
    }
  }

  return new Response(JSON.stringify({ received: true }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  })
})

One critical thing: you must read the body as raw text with request.text() before doing anything else. If you parse it as JSON first, the signature verification will fail because Stripe signs the raw bytes.

Updating the Database with Drizzle ORM

Once an event is verified, update your database. With Drizzle ORM, this is clean and type-safe:

async function handleSubscriptionChange(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string

  await db
    .update(subscriptions)
    .set({
      stripeSubscriptionId: subscription.id,
      status: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
      updatedAt: new Date(),
    })
    .where(eq(subscriptions.stripeCustomerId, customerId))
}

async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string

  await db
    .update(subscriptions)
    .set({
      status: 'canceled',
      updatedAt: new Date(),
    })
    .where(eq(subscriptions.stripeCustomerId, customerId))
}

async function handleCheckoutComplete(session: Stripe.CheckoutSession) {
  if (session.mode !== 'subscription') return

  const userId = session.metadata?.userId
  if (!userId) return

  await db
    .update(subscriptions)
    .set({
      stripeCustomerId: session.customer as string,
      stripeSubscriptionId: session.subscription as string,
      status: 'active',
    })
    .where(eq(subscriptions.userId, userId))
}

Make sure you're passing userId in session.metadata when you create the checkout session on the front end — that's how you link a Stripe customer back to your user record.

The Full Subscription Lifecycle Flow

Here's how a typical subscription moves through your webhook handler from first payment to cancellation:

flowchart TD
    A[User clicks Subscribe] --> B[Stripe Checkout Session Created]
    B --> C{Payment?}
    C -->|Successful| D[checkout.session.completed]
    C -->|Failed| E[No webhook - user stays on Checkout]
    D --> F[Link Stripe customer to user in DB]
    F --> G[customer.subscription.created]
    G --> H[Set status: active — grant access]
    H --> I{Each billing period}
    I -->|Renewed| J[customer.subscription.updated]
    I -->|User cancels| K[customer.subscription.deleted]
    I -->|Payment fails| L[invoice.payment_failed]
    J --> H
    K --> M[Set status: canceled — revoke access]
    L --> N[Set status: past_due — send dunning email]

This is the minimum event set you need to handle. invoice.payment_failed is the one most people skip — and then wonder why delinquent subscriptions still have access.

Testing Webhooks Locally with the Stripe CLI

You can't test webhooks by reloading your browser. Use the Stripe CLI to forward real events to your local TanStack Start dev server:

# Install Stripe CLI (macOS)
brew install stripe/stripe-cli/stripe

# Authenticate
stripe login

# Forward events to your local webhook endpoint
stripe listen --forward-to localhost:3000/api/stripe/webhook

# In a second terminal, trigger a test event
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated

The stripe listen command prints a webhook signing secret — use it as your STRIPE_WEBHOOK_SECRET in .env.local. Do not use your production webhook secret locally. When you deploy, create a separate endpoint in the Stripe Dashboard and use that signing secret in production.

Handling Idempotency (The Part Everyone Skips)

Stripe delivers events at least once. That means your handler might fire twice for the same event. For a DB update that's fine — upserts are naturally idempotent. But for side effects like sending a welcome email or provisioning a resource, you'll do it twice unless you track processed events.

// Before processing any event
const alreadyProcessed = await db.query.webhookEvents.findFirst({
  where: eq(webhookEvents.stripeEventId, event.id),
})

if (alreadyProcessed) {
  return new Response(JSON.stringify({ received: true, duplicate: true }), {
    status: 200,
  })
}

// ... process the event ...

// After processing, mark it done
await db.insert(webhookEvents).values({
  stripeEventId: event.id,
  type: event.type,
  processedAt: new Date(),
})

Add a webhook_events table to your Drizzle schema with a UNIQUE constraint on stripe_event_id. The constraint is your safety net if two requests race.

Setting Up the Production Webhook Endpoint

Once you deploy, go to Stripe Dashboard → Developers → Webhooks → Add endpoint. Set the URL to your production endpoint and select these events at minimum:

  • checkout.session.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_failed
  • invoice.payment_succeeded

Copy the signing secret Stripe generates for that endpoint and set it as STRIPE_WEBHOOK_SECRET in your production environment. Each endpoint gets its own secret — don't reuse them.

If you're using Better-Auth for authentication, make sure your webhook endpoint is excluded from any auth middleware. Stripe doesn't send auth cookies — it sends the signature header.


FAQ

Do I need to verify the Stripe webhook signature every time? Yes, always — no exceptions. Without it, any attacker can POST to your endpoint and trigger subscription upgrades or account changes. The verification step is one line of code and should never be skipped.

What's the difference between STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET? STRIPE_SECRET_KEY authenticates your requests to Stripe. STRIPE_WEBHOOK_SECRET lets you verify events Stripe sends to you. They're completely separate — you need both. Each webhook endpoint in the Stripe Dashboard has its own unique signing secret.

Why does signature verification fail on my deployed server? Almost always because something is modifying the raw request body before you read it — a middleware parsing JSON, for example. Read the body as raw text with request.text() before any other processing. Also check that you're using the correct signing secret for your deployed endpoint, not the local CLI secret.

How do I handle Stripe webhook retries? Stripe retries failed events (non-200 responses) up to 72 hours with exponential backoff. Make your handler idempotent and always return 200 for events you've successfully processed — even if you decided to ignore them. Only return 400 for genuine errors like failed signature verification.

Should I process webhook events synchronously or queue them? For low-volume SaaS, processing synchronously in the handler is fine as long as you respond within 30 seconds. For anything that might take longer (complex provisioning, third-party API calls), push the event to a queue and return 200 immediately. Stripe only cares that you acknowledge receipt.


If you'd rather skip wiring all of this from scratch, BetterStarter comes with Stripe webhooks, subscription management, Better-Auth, Drizzle ORM, and transactional email pre-configured — $99 one-time, no recurring fees. Clone it, set your env vars, and your billing infrastructure is done.