· cloudflare / cloudflare-workers / d1
How to Deploy a Cloudflare Worker with D1 + Stripe
Build a Stripe webhook handler on Cloudflare Workers with D1. Covers scaffold, wrangler.toml, Drizzle migrations, the raw-body gotcha, and production deploy.
By Ethan
1,514 words · 8 min read
Cloudflare Workers handle Stripe webhooks without cold starts, VPCs, or connection pools. D1 gives you a SQLite-compatible database at the edge, included in the Workers paid plan at no extra cost. Together they cover the full payment backend for most indie and early-stage products.
This guide builds a working Stripe webhook handler that verifies signatures and writes payment events to D1. You get every code file you need — from scaffold to production deploy.
Who this is for
TypeScript developers who want a lightweight payment backend without managing a server. You need a Cloudflare account, a Stripe account, and basic CLI skills. If you are evaluating Workers against Lambda before committing, the comparison table below covers the key tradeoffs.
Workers + D1 vs Lambda + RDS
| Dimension | Workers + D1 | Lambda + RDS |
|---|---|---|
| Cold starts | ~0ms (V8 isolates) | 100ms–3s |
| VPC config | None needed | Required for RDS |
| Connection pooling | Built-in | Requires RDS Proxy (~$0.015/hr) |
| Global distribution | Automatic (edge PoPs) | Single region by default |
| Free tier | 100k req/day + 5M D1 row reads/day | 1M req/month + 750hr RDS (12mo only) |
| SQL dialect | SQLite | PostgreSQL / MySQL |
| Max DB size | 10 GB per database | Up to 64 TB (Aurora Serverless) |
D1 loses when you need full Postgres features — JSON operators, full-text search, complex CTEs — or when a single database exceeds 10 GB. For payment webhooks and a users table, you won’t hit those limits.
Step 1: Scaffold the project
npm create cloudflare@latest -- my-stripe-worker
At the prompts, select:
- Hello World example
- Worker only
- TypeScript: Yes
- Deploy: No
Install the dependencies:
cd my-stripe-worker
npm install stripe drizzle-orm
npm install -D drizzle-kit
Your project structure after scaffold:
my-stripe-worker/
├── wrangler.toml
├── src/
│ └── index.ts
├── package.json
└── tsconfig.json
Step 2: Create the D1 database
npx wrangler d1 create my-app-db
Wrangler prints the database ID after creation — copy it. You need it in wrangler.toml.
Step 3: Configure wrangler.toml
name = "my-stripe-worker"
main = "src/index.ts"
compatibility_date = "2024-04-01"
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "<UUID-from-step-2>"
migrations_dir = "drizzle/migrations"
[vars]
# Non-sensitive config only. Secrets go via `wrangler secret put` — never here.
binding = "DB" is the JavaScript variable name. Your handler accesses the database as env.DB. The database_name is permanent; binding can change. Stripe secrets (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET) are injected at runtime via wrangler secret put — they never appear in this file.
Step 4: Define the schema with Drizzle
Create src/schema.ts:
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
stripeCustomerId: text('stripe_customer_id'),
createdAt: integer('created_at', { mode: 'timestamp' }),
});
D1 is SQLite-compatible, so Drizzle uses sqlite-core, not pg-core. For a comparison of Drizzle against Kysely across both SQLite and Postgres targets, see Drizzle ORM vs Kysely.
Create drizzle.config.ts at the project root:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'sqlite',
driver: 'd1-http',
schema: './src/schema.ts',
out: './drizzle',
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID!,
token: process.env.CLOUDFLARE_API_TOKEN!,
},
});
d1-http is Drizzle’s first-party D1 adapter. dialect: 'sqlite' tells drizzle-kit to generate SQLite-compatible migrations.
Step 5: Generate and apply migrations
Generate the SQL migration from your schema:
npx drizzle-kit generate
This writes drizzle/migrations/0000_create_users.sql (or similar). Apply it locally first:
npx wrangler d1 migrations apply my-app-db --local
Then apply to production:
npx wrangler d1 migrations apply my-app-db --remote
Applied migrations are tracked in a d1_migrations table inside your database. Re-running the command is safe — Wrangler skips already-applied migrations.
Step 6: Write the Stripe webhook handler
Replace the contents of src/index.ts:
import Stripe from 'stripe';
import { drizzle } from 'drizzle-orm/d1';
import { eq } from 'drizzle-orm';
import { users } from './schema';
export interface Env {
DB: D1Database;
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method !== 'POST' || url.pathname !== '/webhook') {
return new Response('Not found', { status: 404 });
}
// Read the raw body as a string BEFORE any JSON parsing.
// Stripe's HMAC check requires the exact byte sequence it sent.
// request.json() reorders keys and breaks the signature — use request.text().
const rawBody = await request.text();
const sig = request.headers.get('stripe-signature');
if (!sig) {
return new Response('Missing stripe-signature header', { status: 400 });
}
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2024-12-18.acacia',
httpClient: Stripe.createFetchHttpClient(), // recommended on Workers
});
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(
rawBody,
sig,
env.STRIPE_WEBHOOK_SECRET,
undefined,
Stripe.createSubtleCryptoProvider(),
);
} catch (err) {
return new Response(`Webhook error: ${(err as Error).message}`, { status: 400 });
}
const db = drizzle(env.DB);
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
if (session.customer && session.customer_email) {
await db
.update(users)
.set({ stripeCustomerId: session.customer as string })
.where(eq(users.email, session.customer_email));
}
break;
}
case 'payment_intent.succeeded': {
// Add payment success logic here
break;
}
default:
// Return 200 for unhandled events — Stripe retries on non-2xx
break;
}
return Response.json({ received: true });
},
};
The Workers runtime exposes Web Crypto as async-only. stripe-node runs without polyfills, but you must use constructEventAsync with Stripe.createSubtleCryptoProvider() — the synchronous constructEvent throws SubtleCryptoProvider cannot be used in a synchronous context on Workers.
Step 7: Deploy and set secrets
Deploy the Worker:
npx wrangler deploy
Set the Stripe secrets. Wrangler prompts you to type the value interactively — it never writes to disk or wrangler.toml:
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRET
Get STRIPE_WEBHOOK_SECRET from the Stripe Dashboard → Developers → Webhooks → your endpoint → Signing secret.
For local testing, use the Stripe CLI:
stripe listen --forward-to localhost:8787/webhook
The CLI prints a temporary whsec_... secret for the session. Use it only for local development.
Gotchas
Workers only expose async Web Crypto — use constructEventAsync.
The synchronous stripe.webhooks.constructEvent() throws SubtleCryptoProvider cannot be used in a synchronous context on Workers. Web Crypto is present but async-only. Always call stripe.webhooks.constructEventAsync(rawBody, sig, secret, undefined, Stripe.createSubtleCryptoProvider()) and await it. This is the most common Stripe-on-Workers failure.
Never call request.json() before constructEventAsync.
Stripe’s HMAC check runs against the literal bytes it sent. request.json() parses and re-serializes the body, which can change whitespace or key order. The result: constructEventAsync throws “No signatures found matching the expected signature.” Use request.text() and pass the string directly to constructEventAsync.
Keep secrets out of wrangler.toml.
wrangler.toml is typically committed to git. Anything in [vars] is plaintext in version control. Use wrangler secret put for every credential. The [vars] block is for non-sensitive config like a public API base URL.
D1 is SQLite, not Postgres.
SQLite lacks Postgres JSON operators (->, @>), multi-row RETURNING, and advisory locks. Payment webhook patterns — inserting orders, updating user records — work fine. If you’re choosing between Postgres and SQLite for your use case, Turso vs D1 covers the tradeoffs between the two SQLite-at-the-edge options.
Apply production migrations with --remote.
Running wrangler d1 migrations apply without --remote only affects your local D1 replica. Production schema changes need --remote.
Free-tier CPU limit is 10ms per invocation. A webhook handler that does a few D1 reads and writes stays well under this. If you process large event batches in a single invocation, move to the paid plan ($5/month). The paid plan raises the per-invocation CPU limit to 30 seconds; the plan includes 30M CPU-ms per month as the monthly pool (separate from the per-request cap).
Pricing
Workers free tier: 100,000 requests per day, 10ms CPU per invocation.
Workers Paid ($5/month minimum):
| Metric | Included | Overage |
|---|---|---|
| Requests | 10M / month | $0.30 / million |
| CPU time | 30M ms / month | $0.02 / million ms |
D1 on the paid plan:
| Metric | Included | Overage |
|---|---|---|
| Rows read | 25B / month | $0.001 / million |
| Rows written | 50M / month | $1.00 / million |
| Storage | 5 GB | $0.75 / GB-month |
No egress fees. No per-database fees. A Stripe webhook handler at 10,000 requests per day runs on the free tier. D1 is included in the $5/month Workers paid plan — there is no separate database line item until you exceed the included rows.
What you built
A Stripe webhook handler on Cloudflare Workers that:
- Verifies Stripe signatures using the raw request body (no polyfills)
- Writes payment events to D1 via Drizzle ORM
- Manages schema changes with versioned Wrangler migrations
- Keeps all secrets out of code and config
The same pattern extends to any payment event: subscription renewals, refunds, disputes. Add case branches to the switch and additional Drizzle writes as needed.