integrating-stripe-webhooks

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Integrating Stripe Webhooks

集成Stripe Webhook

Overview

概述

Stripe webhooks require raw request bodies for signature verification. Most web frameworks parse JSON automatically, breaking verification. This skill provides framework-specific solutions for the raw body problem and documents common TypeScript type mismatches.
Stripe webhook需要原始请求体来进行签名验证。大多数Web框架会自动解析JSON,这会破坏验证流程。本技能提供针对不同框架的原始请求体问题解决方案,并记录了常见的TypeScript类型不匹配问题。

When to Use

使用场景

Use this skill when:
  • Getting "Raw body not available" errors from Stripe webhooks
  • Webhook signature verification fails with 400 errors
  • Implementing new Stripe webhook endpoints
  • Getting
    TypeError: Cannot read property 'current_period_start'
    from subscription events
  • Webhooks return 404 (route registration issues)
Don't use for:
  • General Stripe API integration (not webhooks)
  • Frontend Stripe Elements implementation
  • Stripe checkout session creation (use Stripe docs)
在以下场景使用本技能:
  • 从Stripe webhook收到“Raw body not available”错误
  • Webhook签名验证失败并返回400错误
  • 实现新的Stripe webhook端点
  • 从订阅事件中收到
    TypeError: Cannot read property 'current_period_start'
    错误
  • Webhook返回404(路由注册问题)
请勿在以下场景使用:
  • 通用Stripe API集成(非webhook相关)
  • 前端Stripe Elements实现
  • Stripe结账会话创建(请参考Stripe官方文档)

Quick Reference

快速参考

ProblemSolution
Raw body not availableConfigure custom body parser (see framework examples)
Signature verification failsUse raw body bytes/buffer, not parsed JSON
404 on webhook endpointRegister webhook route inside API prefix
current_period_start
undefined
Access from
subscription.items.data[0]
not root
URI validation errorsURL-encode dynamic parameters with
encodeURIComponent()
问题解决方案
原始请求体不可用配置自定义请求体解析器(查看框架示例)
签名验证失败使用原始请求体字节/缓冲区,而非解析后的JSON
Webhook端点返回404在API前缀内注册webhook路由
current_period_start
未定义
subscription.items.data[0]
而非根节点获取
URI验证错误使用
encodeURIComponent()
对动态参数进行URL编码

Critical: Raw Body Parsing

关键要点:原始请求体解析

THE PROBLEM: Stripe's
constructEvent()
requires the exact bytes received to verify the signature. JSON parsing modifies the body, breaking verification.
THE SOLUTION: Access raw body before any parsing middleware.
问题: Stripe的
constructEvent()
方法需要接收到的原始字节来验证签名。JSON解析会修改请求体,导致验证失败。
解决方案: 在任何解析中间件之前获取原始请求体。

Framework Examples

框架示例

Node.js - Fastify (most common for new projects):
typescript
// In main server file, BEFORE registering routes
server.addContentTypeParser('application/json',
  { parseAs: 'buffer' },
  async (req: any, body: Buffer) => {
    req.rawBody = body;  // Store for webhooks
    return JSON.parse(body.toString('utf8'));  // Parse for other routes
  }
);

// In webhook handler
const rawBody = (request as any).rawBody;
const event = stripe.webhooks.constructEvent(
  rawBody, signature, webhookSecret
);
Node.js - Express:
javascript
// Define webhook route BEFORE express.json() middleware
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const event = stripe.webhooks.constructEvent(
      req.body,  // Already raw Buffer
      req.headers['stripe-signature'],
      webhookSecret
    );
  }
);

app.use(express.json());  // After webhook route
Python - FastAPI:
python
@app.post('/webhooks/stripe')
async def stripe_webhook(request: Request):
    payload = await request.body()  # Use .body() not .json()
    signature = request.headers.get('stripe-signature')

    event = stripe.Webhook.construct_event(
        payload, signature, webhook_secret
    )
General Pattern: Get raw bytes/buffer → verify signature → use parsed event from Stripe.
Node.js - Fastify(新项目最常用):
typescript
// In main server file, BEFORE registering routes
server.addContentTypeParser('application/json',
  { parseAs: 'buffer' },
  async (req: any, body: Buffer) => {
    req.rawBody = body;  // Store for webhooks
    return JSON.parse(body.toString('utf8'));  // Parse for other routes
  }
);

