Stripe Webhook 集成实战:完整接收、验签与事件处理代码

Stripe Webhook 完整集成指南:Dashboard 配置、Endpoint Secret 获取、Go/Node.js 签名验证、8 种核心事件处理(invoice.paid/subscription.created 等)、常见问题排查、测试环境最佳实践。

Stripe Webhook 集成实战:完整接收、验签与事件处理代码

TL;DR 本文是 Stripe Webhook 的端到端集成指南:

  • 5 分钟完成 Stripe Dashboard 配置 + Endpoint Secret 获取
  • 掌握 Stripe 签名验证的完整流程(含 Go/Node.js 可运行代码)
  • 获取 8 种核心事件的处理模板:支付成功、订阅创建、退款、 disputes 等

阅读收益

  • ⭐⭐⭐ 高:直接对接全球最流行的支付平台
  • 📖 难度:初中级,面向集成 Stripe 的后端 / 全栈开发者
  • ⏱️ 约 14 分钟,含可运行代码 + Dashboard 截图说明

30 秒速览:Stripe Webhook 为何不可或缺

Stripe 的支付流程是异步的:用户完成支付后,状态不会立即同步到你的服务器。Webhook 是唯一可靠的方式获取支付结果。

用户支付 ──► Stripe 处理(3-10 秒)──► Webhook 推送到你的服务器
                                            │
                                            ▼
                                    ┌───────────────┐
                                    │ 更新订单状态    │
                                    │ 发送确认邮件    │
                                    │ 开通服务权限    │
                                    └───────────────┘

⚠️ 绝对不能只依赖前端回调:用户可能关闭页面、网络中断,只有 Webhook 保证送达。


1. Stripe Dashboard 配置

1.1 获取 Endpoint Secret

# Step 1: 登录 Stripe Dashboard
open https://dashboard.stripe.com/webhooks

# Step 2: 点击 "Add endpoint"
# Step 3: 填写你的 Webhook URL
#   https://api.yourapp.com/webhook/stripe

# Step 4: 选择需要监听的事件(Events to send)
#   - invoice.paid              ✅ 支付成功(最核心)
#   - customer.subscription.created  ✅ 订阅创建
#   - customer.subscription.deleted  ✅ 订阅取消
#   - charge.refunded           ✅ 退款
#   - charge.dispute.created    ✅ 争议

# Step 5: 保存后复制 "Signing secret"
#   格式:whsec_xxxxxxxxxxxxxxxx

1.2 测试环境配置

# Stripe 提供测试模式的独立 Webhook Endpoint
# Dashboard 切换开关:Test mode / Live mode

# 测试用信用卡号(Stripe 官方提供)
# 成功: 4242 4242 4242 4242
# 失败: 4000 0000 0000 0002
# 需要 3DS: 4000 0025 0000 3155

1.3 Stripe CLI 本地测试(无需部署)

# 安装 Stripe CLI
brew install stripe/stripe-cli/stripe

# 登录
stripe login

# 监听并转发到本地
stripe listen --forward-to localhost:8080/webhook/stripe

# 输出:
# Ready! Your webhook signing secret is whsec_xxx (^C to quit)
# 这个 secret 和 Dashboard 里的一致

💡 本地调试必备:Stripe CLI 会生成测试事件并本地转发,无需部署到服务器。


2. 签名验证:Stripe 特有格式

2.1 Stripe-Signature 格式解析

Stripe 的签名头格式与通用 Webhook 不同:

Stripe-Signature: t=1234567890,v1=abc123...,v0=def456...
  • t:时间戳(Unix epoch)
  • v1:当前版本的签名(用这个)
  • v0:旧版本签名(向后兼容,通常忽略)

签名计算方式:

signed_payload = timestamp + "." + request_body
signature = HMAC_SHA256(endpoint_secret, signed_payload)

⚠️ 关键点:签名是基于 timestamp + "." + body,不是仅 body。


2.2 Go 完整实现(含时间戳校验)

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "net/http"
    "strconv"
    "strings"
    "time"
)

const stripeSecret = "whsec_xxxxxxxxxxxxxxxx" // 从 Dashboard 获取

func verifyStripeSignature(payload []byte, signatureHeader string) bool {
    // Step 1: 解析签名头
    parts := strings.Split(signatureHeader, ",")
    var timestamp string
    var signatures []string
    
    for _, part := range parts {
        kv := strings.SplitN(part, "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            timestamp = kv[1]
        case "v1":
            signatures = append(signatures, kv[1])
        }
    }
    
    if timestamp == "" || len(signatures) == 0 {
        return false
    }
    
    // Step 2: 时间戳校验(防重放,5 分钟窗口)
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false
    }
    if time.Now().Unix()-ts > 300 {
        return false // 请求超过 5 分钟,拒绝
    }
    
    // Step 3: 计算签名
    signedPayload := timestamp + "." + string(payload)
    mac := hmac.New(sha256.New, []byte(stripeSecret))
    mac.Write([]byte(signedPayload))
    expected := hex.EncodeToString(mac.Sum(nil))
    
    // Step 4: 对比签名(常量时间比较)
    for _, sig := range signatures {
        if hmac.Equal([]byte(sig), []byte(expected)) {
            return true
        }
    }
    return false
}

