paddle-webhooks について
paddle-webhooksは、Next.jsでPaddleのサーバー側Webhookエンドポイントを構築する開発者向けスキルです。署名検証、イベント処理、再試行、冪等性、ローカルテストを解説します。
主な機能
- 01通知先の設定
- 02Next.js Route Handlerの作成ガイド
- 03Paddle SDKによる署名検証
- 04再試行と冪等性の処理
- 05イベント順序の保護
- 06ローカルエンドポイントのテスト
活用シーン
- 01サブスクリプションや取引の更新受信
- 02無効な署名のWebhookを拒否
- 03再配信イベントの重複排除
- 04Paddleイベントでローカルエンドポイントをテスト
MCP 対応
Paddle MCPツールを使った通知先の管理、イベント確認、失敗した通知の再送方法を説明します。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ paddlehq/ paddle-agent-skills --skill paddle-webhooksインストール後、Claude Code で/paddle-webhooksを入力して読み込まれたか確認します。
詳細
Next.js で Paddle の Webhook を安全に受信する
このスキルを使用する場面
Paddle の Webhook イベント(サブスクリプションの変更、トランザクション、顧客情報の更新、支払いなど)を受信するサーバー側エンドポイントを構築する際に、このスキルを使用します。Paddle での通知先の作成、署名を検証してイベントを処理する Next.js Route Handler の作成、再試行と冪等性の処理、ローカルでのテストについて説明します。
このスキルは、ほとんどすべてのサーバー側 Paddle インテグレーションの基礎となります。以下と組み合わせて使用してください。
subscription-sync— Webhook イベントをデータベースの行に反映します。sandbox-testing— Webhook シミュレーターを使ってエンドポイントをテストします。checkout-web— ここで受信するイベントを発生させる、クライアント側の連携機能です。
配信の契約
このスキルでの設計上の選択はすべて、Paddle の Webhook 配信に関する以下の事実に基づいています。まずこのセクションを読んでください。それ以外はすべて実装の詳細です。
- 5 秒以内の
2xxのみが「配信済み」となります。400、401、500、503、リダイレクト、接続タイムアウトなど、それ以外の応答はすべて配信失敗として扱われ、再試行されます。インテグレーター側で「再試行を停止する」ことを意味するステータスコードはありません。Paddle は、2xx以外のすべての応答に対して再試行します。 - 再試行スケジュール。 Paddle は指数バックオフ方式で再試行します。サンドボックスでは、約 2 分以内に合計 3 回試行します。本番環境では、約 2 日間で合計最大 60 回試行します。接続タイムアウトも、
2xx以外の応答と同じ再試行回数に含まれます。正確な再試行のタイミングに依存するようなハンドラーを作らないでください。 - 再試行のたびに同じ
event.eventIdが使われます。2xxを返すか、再試行回数の上限に達するまで、Paddle は同一のペイロードを再送します(署名のタイムスタンプは新しくなります)。この ID を重複排除キーとして使用してください。 - イベントの順序は保証されません。 再試行や再生によって、新しいイベントの後にイベントが届くことがあります。
event.occurredAtを保存し、最後に適用したイベントより新しいイベントのみを適用してください(ステップ 5)。 - リダイレクトは追跡されません。
301または302は、リダイレクト先へ転送されず、配信失敗として扱われます。 - 失敗した通知は約 90 日間再生できます。 再試行が尽きると、通知は
failedとしてマークされます。Paddle > Events > Notifications で再生できます(通知先を開き、通知の Retry を選択します)。または、execute呼び出し内でclient.notifications.replay("ntf_…")を使って Paddle MCP サーバー経由で再生できます(通知 ID は本文のフィールドではなく、位置指定のパスパラメーターです)。イベントは 90 日間保持されます。長期の停止後は、GET /eventsとfrom/toフィルターを使って未取得のイベントを一覧表示するか、API に問い合わせて現在の状態を取得してください。
Paddle MCP は、サーバーごとに 3 つのツール(
search、execute、report_missing_tool)を提供します。ワークフローは次のとおりです。searchを呼び出して正確なメソッド名とパラメーターの形式を確認し、次にclient.<resource>.<operation>(...)を呼び出す非同期関数を指定してexecuteを呼び出します。メソッドパスは camelCase です(client.clientTokens.create、client.pricingPreview.preview)。本文のパラメーターとレスポンスのフィールドは snake_case です(tax_category、product_id、unit_price、currency_code)。リストメソッドは{ pagination: { hasMore, estimatedTotal }, <resource>: [...] }を返し、dataは返しません。配列のキーはリソースの API パスセグメントです。したがって、メソッドパスが camelCase の場合、キーは kebab-case になります。例:(await client.products.list()).products、(await client.clientTokens.list())["client-tokens"]。pagination.hasMoreが true の間は、.next()ではなく{ after: "<last_id>" }を使ってページを取得してください。client.currencies.list()のようにページネーションのないリストは、通常の配列を返します。単一エンティティを返すメソッドは、そのエンティティを返します。複数ステップのワークフローは 1 回のexecute内で連結してください。呼び出し間で変数は保持されません。上限は、1 回の execute あたり API 呼び出し 50 回、タイムアウト 30 秒、コード 32,000 文字です。 Paddle CLI(paddle、v0.2.0 以降)は、ユーザーが保存したログイン情報を使って、ターミナルから Paddle API の操作を実行します。リソースコマンドは API リファレンスに対応しています。たとえば、paddle products createは「商品を作成する」コマンドです。paddle <resource> --helpを実行してコマンドを確認し、作成または更新コマンドに--schemaを追加するとリクエスト本文を確認できます。コマンドがないエンドポイントにはpaddle api <path>を使ってください。デフォルトではライブアカウントで実行されるため、ユーザーがライブ環境で作業している場合を除き、--environment sandboxを指定してください。エージェントが実行すると、API の JSON(data、meta)が返され、meta.environmentに使用したアカウントが示されます。キャンセルおよび削除コマンドには--confirm <id>が必要です。ユーザーが該当するキャンセルまたは削除に同意した後にのみ追加し、拒否を回避するために使用してはいけません。paddle apiによる書き込みは確認されないため、同様に扱ってください。終了コード5は403を示します。権限が不足しているか、別のアカウントでログインしています。終了コード7は検証エラーです。paddle whoamiにログイン情報が表示されない場合は、ユーザー自身にpaddle loginの実行を依頼してください(sandbox では API キーを使用します。paddle login --environment sandbox --api-key …)。チャットで API キーを尋ねてはいけません。OAuth によるライブログインでは、ユーザーの Paddle ロールで許可されたすべての変更が可能なため、各ライブ変更の内容を伝え、実行前に明確な同意を得てください。CI/CD では、必要な権限のみを持つライブ API キーをPADDLE_API_KEYとして設定してください。インストール方法については、Paddle CLI を参照してください。
前提条件
以下が必要です。
- 商品と価格を設定済みの Paddle アカウント(開発には sandbox でも構いません)。まだ設定していない場合は、
catalog-setupを参照してください。 - 実際の Paddle イベントをローカルコードに送信する場合は、開発環境に外部からアクセスできる URL(下記の「ローカルテスト」を参照)。
- 3 つの環境変数:
NEXT_PUBLIC_PADDLE_ENV=sandbox # or "production"
PADDLE_API_KEY=pdl_sdbx_apikey_... # server-side; never expose to the browser
PADDLE_NOTIFICATION_WEBHOOK_SECRET=pdl_ntfset_... # secret for THIS notification destination
Webhook の検証では API を呼び出さないため、ルートハンドラーに API キーの権限は必要ありません。API または Paddle MCP を使って Webhook を管理する場合は、宛先の作成または更新には notification_setting.write(宛先の一覧表示またはシークレットの読み取りには notification_setting.read)、通知、ログ、イベントには notification.read、再送信には notification.write、シミュレーションの実行には notification_simulation.write(結果の確認には notification_simulation.read)が必要です。Paddle > My account > Settings > Authentication で API キーを作成してください。
サーバー SDK をインストールします。
npm install @paddle/paddle-node-sdk
ステップ 1:通知先を作成する
通知先は、Paddle に「これらのイベントをこの URL に送信する」よう指示するものです。通知先ごとに固有のシークレットがあります。サンドボックスと本番環境では、それぞれ別の通知先と別のシークレットを使用し、決して共有しないでください。
- Paddle ダッシュボードで、Paddle > Events > Notifications に移動します。
- New destination をクリックします。
- 次の項目を設定します。
- Notification type:
Webhook。 - URL:エンドポイント(例:
https://your-app.com/api/webhookまたはトンネル経由のローカル URL)。 - Description:
Local dev(またはProduction)。 - Usage type:開発用の通知先には
Bothを設定します。これにより、実際のサンドボックスイベントとシミュレーターのイベントを受信できます。本番環境にはPlatform(実際のイベントのみ)が適しています。 - Events:必要なイベントを選択します。まずは
transaction.completed、subscription.created、subscription.updated、subscription.canceled、customer.created、customer.updatedを選択してください。後から追加で登録できます。
- Notification type:
- 保存したら、通知先の Secret key を
PADDLE_NOTIFICATION_WEBHOOK_SECRETにコピーします。後で通知先の編集画面から再度コピーするか、GET /notification-settings/{id}のendpoint_secret_keyとして取得できます。
トンネルの URL が変わった場合は、新しい通知先を作成するのではなく、既存の通知先の URL を編集してください。これにより、シークレットを同じままにできます。
利用可能な Paddle MCP サーバーがある場合は、ダッシュボードを使わず、execute 呼び出しの中で通知先を作成してください(リソースは camelCase、本文は snake_case です。上記の規則を参照してください)。
async (client) =>
client.notificationSettings.create({
description: "Local dev", // required
destination: "https://abc123.ngrok.app/api/webhook",
type: "url",
subscribed_events: ["transaction.completed", "subscription.created", "subscription.updated"],
traffic_source: "all", // also receive simulator events; defaults to "platform"
});
レスポンスには endpoint_secret_key が含まれます。409 notification_setting_cannot_be_duplicate が返された場合、その URL の通知先はすでに存在します。(await client.notificationSettings.list())["notification-settings"] で通知先を見つけて再利用するか、client.notificationSettings.update("ntfset_…", { ... }) で変更してください。別の通知先を作成しないでください。400 notification_maximum_active_settings_reached が返された場合、アカウントの有効な通知先数が上限に達しています。ユーザー自身の通知先を再利用するか、どれを無効化するかユーザーに確認してください。
Paddle CLI を使う場合は、同じ本文を paddle notification-settings create に渡します。シークレットがターミナルやチャットに出力されないよう、.env.local に直接追記してください。
paddle --environment sandbox notification-settings create \
--jq '"PADDLE_NOTIFICATION_WEBHOOK_SECRET=" + .data.endpoint_secret_key' \
--json '{
"description": "Local dev",
"destination": "https://abc123.ngrok.app/api/webhook",
"type": "url",
"subscribed_events": ["transaction.completed", "subscription.created", "subscription.updated"],
"traffic_source": "all"
}' >> .env.local
新しい通知先の ID は paddle --environment sandbox notification-settings list --jq '.data[] | {id, destination}' で確認できます。後から paddle notification-settings update ntfset_… --json '{ ... }' で変更できます。
ステップ 2:Paddle SDK ヘルパーを作成する
リクエストごとに初期化せずに済むよう、Node SDK のインスタンスを 1 つ作成します。
// utils/paddle/get-paddle-instance.ts
import { Environment, LogLevel, Paddle, type PaddleOptions } from "@paddle/paddle-node-sdk";
export function getPaddleInstance() {
const options: PaddleOptions = {
environment: (process.env.NEXT_PUBLIC_PADDLE_ENV as Environment) ?? Environment.sandbox,
logLevel: LogLevel.error,
};
if (!process.env.PADDLE_API_KEY) {
throw new Error("PADDLE_API_KEY is not set");
}
return new Paddle(process.env.PADDLE_API_KEY, options);
}
ステップ 3:ルートハンドラーを記述する
ルールは 2 つです。簡単に確認できる入力は事前に検証し(400 を返します)、それ以外のすべての処理を単一の try/catch で囲み、シグネチャ検証の失敗を含め、例外が発生した場合は 2xx 以外を返して Paddle が再試行できるようにします。2xx 以外であればどのステータスコードでも構いません(このスキルでは 500 を使用します。401 も同様に有効です)。イベントが失われるのは 2xx を返した場合だけです。
// app/api/webhook/route.ts
import { NextRequest } from "next/server";
import { getPaddleInstance } from "@/utils/paddle/get-paddle-instance";
import { processEvent } from "@/utils/paddle/process-webhook";
export async function POST(request: NextRequest) {
const signature = request.headers.get("paddle-signature") ?? "";
const rawBody = await request.text();
const secret = process.env.PADDLE_NOTIFICATION_WEBHOOK_SECRET ?? "";
// Pre-validation: a request with no signature header or empty body
// can't be verified or processed. 400 is fine here — Paddle will still
// retry, but that's okay; this is a "you sent us nothing" case.
if (!signature || !rawBody) {
return Response.json({ error: "Missing signature or body" }, { status: 400 });
}
try {
const paddle = getPaddleInstance();
// Throws on signature mismatch, expired timestamp, or malformed event.
const eventData = await paddle.webhooks.unmarshal(rawBody, secret, signature);
if (eventData) {
await processEvent(eventData);
}
// Acknowledge fast. Heavy work belongs in a queue (see Step 6).
return Response.json({ received: true });
} catch (e) {
console.error("Webhook error:", e);
// Any non-2xx tells Paddle to retry — 401 and 500 are equally fine here;
// only a 2xx would mark the event delivered and lose it. A thrown
// unmarshal could be a tampered request OR a rotated secret that hasn't
// been redeployed — they're indistinguishable, so retrying recovers the
// second case automatically and the first is harmless. We return 500.
return Response.json({ error: "Internal error" }, { status: 500 });
}
}
paddle.webhooks.unmarshal() は、1 回の呼び出しで次の 3 つを行います。
- シークレットを使い、
paddle-signatureヘッダーの HMAC シグネチャを未加工の本文と照合して検証します。 - シグネチャが無効な場合、タイムスタンプが古すぎる場合、またはペイロードの形式が正しくない場合に例外をスローします。
- デシリアライズされたペイロードを含む、型付きの
EventEntityを返します。
シグネチャ検証の失敗も含め、なぜ単一の catch で 2xx 以外を返すのでしょうか。unmarshal が例外をスローしても、その理由はわかりません。改ざんされたリクエスト、誤ったシークレットやローテーション後のシークレット、期限切れのタイムスタンプ、形式が正しくないイベントは、すべて同じ汎用エラーとして扱われます。2xx 以外のレスポンスはすべて同じ再試行回数で再試行されるため、401 と 500 はどちらもイベントの保護という点で同等です。どちらを選んでも、再デプロイ後にシークレットがローテーションされていれば自動的に復旧します。「偽造」の場合は 401、「一時的なエラー」の場合は 500 のように分けようとしないでください。これらのケースを区別できないため、2xx 以外のステータスコードを 1 つ選び、catch 全体で使ってください。イベントを失うのは、失敗時に 2xx を返す場合だけです。
ステップ 4:イベントをハンドラーに振り分ける
ルートハンドラーは薄く保ちます。イベント固有のロジックは別の関数に移し、EventName enum を使って eventType で分岐します。TypeScript は各ケースで event.data の型を絞り込みます:
// utils/paddle/process-webhook.ts
import {
type EventEntity,
EventName,
type SubscriptionCreatedEvent,
type SubscriptionUpdatedEvent,
type SubscriptionCanceledEvent,
type TransactionCompletedEvent,
type CustomerCreatedEvent,
type CustomerUpdatedEvent,
} from "@paddle/paddle-node-sdk";
/**
* Paddle delivers at-least-once and in no guaranteed order. The same
* event.eventId arrives on every retry, so every handler below MUST be
* idempotent: UPSERT keyed on the Paddle resource id and guarded by
* event.occurredAt (Step 5), or dedupe on event.eventId before non-DB
* side effects.
*/
export async function processEvent(event: EventEntity) {
// Idempotency: handlers may run multiple times for the same event.eventId.
switch (event.eventType) {
case EventName.SubscriptionCreated:
case EventName.SubscriptionUpdated:
case EventName.SubscriptionCanceled:
return handleSubscription(event);
case EventName.TransactionCompleted:
return handleTransactionCompleted(event);
case EventName.CustomerCreated:
case EventName.CustomerUpdated:
return handleCustomer(event);
default:
// Subscribed to events you don't handle yet? No-op. Better than throwing.
return;
}
}
async function handleSubscription(
event: SubscriptionCreatedEvent | SubscriptionUpdatedEvent | SubscriptionCanceledEvent,
) {
// TODO: UPSERT keyed on event.data.id, applied only if event.occurredAt is
// newer than the stored value. See Step 5 and subscription-sync.
}
async function handleTransactionCompleted(event: TransactionCompletedEvent) {
// TODO: idempotent UPSERT keyed on event.data.id, OR dedupe on event.eventId
// before non-DB side effects (sending receipts, granting credits).
}
async function handleCustomer(event: CustomerCreatedEvent | CustomerUpdatedEvent) {
// TODO: idempotent UPSERT keyed on event.data.id.
}
Node SDK は EventName(文字列の判別子)と、各イベントに対応する型付きイベントをエクスポートするため、eventType で分岐すると TypeScript が event.data の型を絞り込みます。event.eventType === "subscription.created" のような文字列リテラルとの比較でも絞り込めますが、EventName のほうが読みやすく、リファクタリング時も安全です。
ステップ 5:ハンドラーを冪等かつ順序に依存しないものにする
基本方針は、Paddle リソース ID をキーにした UPSERT 形式でハンドラーを記述し、イベントの occurredAt を行とともに保存することです。Paddle は配信順序を保証しないため、再試行された古いイベントや再生された古いイベントが新しいイベントの後に届く場合があります。保存済みの値より occurredAt が新しい場合にのみイベントを適用してください。並行配信が割り込まないよう、チェックと書き込みは単一のアトミックなステートメントで行います:
async function handleSubscription(
event: SubscriptionCreatedEvent | SubscriptionUpdatedEvent | SubscriptionCanceledEvent,
) {
// Insert, or update only if this event is newer than the last one applied.
await db.query(
`INSERT INTO subscriptions (id, status, next_billed_at, last_event_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET
status = EXCLUDED.status,
next_billed_at = EXCLUDED.next_billed_at,
last_event_at = EXCLUDED.last_event_at
WHERE subscriptions.last_event_at IS NULL
OR subscriptions.last_event_at < EXCLUDED.last_event_at`,
[event.data.id, event.data.status, event.data.nextBilledAt, event.occurredAt],
);
}
last_event_at は TIMESTAMPTZ 列で、event.occurredAt は ISO 8601 形式の文字列です。イベントを受信した時刻ではなく、イベントが発生した時刻と比較してください。重複配信や遅延配信があっても、追加の記録管理なしに何も変更されません。サブスクリプションの状態、顧客レコード、取引レコードなど、安定した Paddle ID をキーにできるものなら、ほとんどの Webhook 処理でこの形式を使えます。Supabase 版については subscription-sync を参照してください。
UPSERT だけでは不十分な場合:イベント ID 台帳
領収書メールの送信、一度限りのクレジット付与、リクエストごとに課金されるサードパーティ API の呼び出しなど、自然には冪等にならない副作用があります。その場合は、event.eventId を使って明示的に重複を排除してください:
import { db } from "@/lib/db";
export async function processEvent(event: EventEntity) {
const seen = await db.processedWebhooks.findUnique({
where: { eventId: event.eventId },
});
if (seen) return; // Already handled — return 200 from the route.
await db.$transaction(async (tx) => {
await routeEvent(event, tx);
await tx.processedWebhooks.create({ data: { eventId: event.eventId } });
});
}
最小限のテーブル:
CREATE TABLE processed_webhooks (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMP NOT NULL DEFAULT NOW()
);
UPSERT 形式では副作用をカバーできない場合に限り、台帳を使ってください。サブスクリプション、顧客、取引の同期処理の大半では、条件付き UPSERT で十分です。
ステップ 6:すばやく応答し、負荷の高い処理はキューに入れる
5 秒のタイムアウトは実際に適用されます。ハンドラーの処理にそれ以上かかると、Paddle は接続タイムアウトとして扱い、配信をタイムアウトにし、試行を再試行回数の上限にカウントします。
手順:
- 署名を検証します。
event.eventIdを記録します(または、処理が軽ければリソースを同期的に UPSERT します)。- メールの送信、サードパーティ API の呼び出し、
paddle.transactions.list()からの取得、PDF の生成など、負荷の高い処理はすべてバックグラウンドキューに送ります。 200を返します。
具体的には、Webhook ハンドラー内で paddle.transactions.list() を直接実行しないでください。メールも直接送信しないでください。ルートハンドラー内から Stripe やその他のサードパーティを呼び出さないでください。検証し、キューに入れ、応答します。
まだキューがない場合は、Vercel Queue、AWS SQS、あるいは「pending_jobs テーブルに挿入し、cron で処理する」というシンプルな方法でも十分です。
ローカルでのテスト
2 つの方法があります。
A. 公開 URL を localhost にトンネリングします。 ngrok、cloudflared、または vercel dev --listen 0.0.0.0 を使い、その後トンネルを設定します。
ngrok http 3000
# Use the https URL as your notification destination URL: https://abc123.ngrok.app/api/webhook
これで、サンドボックスからの実際の Paddle イベントがローカルのルートハンドラーに届きます。ハンドラーを変更した後にイベントを再送するには、paddle --environment sandbox notifications list --notification-setting-id ntfset_… でイベントを見つけ、paddle --environment sandbox notifications replay ntf_… を実行します。
B. Webhook シミュレーターを使用します。 シミュレーターは通知の送信先に送信するため、localhost を使う場合もトンネルが必要です。
- localhost へのトンネルを設定し、送信先をそのトンネルに向けます。Usage type は
SimulationまたはBothにする必要があり、シミュレートするイベントを購読していなければなりません。そうでない場合、シミュレートされたイベントはabortedとしてマークされ、送信されません。 - ダッシュボードで Paddle > Events > Simulations に移動します。
- イベントタイプと同じ名前(
subscription.created、transaction.paidなど)の単一イベントか、シナリオ(subscription_creation、subscription_renewal、subscription_pause、subscription_resume、subscription_cancellation)を選択します。 - Paddle はペイロードを構築し、送信先のシークレットで署名して、URL に 1 回 POST します。シミュレートされたイベントは再試行されず、通知ログにも表示されません。シミュレーション実行のイベントを確認してください。そこから再送できます。
サンドボックスとシミュレーターを使用した一連の手順については、sandbox-testing を参照してください。
よくある落とし穴
- 検証に失敗したのに
2xxを返す。 イベントが失われる原因となるステータスの誤りは、これだけです。Paddle は2xxのレスポンスのみを配信成功と見なすため、unmarshalに失敗したときに2xxを返すと、イベントは配信済みとして扱われ、再試行されません。400、401、500、503など、2xx以外はすべて同じ再試行回数で再試行されます。そのため、署名の検証に失敗して401を返しても、イベントは失われません(シークレットをローテーションした場合でも、復旧のための再試行期間が十分に確保されます)。インテグレーター側で再試行を止めるステータスは2xxだけです。実際の不正利用を防ぐ必要がある場合は、ハンドラー内ではなく、エッジでレート制限を設定してください。 - 例外処理を「署名の検証失敗 → 401」と「ハンドラーのエラー → 500」に分ける。
unmarshalは、改ざんされたリクエスト、誤ったシークレットやローテーション前のシークレット、期限切れのタイムスタンプ、不正なイベントのいずれでも、区別なく例外をスローします。そのため、この振り分けは見かけだけのもので、どのケースなのか実際には判別できません。2xx以外のステータスを1つ選び、例外処理全体で使用してください。例外処理は1つ、ステータスは1つ、運用上の説明も1つです。 - 検証する前に本文を解析する。
request.json()で JSON を読み込んでから再シリアライズして検証すると、バイト列が Paddle の署名対象と一致しません。必ずawait request.text()を使用し、生の文字列をunmarshal()に渡してください。 - シークレットが間違っている。 通知先ごとに専用のシークレットがあります。サンドボックスのシークレットを本番環境の通知先に使う(またはその逆)と、配信のたびに
unmarshalが例外をスローします。PADDLE_NOTIFICATION_WEBHOOK_SECRETは対象の通知先と一致させる必要があります。また、これはPADDLE_API_KEYとは別の値です。 - ハンドラーの処理が遅い。 ルートハンドラー内で30秒処理すると、5秒でタイムアウトし、配信失敗として扱われて再試行が1回消費されます。検証し、キューに追加し、応答を返してください。
- Webhook が順序どおりに届くと思い込む。 順序は保証されません。最初の配信が再試行されている場合、対応する
subscription.createdより先にsubscription.updatedが届くことがあります。また、新しいイベントの後に、古いイベントが再試行または再生されて届くこともあります。単純な UPSERT では、古いイベントによって新しい状態が上書きされます。event.occurredAtを保存し、新しいイベントのみを適用してください(ステップ5)。副作用についてはevent.eventIdで重複排除してください。 - 長期間の停止後にイベントを取りこぼす。 ライブ環境での配信試行は、約2日後に停止します(サンドボックスでは約2分後です)。その後は、Paddle > Events > Notifications または
client.notifications.replayを使って、失敗した通知(最長で約90日前のもの)を再生してください。GET /events(from/to)で未受信のイベントを一覧表示するか、API で現在の状態を照会することもできます。 - クロックのずれ。 SDK は
tsが5秒を超えて古い署名を拒否するため、サーバーの時計が進んでいると、すべてのunmarshalが例外をスローします。サーバーの時計を同期してください(NTP)。 - IP の許可リストへの登録。 ファイアウォールが既知の送信元のみを許可する場合は、
GET /ipsで取得したアドレスを許可リストに登録してください。サンドボックス環境とライブ環境では異なるリストが返されるため、それぞれの API ベース URL から取得してください。署名の検証も必ず行ってください。 - 適切なイベントの購読を忘れる。 通知先から送信されるのは、ダッシュボードでチェックしたイベントだけです。コードに新しいハンドラーを追加しても、対応するイベントにチェックを入れなければ、イベントは通知されないままになります。
- Pages Router での本文の解析。 Pages Router の例では、生の本文にアクセスできるように
bodyParserを無効にしています。App Router の Route Handler では必要ありません。request.text()は常に生のバイト列を返します。
統合を検証する
processEventの先頭にconsole.log(event.eventType, event.eventId)を追加します。- localhost をトンネル経由で公開し(
ngrok http 3000)、送信先 URL をトンネルの URL に更新します。送信先の Usage type はSimulationまたはBothにする必要があります。 - ダッシュボードで Paddle > Events > Simulations に移動し、送信先に対して
subscription.createdのシミュレーションを実行します。 - 次の点を確認します。
- ルートハンドラーにイベントタイプが記録されます。
- シミュレーション実行のイベントに 200 レスポンスが表示されます。シミュレートされたイベントは 1 回だけ試行され、通知ログには表示されません。(Paddle MCP サーバーが利用可能な場合、
client.simulations.runsEvents.list("ntfsim_…", "ntfsimrun_…")はeventsを返し、それぞれにステータス、リクエスト、レスポンスが含まれます。)
.env.localのシークレットを意図的に改ざんし、シミュレートされたイベントを再生します。次の点を確認します。- ハンドラーが 2xx 以外を返します(このスキルのハンドラーは 500 を返します)。
- 実行のイベントに配信失敗が表示されます。シミュレートされたイベントは再試行されません。
- 正しいシークレットに戻し、イベントをもう一度再生します(ダッシュボードから、または
client.simulations.runsEvents.replay(simulationId, runId, eventId)を使用します)。成功するはずです。
- 実際のフローを実行します。サンドボックスでチェックアウトを完了し(
checkout-webを参照)、その結果としてtransaction.completedとsubscription.createdのイベントが届くことを確認します。実際の配信は Paddle > Events > Notifications に表示されます。送信先を開き、通知の View Logs を選択します。(Paddle MCP を使用する場合は、client.notifications.list({ notification_setting_id: ["ntfset_…"] })で通知 ID を検索し、次にclient.notifications.logs.list("ntf_…", { per_page: 50 })を実行します。これは{ pagination, logs }を返します。ログはnotificationsの下にネストされ、通知 ID は位置指定のパスパラメーターです。)
関連ドキュメント
- ウェブフックの概要
- 署名の検証
- 通知の送信先
- ウェブフックへの応答(再試行の仕様)
- シミュレーターでウェブフックをテストする
- サブスクリプションイベントのリファレンス
- Node SDK リファレンス
- リファレンス実装:paddle-nextjs-starter-kit —
src/app/api/webhook/route.tsとsrc/utils/paddle/process-webhook.tsを参照してください。