// In webhook handler
const rawBody = (request as any).rawBody;
const event = stripe.webhooks.constructEvent(
  rawBody, signature, webhookSecret
);
Node.js - Express:
javascript
// Define webhook route BEFORE express.json() middleware
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const event = stripe.webhooks.constructEvent(
      req.body,  // Already raw Buffer
      req.headers['stripe-signature'],
      webhookSecret
    );
  }
);

app.use(express.json());  // After webhook route
Python - FastAPI:
python
@app.post('/webhooks/stripe')
async def stripe_webhook(request: Request):
    payload = await request.body()  # Use .body() not .json()
    signature = request.headers.get('stripe-signature')

    event = stripe.Webhook.construct_event(
        payload, signature, webhook_secret
    )
通用模式: 获取原始字节/缓冲区 → 验证签名 → 使用Stripe返回的解析后事件。

Common Mistakes

常见错误

1. Subscription Period Fields Missing

1. 订阅周期字段缺失

Error:
TypeError: Cannot read property 'current_period_start' of undefined
Cause: Stripe returns period dates in
subscription.items.data[0]
, not at subscription root. TypeScript types don't include these fields on
SubscriptionItem
.
Fix:
typescript
// ❌ WRONG - fields don't exist here
new Date(subscription.current_period_start * 1000)

// ✅ CORRECT - get from first subscription item
const firstItem = subscription.items.data[0] as any;
const periodStart = firstItem?.current_period_start || subscription.billing_cycle_anchor;
const periodEnd = firstItem?.current_period_end || subscription.billing_cycle_anchor;

await updateOrg({
  start_date: new Date(periodStart * 1000),
  end_date: new Date(periodEnd * 1000),
});
错误:
TypeError: Cannot read property 'current_period_start' of undefined
原因: Stripe在
subscription.items.data[0]
中返回周期日期,而非订阅根节点。TypeScript类型定义中
SubscriptionItem
不包含这些字段。
修复方案:
typescript
// ❌ 错误写法 - 这些字段不存在于此处
new Date(subscription.current_period_start * 1000)

// ✅ 正确写法 - 从第一个订阅项获取
const firstItem = subscription.items.data[0] as any;
const periodStart = firstItem?.current_period_start || subscription.billing_cycle_anchor;
const periodEnd = firstItem?.current_period_end || subscription.billing_cycle_anchor;

await updateOrg({
  start_date: new Date(periodStart * 1000),
  end_date: new Date(periodEnd * 1000),
});

2. Route Not Found (404)

2. 路由未找到(404)

Cause: Webhook routes registered outside API prefix.
typescript
// ❌ WRONG - creates /webhooks/stripe instead of /api/v1/webhooks/stripe
export async function registerRoutes(server) {
  server.register(async (api) => {
    await api.register(subscriptionRoutes, { prefix: '/subscriptions' });
  }, { prefix: '/api/v1' });

  await server.register(webhookRoutes, { prefix: '/webhooks' });  // Outside!
}

// ✅ CORRECT - inside API prefix
export async function registerRoutes(server) {
  server.register(async (api) => {
    await api.register(subscriptionRoutes, { prefix: '/subscriptions' });
    await api.register(webhookRoutes, { prefix: '/webhooks' });  // Inside
  }, { prefix: '/api/v1' });
}
原因: Webhook路由注册在API前缀之外。
typescript
// ❌ 错误写法 - 创建的是/webhooks/stripe而非/api/v1/webhooks/stripe
export async function registerRoutes(server) {
  server.register(async (api) => {
    await api.register(subscriptionRoutes, { prefix: '/subscriptions' });
  }, { prefix: '/api/v1' });

  await server.register(webhookRoutes, { prefix: '/webhooks' });  // 在外部!
}

// ✅ 正确写法 - 在API前缀内部
export async function registerRoutes(server) {
  server.register(async (api) => {
    await api.register(subscriptionRoutes, { prefix: '/subscriptions' });
    await api.register(webhookRoutes, { prefix: '/webhooks' });  // 在内部
  }, { prefix: '/api/v1' });
}

3. URL Encoding in Checkout URLs

3. 结账URL中的URL编码问题

