開發者指南Webhooks
Webhook 傳遞機制
了解 Webhook 傳遞、重試策略和簽章驗證
Webhook 傳遞機制
本指南說明 Recur Webhook 的傳遞機制、重試策略,以及如何在您的伺服器上正確處理和驗證 Webhook。
傳遞流程
當事件發生時,Recur 會:
- 建立事件 Payload
- 使用您的 Webhook Secret 產生簽章
- 發送 POST 請求到您的端點
- 等待回應(最長 20 秒)
- 如果失敗,啟動重試機制
請求格式
HTTP Headers
每個 Webhook 請求包含以下標頭:
| Header | 說明 |
|---|---|
Content-Type | application/json |
X-Recur-Signature | HMAC-SHA256 簽章(Base64 編碼) |
X-Recur-Event-Id | 唯一事件 ID |
X-Recur-Event-Type | 事件類型(如 subscription.activated) |
X-Recur-Timestamp | 事件時間戳(Unix 毫秒) |
Request Body
{
"id": "evt_abc123def456",
"type": "subscription.activated",
"timestamp": "2024-01-15T10:30:00.000Z",
"data": {
"id": "sub_xyz789",
"customer_id": "cus_123",
"plan_id": "plan_pro",
"status": "active",
"amount": 299,
"billing_period": "monthly",
"current_period_start": "2024-01-15T00:00:00.000Z",
"current_period_end": "2024-02-15T00:00:00.000Z",
"created_at": "2024-01-15T10:30:00.000Z",
"updated_at": "2024-01-15T10:30:00.000Z"
}
}簽章驗證
重要:務必驗證每個 Webhook 請求的簽章,確保請求確實來自 Recur。
簽章演算法
Recur 使用 HMAC-SHA256 演算法產生簽章:
- 將請求 Body(JSON 字串)作為訊息
- 使用您的 Webhook Secret 作為密鑰
- 計算 HMAC-SHA256 雜湊
- 將結果進行 Base64 編碼
驗證範例
import crypto from 'crypto';
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express.js 範例
app.post('/api/webhooks/recur', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-recur-signature'] as string;
const payload = req.body.toString();
if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET!)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
// 處理事件...
console.log('Received event:', event.type);
res.status(200).json({ received: true });
});import hmac
import hashlib
import base64
from flask import Flask, request, jsonify
app = Flask(__name__)
def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).digest()
expected_b64 = base64.b64encode(expected).decode('utf-8')
return hmac.compare_digest(signature, expected_b64)
@app.route('/api/webhooks/recur', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Recur-Signature')
payload = request.get_data()
if not verify_webhook_signature(payload, signature, os.environ['WEBHOOK_SECRET']):
return jsonify({'error': 'Invalid signature'}), 401
event = request.get_json()
# 處理事件...
print(f"Received event: {event['type']}")
return jsonify({'received': True}), 200<?php
function verifyWebhookSignature(string $payload, string $signature, string $secret): bool {
$expected = base64_encode(hash_hmac('sha256', $payload, $secret, true));
return hash_equals($expected, $signature);
}
// 處理 Webhook
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_RECUR_SIGNATURE'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
if (!verifyWebhookSignature($payload, $signature, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$event = json_decode($payload, true);
// 處理事件...
error_log("Received event: " . $event['type']);
http_response_code(200);
echo json_encode(['received' => true]);package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"os"
)
func verifyWebhookSignature(payload []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(signature), []byte(expected))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
signature := r.Header.Get("X-Recur-Signature")
secret := os.Getenv("WEBHOOK_SECRET")
if !verifyWebhookSignature(payload, signature, secret) {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid signature"})
return
}
var event map[string]interface{}
json.Unmarshal(payload, &event)
// 處理事件...
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}重試機制
重試策略
當 Webhook 傳遞失敗時,Recur 會自動重試:
| 重試次數 | 延遲時間 |
|---|---|
| 第 1 次 | 立即 |
| 第 2 次 | 5 分鐘後 |
| 第 3 次 | 30 分鐘後 |
| 第 4 次 | 2 小時後 |
| 第 5 次 | 5 小時後 |
| 第 6 次 | 10 小時後 |
| 第 7-10 次 | 每 12 小時 |
總共最多重試 10 次,橫跨約 3 天。
失敗條件
以下情況視為傳遞失敗:
- HTTP 狀態碼非 2xx
- 請求逾時(超過 20 秒)
- 連線失敗(無法建立連線)
- SSL/TLS 驗證失敗
成功回應
請確保您的端點:
- 在 20 秒內回傳回應
- 回傳 2xx 狀態碼(200, 201, 202, 204 等)
- 可選:回傳 JSON 確認
{
"received": true
}冪等處理
同一事件可能因重試而被傳遞多次,請確保您的處理邏輯是冪等的。
使用 event.id 來避免重複處理:
import { prisma } from '@/lib/prisma';
async function handleWebhook(event: WebhookEvent) {
// 檢查是否已處理過
const existing = await prisma.processedWebhook.findUnique({
where: { eventId: event.id }
});
if (existing) {
console.log(`Event ${event.id} already processed, skipping`);
return { status: 'skipped' };
}
// 處理事件
await processEvent(event);
// 記錄已處理
await prisma.processedWebhook.create({
data: { eventId: event.id, processedAt: new Date() }
});
return { status: 'processed' };
}非同步處理
對於耗時操作,建議使用佇列進行非同步處理:
import { Queue } from 'bullmq';
const webhookQueue = new Queue('webhooks');
app.post('/api/webhooks/recur', async (req, res) => {
// 1. 驗證簽章
if (!verifySignature(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. 快速回應
res.status(200).json({ received: true });
// 3. 加入佇列非同步處理
await webhookQueue.add('process', {
event: req.body,
receivedAt: new Date().toISOString()
});
});常見問題
403 Forbidden
您的端點可能有認證中介軟體阻擋請求。確保 Webhook 路由不需要認證:
// Next.js 範例 - 排除 webhook 路由
export const config = {
matcher: ['/((?!api/webhooks).*)'],
};404 Not Found
確認:
- URL 路徑正確
- 端點已部署
- 使用 POST 方法
簽章驗證失敗
確認:
- 使用正確的 Webhook Secret
- 使用原始 Request Body(未解析)
- Secret 正確編碼(不需要 Base64 解碼)
請求逾時
優化端點效能:
- 使用非同步處理
- 避免在回應前執行耗時操作
- 考慮增加伺服器資源