Updated Nov 5, 20266 min read

How to Handle Stripe Webhooks in a TypeScript API

Verifying signatures over the raw body, acknowledging fast, deduplicating retries and processing events through a queue, with code that deploys to your own AWS or GCP account.

Stripe webhooks look simple: Stripe POSTs an event, you update your database. In production, three things go wrong. Signature verification fails because a body parser changed the bytes. Slow handlers time out and Stripe retries, so the same payment is recorded twice. And a burst of renewals at the start of the month arrives faster than the handler can work through it.

Stripe's webhook guide answers all three: verify against the raw body, return a 2xx before doing complex work, track event ids to catch duplicates, and process events through an asynchronous queue. This guide implements exactly that in TypeScript for checkout.session.completed, with Postgres for the records and a queue between the webhook and the work.

Quick start with Encore

Encore is a tool that works alongside your framework for TypeScript and Go, with a platform that provisions and deploys into your own AWS or GCP account. The queue, the database and the two Stripe secrets are declared in the same file as the webhook, run locally with encore run, and deploy to your own AWS or GCP account. The payments table uses the Stripe event id as its primary key:

-- payments/migrations/1_create_payments.up.sql CREATE TABLE payments ( stripe_event_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, email TEXT, amount_total BIGINT NOT NULL, currency TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );

The webhook is a raw endpoint, so the body reaches constructEvent exactly as Stripe sent it. It publishes to a Pub/Sub topic and returns 200 right away, and the subscriber does the database work:

// payments/webhook.ts import { api } from "encore.dev/api"; import { secret } from "encore.dev/config"; import { Topic, Subscription } from "encore.dev/pubsub"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import log from "encore.dev/log"; import Stripe from "stripe"; const db = new SQLDatabase("payments", { migrations: "./migrations" }); const stripeKey = secret("StripeSecretKey"); const webhookSecret = secret("StripeWebhookSecret"); // whsec_... from the dashboard or stripe listen let client: Stripe | undefined; const stripe = () => (client ??= new Stripe(stripeKey())); export interface CheckoutCompleted { eventId: string; sessionId: string; email: string | null; amountTotal: number; currency: string; } export const checkoutCompleted = new Topic<CheckoutCompleted>("checkout-completed", { deliveryGuarantee: "at-least-once", }); // Raw endpoint, because the signature is computed over the exact request bytes. export const stripeWebhook = api.raw( { expose: true, method: "POST", path: "/webhooks/stripe" }, async (req, resp) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks); let event: Stripe.Event; try { event = stripe().webhooks.constructEvent( body, req.headers["stripe-signature"] as string, webhookSecret(), ); } catch (err) { log.warn("invalid stripe signature", { error: String(err) }); resp.writeHead(400).end(); return; } if ( event.type === "checkout.session.completed" || event.type === "checkout.session.async_payment_succeeded" ) { const session = event.data.object; // Delayed payment methods complete the session while still unpaid; // async_payment_succeeded arrives once the money has settled. if (session.payment_status === "unpaid") { resp.writeHead(200).end(); return; } await checkoutCompleted.publish({ eventId: event.id, sessionId: session.id, email: session.customer_details?.email ?? null, amountTotal: session.amount_total ?? 0, currency: session.currency ?? "usd", }); } // Acknowledge fast; the subscriber does the database work. resp.writeHead(200).end(); }, ); const _ = new Subscription(checkoutCompleted, "record-payment", { handler: async (e) => { // Stripe can send the same event more than once, so dedupe on its id. const inserted = await db.queryRow` INSERT INTO payments (stripe_event_id, session_id, email, amount_total, currency) VALUES (${e.eventId}, ${e.sessionId}, ${e.email}, ${e.amountTotal}, ${e.currency}) ON CONFLICT (stripe_event_id) DO NOTHING RETURNING stripe_event_id`; if (!inserted) return; log.info("payment recorded", { sessionId: e.sessionId, amount: e.amountTotal }); }, retryPolicy: { maxRetries: 5, minBackoff: "10s" }, });