Error:
"body/successUrl must match format 'uri'"
Cause: Organization names or parameters with spaces not URL-encoded.
typescript
// ❌ WRONG - "Broke Org" creates invalid URL
const successUrl = `${origin}/orgs?name=${orgName}&subscription=success`;

// ✅ CORRECT - encode dynamic parameters
const successUrl = `${origin}/orgs?name=${encodeURIComponent(orgName)}&subscription=success`;
错误:
"body/successUrl must match format 'uri'"
原因: 组织名称或包含空格的参数未进行URL编码。
typescript
// ❌ 错误写法 - "Broke Org"会创建无效URL
const successUrl = `${origin}/orgs?name=${orgName}&subscription=success`;

// ✅ 正确写法 - 对动态参数进行编码
const successUrl = `${origin}/orgs?name=${encodeURIComponent(orgName)}&subscription=success`;

Implementation Checklist

实施检查清单

Server Setup:
  • Configure raw body parser BEFORE routes
  • Register webhook routes inside API prefix (if using one)
  • Set
    STRIPE_WEBHOOK_SECRET
    environment variable
  • Verify webhook secret is configured before processing
Webhook Handler:
  • Validate
    stripe-signature
    header exists
  • Access raw body (not parsed JSON)
  • Use
    stripe.webhooks.constructEvent()
    for verification
  • Handle
    SignatureVerificationError
    separately
  • Return 200 for received events (even if processing fails)
  • Log all events with ID and type
Subscription Events:
  • Get period dates from
    subscription.items.data[0]
  • Cast to
    any
    to access TypeScript-missing fields
  • Fallback to
    billing_cycle_anchor
    if items missing
  • Store
    org_id
    in subscription metadata
  • Update verification status based on subscription status
Frontend:
  • URL-encode all dynamic parameters
  • URL-encode organization names in success/cancel URLs
  • Handle checkout errors gracefully
  • Poll for verification after checkout success
服务器设置:
  • 在路由之前配置原始请求体解析器
  • 在API前缀内注册webhook路由(如果使用前缀)
  • 设置
    STRIPE_WEBHOOK_SECRET
    环境变量
  • 在处理之前验证webhook密钥已配置
Webhook处理函数:
  • 验证
    stripe-signature
    请求头存在
  • 获取原始请求体(而非解析后的JSON)
  • 使用
    stripe.webhooks.constructEvent()
    进行验证
  • 单独处理
    SignatureVerificationError
    错误
  • 对收到的事件返回200响应(即使处理失败)
  • 记录所有事件的ID和类型
订阅事件:
  • subscription.items.data[0]
    中获取周期日期
  • 转换为
    any
    类型以访问TypeScript未定义的字段
  • 如果订阅项缺失,回退到
    billing_cycle_anchor
  • 在订阅元数据中存储
    org_id
  • 根据订阅状态更新验证状态
前端:
  • 对所有动态参数进行URL编码
  • 对成功/取消URL中的组织名称进行URL编码
  • 优雅处理结账错误
  • 结账成功后轮询验证状态

Testing Locally

本地测试

bash
undefined
bash
undefined

Install Stripe CLI

安装Stripe CLI

brew install stripe/stripe-cli/stripe
brew install stripe/stripe-cli/stripe

Forward webhooks to local server

将webhook转发到本地服务器

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

Trigger test events

触发测试事件

stripe trigger customer.subscription.created stripe trigger customer.subscription.updated stripe trigger invoice.paid
undefined
stripe trigger customer.subscription.created stripe trigger customer.subscription.updated stripe trigger invoice.paid
undefined

Real-World Impact

实际效果

Before applying these patterns:
  • Webhooks fail with 400 "Invalid signature"
  • Subscription updates crash with undefined property errors
  • Hours debugging TypeScript type mismatches
  • Checkout fails with URL validation errors
After applying:
  • Webhooks verify successfully
  • Subscription data extracts correctly
  • Type-safe with explicit casting
  • Checkout URLs work with any organization name
应用这些模式之前:
  • Webhook因400“无效签名”失败
  • 订阅更新因未定义属性错误崩溃
  • 花费数小时调试TypeScript类型不匹配问题
  • 结账因URL验证错误失败
应用之后:
  • Webhook验证成功
  • 订阅数据正确提取
  • 通过显式转换实现类型安全
  • 结账URL适用于任何组织名称

References

参考资料