開發者指南Examples
Webhook 處理範例
處理訂閱狀態變更通知
Webhook 處理範例
學習如何設定和處理 Recur 的 Webhook 通知,以追蹤訂閱狀態變更。
什麼是 Webhook?
Webhook 是一種即時通知機制,當訂閱狀態發生變更時(如訂閱成功、取消、續訂等),Recur 會自動發送 HTTP POST 請求到你指定的 URL。
設定 Webhook URL
在 Recur 儀表板 中設定你的 Webhook URL:
- 進入「設定」→「Webhooks」
- 新增 Webhook URL(例如:
https://yourdomain.com/api/webhooks/recur) - 選擇要接收的事件類型
- 儲存設定
基本 Webhook 處理器
Next.js API Route
// app/api/webhooks/recur/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
// Webhook 事件類型
type WebhookEventType =
| 'subscription.created'
| 'subscription.updated'
| 'subscription.cancelled'
| 'subscription.renewed'
| 'refund.created'
| 'refund.succeeded'
| 'refund.failed';
type SubscriptionData = {
subscriptionId: string;
organizationId: string;
planId: string;
subscriberId: string;
status: 'active' | 'cancelled' | 'expired';
currentPeriodStart: string;
currentPeriodEnd: string;
};
type RefundData = {
id: string;
refund_number: string;
charge_id: string;
order_id: string | null;
subscription_id: string | null;
customer_id: string | null;
amount: number;
currency: string;
status: 'pending' | 'processing' | 'succeeded' | 'failed';
reason: string;
reason_detail: string | null;
original_amount: number;
refunded_amount: number;
failure_code: string | null;
failure_message: string | null;
};
type WebhookEvent = {
type: WebhookEventType;
data: SubscriptionData | RefundData;
timestamp: string;
};
export async function POST(request: NextRequest) {
try {
// 1. 驗證 Webhook 簽名
const signature = request.headers.get('x-recur-signature');
const body = await request.text();
if (!verifyWebhookSignature(body, signature)) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// 2. 解析事件資料
const event: WebhookEvent = JSON.parse(body);
// 3. 根據事件類型處理
switch (event.type) {
case 'subscription.created':
await handleSubscriptionCreated(event.data as SubscriptionData);
break;
case 'subscription.updated':
await handleSubscriptionUpdated(event.data as SubscriptionData);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(event.data as SubscriptionData);
break;
case 'subscription.renewed':
await handleSubscriptionRenewed(event.data as SubscriptionData);
break;
// 退款事件
case 'refund.created':
await handleRefundCreated(event.data as RefundData);
break;
case 'refund.succeeded':
await handleRefundSucceeded(event.data as RefundData);
break;
case 'refund.failed':
await handleRefundFailed(event.data as RefundData);
break;
default:
console.log('Unknown event type:', event.type);
}
// 4. 回傳 200 表示成功接收
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
// 驗證 Webhook 簽名
function verifyWebhookSignature(body: string, signature: string | null): boolean {
if (!signature) return false;
const webhookSecret = process.env.RECUR_WEBHOOK_SECRET!;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// 處理訂閱建立
async function handleSubscriptionCreated(data: SubscriptionData) {
console.log('New subscription created:', data.subscriptionId);
// 更新資料庫
await db.subscription.create({
data: {
id: data.subscriptionId,
subscriberId: data.subscriberId,
planId: data.planId,
status: data.status,
currentPeriodStart: new Date(data.currentPeriodStart),
currentPeriodEnd: new Date(data.currentPeriodEnd),
},
});
// 發送歡迎郵件
await sendWelcomeEmail(data.subscriberId);
}
// 處理訂閱更新
async function handleSubscriptionUpdated(data: SubscriptionData) {
console.log('Subscription updated:', data.subscriptionId);
await db.subscription.update({
where: { id: data.subscriptionId },
data: {
status: data.status,
currentPeriodEnd: new Date(data.currentPeriodEnd),
},
});
}
// 處理訂閱取消
async function handleSubscriptionCancelled(data: SubscriptionData) {
console.log('Subscription cancelled:', data.subscriptionId);
await db.subscription.update({
where: { id: data.subscriptionId },
data: { status: 'cancelled' },
});
// 發送取消確認郵件
await sendCancellationEmail(data.subscriberId);
}
// 處理訂閱續訂
async function handleSubscriptionRenewed(data: SubscriptionData) {
console.log('Subscription renewed:', data.subscriptionId);
await db.subscription.update({
where: { id: data.subscriptionId },
data: {
currentPeriodStart: new Date(data.currentPeriodStart),
currentPeriodEnd: new Date(data.currentPeriodEnd),
},
});
// 發送續訂通知
await sendRenewalNotification(data.subscriberId);
}
// 處理退款建立
async function handleRefundCreated(data: RefundData) {
console.log('Refund created:', data.refund_number);
// 記錄退款申請
await db.refundLog.create({
data: {
refundId: data.id,
refundNumber: data.refund_number,
chargeId: data.charge_id,
amount: data.amount,
status: 'pending',
},
});
}
// 處理退款成功
async function handleRefundSucceeded(data: RefundData) {
console.log('Refund succeeded:', data.refund_number);
// 更新退款記錄
await db.refundLog.update({
where: { refundId: data.id },
data: { status: 'succeeded' },
});
// 如果是訂閱退款,可能需要撤銷用戶權限
if (data.subscription_id) {
await revokeUserAccess(data.customer_id, data.subscription_id);
}
// 發送退款成功通知
if (data.customer_id) {
await sendRefundSuccessEmail(data.customer_id, data.amount);
}
}
// 處理退款失敗
async function handleRefundFailed(data: RefundData) {
console.error('Refund failed:', data.refund_number, data.failure_message);
// 更新退款記錄
await db.refundLog.update({
where: { refundId: data.id },
data: {
status: 'failed',
failureCode: data.failure_code,
failureMessage: data.failure_message,
},
});
// 通知管理員處理失敗的退款
await notifyAdminRefundFailed(data);
}Express.js 範例
// server.js
const express = require('express');
const crypto = require('crypto');
const app = express();
// 使用 raw body parser 以驗證簽名
app.post('/webhooks/recur', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const signature = req.headers['x-recur-signature'];
const body = req.body.toString();
// 驗證簽名
if (!verifyWebhookSignature(body, signature)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 解析事件
const event = JSON.parse(body);
// 處理事件
switch (event.type) {
case 'subscription.created':
await handleSubscriptionCreated(event.data);
break;
case 'subscription.cancelled':
await handleSubscriptionCancelled(event.data);
break;
// 退款事件
case 'refund.created':
console.log('Refund created:', event.data.refund_number);
break;
case 'refund.succeeded':
console.log('Refund succeeded:', event.data.refund_number);
// 更新訂單狀態、撤銷權限等
break;
case 'refund.failed':
console.error('Refund failed:', event.data.failure_message);
// 通知管理員
break;
// ... 其他事件類型
}
res.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ error: 'Processing failed' });
}
});
function verifyWebhookSignature(body, signature) {
const webhookSecret = process.env.RECUR_WEBHOOK_SECRET;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
app.listen(3000, () => {
console.log('Server listening on port 3000');
});重試機制
Recur 會在 Webhook 處理失敗時自動重試:
- 第 1 次重試:1 分鐘後
- 第 2 次重試:5 分鐘後
- 第 3 次重試:30 分鐘後
- 第 4 次重試:2 小時後
- 第 5 次重試:6 小時後
確保你的 Webhook 處理器返回 2xx 狀態碼,否則會觸發重試。
冪等性處理
由於可能會收到重複的 Webhook 事件,建議實作冪等性處理:
async function handleSubscriptionCreated(data: WebhookEvent['data']) {
// 使用 subscriptionId 作為唯一識別
const existing = await db.subscription.findUnique({
where: { id: data.subscriptionId },
});
// 如果已存在,跳過處理(冪等性)
if (existing) {
console.log('Subscription already processed:', data.subscriptionId);
return;
}
// 建立新訂閱
await db.subscription.create({
data: {
id: data.subscriptionId,
// ... 其他欄位
},
});
}安全性最佳實踐
1. 驗證簽名
始終驗證 Webhook 簽名以確保請求來自 Recur:
function verifyWebhookSignature(body: string, signature: string | null): boolean {
if (!signature) return false;
const webhookSecret = process.env.RECUR_WEBHOOK_SECRET!;
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(body)
.digest('hex');
// 使用時間安全比較避免時間攻擊
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}2. 使用 HTTPS
確保你的 Webhook URL 使用 HTTPS 協議。
3. 保護環境變數
將 Webhook Secret 儲存在環境變數中,不要硬編碼在程式碼裡:
# .env
RECUR_WEBHOOK_SECRET=your_webhook_secret_here測試 Webhook
本地測試
使用 ngrok 將本地伺服器暴露到網際網路:
# 啟動 ngrok
ngrok http 3000
# 使用 ngrok 提供的 URL 作為 Webhook URL
# https://abc123.ngrok.io/api/webhooks/recur模擬 Webhook 事件
curl -X POST https://yourdomain.com/api/webhooks/recur \
-H "Content-Type: application/json" \
-H "x-recur-signature: your_signature_here" \
-d '{
"type": "subscription.created",
"data": {
"subscriptionId": "sub_123",
"organizationId": "org_456",
"planId": "plan_789",
"subscriberId": "user_abc",
"status": "active",
"currentPeriodStart": "2024-01-01T00:00:00Z",
"currentPeriodEnd": "2024-02-01T00:00:00Z"
},
"timestamp": "2024-01-01T00:00:00Z"
}'監控和除錯
記錄所有事件
async function logWebhookEvent(event: WebhookEvent) {
await db.webhookLog.create({
data: {
type: event.type,
data: JSON.stringify(event.data),
timestamp: new Date(event.timestamp),
processed: true,
},
});
}設定錯誤警報
async function handleWebhookError(error: Error, event: WebhookEvent) {
console.error('Webhook processing error:', error);
// 發送錯誤通知到 Slack/Discord
await notifyError({
message: 'Webhook processing failed',
error: error.message,
event: event.type,
});
// 記錄到錯誤追蹤系統(如 Sentry)
Sentry.captureException(error, {
contexts: {
webhook: {
type: event.type,
data: event.data,
},
},
});
}下一步
- 🔐 了解 Webhook 安全性
- 📊 設定 事件監控
- 🛠️ 查看 Webhook API 參考