mcpField
W
webhook-integration

webhook-integration

github.com/dodopayments開発131.1k 回インストール3 週間前に更新
リポジトリ

webhook-integration について

Dodo PaymentsのWebhook連携を実装する開発スキルです。署名検証、冪等処理、イベント対応、ローカルテストの手順を案内します。

主な機能

  1. 01Webhook署名の検証
  2. 02リクエスト本文の保持
  3. 03イベント種別への対応
  4. 04webhook-idによる重複排除
  5. 05Webhookのローカルテスト
  6. 06再試行と順不同イベントへの対応

活用シーン

  1. 01決済イベントの受信先を構築
  2. 02サブスクリプションと返金状態の同期
  3. 03重複配信の安全な処理
  4. 04開発中のイベント処理テスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dodopayments/skills --skill webhook-integration

インストール後、Claude Code で/webhook-integrationを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
common-mistakes.md
event-catalog.md
local-testing.md

詳細

Dodo Payments Webhook Integration

決済イベントが発生すると、Webhooks によってリアルタイム通知が送信されます。ワークフローの自動化、データベースの更新、確認通知の送信、システム間の同期に活用できます。

このスキルを使用する場面

  • 決済、サブスクリプション、返金イベントを受信するWebhookエンドポイントの設定
  • Webhookの真正性を確認する署名検証の実装
  • 特定のイベントタイプ(決済成功、サブスクリプション更新など)の処理
  • 開発中にWebhooksをローカルでテスト
  • 再試行に対応するための、冪等性を保ったWebhook処理の実装

主な概念

Webhook:イベント発生時にDodoからエンドポイントへ送信されるHTTP POSTリクエストです。

署名検証:Webhookが攻撃者ではなくDodoから送信されたことを証明する暗号学的な仕組みです。本番環境では必須です。

冪等性:同じWebhookを複数回処理しても、結果が変わらないことです。webhook-idヘッダーを使って重複を検出し、スキップします。

Raw body:解析前にDodoから受信した正確なバイト列です。署名検証に必要です。


セットアップ:Webhookエンドポイントの作成

1. ダッシュボードでエンドポイントを作成する
  1. Developer → Webhooksに移動します
  2. Create Webhookをクリックします
  3. エンドポイントのURLを入力します(本番環境ではHTTPSが必須です)
  4. 受信するイベントを選択します
  5. 署名シークレットをコピーします
2. 署名シークレットを保存する
export DODO_PAYMENTS_WEBHOOK_KEY=whsec_...

SDKがこれを自動的に読み込みます。クライアントの初期化時に明示的に渡すこともできます。


Webhookのヘッダーとペイロード

すべてのWebhookリクエストには、必須ヘッダーが3つ含まれます(すべて小文字で、ハイフン区切りです)。

ヘッダー例用途
webhook-idevt_abc123このWebhook配信の一意の識別子
webhook-signaturev1,base64_signature_here検証用のHMAC-SHA256署名
webhook-timestamp1704067200イベント送信時の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からの署名なしテストペイロードに限って使用してください。

Raw bodyの要件

署名検証には、Dodoから送信された正確なバイト列が必要です。先にJSONを解析してから再シリアライズすると、バイト列が変わり、検証に失敗します。

フレームワーク別の設定:

フレームワークRaw bodyの設定
Expressapp.use(express.raw({ type: 'application/json' }))
Next.jsルートハンドラーでreq.text()を使用します。ミドルウェアは不要です
Fastifyカスタムのコンテンツタイプパーサーを使用します(アダプターのドキュメントを参照)
Hono組み込み機能のため、特別な設定は不要です
FastAPIawait request.body()でバイト列を取得します
Goio.ReadAll(r.Body)

Webhookイベントカタログ

詳しいガイド:references/event-catalog.md。

対象となる内容:

  • 決済イベント
  • サブスクリプションイベント
  • 返金イベント
  • 異議申し立てイベント
  • ライセンスキーイベント
  • エンタイトルメント付与イベント
  • クレジットイベント
  • 回収と督促のイベント
  • 支払いイベント

本番環境向けハンドラーパターン

イベントを永続的に記録したらすぐに応答し、非同期で処理して、冪等性キーを使用します。ワーカーでは、冪等性の要求を登録し、永続化が必要なすべてのビジネス上の変更を1つのデータベーストランザクション内で適用します。処理で例外が発生した場合、トランザクションによって要求の登録がロールバックされるため、安全にジョブを再試行できます。

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 以外のレスポンスでは再試行されます。
再試行8回試行します。即時、5秒後、5分後、30分後、2時間後、5時間後、10時間後、10時間後です
冪等性webhook-id を使用して重複を検出し、スキップします
順序保証されません。イベントは順不同で届く場合があります。
ペイロードの鮮度配信時点での最新のリソース状態が含まれます
トランスポート本番環境では HTTPS を使用してください

フレームワークアダプターのショートカット

サポート対象のフレームワークを使用する場合は、組み込みの Webhook 処理を利用できる公式アダプターパッケージを使用してください。

フレームワークパッケージWebhook ハンドラー
Next.js@dodopayments/nextjsWebhooks({ webhookKey, onPayload })
Nuxt@dodopayments/nuxtWebhooks({ webhookKey, onPayload })
Express@dodopayments/expressWebhooks({ webhookKey, onPayload }) - 先に express.json() を登録してください。express.raw() ではありません(パース済みの req.body に対して検証します)
Fastify@dodopayments/fastifyWebhooks({ webhookKey, onPayload })
Hono@dodopayments/honoWebhooks({ webhookKey, onPayload })
Astro@dodopayments/astroWebhooks({ webhookKey, onPayload })
SvelteKit@dodopayments/sveltekitWebhooks({ webhookKey, onPayload })
Remix@dodopayments/remixWebhooks({ webhookKey, onPayload })
TanStack Start@dodopayments/tanstackWebhooks({ webhookKey, onPayload })
Bun@dodopayments/bunWebhooks({ webhookKey, onPayload })
Better Auth@dodopayments/better-authwebhooks({ 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);
    });
  },
});

subscription.active は webhook-id を公開するハンドラーでのみ処理し、その要求の登録とエンタイトルメントの変更を同じトランザクション内でコミットしてください。


ローカルテスト

詳細なガイド:references/local-testing.md。

内容:

  • ダッシュボードのテストツール
  • CLI:ライブ転送
  • CLI:署名なしのモックイベント
  • トンネル

よくある間違い

詳細なガイド:references/common-mistakes.md。

内容:

  • webhook-id.webhook-timestamp.raw_body 以外のものに署名する
  • パース済みのボディを再シリアライズする
  • 署名ヘッダーを単純にカンマで分割する
  • 長さが一致しないときに timingSafeEqual が例外をスローする
  • 検証済みの Webhook ではなく return_url からアクセス権を付与する
  • 2xx を返す前に時間のかかる処理を行う
  • 永続的に保存する前に受信を確認する
  • 決済処理の前に冪等性の要求をコミットする

Resources

コメント