Claude CodeでPAY.JPの定期課金とWebhook検証を実装する — 署名ではなくトークンで守る
PAY.JPのWebhookにHMAC署名はなく、固定トークンのヘッダーで正当性を見ます。定期課金の作成からイベント受信、状態遷移までをClaude Codeに書かせる手順です。
PAY.JPのWebhookには、StripeのようなHMAC署名がありません。リクエストの正当性は、ヘッダーX-Payjp-Webhook-Tokenに入ったアカウント固有のトークンIDを、受信側が照合して判断します。「署名検証」と呼ばれがちな処理の中身は、共有トークンの比較です。
この違いを知らずにClaude Codeへ「PAY.JPのWebhook署名を検証して」と頼むと、存在しない署名スキームを前提にしたコードが返ってきかねません。この記事は、仕様を先に渡したうえで、トークン化、定期課金の作成、Webhookの受信と状態更新までを書かせる手順です。PAY.JP API v1のドキュメントに沿っています。
署名ではなくトークンで見る — Stripeとの設計差
PAY.JPのWebhookは、すべてPOSTで届きます。ボディはイベントのJSONで、dataにはAPIが返すオブジェクトと同じ内容が入ります。レスポンスは、HTTPステータスが200であることだけが要件です。ボディは空でも構いません。
Stripeを実装した経験があると、同じ型で書きたくなります。しかし前提が違います。
| 観点 | PAY.JP(v1) | Stripe |
|---|---|---|
| 正当性の確認 | PAY.JP(v1)X-Payjp-Webhook-Tokenヘッダーの固定値と照合 | StripeStripe-SignatureのタイムスタンプとHMAC-SHA256を検証 |
| 検証に使うもの | PAY.JP(v1)アカウントのトークンID(管理画面のアカウント設定で確認) | Stripeエンドポイントごとのwhsec_シークレットと未加工のボディ |
| ボディの改ざん検知 | PAY.JP(v1)仕組みなし | Stripe署名がボディに紐づく |
| 再試行 | PAY.JP(v1)4xx/5xxのとき3分間隔で最大3回 | Stripe本番は指数バックオフで最長3日間 |
| 重複への備え | PAY.JP(v1)イベントIDの記録か冪等な処理を推奨 | StripeイベントIDで追跡し、createdを順序判定に使わない |
最大の違いは、ボディの完全性を暗号で守る層がないことです。トークンが漏れれば、第三者が任意のイベントを偽装できます。トークンは時間で変わらず、タイムスタンプも含まないため、盗んだリクエストの再送も防げません。Stripeはタイムスタンプに5分の許容範囲を設けて再送を弾きますが、PAY.JPにはその層がありません。
だからPAY.JPでは、トークン照合だけを「検証」とみなさない設計が要ります。この記事では、次の3層で組みます。
- 入口: HTTPSで受け、トークンを定数時間で比較する
- 受信箱: イベントIDを主キーにして、同じイベントを一度だけ処理する
- 状態の確定: イベントの中身を信用せず、処理時にAPIから定期課金を取り直す
3つ目はPAY.JPが定めた手順ではなく、トークン方式の弱さを埋めるためのこの記事の設計です。Stripeの実装はStripe webhookの冪等受信に、状態遷移の考え方はStripeサブスクの同期にあります。HMAC署名を検証する側の実装はManaged Agentsのwebhookが参考になります。
実装前にClaude Codeへ渡す仕様
Claude Codeは、公式ドキュメントにある仕様を渡さなくても、それらしいコードを返します。国内の決済サービスは学習データが薄いぶん、Stripeの作法が混ざりやすい領域です。プロジェクトのCLAUDE.mdに、次の断片を置いておきます。
## PAY.JP(API v1)の前提
- 認証: シークレットキーをBasic認証のユーザー名にする(パスワードは空)
- カード情報は自社サーバーを通さない。payjp.jsでトークン化し、サーバーにはトークンIDだけ送る
- トークンは一度使うと再利用できない
- 定期課金は プラン作成 → 顧客作成(card=トークンID) → 定期課金作成(plan, customer) の順
- Webhookの検証は X-Payjp-Webhook-Token ヘッダーの照合のみ。HMAC署名は存在しない
- Webhookは200以外だと再送される(4xx/5xxで3分間隔・最大3回)。ボディは空でよい
- 同じイベントが複数回届く前提で、イベントID(evnt_...)で重複を除く
- 課金に失敗した定期課金は status が paused になり、resume を呼ぶまで再課金されないポイントは、CLAUDE.mdに書くのが「できること」ではなく「存在しないもの」だという点です。署名スキームがないと明記しておけば、署名検証用のヘルパーを勝手に作る方向へ進みにくくなります。
カード情報をトークン化し、定期課金を作る
PAY.JPの定期課金は前払い式です。課金日もトライアルも指定しなければ、定期課金の作成と同時に初回課金が走ります。この初回課金に失敗すると、定期課金そのものが作成されません。作成の成功と課金の成功が同時に決まるので、実装は単純になります。
流れは3段階です。
定期課金が始まるまでの3段階
- 1
ブラウザでトークン化
カード番号・有効期限・CVCの入力欄は、payjp.jsで生成します。自前でフォームを作ると、PCI-DSS準拠とはみなされません。
payjp.createToken()が返すトークンIDだけをサーバーへ送ります。 - 2
サーバーで顧客を作る
トークンIDを
cardに渡して顧客を作成します。トークンは一度使うと再利用できないため、失敗時はブラウザ側でトークンを作り直します。 - 3
顧客とプランを結びつける
顧客IDとプランIDを渡して定期課金を作成します。成功すると
charge.succeededとsubscription.createdのイベントが生成されます。
サーバー側をClaude Codeに書かせるときは、SDKに頼らず、fetchでAPIを呼ぶ形を指定すると仕様との対応が追いやすくなります。次は、その指示で得たい形の一例です。
const BASE = "https://api.pay.jp/v1";
const AUTH =
"Basic " + Buffer.from(`${process.env.PAYJP_SECRET_KEY}:`).toString("base64");
export class PayjpError extends Error {
constructor(public status: number, public body: unknown) {
super(`PAY.JP API error: ${status}`);
}
}
async function payjp<T>(
path: string,
params?: Record<string, string>,
method: "GET" | "POST" = params ? "POST" : "GET",
): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Authorization: AUTH,
"Content-Type": "application/x-www-form-urlencoded",
},
body: params ? new URLSearchParams(params) : undefined,
});
if (!res.ok) throw new PayjpError(res.status, await res.json());
return (await res.json()) as T;
}
export async function startSubscription(input: {
tokenId: string;
email: string;
planId: string;
userId: string;
}) {
const customer = await payjp<{ id: string }>("/customers", {
email: input.email,
card: input.tokenId,
"metadata[user_id]": input.userId,
});
return payjp<{ id: string; status: string }>("/subscriptions", {
plan: input.planId,
customer: customer.id,
"metadata[user_id]": input.userId,
});
}metadata[user_id]は、APIのリファレンスにある書き方です。顧客と定期課金の両方に自社のユーザーIDを持たせておくと、Webhookを受けたときに自社のレコードへ戻しやすくなります。メタデータは1オブジェクトにつき最大20キーで、キーは40文字、値は500文字までです。
月末に作る定期課金は課金日がずれる
課金日を指定しない定期課金は、作成した日時が基準になります。翌月に同じ日がなければ、もっとも近い日に寄せられます。29日、30日、31日に作成すると、日数の少ない月を通るたびに、以降の課金日が前へずれていきます。
さらに、PAY.JPは内部をUTCで管理しているため、日本時間の9時を挟んで挙動が変わります。ドキュメントは、課金日(billing_day)を指定するか、trial_endで開始時刻を9時以降へずらすことを案内しています。Claude Codeへの指示には「月末に作成されるケースのテストを書く」と入れておくと、この性質に気づく足場になります。
Webhookを受ける — 検証、記録、返信の順
受信エンドポイントは、次の順に処理します。
- トークンを照合し、合わなければ
401で返す - イベントIDを受信箱に記録する(既に記録済みなら何もせず
200) 200を返す- 受信箱から取り出して、状態を更新する
PAY.JPの再送は4xxと5xxで起きます。正当なリクエストなのに設定ミスでトークンが合わなかった場合、401は再送を呼び、管理画面や自社のログで気づくきっかけにもなります。
import crypto from "node:crypto";
import express from "express";
const app = express();
function tokenMatches(received: string | undefined): boolean {
const expected = process.env.PAYJP_WEBHOOK_TOKEN ?? "";
if (!received || !expected) return false;
// 長さの違いで例外にならないよう、ハッシュ同士を比べる
const a = crypto.createHash("sha256").update(received).digest();
const b = crypto.createHash("sha256").update(expected).digest();
return crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/payjp", express.json(), async (req, res) => {
if (!tokenMatches(req.get("X-Payjp-Webhook-Token"))) {
return res.sendStatus(401);
}
const event = req.body as { id: string; type: string; livemode: boolean };
const inserted = await inbox.insertIfNew({
eventId: event.id,
type: event.type,
livemode: event.livemode,
payload: req.body,
});
// 重複でも200を返す。再送を止めるため
res.sendStatus(200);
if (inserted) queueMicrotask(() => processInboxEvent(event.id));
});受信箱の実体は、イベントIDを主キーにしたテーブルです。PostgreSQLなら、INSERT ... ON CONFLICT (event_id) DO NOTHINGの影響行数が1かどうかで「初めて見るイベントか」が決まります。PAY.JPのドキュメントも、イベントIDをキャッシュなどに一定時間記録して同一イベントをスキップするか、複数回処理しても状態が同じになる冪等なロジックにするよう勧めています。
受信したボディを一定時間保管しておくことも推奨されています。処理中に例外が出たとき、管理画面からの再送に頼らず、ログから再現できるためです。上の受信箱がpayloadを丸ごと持つのは、そのためです。
状態はイベントの中身ではなくAPIから決める
processInboxEventは、イベントの種別ごとに定期課金を取り直して、自社のデータベースへ反映します。dataのスナップショットではなくGET /v1/subscriptions/:idの結果を使う理由は2つあります。
- トークン方式では、
dataの内容が本物だと暗号では保証できない - 公式のWebhookページには配信順の保証が書かれておらず、遅延する場合があると明記されている(月末月初や毎時0分付近は特に)
type Sub = {
id: string;
status: "trial" | "active" | "paused" | "canceled";
current_period_end: number;
metadata: { user_id?: string };
};
const HANDLED = new Set([
"subscription.created",
"subscription.renewed",
"subscription.paused",
"subscription.resumed",
"subscription.canceled",
"subscription.deleted",
]);
async function processInboxEvent(eventId: string) {
const row = await inbox.get(eventId);
if (!HANDLED.has(row.type)) return inbox.markDone(eventId);
const subId = row.payload.data.id as string;
const sub = await payjp<Sub>(`/subscriptions/${subId}`);
const userId = sub.metadata.user_id;
if (!userId) return inbox.markDone(eventId, "no user_id");
await db.upsertEntitlement(userId, {
subscriptionId: sub.id,
status: sub.status,
periodEnd: new Date(sub.current_period_end * 1000),
lastEventAt: new Date(),
});
await inbox.markDone(eventId);
}subscription.deletedで取り直すと、削除済みの定期課金はAPIから取れない可能性があります。そのため実際のコードでは、この種別だけはdataの内容で権限を閉じる分岐が必要になります。Claude Codeにこの設計を伝えるときは、「削除イベントの扱いをテストで固定する」と明示しておくと、見落としを防げます。
イベントと状態の対応表
定期課金に関わるイベントと、statusの動きを対応づけておくと、Claude Codeへテスト観点を渡しやすくなります。ドキュメントの記述から、次のように読み取れます。
| 場面 | 発生するイベント | 定期課金のstatus |
|---|---|---|
| 作成(即時課金)に成功 | 発生するイベントcharge.succeeded、subscription.created | 定期課金のstatusactive |
| 作成(即時課金)に失敗 | 発生するイベントcharge.failed | 定期課金のstatus定期課金は作成されない |
| 作成(トライアルあり) | 発生するイベント課金は未来に予定される | 定期課金のstatustrial |
| 2回目以降の課金に成功 | 発生するイベントcharge.succeeded、subscription.renewed | 定期課金のstatusactive(トライアル中ならtrialから切り替わる) |
| 2回目以降の課金に失敗 | 発生するイベントcharge.failed、subscription.paused | 定期課金のstatuspaused |
| 再開 | 発生するイベントsubscription.resumed(失敗課金の再開ではcharge.succeededも) | 定期課金のstatusactive |
| キャンセル | 発生するイベントsubscription.canceled | 定期課金のstatuscanceled |
注意したいのはpausedです。カードの有効期限切れなどで課金に失敗すると、サイクルは止まり、再開のリクエストが来るまで今後の課金は行われません。再開前に顧客のカードを有効なものへ更新する必要があります。Stripeのように自動で再試行を重ねる動きを想定していると、停止に気づけないまま権限だけが残ります。pausedを受けたら権限を止めるか猶予期間に入れるか、プロダクト側の方針を先に決めておきます。
また、キャンセルは即時の終了とは限りません。キャンセル後はcanceledになり、その後に定期課金が削除されます。削除前なら再開でキャンセルを解除できます。一度削除した定期課金は元に戻せず、同じプランを再び購読したいときは、削除前なら再開、削除後なら新規作成になります。
テストを回し続けるフックと、手元で叩く確認コマンド
実装の途中で壊れやすいのは、トークン照合と重複処理です。Claude Codeにはファイルを編集するたび、これらのテストを走らせるフックを設定できます。PostToolUseは、ツール呼び出しが成功したあとに発火します。matcherでEdit|Writeを指定すると、ファイル編集のときだけ動きます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/payjp-test.sh"
}
]
}
]
}
}#!/usr/bin/env bash
# テストが落ちたら exit 2 でClaudeにstderrを見せる
out=$(npx vitest run src/payjp 2>&1) || { echo "$out" | tail -40 >&2; exit 2; }終了コードを2にするのがコツです。ツールはすでに実行済みなので止められませんが、PostToolUseが2で終わるとstderrがClaudeに届き、修正のきっかけになります。1で終わると、非ブロッキングのエラー扱いです。
テストには、最低でも次の4ケースを書かせます。
- トークンなしと誤ったトークンで
401が返る - 同じイベントIDを2回送ると、状態更新が1回だけ走る
- 同じイベントが2回目にエラーとなったあとも、受信箱の記録は残る
subscription.pausedの後に権限が止まり、subscription.resumedで戻る
ローカルでエンドポイントを叩くには、管理画面のテスト送信のほか、手元から次のようにヘッダー付きで送る方法もあります。トークンの値は、ダミーの文字列で構いません。
curl -i http://localhost:3000/webhooks/payjp \
-H "Content-Type: application/json" \
-H "X-Payjp-Webhook-Token: whook_dummy" \
-d '{"id":"evnt_test_1","type":"subscription.renewed","livemode":false,
"data":{"id":"sub_test_1"}}'同じコマンドを2回続けて実行し、2回目も200で、状態更新のログが増えなければ重複排除が効いています。トークンを変えて401になることも確認します。
テストモードと本番モードを取り違えない
管理画面でWebhookの送信先を追加するときは、イベントが送られるモードをテストか本番かで選びます。送信先は複数追加できるため、本番用とテスト用で別のURLを用意できます。受信箱にlivemodeを残しておくと、テストイベントが本番の権限を書き換えるミスを検知できます。
テストカードは、トークンが作れるものとエラーを返すものに分かれています。たとえば4242424242424242はVisaでトークンを作れ、4000000000000002はcard_declinedを返します。この2枚で、成功とカード拒否の両方の経路を確かめられます。
受信できないときの切り分け
Webhookが届かないとき、まず疑うのは自社側です。PAY.JPのドキュメントは、受信エンドポイントのサーバーエラーやSSL証明書の期限切れが原因の不達が多いと述べています。リトライは3分間隔で最大3回なので、証明書が切れたまま半日放置すると、その間のイベントは再送されません。
自社側に問題がなければ、遅延を疑います。要因としてドキュメントが挙げているのは、月末月初や毎時0分付近の集中、計画メンテナンス、システム障害です。とくに月末月初は定期課金の更新が集中するため、数時間の遅延を見込んだ設計が推奨されています。
管理画面からの再送が難しい場合は、データからの復旧になります。ドキュメントの例では、会員レコードに定期課金IDと最後にイベントを受け取った日時を持たせ、1か月と1日以上前のレコードについてAPIで定期課金の状態を取り直します。これをそのまま定期ジョブとしてClaude Codeに書かせられます。
// 1か月+1日以上、定期課金のイベントを受け取っていない会員を突き合わせる
const stale = await db.findEntitlementsNotUpdatedSince(
new Date(Date.now() - 32 * 24 * 60 * 60 * 1000),
);
for (const e of stale) {
const sub = await payjp<Sub>(`/subscriptions/${e.subscriptionId}`);
await db.upsertEntitlement(e.userId, {
subscriptionId: sub.id,
status: sub.status,
periodEnd: new Date(sub.current_period_end * 1000),
lastEventAt: new Date(),
});
}32日という値は、「1か月+1日以上」を日数に置き換えた近似です。月の長さに合わせたい場合は、会員ごとのcurrent_period_endを基準にする方法もあります。
v1とv2の違いに注意する
この記事のエンドポイントとドキュメントは、PAY.JP API v1のものです。v1のページには「v2のドキュメントはこちら」という案内があり、移行ガイドも用意されています。既存の契約や新規導入でどちらを使うかは、管理画面と移行ガイドで確かめてから進めます。
Claude Codeに実装させるときは、CLAUDE.mdの冒頭に「APIバージョンはv1」と書いておきます。v2の仕様とv1の仕様が混ざったコードが返るのを防げます。
まとめ
PAY.JPのWebhookは、署名ではなく固定トークンの照合で成り立っています。そのぶん、Stripeと同じ感覚で書くと防御が一段足りません。HTTPSとトークン照合の先に、受信箱によるイベントIDの重複排除と、APIから状態を取り直す処理を重ねる構成が、この仕様に合います。
Claude Codeに任せるなら、渡すものは3つです。存在しない署名スキームを書かせないためのCLAUDE.md、pausedを含む状態遷移のテスト観点、そして編集のたびにテストを走らせるフック。PAY.JP固有の落とし穴は、月末の課金日のずれと、失敗後に止まり続ける定期課金です。この2点だけは、実装前にプロダクト側の方針を決めておくと、あとの修正が小さくなります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。