mcpField
D
dodo-webhook

dodo-webhook

github.com/tushaarmehtaa开发1246 次安装2 周前更新
代码仓库

关于 dodo-webhook

dodo-webhook 是用于在现有应用中接入 Dodo Payments Webhook 的技能,面向开发者。它涵盖签名验证、事件处理、幂等控制和数据库更新,并针对常见 Web 技术栈提供指引。

主要功能

  1. 01识别框架与数据库
  2. 02验证 Standard Webhooks 签名
  3. 03路由支付与订阅事件
  4. 04确保事件幂等处理
  5. 05检查结账元数据
  6. 06本地测试 Webhook

适用场景

  1. 01为应用接入 Dodo 支付 Webhook
  2. 02在付款后更新用户额度
  3. 03避免重试导致重复处理

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/tushaarmehtaa/tushar-skills --skill dodo-webhook

安装后在 Claude Code 中输入/dodo-webhook确认已加载。

详细介绍

端到端接入 Dodo Payments Webhook——包括签名验证、事件路由、幂等性和数据库同步。三个特定错误会导致功能悄无声息地失效。本文会逐一说明。

为什么要这样做

Dodo 使用 Standard Webhooks 规范,与 Stripe 不同。以下错误会让你吃尽苦头:

  1. 使用原始的 DODO_WEBHOOK_SECRET 字符串进行验证——这样行不通。密钥采用 whsec_xxxxx 格式。使用前必须去掉 whsec_ 前缀,并对其余部分进行 Base64 解码。在仅记录日志的模式下,这会悄无声息地失败,所以你可能会以为验证正常,直到严格测试时才发现问题。
  2. 在验证之前让任何中间件将请求正文解析为 JSON。签名验证针对的是原始正文的字节内容。一旦解析并重新序列化,字节就会改变,验证也会失败。
  3. 在处理错误时返回非 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