The webhook only verifies and enqueues, so it answers Stripe in milliseconds even during a spike. If the database is briefly unavailable, the subscriber fails and is retried with backoff, then lands in a dead-letter queue, instead of making Stripe retry the whole webhook. And because the topic is at-least-once and Stripe can deliver an event twice, the ON CONFLICT insert keyed on the event id makes both kinds of duplicates harmless.

To test locally, set the secrets and forward test events with the Stripe CLI. stripe listen prints the whsec_ secret to use for the local environment:

~/shop
$ encore secret set --type local StripeSecretKey$ encore secret set --type local StripeWebhookSecret$ encore run$ stripe listen --forward-to localhost:4000/webhooks/stripe$ stripe trigger checkout.session.completed

On deploy, Encore provisions the topic as SNS and SQS on AWS or Pub/Sub on GCP, the database as RDS or Cloud SQL, and the secrets in the cloud's secret manager, in your own account. Set the production secrets with encore secret set --type prod using the signing secret from the endpoint you register in the Stripe dashboard.

Coding agents get the same structure: adding a handler for invoice.paid is a new branch in the webhook and a subscriber, which an agent can write and test locally with stripe trigger before anyone deploys it.

The TypeScript secrets docs cover how the Stripe keys are stored per environment.

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

Without Encore: Express or Fastify

The pattern carries over to any TypeScript server. The detail that trips people up is the body parser. In Express, register the webhook route with express.raw({ type: "application/json" }) before any global express.json() middleware, and pass req.body (a Buffer) to stripe.webhooks.constructEvent. In Fastify, add a content type parser that keeps the body as a Buffer for that route.

For the queue, a common setup on AWS is SQS with a dead-letter queue and a worker process that polls it, or EventBridge, since Stripe can also send events directly to an Amazon EventBridge bus. Either way you need the queue, the worker deployment, IAM permissions for both and the secrets in Secrets Manager, usually in Terraform.

Good to know

  • Each endpoint has its own signing secret, and the test and live versions differ. Store one per environment.
  • constructEvent rejects events whose timestamp is more than five minutes old by default, so keep server clocks synced.
  • Stripe does not guarantee event order. If you handle several event types for the same object, fetch the current object from the API instead of relying on the order events arrive in.
  • Subscribe the endpoint only to the event types you handle.
  • For new integrations Stripe also offers thin events, which carry a reference to the object instead of a snapshot. The verification and idempotency steps are the same.

How to choose

Use Encore when you want the webhook, queue, database and secrets in one TypeScript codebase, with local testing through the Stripe CLI and deploys to your own AWS or GCP account. Wire it up by hand in Express or Fastify when the webhook has to live inside an existing server and you already run a queue and workers you can reuse.

Frequently asked questions

Why does Stripe webhook signature verification fail in my TypeScript app?

Usually because a JSON body parser ran first. Stripe signs the exact bytes it sent, so constructEvent needs the raw request body as a Buffer or string. Read the body yourself on the webhook route instead of letting a framework parse it, and check that you use the whsec_ secret for that specific endpoint.

How do I stop processing the same Stripe event twice?

Store the event id in a table with a unique constraint and skip events you have already recorded, for example INSERT ... ON CONFLICT (stripe_event_id) DO NOTHING. Stripe can deliver the same event more than once and retries failed deliveries for up to three days in live mode.

What is the simplest way to handle Stripe webhooks with a queue in TypeScript?

With Encore, the webhook is an api.raw endpoint that verifies the signature and publishes to new Topic<CheckoutCompleted>("checkout-completed", { deliveryGuarantee: "at-least-once" }). A Subscription with a retryPolicy does the database work, and Encore provisions the topic as SNS and SQS on AWS or Pub/Sub on GCP.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders