关于 webhook-integration
面向接入 Dodo Payments 的开发者,提供支付、订阅和退款 Webhook 的实现指导,涵盖验签、幂等处理与本地测试。
主要功能
- 01验证 Webhook 签名
- 02保留原始请求体
- 03按事件类型处理
- 04使用 webhook-id 去重
- 05本地测试 Webhook
- 06处理重试与乱序事件
适用场景
- 01搭建支付事件接收端点
- 02同步订阅与退款状态
- 03安全处理重复投递
- 04开发期间测试事件处理
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dodopayments/ skills --skill webhook-integration安装后在 Claude Code 中输入/webhook-integration确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/common-mistakes.mdevent-catalog.mdlocal-testing.md详细介绍
Dodo Payments Webhook 集成
Webhook 会在付款事件发生时实时发送通知。你可以用它自动化工作流、更新数据库、发送确认信息,并确保系统保持同步。
何时使用此技能
- 设置 Webhook 端点,以接收付款、订阅或退款事件
- 实现签名验证,确保 Webhook 的真实性
- 处理特定事件类型(付款成功、订阅续订等)
- 在开发期间于本地测试 Webhook
- 确保 Webhook 处理具有幂等性,以应对重试
核心概念
Webhook:事件发生时,Dodo 向你的端点发送的 HTTP POST 请求。
签名验证:用于证明 Webhook 来自 Dodo 而非攻击者的加密凭证。生产环境中必须进行验证。
幂等性:多次处理同一个 Webhook 会产生相同结果。使用 webhook-id 标头检测并跳过重复项。
原始请求体:收到的来自 Dodo 的确切字节内容,尚未经过解析。签名验证必需此内容。
设置:创建 Webhook 端点
1. 在控制面板中创建端点
- 前往 Developer → Webhooks
- 点击 Create Webhook
- 输入你的端点 URL(生产环境中必须使用 HTTPS)
- 选择你要接收的事件
- 复制签名密钥
2. 保存签名密钥
export DODO_PAYMENTS_WEBHOOK_KEY=whsec_...
SDK 会自动读取此密钥。你也可以在初始化客户端时显式传入。
Webhook 标头和载荷
每个 Webhook 请求都包含三个必需的标头(均为小写,并以连字符分隔):
| 标头 | 示例 | 用途 |
|---|---|---|
webhook-id | evt_abc123 | 此 Webhook 投递的唯一标识符 |
webhook-signature | v1,base64_signature_here | 用于验证的 HMAC-SHA256 签名 |
webhook-timestamp | 1704067200 | 发送事件时的 Unix 时间戳(秒) |
请求体为 JSON:
{
"business_id": "bus_xxxxx",
"type": "payment.succeeded",
"timestamp": "2024-01-01T12:00:00Z",
"data": {
"payload_type": "Payment",
"payment_id": "pay_xxxxx",
"status": "succeeded",
"total_amount": 2999,
"currency": "USD",
"customer": {
"customer_id": "cus_xxxxx",
"email": "[email protected]",
"name": "John Doe"
}
}
}
验证:核心环节
处理前务必验证签名。 未经验证的 Webhook 可能遭到伪造。
首选:SDK 辅助工具
这是最简单、最安全的方式。SDK 会处理所有验证细节。
TypeScript/Node:
import DodoPayments from 'dodopayments';
import express from 'express';
const app = express();
app.use(express.raw({ type: 'application/json' }));
// `environment` is a narrow union, but env vars are `string | undefined`.
// Narrow explicitly rather than casting, and default to test mode so a missing
// variable can never accidentally hit live.
const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment,
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
});
app.post('/webhook', async (req, res) => {
try {
const unwrapped = client.webhooks.unwrap(req.body.toString(), {
headers: {
'webhook-id': req.headers['webhook-id'] as string,
'webhook-signature': req.headers['webhook-signature'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
},
});
// Signature verified. Process the event.
console.log(`Received ${unwrapped.type}`);
res.json({ received: true });
} catch (error) {
res.status(401).json({ error: 'Invalid signature' });
}
});
Python:
from typing import Literal
from fastapi import FastAPI, Request, HTTPException
from dodopayments import DodoPayments
import os
# `environment` is Literal["live_mode", "test_mode"], not str. Passing the raw
# variable through raises at construction: unset gives
# `ValueError: Unknown environment: None`, and a typo like "test" gives
# `Unknown environment: test`. Narrow it, defaulting to test mode so a
# misconfigured variable can never select live.
ENVIRONMENT: Literal["live_mode", "test_mode"] = (
"live_mode" if os.getenv("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" else "test_mode"
)
app = FastAPI()
client = DodoPayments(
bearer_token=os.getenv("DODO_PAYMENTS_API_KEY"),
environment=ENVIRONMENT,
webhook_key=os.getenv("DODO_PAYMENTS_WEBHOOK_KEY"),
)
@app.post("/webhook")
async def handle_webhook(request: Request):
try:
unwrapped = client.webhooks.unwrap(
await request.body(),
headers={
"webhook-id": request.headers.get("webhook-id", ""),
"webhook-signature": request.headers.get("webhook-signature", ""),
"webhook-timestamp": request.headers.get("webhook-timestamp", ""),
},
)
# Signature verified. Process the event.
return {"received": True}
except Exception:
raise HTTPException(status_code=401, detail="Invalid signature")
Go:
import (
"io"
"net/http"
"os"
"github.com/dodopayments/dodopayments-go"
"github.com/dodopayments/dodopayments-go/option"
)
// The Go SDK has no WithEnvironment(string). It exposes two explicit options,
// so narrow here and default to test mode: an unset or misspelled variable must
// never select live mode.
func dodoEnvironment() option.RequestOption {
if os.Getenv("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" {
return option.WithEnvironmentLiveMode()
}
return option.WithEnvironmentTestMode()
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
dodoEnvironment(),
option.WithWebhookKey(os.Getenv("DODO_PAYMENTS_WEBHOOK_KEY")),
)
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Cannot read body", http.StatusBadRequest)
return
}
// Unwrap takes the raw body and the request headers directly. It is not
// context-aware and does not take a map: the signature is
// Unwrap(payload []byte, headers http.Header, opts ...option.RequestOption).
if _, err := client.Webhooks.Unwrap(rawBody, r.Header); err != nil {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Signature verified. Process the event.
w.WriteHeader(http.StatusOK)
}
替代方案:standardwebhooks 包
如果你更倾向于手动验证,或未使用 Dodo SDK:
import { Webhook } from "standardwebhooks";
import express from "express";
const app = express();
app.use(express.raw({ type: "application/json" }));
const webhook = new Webhook(process.env.DODO_PAYMENTS_WEBHOOK_KEY);
app.post("/webhooks/dodo", async (req, res) => {
try {
const payload = req.body.toString();
await webhook.verify(payload, req.headers);
const event = JSON.parse(payload);
console.log(`Received ${event.type}`);
res.json({ received: true });
} catch (error) {
res.status(401).json({ error: "Invalid signature" });
}
});
unwrap() 与 unsafeUnwrap()
unwrap()会验证签名。所有生产环境的 Webhook 都应使用此方法。unsafeUnwrap()会跳过验证。仅用于来自dodo wh trigger的未签名测试载荷。
原始请求体要求
签名验证需要 Dodo 发送的确切字节内容。如果先解析 JSON,再重新序列化,字节内容就会改变,验证也会失败。
特定框架的设置:
| 框架 | 原始请求体设置 |
|---|---|
| Express | app.use(express.raw({ type: 'application/json' })) |
| Next.js | 在路由处理程序中使用 req.text();无需中间件 |
| Fastify | 自定义内容类型解析器(参见适配器文档) |
| Hono | 内置支持;无需特殊设置 |
| FastAPI | await request.body() 返回字节 |
| Go | io.ReadAll(r.Body) |
Webhook 事件目录
完整指南:references/event-catalog.md。
涵盖:
- 付款事件
- 订阅事件
- 退款事件
- 争议事件
- 许可证密钥事件
- 权益授予事件
- 额度事件
- 恢复与催款事件
- 付款事件
生产级处理程序模式
先快速持久化记录事件,再异步处理,并使用幂等键。在工作器中,在一个数据库事务内插入幂等声明并应用所有持久化业务变更。如果处理时抛出异常,事务会回滚该声明,以便安全重试任务:
import DodoPayments from 'dodopayments';
import express from 'express';
import { Queue, Worker } from 'bullmq';
// BullMQ is illustrative; use your preferred durable async queue.
const app = express();
app.use(express.raw({ type: 'application/json' }));
// `environment` is a narrow union, but env vars are `string | undefined`.
// Narrow explicitly rather than casting, and default to test mode so a missing
// variable can never accidentally hit live.
const environment = process.env.DODO_PAYMENTS_ENVIRONMENT === 'live_mode' ? 'live_mode' : 'test_mode';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment,
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
});
type WebhookEvent = ReturnType<typeof client.webhooks.unwrap>;
type WebhookJob = { event: WebhookEvent; webhookId: string };
const connection = {
host: process.env.REDIS_HOST ?? '127.0.0.1',
port: Number(process.env.REDIS_PORT ?? '6379'),
};
const eventQueue = new Queue<WebhookJob>('webhook-events', { connection });
app.post('/webhook', async (req, res) => {
let unwrapped: WebhookEvent;
try {
unwrapped = client.webhooks.unwrap(req.body.toString(), {
headers: {
'webhook-id': req.headers['webhook-id'] as string,
'webhook-signature': req.headers['webhook-signature'] as string,
'webhook-timestamp': req.headers['webhook-timestamp'] as string,
},
});
} catch (error) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Durably enqueue before acknowledging, keyed by webhook-id for idempotency
const webhookId = req.headers['webhook-id'] as string;
try {
await eventQueue.add(
`process-${unwrapped.type}`,
{ event: unwrapped, webhookId },
{
jobId: webhookId, // Prevents duplicate queue entries
attempts: 8,
backoff: { type: 'exponential', delay: 1000 },
}
);
return res.json({ received: true });
} catch (error) {
console.error('Failed to persist webhook', error);
return res.status(503).json({ error: 'Webhook persistence failed' });
}
});
// Async worker. webhookLog.webhookId must have a unique constraint.
const worker = new Worker<WebhookJob>(
'webhook-events',
async (job) => {
const { event, webhookId } = job.data;
await db.$transaction(async (tx) => {
const claim = await tx.webhookLog.createMany({
data: [{ webhookId, eventType: event.type }],
skipDuplicates: true,
});
if (claim.count === 0) return;
switch (event.type) {
case 'payment.succeeded':
await handlePaymentSucceeded(event.data, tx);
break;
case 'subscription.active':
await handleSubscriptionActive(event.data, tx);
break;
// ... handle other events
}
});
},
{ connection }
);
上述处理程序必须通过 tx 执行权益写入。通过事务性发件箱排队发送电子邮件或执行其他外部工作;数据库事务无法回滚已发送的外部请求。
交付语义
了解 Dodo 如何交付 Webhook:
| 属性 | 行为 |
|---|---|
| 超时 | 连接和读取均为 30 秒 |
| 成功 | 任何 2xx 响应都会确认交付。持久化记录事件后立即返回 200。 |
| 失败 | 任何非 2xx 响应都会触发重试。 |
| 重试 | 共尝试八次:立即、5s、5m、30m、2h、5h、10h、10h |
| 幂等性 | 使用 webhook-id 检测并跳过重复事件 |
| 顺序 | 不保证顺序。事件可能乱序到达。 |
| 负载时效性 | 交付的内容包含交付时资源的最新状态 |
| 传输 | 在生产环境中使用 HTTPS |
框架适配器快捷方式
如果你使用受支持的框架,请使用官方适配器包来内置 Webhook 处理:
| 框架 | 包 | Webhook 处理程序 |
|---|---|---|
| Next.js | @dodopayments/nextjs | Webhooks({ webhookKey, onPayload }) |
| Nuxt | @dodopayments/nuxt | Webhooks({ webhookKey, onPayload }) |
| Express | @dodopayments/express | Webhooks({ webhookKey, onPayload }) - 先注册 express.json(),不要使用 express.raw()(它会针对解析后的 req.body 进行验证) |
| Fastify | @dodopayments/fastify | Webhooks({ webhookKey, onPayload }) |
| Hono | @dodopayments/hono | Webhooks({ webhookKey, onPayload }) |
| Astro | @dodopayments/astro | Webhooks({ webhookKey, onPayload }) |
| SvelteKit | @dodopayments/sveltekit | Webhooks({ webhookKey, onPayload }) |
| Remix | @dodopayments/remix | Webhooks({ webhookKey, onPayload }) |
| TanStack Start | @dodopayments/tanstack | Webhooks({ webhookKey, onPayload }) |
| Bun | @dodopayments/bun | Webhooks({ webhookKey, onPayload }) |
| Better Auth | @dodopayments/better-auth | 使用 webhooks({ webhookKey, onPayload }) 的插件 |
| Convex | @dodopayments/convex | 带有已验证 HTTP 处理程序的组件 |
Next.js 示例:
适配器回调不会暴露 webhook-id,因此此支付示例使用 payment_id 作为稳定键,并将声明与持久化履约操作一并提交。对于没有经过验证的稳定标识符的事件类型,请使用会暴露 webhook-id 的处理程序。
// app/api/webhook/dodo-payments/route.ts
import { Webhooks } from "@dodopayments/nextjs";
export const POST = Webhooks({
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY,
onPayload: async (payload) => {
// payload is already verified
console.log(`Received ${payload.type}`);
if (payload.type !== 'payment.succeeded') return;
// webhookLog.webhookId must have a unique constraint. If fulfillment
// throws, the claim rolls back and Dodo's redelivery can retry it.
await db.$transaction(async (tx) => {
const claim = await tx.webhookLog.createMany({
data: [{
webhookId: payload.data.payment_id,
eventType: payload.type,
}],
skipDuplicates: true,
});
if (claim.count === 0) return;
await handlePaymentSucceeded(payload.data, tx);
});
},
});
仅在会暴露 webhook-id 的处理程序中处理 subscription.active,然后在同一事务中提交该声明和权益变更。
本地测试
完整指南:references/local-testing.md。
涵盖:
- 控制面板测试工具
- CLI:实时转发
- CLI:未签名的模拟事件
- 隧道
常见错误
完整指南:references/common-mistakes.md。
涵盖:
- 对
webhook-id.webhook-timestamp.raw_body以外的内容进行签名 - 重新序列化解析后的正文
- 对签名标头进行简单的逗号分割
timingSafeEqual因长度不匹配而抛出异常- 根据
return_url而非已验证的 Webhook 授予访问权限 - 在响应 2xx 前执行耗时操作
- 在持久化存储前确认收到事件
- 在完成履约前提交幂等声明