mcpField
D
dodo-webhook

dodo-webhook

github.com/tushaarmehtaa開発1246 回インストール2 週間前に更新
リポジトリ

dodo-webhook について

dodo-webhookは、既存アプリにDodo PaymentsのWebhook処理を実装するためのスキルです。一般的なWebスタックを対象に、署名検証、イベント処理、冪等性管理、データベース更新を案内します。

主な機能

  1. 01フレームワークとDBの確認
  2. 02Standard 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 を、署名検証、イベントの振り分け、冪等性、データベース同期まで一貫して接続します。これを黙って壊してしまう具体的なミスが 3 つあります。そのすべてを取り上げます。

これが必要な理由

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 の 3 つのヘッダーすべてを転送する必要があります。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 以外の応答があるとイベントを再試行し、重複して配信することもあります。各イベントを厳密に 1 回だけ処理します。

-- 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 に同じ決済イベントを再試行させないでください。
  • 冪等性は必須です。 決済プロバイダーは再試行します。各イベントを厳密に 1 回だけ処理してください。
  • チェックアウトのメタデータは、必要最小限かつ過不足なく設定してください。 userId は必須です。モデルに関係する場合は credits や planId を追加します。

コメント