recur-webhooks について
recur-webhooksは、Recurの決済・サブスクリプションイベントを扱う開発スキルです。署名検証、イベント処理、冪等性、再試行に加え、ローカルテストと配信状況の調査方法を説明します。
主な機能
- 01決済・サブスクリプションイベントの処理
- 02生のリクエスト本文による署名検証
- 03冪等なイベント処理の設計
- 04再試行とキュー利用時の注意点
- 05ローカル転送と配信状況の確認
活用シーン
- 01初回決済や更新イベントの受信
- 02再配信による重複処理の防止
- 03Webhookエンドポイントのローカルテスト
MCP 対応
MCPのcreate_webhookでエンドポイントを設定し、test_webhookでテストイベントを送信、get_webhook_eventsで配信記録を確認できると記載されています。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ recur-tw/ skills --skill recur-webhooksインストール後、Claude Code で/recur-webhooksを入力して読み込まれたか確認します。
同梱ファイルscripts/test-webhook.shscripts/verify-signature.ts
詳細
Recur Webhook 連携
リアルタイムの決済イベントとサブスクリプションイベントを受信するため、Recur Webhook の実装を支援します。
Webhook イベント
主なイベント(最も一般的)
| イベント | 発生するタイミング |
|---|---|
checkout.completed | 決済が成功し、サブスクリプションまたは注文が作成されたとき |
subscription.activated | サブスクリプションが有効になったとき |
subscription.cancelled | サブスクリプションがキャンセルされたとき — data.status / data.current_period_end を確認してください。期間終了時のキャンセルではその日付までアクセスが維持されますが、ダッシュボードからのキャンセルや返金によるキャンセルでは直ちにアクセスが停止します |
subscription.renewed | 継続課金が成功したとき |
subscription.past_due | 決済に失敗し、サブスクリプションが危険な状態になったとき |
invoice.paid / invoice.payment_failed | 請求書の結果。請求書が作成されるのは更新時とプラン切り替え時のみなので、billing_reason は subscription_cycle または subscription_update となり、subscription_create にはなりません — 初回決済では請求書は作成されません。更新決済が成功した場合は subscription.renewed も送信され、請求が失敗した場合は猶予期間がある場合に限り subscription.past_due も送信されます。利用可能なカードがない状態で更新が行われた場合、請求書イベントは送信されません — subscription.payment_method_required / subscription.revoked を参照してください |
order.paid / order.payment_failed | 注文の決済が成功または失敗したとき — 単発購入だけに限りません。サブスクリプションの初回決済は請求書ではなく注文なので、両イベントには billing_reason: 'subscription_create' も含まれ、この初回請求を報告するのはこれらのイベントのみです。どちらの分岐も処理してください。purchase は単発購入、subscription_create は登録です。subscription_create の分岐が空だと、新規登録者がすべて黙って失われます |
refund.created | 返金が開始されたとき |
サポート対象の全イベント(recur-tw/server の WEBHOOK_EVENT_TYPES)
type WebhookEventType =
| 'checkout.created' | 'checkout.completed'
| 'order.paid' | 'order.payment_failed'
| 'subscription.created' | 'subscription.activated' | 'subscription.cancelled'
| 'subscription.revoked' | 'subscription.expired' | 'subscription.trial_ending'
| 'subscription.upgraded' | 'subscription.downgraded' | 'subscription.renewed'
| 'subscription.past_due' | 'subscription.payment_method_required'
| 'subscription.schedule_created' | 'subscription.schedule_executed' | 'subscription.schedule_cancelled'
| 'invoice.created' | 'invoice.paid' | 'invoice.payment_failed'
| 'customer.created' | 'customer.updated'
| 'product.created' | 'product.updated'
| 'refund.created' | 'refund.succeeded' | 'refund.failed'
| 'einvoice.issued' | 'einvoice.issue_failed' | 'einvoice.voided' | 'einvoice.allowance_created'
subscription.updated、subscription.payment_failed、invoice.refunded はありません。
署名の検証
- ヘッダー:
X-Recur-Signature(大文字と小文字を区別せずに読み取ります) - アルゴリズム:Base64 エンコード(16 進数ではありません)の HMAC-SHA256 を生のボディに対して計算します
recur-tw/serverのrecur.webhooks.verify(payload, signature, secret)を使用します。この関数は 定数時間比較を行い、不一致の場合はWebhookSignatureVerificationErrorをスローします
Webhook ハンドラーの実装
Next.js App Router
// app/api/webhooks/recur/route.ts
import { NextResponse } from 'next/server'
import { Recur } from 'recur-tw/server'
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
export async function POST(request: Request) {
const payload = await request.text() // raw body — never re-serialize
const signature = request.headers.get('x-recur-signature')
let event
try {
event = recur.webhooks.verify(payload, signature, process.env.RECUR_WEBHOOK_SECRET!)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
// event.data is the object itself (snake_case keys), e.g. event.data.id for checkout,
// order, subscription and invoice events. einvoice.* payloads have order_id / invoice_id
// instead, so use the envelope event.id whenever you need an identifier for every event.
switch (event.type) {
case 'checkout.completed':
await handleCheckoutCompleted(event.data)
break
case 'subscription.activated':
await handleSubscriptionActivated(event.data)
break
case 'subscription.cancelled':
await handleSubscriptionCancelled(event.data)
break
// A successful renewal emits BOTH invoice.paid and subscription.renewed. A failed
// CHARGE emits invoice.payment_failed, plus subscription.past_due only when the product
// has a grace period. Event-id dedupe does not help — the paired events have different
// ids — so act on the invoice event and treat the subscription one as informational.
//
// A renewal with NO usable card emits no invoice event at all: it sends
// subscription.payment_method_required (grace) or subscription.revoked (no grace).
// Handle those below, or you will miss those failures entirely.
case 'invoice.paid':
// Invoices exist only for renewals and plan switches, so billing_reason here is
// 'subscription_cycle' or 'subscription_update' — never 'subscription_create'.
// A subscription's FIRST payment produces no invoice at all; it arrives as
// order.paid. One-time purchases produce no invoice either.
if (event.data.billing_reason === 'subscription_cycle') {
await handleRenewal(event.data) // extend access, record recurring revenue
} else {
await handleSwitchInvoice(event.data) // 'subscription_update': the plan switch
}
break
case 'order.paid':
// Orders cover BOTH one-time buys and a subscription's first payment. Nothing else
// reports that first payment, so both branches have to do real work.
if (event.data.billing_reason === 'purchase') {
await handleOneTimePurchase(event.data)
} else {
await handleFirstSubscriptionPayment(event.data) // 'subscription_create'
}
break
case 'order.payment_failed':
// Same split. A failed signup emits ONLY this event — no invoice.payment_failed
// follows it — so an empty branch here loses the failure entirely.
if (event.data.billing_reason === 'purchase') {
await handlePurchaseFailed(event.data)
} else {
await handleSignupFailed(event.data) // 'subscription_create'
}
break
case 'subscription.renewed':
break // informational: same renewal as invoice.paid
case 'invoice.payment_failed':
await handlePaymentFailure(event.data) // dunning email, mark at risk
break
case 'subscription.past_due':
break // informational: same failure
case 'subscription.payment_method_required':
await handlePaymentFailure(event.data) // no card on file — no invoice event fires
break
case 'subscription.revoked':
await handleSubscriptionCancelled(event.data) // access ended immediately
break
case 'refund.created':
await handleRefundCreated(event.data)
break
default:
console.log(`Unhandled event type: ${event.type}`)
}
return NextResponse.json({ received: true })
}
type Payload = Record<string, unknown>
async function handleCheckoutCompleted(data: Payload) {
// data.id (checkout id), data.status, data.amount (whole TWD), data.product_id,
// data.customer { id, email, name }
}
async function handleSubscriptionActivated(data: Payload) {
// data.id (subscription id), data.status, data.product_id, data.customer
}
async function handleSubscriptionCancelled(data: Payload) {
// data.id, data.status, data.current_period_end.
// Period-end cancellation: keep access until current_period_end.
// Immediate cancellation (dashboard, refund): status is already "canceled" — revoke now.
}
async function handleRenewal(data: Payload) {} // invoice, 'subscription_cycle'
async function handleSwitchInvoice(data: Payload) {} // invoice, 'subscription_update'
async function handleOneTimePurchase(data: Payload) {} // order, 'purchase'
async function handleFirstSubscriptionPayment(data: Payload) {} // order, 'subscription_create'
async function handlePurchaseFailed(data: Payload) {} // order failure, 'purchase'
async function handleSignupFailed(data: Payload) {} // order failure, 'subscription_create'
async function handlePaymentFailure(data: Payload) {}
async function handleRefundCreated(data: Payload) {}
Express.js
import express from 'express'
import { Recur } from 'recur-tw/server'
// Your own dispatch — the claim-first version is in "Idempotency" below.
import { handleEvent } from './handle-event'
const app = express()
const recur = new Recur(process.env.RECUR_SECRET_KEY!)
// Use the raw body for signature verification
app.post('/api/webhooks/recur', express.raw({ type: 'application/json' }), async (req, res) => {
const payload = req.body.toString()
const signature = req.header('x-recur-signature') ?? null
let event
try {
event = recur.webhooks.verify(payload, signature, process.env.RECUR_WEBHOOK_SECRET!)
} catch {
return res.status(401).json({ error: 'Invalid signature' })
}
// Dispatch before answering: a 2xx tells Recur the event is handled.
try {
await handleEvent(event)
} catch (err) {
console.error('webhook handler failed', event.id, err)
return res.status(500).json({ error: 'handler failed' }) // Recur retries
}
res.json({ received: true })
})
SDK を使用しない場合
2 つの実装方法があります。1 つ目は Web Crypto を使用し、エッジ、ワーカー、Node など、どこでも実行できます。2 つ目は Node 専用の node:crypto バージョンです。Web Crypto には timingSafeEqual がないため、1 つ目では === を使わず、バイト列を直接比較します。
async function verifyEdge(payload: string, signature: string | null, secret: string) {
if (!signature || !secret) return false
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
)
const mac = await crypto.subtle.sign('HMAC', key, enc.encode(payload))
const expected = btoa(String.fromCharCode(...new Uint8Array(mac)))
// Constant time over equal-length strings (the length itself is not secret).
if (expected.length !== signature.length) return false
let diff = 0
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i)
}
return diff === 0
}
Node.js(node:crypto、エッジランタイムでは利用できません):
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(payload: string, signature: string | null, secret: string): boolean {
// Same guards as recur.webhooks.verify(): a missing header or a blank
// RECUR_WEBHOOK_SECRET must never reach the HMAC.
if (!signature || !secret) return false
const expected = createHmac('sha256', secret).update(payload).digest('base64')
const a = Buffer.from(signature), b = Buffer.from(expected)
return a.length === b.length && timingSafeEqual(a, b)
}
イベントペイロードの構造
interface WebhookEvent {
id: string // Event ID (for idempotency), e.g. 'evt_abc123'
type: WebhookEventType
timestamp: string // ISO 8601
data: Record<string, unknown> // the object itself, snake_case keys — NOT { object: {...} }
}
// Most payloads key on data.id; einvoice.* payloads use order_id / invoice_id instead,
// so log or correlate on the envelope event.id when you need something universal.
// Enum-derived fields (status, type, billing_reason, switch_type, reason) are lowercased
// before delivery: "active", "canceled", "complete" — compare against lowercase values.
// The REST API does NOT do this: the same field reads "SUBSCRIPTION_UPDATE" / "ACTIVE"
// there (see openapi.json). Only webhook payloads are lowercased.
// Example: checkout.completed
{
"id": "evt_abc123",
"type": "checkout.completed",
"timestamp": "2026-01-15T10:30:00.000Z",
"data": {
"id": "checkout_xyz",
"status": "complete",
"subtotal": 990, // whole TWD, before discount
"discount": null,
"amount": 990, // whole TWD, after discount
"currency": "TWD",
"product_id": "cmfxq8n2a0001l8yz3k5p9t7d",
"customer": { "id": "cust_456", "external_id": null, "email": "[email protected]", "name": "Test User" },
"customer_email": null, // only set when the checkout had no customer record yet
"created_at": "2026-01-15T10:25:00.000Z",
"completed_at": "2026-01-15T10:30:00.000Z",
"metadata": null
}
}
Webhook の設定
- Recur ダッシュボード → 開発者 → Webhooks(
app.recur.tw/developers/webhooks)→ エンドポイントを追加します(または MCP のcreate_webhookを使用します) - エンドポイント URL を入力します(例:
https://yourapp.com/api/webhooks/recur) - 受信するイベントを選択します
- Webhook Secret を
RECUR_WEBHOOK_SECRETにコピーします
SDK の例では new Recur(process.env.RECUR_SECRET_KEY!) を作成するため、この変数に実際の sk_ キーが設定されていないとエラーになります — Webhook 専用のサービスにもキーが必要です。
RECUR_SECRET_KEY=sk_test_xxx # server key; the Recur client refuses to construct without it
RECUR_WEBHOOK_SECRET=<secret> # per-endpoint signing secret from the step above (64-char hex, no prefix)
上記の「SDK を使用しない場合」にある独自実装の検証コードには、RECUR_WEBHOOK_SECRET のみが必要です。
Webhook のローカルテスト
# Expose your dev server
ngrok http 3000
# then register https://xxxx.ngrok.io/api/webhooks/recur in the dashboard
# Or forward live events to your dev server with the CLI (after `npx @recur-tw/cli login`)
npx @recur-tw/cli webhooks listen http://localhost:3000/api/webhooks/recur
webhooks listen は、転送するイベントすべてにセッションごとの独自のシークレット(whsec_…。接続時に「Signing secret」として表示されます)で再署名します。リッスン中は RECUR_WEBHOOK_SECRET にその値を設定してください。設定しないと、すべてのイベントの検証に失敗します。
MCP の test_webhook はテストイベントをエンドポイントに送信します。get_webhook_events では配信状況を確認できます。
ベストプラクティス
1. 署名を必ず検証する
検証していない Webhook ペイロードは決して信頼しないでください。recur.webhooks.verify()、または SDK を使用していない場合は、上記の定数時間検証コードのいずれかを使用してください。
2. 冪等性を処理する
Webhook は複数回配信されることがあり、2 件の配信が同時に届くこともあります。 読み取り、処理、挿入の順に行う方法には競合状態があり、両方のリクエストが行の存在を確認できず、どちらも副作用を実行してしまいます。まず一意制約付きの挿入でイベントを確保してから処理します。
const LEASE_MS = 5 * 60_000 // a PROCESSING row older than this lost its handler
// Returns true if this delivery owns the event and should process it.
// `token` fences the claim: a worker that lost its lease cannot mark the event DONE.
async function claim(eventId: string, token: string): Promise<boolean> {
// eventId has a UNIQUE constraint — the insert is the lock.
try {
await db.webhookEvent.create({
data: { eventId, status: 'PROCESSING', claimedAt: new Date(), claimToken: token },
})
return true
} catch (err) {
if (!isUniqueViolation(err)) throw err
}
// The row already exists. Take it over ONLY if it is retryable: a previous attempt
// failed, or a claim went stale. The conditional update is the atomic part — DONE rows
// and fresh PROCESSING claims match nothing, so exactly one worker wins.
const { count } = await db.webhookEvent.updateMany({
where: {
eventId,
OR: [
{ status: 'FAILED' },
{ status: 'PROCESSING', claimedAt: { lt: new Date(Date.now() - LEASE_MS) } },
],
},
data: { status: 'PROCESSING', claimedAt: new Date(), claimToken: token },
})
return count === 1
}
async function handleEvent(event: WebhookEvent) {
const token = crypto.randomUUID()
if (!(await claim(event.id, token))) return // already done, or another delivery owns it
await processClaimedEvent(event, token)
}
// Takes an event this process already owns. The async worker calls THIS, not handleEvent —
// re-claiming its own fresh PROCESSING row would return false and drop the work.
async function processClaimedEvent(event: WebhookEvent, token: string) {
try {
// Process event...
// Only the current owner may finish it: a worker whose lease expired matches nothing.
const { count } = await db.webhookEvent.updateMany({
where: { eventId: event.id, claimToken: token },
data: { status: 'DONE', processedAt: new Date() },
})
if (count === 0) console.warn('lost the claim for', event.id)
} catch (err) {
// Mark retryable and return 5xx so Recur redelivers; the next attempt reclaims
// the FAILED row above instead of being swallowed as a duplicate.
await db.webhookEvent.updateMany({
where: { eventId: event.id, claimToken: token },
data: { status: 'FAILED' },
})
throw err
}
}
終端状態になるのは DONE だけです。FAILED の行や、期限切れの PROCESSING 行は次の配信で再度確保されるため、処理の途中でハンドラーが停止してもイベントが放置されません。
この方法でできることと、できないこと:
- リースにより、停止したワーカーがイベントをブロックする時間を制限できます。リトライによって行が再確保された後も、遅いワーカーが処理を続けることまでは防げません。確保トークンによって、そのワーカーがイベントを
DONEにすることだけは防げます。副作用はすでに両方で実行されている可能性があるため、イベント ID だけでなく、対象オブジェクト(サブスクリプション、注文、請求書の ID)に対しても副作用が冪等になるようにしてください。 claim()が false を返すのは、「別の処理が確保している、またはすでに処理済み」という意味です。両者を区別できず、配信を再試行させたい場合は、200 ではなく 5xx を返してください。Recur は再配信し、次の試行でDONEを確認するか、期限切れのリースを再確保します。- この方法でスイーパーが不要になるわけではありません。追加の配信がないままリースの期限を過ぎた
PROCESSINGの行をリセットするか、アラートを出すには、定期的なジョブが必要です。
3. Recur への応答
200 を返すことは、そのイベントへの対応を引き受けたという約束です。 Recur は 200 の後に再配信しないため、スキップした処理は自分で再試行しない限り失われます。
-
同期的に処理する(上記のハンドラー)場合は、処理が短いときに使います。成功時は 200、失敗時は 5xx を返し、Recur のリトライに任せます。これがデフォルトです。
-
非同期処理にするのは、キューが耐久性を担保する場合に限ります。つまり、リトライ、デッドレターキュー、再実行手段が必要です。重複を二重にキューへ登録しないよう先に確保し、ワーカーが自分の行を完了できるようトークンを渡します。
const token = crypto.randomUUID() if (await claim(event.id, token)) { try { await queue.add('recur-webhook', { event, token }, { attempts: 5, backoff: 'exponential' }) } catch (err) { // The claim exists but nothing owns the work. Release it so the next delivery // reclaims the FAILED row, and answer 5xx so Recur actually redelivers — // otherwise a 200 here strands the event behind a fresh PROCESSING claim. await db.webhookEvent.updateMany({ where: { eventId: event.id, claimToken: token }, data: { status: 'FAILED' }, }) throw err } } return NextResponse.json({ received: true })ワーカーは
handleEventではなく、そのトークンを使ってprocessClaimedEvent(event, token)を呼び出します。handleEventを呼び出すと、ワーカー自身が新しいPROCESSING行を確保しようとしてfalseを受け取り、処理を破棄してしまいます。確保後、
queue.addが成功する前にクラッシュすると、ジョブが所有していない新しいPROCESSING行が残ります。次の配信ではclaim() === falseとなり、イベントは確認応答されます。この状態を復旧できるのはスイーパーだけです。リース期限を過ぎた行を対象にスイーパーを実行するか、claim()が false を返すたびに 5xx を返し、リースの期限が切れるまで Recur に再配信を続けさせてください。ジョブが試行回数を使い切った場合、残るのはデッドレター処理だけです。Recur はすでにイベントが配信済みだとみなしています。
4. リトライを適切に処理する
Recur は配信に失敗するとリトライします。ハンドラーは冪等にしてください。
5. エンベロープを記録する
console.log('Webhook received:', { type: event.type, id: event.id, timestamp: event.timestamp })
Webhook のデバッグ
ダッシュボード → Webhooks → エンドポイント → 配信ログ(または MCP get_webhook_events)。
401 Unauthorized
RECUR_WEBHOOK_SECRETが間違っている- 本文が再シリアライズされている(
JSON.stringify(await request.json()))— 生テキストを確認してください - 独自に実装した HMAC が 16 進数形式になっている — Recur の署名は Base64 です
タイムアウト(応答なし)
- 重い処理はリクエストの外に移してください。ただし、耐久性のあるキューを経由させてください。200 を返すと Recur にイベントを処理済みだと伝えることになり、再配信されません(上記「Recur への応答」を参照)
イベントが見つからない
- エンドポイントでイベントタイプが選択されていない
- エンドポイント URL が間違っているか、インターネットから到達できない
関連スキル
/recur-quickstart- SDK の初期設定/recur-checkout- 決済フローの実装/recur-entitlements- Webhook の後にサブスクリプションへのアクセスを確認