关于 recur-webhooks
recur-webhooks 是 Recur Webhook 集成开发技能,面向接收支付和订阅事件的应用开发者。它介绍签名验证、事件处理及幂等重试,并说明本地测试和投递排查方式。
主要功能
- 01处理支付与订阅事件
- 02验证原始请求体签名
- 03设计事件幂等处理
- 04说明重试与队列注意事项
- 05介绍本地转发和投递排查
适用场景
- 01接收首次付款及续订事件
- 02防止重复投递造成重复处理
- 03在本地测试 Webhook 端点
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(读取时不区分大小写) - 算法:对原始请求体执行 HMAC-SHA256,并使用 Base64 编码(不是十六进制)
- 使用
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
有两种实现。第一种使用 Web Crypto,可在任何环境(边缘运行时、workers、Node)中运行;
第二种是仅适用于 Node 的 node:crypto 版本。Web Crypto 没有 timingSafeEqual,因此
第一种实现会自行比较字节,而不是使用 ===:
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)→ 添加端点(或使用 MCPcreate_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 可能会被多次投递,两个投递也可能并发到达。 先读取、再处理、最后插入存在竞争条件:两个请求都会发现没有记录,然后都执行副作用。先认领事件,使用唯一插入,然后再处理:
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 表示“事件由其他进程持有,或已完成”。如果无法区分这两种情况,并且希望重试投递,请返回 5xx,而不是 200——Recur 会重新投递,下次尝试会发现事件已完成,或重新认领过期的租约。- 这里的做法不能替代清理任务。超过租约期限且没有后续投递的
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 })工作进程使用该令牌调用
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
控制面板 → Webhook → 端点 → 投递日志(或 MCP get_webhook_events)。
401 Unauthorized
RECUR_WEBHOOK_SECRET错误- 请求体被重新序列化(
JSON.stringify(await request.json()))——请验证原始文本 - 手动编写的 HMAC 使用了十六进制编码——Recur 签名使用 Base64
超时(无响应)
- 将繁重的工作移出请求处理流程,但只能放在持久化队列之后——返回 200 表示告诉 Recur 事件已处理,它不会重新投递(参见上文“响应 Recur”)
事件缺失
- 端点未选择该事件类型
- 端点 URL 错误,或无法从互联网访问
相关技能
/recur-quickstart- 初始 SDK 设置/recur-checkout- 实现支付流程/recur-entitlements- Webhook 触发后检查订阅访问权限