关于 dodo-webhook
dodo-webhook 是用于在现有应用中接入 Dodo Payments Webhook 的技能,面向开发者。它涵盖签名验证、事件处理、幂等控制和数据库更新,并针对常见 Web 技术栈提供指引。
主要功能
- 01识别框架与数据库
- 02验证 Standard Webhooks 签名
- 03路由支付与订阅事件
- 04确保事件幂等处理
- 05检查结账元数据
- 06本地测试 Webhook
适用场景
- 01为应用接入 Dodo 支付 Webhook
- 02在付款后更新用户额度
- 03避免重试导致重复处理
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ tushaarmehtaa/ tushar-skills --skill dodo-webhook安装后在 Claude Code 中输入/dodo-webhook确认已加载。
详细介绍
端到端接入 Dodo Payments Webhook——包括签名验证、事件路由、幂等性和数据库同步。三个特定错误会导致功能悄无声息地失效。本文会逐一说明。
为什么要这样做
Dodo 使用 Standard Webhooks 规范,与 Stripe 不同。以下错误会让你吃尽苦头:
- 使用原始的
DODO_WEBHOOK_SECRET字符串进行验证——这样行不通。密钥采用whsec_xxxxx格式。使用前必须去掉whsec_前缀,并对其余部分进行 Base64 解码。在仅记录日志的模式下,这会悄无声息地失败,所以你可能会以为验证正常,直到严格测试时才发现问题。 - 在验证之前让任何中间件将请求正文解析为 JSON。签名验证针对的是原始正文的字节内容。一旦解析并重新序列化,字节就会改变,验证也会失败。
- 在处理错误时返回非 200 状态码。Dodo 会对任何非 200 响应进行重试。如果处理程序抛出异常并返回 500,同一个支付事件就会被反复处理。
阶段 1:检测技术栈
检查代码库:
- 框架:Next.js App Router / Pages Router / FastAPI / Express?
- 数据库 ORM:Prisma / Drizzle / Supabase / Mongoose / 原生 SQL?
- 用户模型:哪个字段存储套餐或额度?
userId如何存储? - 现有 Webhook 路由:是否已有
/api/webhooks/目录?
阶段 2:安装依赖项
# Node / Next.js
npm install standardwebhooks
# Python / FastAPI
pip install standardwebhooks
添加到 .env.example:
DODO_API_KEY=
DODO_WEBHOOK_SECRET= # whsec_... format from Dodo dashboard
DODO_PRODUCT_ID= # Product ID for the thing users are buying
APP_URL= # Frontend URL for checkout redirect
阶段 3:Webhook 端点
Next.js App Router
// app/api/webhooks/dodo/route.ts
import { Webhook } from 'standardwebhooks';
export async function POST(req: Request) {
// CRITICAL: raw body — never req.json() here
const body = await req.text();
const webhookId = req.headers.get('webhook-id');
const webhookTimestamp = req.headers.get('webhook-timestamp');
const webhookSignature = req.headers.get('webhook-signature');
if (!webhookId || !webhookTimestamp || !webhookSignature) {
return new Response('Missing webhook headers', { status: 400 });
}
// Verify signature
let event: Record<string, any>;
try {
const secret = process.env.DODO_WEBHOOK_SECRET!;
// Strip whsec_ prefix — this is the step everyone misses
const base64Secret = secret.startsWith('whsec_') ? secret.slice(6) : secret;
const wh = new Webhook(base64Secret);
event = wh.verify(body, {
'webhook-id': webhookId,
'webhook-timestamp': webhookTimestamp,
'webhook-signature': webhookSignature,
}) as Record<string, any>;
} catch (err) {
console.error('Dodo webhook signature verification failed:', err);
return new Response('Invalid signature', { status: 400 });
}
// Idempotency — skip already-processed events
const alreadyProcessed = await checkEventProcessed(webhookId);
if (alreadyProcessed) {
return new Response('Already processed', { status: 200 });
}
try {
await handleEvent(event);
await markEventProcessed(webhookId);
} catch (err) {
// Log but still return 200 — otherwise Dodo retries endlessly
console.error(`Error processing Dodo event ${event.type}:`, err);
}
return new Response('OK', { status: 200 });
}
FastAPI / Python
from fastapi import Request, HTTPException
from standardwebhooks import Webhook
import os
@app.post("/api/webhooks/dodo")
async def dodo_webhook(request: Request):
body = await request.body() # raw bytes
webhook_id = request.headers.get("webhook-id")
webhook_timestamp = request.headers.get("webhook-timestamp")
webhook_signature = request.headers.get("webhook-signature")
if not all([webhook_id, webhook_timestamp, webhook_signature]):
raise HTTPException(status_code=400, detail="Missing headers")
raw_secret = os.environ["DODO_WEBHOOK_SECRET"]
# Strip whsec_ prefix
base64_secret = raw_secret[6:] if raw_secret.startswith("whsec_") else raw_secret
try:
wh = Webhook(base64_secret)
event = wh.verify(body, {
"webhook-id": webhook_id,
"webhook-timestamp": webhook_timestamp,
"webhook-signature": webhook_signature,
})
except Exception as e:
raise HTTPException(status_code=400, detail="Invalid signature")
if await event_already_processed(webhook_id):
return {"received": True}
try:
await handle_event(event)
await mark_event_processed(webhook_id)
except Exception as e:
print(f"Error processing {event.get('type')}: {e}")
# Still return 200
return {"received": True}
Next.js 代理模式
如果前端代理到独立的后端,Next.js 路由必须转发原始请求正文和全部三个 Standard Webhooks 请求头。不要让 Next.js 解析请求正文:
// app/api/webhooks/dodo/route.ts (proxy version)
export async function POST(request: Request) {
const body = await request.text();
const headersToForward: Record<string, string> = {
'Content-Type': 'application/json',
};
for (const h of ['webhook-id', 'webhook-timestamp', 'webhook-signature']) {
const v = request.headers.get(h);
if (v) headersToForward[h] = v;
}
try {
const response = await fetch(`${process.env.BACKEND_URL}/api/webhooks/dodo`, {
method: 'POST',
headers: headersToForward,
body,
});
return new Response(await response.text(), { status: response.status });
} catch {
// Always return 200 to Dodo even if backend is unreachable
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
}
阶段 4:事件路由器和处理程序
async function handleEvent(event: Record<string, any>) {
switch (event.type) {
case 'payment.succeeded':
return handlePaymentSucceeded(event.data);
case 'payment.failed':
return handlePaymentFailed(event.data);
case 'subscription.active':
return handleSubscriptionActivated(event.data);
case 'subscription.cancelled':
return handleSubscriptionCancelled(event.data);
case 'subscription.on_hold':
return handleSubscriptionOnHold(event.data);
default:
console.log(`Unhandled Dodo event type: ${event.type}`);
}
}
生成与代码库中实际用户模型匹配的处理程序:
async function handlePaymentSucceeded(data: Record<string, any>) {
const userId = data.metadata?.userId;
if (!userId) {
console.error('No userId in Dodo payment metadata — cannot update user. Check checkout creation.');
return;
}
const creditsToAdd = parseInt(data.metadata?.credits || '100');
// Adapt this to match the detected ORM and user schema
await db.user.update({
where: { id: userId },
data: {
credits: { increment: creditsToAdd },
dodoCustomerId: data.customer?.id,
},
});
}
async function handleSubscriptionCancelled(data: Record<string, any>) {
const userId = data.metadata?.userId;
if (!userId) return;
await db.user.update({
where: { id: userId },
data: { plan: 'free' },
});
}
userId 来自创建结账时附加的元数据。 如果创建结账时没有设置 metadata: { userId },Webhook 就无法找到用户。务必确认已设置此项。
阶段 5:幂等性层
Dodo 会在收到非 200 响应时重试事件,偶尔也会重复投递。确保每个事件只处理一次。
-- Add this table to your database
CREATE TABLE processed_webhooks (
webhook_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
async function checkEventProcessed(webhookId: string): Promise<boolean> {
const existing = await db.processedWebhook.findUnique({ where: { webhookId } });
return !!existing;
}
async function markEventProcessed(webhookId: string): Promise<void> {
await db.processedWebhook.create({ data: { webhookId } });
}
对于未使用 ORM 的 Supabase:
async function checkEventProcessed(webhookId: string) {
const { data } = await supabase
.from('processed_webhooks')
.select('webhook_id')
.eq('webhook_id', webhookId)
.maybeSingle();
return !!data;
}
阶段 6:创建结账(另一半工作)
只有正确创建结账,Webhook 才能正常工作。检查结账创建端点,并确保元数据中包含 userId:
// api/payments/create-checkout/route.ts
const checkout = await dodo.payments.create({
payment_link: true,
customer: { email: user.email },
product_cart: [{ product_id: process.env.DODO_PRODUCT_ID!, quantity: 1 }],
metadata: {
userId: user.id, // REQUIRED — webhook uses this to find the user
credits: '100', // how many credits to add on success
},
return_url: `${process.env.APP_URL}/checkout/success`,
});
return Response.json({ checkout_url: checkout.payment_link });
阶段 7:本地测试
使用 ngrok 暴露本地服务器,然后在 Dodo 控制面板(测试模式)中将 ngrok URL 添加为 Webhook 端点:
ngrok http 3000
# Add https://your-ngrok-id.ngrok.io/api/webhooks/dodo in Dodo test dashboard
从 Dodo 控制面板触发测试事件,在部署前于本地验证完整流程。
阶段 8:验证
Flow 1: Signature Verification
[ ] whsec_ prefix stripped before use
[ ] Raw body used — not parsed JSON
[ ] Invalid signatures return 400
[ ] Valid signatures proceed to processing
Flow 2: Payment Succeeded
[ ] userId present in event metadata
[ ] User credits updated in database
[ ] dodoCustomerId stored on user
[ ] Event marked as processed in processed_webhooks
Flow 3: Idempotency
[ ] Same event sent twice → processed only once
[ ] No double-credit on duplicate delivery
Flow 4: Error Handling
[ ] Processing error → logged, 200 returned anyway
[ ] Missing userId in metadata → logged, no crash
Flow 5: Subscription Events
[ ] subscription.cancelled → user plan set to free
[ ] subscription.on_hold → user access restricted
重要说明
- 必须去掉
whsec_前缀。 使用原始字符串会悄无声息地失败——不会报错,只是无法完成验证。 - 始终返回 200。 即使数据库出错,也要返回 200。记录错误并排查,但不要让 Dodo 重试同一个支付事件。
- 幂等性不可或缺。 支付服务提供商会进行重试。确保每个事件只处理一次。
- 结账元数据应尽量精简,但必须完整。
userId不可或缺。如果与你的模型相关,还应添加credits或planId。
评论
支持 Markdown