func stripeWebhookHandler(w http.ResponseWriter, r *http.Request) {
    payload, _ := io.ReadAll(r.Body)
    sig := r.Header.Get("Stripe-Signature")
    
    if !verifyStripeSignature(payload, sig) {
        http.Error(w, "Invalid signature", http.StatusBadRequest)
        return
    }
    
    // 验签通过,解析事件
    var event stripe.Event
    if err := json.Unmarshal(payload, &event); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    fmt.Printf("Received event: %s (type: %s)\n", event.ID, event.Type)
    w.WriteHeader(http.StatusOK)
}

Go 要点:

  • Stripe 签名头含多个字段(tv1v0),需逐个解析
  • signedPayload 格式为 timestamp + "." + body,这是 Stripe 特有的
  • 时间戳校验 + 签名验证双重防护

2.3 Node.js 完整实现

const stripe = require('stripe')('sk_test_xxx'); // 你的 API Key
const express = require('express');
const app = express();

const endpointSecret = 'whsec_xxx'; // 从 Dashboard 获取

app.post('/webhook/stripe', express.raw({type: 'application/json'}), (req, res) => {
    const sig = req.headers['stripe-signature'];
    let event;
    
    try {
        // Stripe 官方 SDK 提供内置验证
        event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
    } catch (err) {
        console.log(`⚠️ Webhook signature verification failed: ${err.message}`);
        return res.status(400).send(`Webhook Error: ${err.message}`);
    }
    
    console.log(`✅ Received event: ${event.id} (${event.type})`);
    
    // 处理事件
    handleStripeEvent(event);
    
    res.status(200).send('Received');
});

function handleStripeEvent(event) {
    switch (event.type) {
        case 'invoice.paid':
            handleInvoicePaid(event.data.object);
            break;
        case 'customer.subscription.created':
            handleSubscriptionCreated(event.data.object);
            break;
        // ... 更多事件
    }
}

app.listen(8080, () => console.log('Webhook server running on port 8080'));

Node.js 要点:

  • express.raw() 必须:Stripe SDK 需要原始 body bytes
  • 用 Stripe 官方 SDK 验签stripe.webhooks.constructEvent() 自动处理时间戳 + 签名
  • SDK 版本:stripe npm 包 v8.0+

3. 8 种核心事件处理模板

3.1 事件处理总表

事件类型触发时机业务动作优先级
invoice.paid发票支付成功开通服务、发送确认邮件⭐ 最高
invoice.payment_failed发票支付失败提醒用户更新付款方式⭐⭐ 高
customer.subscription.created订阅创建创建账户记录、发送欢迎邮件⭐⭐ 高
customer.subscription.updated订阅变更更新套餐、记录变更历史⭐⭐ 高
customer.subscription.deleted订阅取消标记过期、发送挽留邮件⭐⭐ 高
charge.refunded退款返还权益、记录退款⭐⭐ 高
charge.dispute.created争议/拒付冻结账户、收集证据🔴 紧急
setup_intent.succeeded支付方式设置成功保存 payment_method_id⭐ 普通

3.2 invoice.paid — 支付成功(最核心)

func handleInvoicePaid(invoice *stripe.Invoice) error {
    // 1. 幂等性检查
    if isProcessed(invoice.ID) {
        return nil
    }
    
    // 2. 获取关联信息
    subscriptionID := invoice.Subscription.ID
    customerID := invoice.Customer.ID
    amountPaid := invoice.AmountPaid // 单位:分
    
    // 3. 更新订单状态
    if err := db.Exec(`
        UPDATE orders 
        SET status = 'paid', 
            paid_at = NOW(),
            stripe_subscription_id = $1,
            amount_paid = $2
        WHERE stripe_invoice_id = $3
    `, subscriptionID, amountPaid, invoice.ID); err != nil {
        return err
    }
    
    // 4. 开通服务(异步)
    go func() {
        provisionService(customerID, subscriptionID)
        sendConfirmationEmail(customerID, invoice)
    }()
    
    // 5. 标记已处理
    markProcessed(invoice.ID)
    return nil
}

3.3 invoice.payment_failed — 支付失败

func handleInvoicePaymentFailed(invoice *stripe.Invoice) error {
    customerID := invoice.Customer.ID
    attemptCount := invoice.AttemptCount
    
    // 1. 更新订单状态
    db.Exec(`UPDATE orders SET status = 'payment_failed', 
        payment_attempts = $1 WHERE stripe_invoice_id = $2`,
        attemptCount, invoice.ID)
    
    // 2. 根据重试次数决定动作
    if attemptCount == 1 {
        // 第一次失败:发送友好提醒
        sendPaymentReminder(customerID, invoice)
    } else if attemptCount >= 3 {
        // 多次失败:标记为逾期,可能暂停服务
        suspendService(customerID)
        sendUrgentNotice(customerID, invoice)
    }
    return nil
}

