· cloudflare / cloudflare-workers / d1

Cách deploy Cloudflare Worker với D1 + Stripe Webhooks

Xây dựng Stripe webhook handler trên Cloudflare Workers với D1. Từng bước: scaffold, wrangler.toml, Drizzle migrations, raw-body, và production deploy.

Bởi

1.682 từ · 9 phút đọc

Cloudflare Workers xử lý Stripe webhooks mà không cần cold start, VPC, hay connection pool. D1 cho bạn một database tương thích SQLite ngay tại edge, đã có sẵn trong gói Workers trả phí mà không tốn thêm. Cộng lại, hai thứ này đủ để xây dựng toàn bộ payment backend cho hầu hết các sản phẩm indie và early-stage.

Hướng dẫn này xây dựng một Stripe webhook handler hoàn chỉnh — xác thực signature và ghi payment event vào D1. Bạn có toàn bộ code cần thiết, từ scaffold đến production deploy.

Dành cho ai

Developer TypeScript muốn có payment backend gọn nhẹ mà không phải quản lý server. Bạn cần tài khoản Cloudflare, tài khoản Stripe, và biết cơ bản về CLI. Nếu bạn đang cân nhắc giữa Workers và Lambda trước khi quyết định, bảng so sánh bên dưới đề cập các đánh đổi chính.

Workers + D1 so với Lambda + RDS

Tiêu chíWorkers + D1Lambda + RDS
Cold start~0ms (V8 isolates)100ms–3s
Cấu hình VPCKhông cầnBắt buộc với RDS
Connection poolingCó sẵnCần RDS Proxy (~$0.015/hr)
Phân phối toàn cầuTự động (edge PoPs)Một region mặc định
Gói free100k req/ngày + 5M D1 row reads/ngày1M req/tháng + 750hr RDS (chỉ 12 tháng đầu)
SQL dialectSQLitePostgreSQL / MySQL
Dung lượng DB tối đa10 GB mỗi databaseLên đến 64 TB (Aurora Serverless)

D1 không phù hợp khi bạn cần đầy đủ tính năng Postgres — JSON operators, full-text search, complex CTE — hoặc khi một database vượt 10 GB. Với payment webhook và bảng users thông thường, bạn sẽ không chạm đến giới hạn đó.

Bước 1: Scaffold project

npm create cloudflare@latest -- my-stripe-worker

Ở các prompt, chọn:

  • Hello World example
  • Worker only
  • TypeScript: Yes
  • Deploy: No

Cài các dependency:

cd my-stripe-worker
npm install stripe drizzle-orm
npm install -D drizzle-kit

Cấu trúc project sau khi scaffold:

my-stripe-worker/
├── wrangler.toml
├── src/
│   └── index.ts
├── package.json
└── tsconfig.json

Bước 2: Tạo database D1

npx wrangler d1 create my-app-db

Wrangler in ra database ID sau khi tạo — hãy copy lại. Bạn cần dùng nó trong wrangler.toml.

Bước 3: Cấu hình 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]
# Chỉ đặt config không nhạy cảm ở đây. Secret dùng `wrangler secret put` — không bao giờ đặt ở đây.

binding = "DB" là tên biến JavaScript. Handler của bạn truy cập database qua env.DB. database_name là cố định; binding có thể đổi tên. Các Stripe secret (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET) được inject lúc runtime qua wrangler secret put — chúng không bao giờ xuất hiện trong file này.

Bước 4: Định nghĩa schema với Drizzle

Tạo 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 tương thích SQLite, nên Drizzle dùng sqlite-core thay vì pg-core. Để so sánh Drizzle với Kysely trên cả SQLite và Postgres, xem Drizzle ORM vs Kysely.

Tạo drizzle.config.ts ở thư mục gốc project:

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 là adapter D1 chính thức của Drizzle. dialect: 'sqlite' báo cho drizzle-kit tạo migration tương thích SQLite.

Bước 5: Tạo và áp dụng migration

Tạo SQL migration từ schema của bạn:

npx drizzle-kit generate

Lệnh này tạo drizzle/migrations/0000_create_users.sql (hoặc tên tương tự). Áp dụng local trước:

npx wrangler d1 migrations apply my-app-db --local

Rồi áp dụng lên production:

npx wrangler d1 migrations apply my-app-db --remote

Migration đã áp dụng được theo dõi trong bảng d1_migrations bên trong database. Chạy lại lệnh là an toàn — Wrangler tự bỏ qua các migration đã có.

Bước 6: Viết Stripe webhook handler

Thay nội dung src/index.ts bằng:

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 });
  },
};

Workers runtime chỉ cung cấp Web Crypto dưới dạng async. stripe-node chạy không cần polyfill, nhưng bạn phải dùng constructEventAsync với Stripe.createSubtleCryptoProvider()constructEvent đồng bộ sẽ throw SubtleCryptoProvider cannot be used in a synchronous context trên Workers.

Bước 7: Deploy và cài đặt secret

Deploy Worker:

npx wrangler deploy

Cài đặt Stripe secret. Wrangler yêu cầu bạn nhập giá trị tương tác — không bao giờ ghi ra disk hay wrangler.toml:

npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRET

Lấy STRIPE_WEBHOOK_SECRET từ Stripe Dashboard → Developers → Webhooks → endpoint của bạn → Signing secret.

Để test local, dùng Stripe CLI:

stripe listen --forward-to localhost:8787/webhook

CLI in ra một secret tạm whsec_... cho phiên làm việc. Chỉ dùng nó để phát triển local.

Những điểm cần lưu ý

Workers chỉ có Web Crypto dạng async — dùng constructEventAsync. stripe.webhooks.constructEvent() đồng bộ sẽ throw SubtleCryptoProvider cannot be used in a synchronous context trên Workers. Web Crypto có sẵn nhưng chỉ ở dạng async. Luôn gọi stripe.webhooks.constructEventAsync(rawBody, sig, secret, undefined, Stripe.createSubtleCryptoProvider())await nó. Đây là lỗi phổ biến nhất khi dùng Stripe trên Workers.

Đừng bao giờ gọi request.json() trước constructEventAsync. Kiểm tra HMAC của Stripe chạy trên đúng byte gốc mà nó đã gửi. request.json() parse và serialize lại body, có thể thay đổi khoảng trắng hoặc thứ tự key. Kết quả: constructEventAsync throw “No signatures found matching the expected signature.” Hãy dùng request.text() và truyền string đó trực tiếp vào constructEventAsync.

Giữ secret ra khỏi wrangler.toml. wrangler.toml thường được commit lên git. Bất kỳ thứ gì trong [vars] đều là plaintext trong version control. Dùng wrangler secret put cho mọi credential. Block [vars] chỉ dành cho config không nhạy cảm như public API base URL.

D1 là SQLite, không phải Postgres. SQLite không có Postgres JSON operators (->, @>), multi-row RETURNING, hay advisory lock. Các pattern webhook thanh toán — thêm order, cập nhật user — hoạt động tốt. Nếu bạn đang chọn giữa Postgres và SQLite cho use case của mình, Turso vs D1 so sánh hai lựa chọn SQLite tại edge.

Áp dụng migration production với --remote. Chạy wrangler d1 migrations apply không có --remote chỉ ảnh hưởng đến D1 replica local. Thay đổi schema trên production cần có --remote.

Giới hạn CPU gói free là 10ms mỗi lần gọi. Một webhook handler chỉ đọc và ghi vài lần vào D1 sẽ ở dưới giới hạn này. Nếu bạn xử lý batch event lớn trong một lần gọi, hãy chuyển lên gói trả phí ($5/tháng). Gói trả phí nâng giới hạn CPU mỗi lần gọi lên 30 giây; 30M CPU-ms mỗi tháng là pool hàng tháng được bao gồm (tách biệt với giới hạn mỗi request).

Giá cả

Gói free Workers: 100.000 request mỗi ngày, 10ms CPU mỗi lần gọi.

Workers Paid ($5/tháng tối thiểu):

Tiêu chíBao gồmVượt mức
Request10M / tháng$0.30 / triệu
CPU time30M ms / tháng$0.02 / triệu ms

D1 trên gói trả phí:

Tiêu chíBao gồmVượt mức
Row đọc25B / tháng$0.001 / triệu
Row ghi50M / tháng$1.00 / triệu
Lưu trữ5 GB$0.75 / GB-tháng

Không có phí egress. Không tính phí theo database. Một Stripe webhook handler với 10.000 request mỗi ngày chạy thoải mái trên gói free. D1 đã có trong gói Workers trả phí $5/tháng — không có dòng phí database riêng cho đến khi bạn vượt số row đã bao gồm.

Bạn đã xây dựng được gì

Một Stripe webhook handler trên Cloudflare Workers:

  • Xác thực Stripe signature dùng raw request body (không cần polyfill)
  • Ghi payment event vào D1 qua Drizzle ORM
  • Quản lý thay đổi schema với versioned Wrangler migration
  • Giữ toàn bộ secret ra khỏi code và config

Pattern này áp dụng được cho mọi payment event: gia hạn subscription, refund, dispute. Thêm các nhánh case vào switch và các lệnh Drizzle write tương ứng.

Tham khảo