3.4 customer.subscription.deleted — 订阅取消

func handleSubscriptionDeleted(subscription *stripe.Subscription) error {
    customerID := subscription.Customer.ID
    
    // 1. 记录取消原因和时间
    db.Exec(`UPDATE subscriptions 
        SET status = 'canceled',
            canceled_at = NOW(),
            cancel_at_period_end = $1
        WHERE stripe_subscription_id = $2`,
        subscription.CancelAtPeriodEnd, subscription.ID)
    
    // 2. 如果是立即取消,立即停止服务
    if !subscription.CancelAtPeriodEnd {
        revokeServiceAccess(customerID)
        sendFarewellEmail(customerID)
    } else {
        // 如果 cancel_at_period_end,服务维持到周期结束
        scheduleServiceRevoke(customerID, subscription.CurrentPeriodEnd)
    }
    return nil
}

3.5 charge.dispute.created — 争议(紧急)

func handleDisputeCreated(dispute *stripe.Dispute) error {
    chargeID := dispute.Charge.ID
    amount := dispute.Amount
    reason := dispute.Reason
    
    // 1. 立即冻结相关资金
    db.Exec(`UPDATE orders SET status = 'disputed', 
        dispute_reason = $1, disputed_at = NOW()
        WHERE stripe_charge_id = $2`, reason, chargeID)
    
    // 2. 暂停相关服务(防止继续消耗)
    suspendService(dispute.Charge.Customer.ID)
    
    // 3. 收集证据(异步)
    go func() {
        evidence := collectEvidence(chargeID)
        submitDisputeEvidence(dispute.ID, evidence)
    }()
    
    // 4. 紧急告警
    alertOpsTeam(fmt.Sprintf("Dispute created: %s, amount: %.2f", dispute.ID, float64(amount)/100))
    return nil
}

4. Stripe Webhook 最佳实践

✅ Do’s

#实践原因
1先返回 200,再异步处理Stripe 5 秒超时,复杂业务可能超时
2所有事件处理幂等Stripe 可能重发,防止重复执行
3记录所有接收事件的审计日志对账、排查争议必需
4用 Stripe SDK 验签官方 SDK 持续更新,避免自己实现出错
5区分 Test/Live Webhook Endpoint防止测试数据污染生产
6配置多个事件类型,不要全选减少不必要的推送和处理

❌ Don’ts

#反模式风险
1只依赖前端回调确认支付用户可能关闭页面,支付状态丢失
2在 Webhook handler 中做同步 HTTP 调用超时导致 Stripe 重试,可能重复处理
3忽略 invoice.payment_failed用户付款失败后无感知,流失
4将 stripe secret 提交到 Git被扫描工具发现后可被利用
5处理所有事件类型但不区分收到未处理的事件时 panic 或报错

5. 测试策略

5.1 Stripe CLI 触发测试事件

# 触发 invoice.paid 事件
stripe trigger invoice.paid

# 触发订阅创建
stripe trigger customer.subscription.created

# 触发退款
stripe trigger charge.refunded

# 查看转发日志
stripe listen --forward-to localhost:8080/webhook/stripe --print-json

5.2 单元测试(Go)

func TestHandleInvoicePaid(t *testing.T) {
    invoice := &stripe.Invoice{
        ID:     "in_test_123",
        AmountPaid: 2000,
        Customer: &stripe.Customer{ID: "cus_test_123"},
        Subscription: &stripe.Subscription{ID: "sub_test_123"},
    }
    
    err := handleInvoicePaid(invoice)
    assert.NoError(t, err)
    
    // 验证订单已更新
    var status string
    db.QueryRow("SELECT status FROM orders WHERE stripe_invoice_id = $1", invoice.ID).Scan(&status)
    assert.Equal(t, "paid", status)
}

6. 常见问题排查

#问题排查方法修复
1No signatures found matching the expected signature确认 endpoint secret 匹配环境(test/live)检查 Dashboard 环境开关
2Timestamp too old服务器时间是否准确?配置 NTP 同步,ntpdchrony
3Stripe 显示 “Timed out”处理时间 > 5 秒?先返回 200,异步处理
4收到重复 invoice.paid幂等性检查未生效?验证 invoice.ID 去重逻辑
5Test 事件发到 Live EndpointDashboard 配置错误?分别配置 test/live 两个 endpoint

下一步


本文全场约 4,000 词,提供 Stripe Dashboard 配置步骤Go/Node.js 完整验签代码8 种核心事件处理模板Stripe CLI 测试命令以及 测试用信用卡号,可直接用于 Stripe 集成开发项目。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「saas」更多文章