---
title: Webhook 傳遞機制
description: 了解 Webhook 傳遞、重試策略和簽章驗證
---

# Webhook 傳遞機制

本指南說明 Recur Webhook 的傳遞機制、重試策略，以及如何在您的伺服器上正確處理和驗證 Webhook。

## 傳遞流程

當事件發生時，Recur 會：

1. 建立事件 Payload
2. 使用您的 Webhook Secret 產生簽章
3. 發送 POST 請求到您的端點
4. 等待回應（最長 30 秒）
5. 如果失敗，啟動重試機制

## 請求格式

### 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

```json
{
  "id": "evt_abc123def456",
  "type": "subscription.activated",
  "timestamp": "2024-01-15T10:30:00.000Z",
  "data": {
    "id": "sub_xyz789",
    "customer_id": "cus_123",
    "product_id": "prod_pro",
    "status": "active",
    "amount": 299,
    "interval": "month",
    "interval_count": 1,
    "current_period_start": "2024-01-15T00:00:00.000Z",
    "current_period_end": "2024-02-15T00:00:00.000Z",
    "metadata": null,
    "created_at": "2024-01-15T10:30:00.000Z",
    "updated_at": "2024-01-15T10:30:00.000Z"
  }
}
```

## 簽章驗證

  **重要**：務必驗證每個 Webhook 請求的簽章，確保請求確實來自 Recur。

### 簽章演算法

Recur 使用 HMAC-SHA256 演算法產生簽章：

1. 將請求 Body（JSON 字串）作為訊息
2. 使用您的 Webhook Secret 作為密鑰
3. 計算 HMAC-SHA256 雜湊
4. 將結果進行 Base64 編碼

### 驗證範例

```typescript
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 });
});
```

```python
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
<?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]);
```

```go
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})
}
```

## 重試機制

Recur 內建可靠的 Webhook 傳遞機制，支援自動重試。

### 自動重試策略

當 Webhook 傳遞失敗時，系統會自動使用**指數退避**策略重試：

| 重試次數 | 延遲時間 | 累計時間 |
|----------|----------|----------|
| 第 1 次 | 1 分鐘後 | 1 分鐘 |
| 第 2 次 | 2 分鐘後 | 3 分鐘 |
| 第 3 次 | 4 分鐘後 | 7 分鐘 |
| 第 4 次 | 8 分鐘後 | 15 分鐘 |
| 第 5 次 | 16 分鐘後 | 31 分鐘 |

總共最多自動重試 **5 次**，橫跨約 **31 分鐘**。

  指數退避策略能避免在目標伺服器暫時故障時造成過多負載，同時確保在問題解決後快速恢復傳遞。

### 手動重試

如果自動重試後仍然失敗，您可以在後台手動觸發重試：

1. 前往「開發者」→「Webhooks」
2. 選擇目標 Webhook 端點
3. 在「事件歷史」中找到失敗的事件
4. 點擊「重試」按鈕

### 失敗條件

以下情況視為傳遞失敗，會觸發自動重試：

- HTTP 狀態碼非 2xx（如 4xx、5xx）
- 請求逾時（超過 30 秒無回應）
- 連線失敗（無法建立連線）
- SSL/TLS 驗證失敗
- 網路錯誤

### 成功回應

請確保您的端點：

1. 在 **30 秒內**回傳回應（建議盡快處理，僅回傳 2xx，耗時任務放到背景執行）
2. 回傳 **2xx 狀態碼**（200, 201, 202, 204 等）
3. 可選：回傳 JSON 確認

```json
{
  "received": true
}
```

  即使處理邏輯需要較長時間，也應該先回傳 2xx 回應，再進行非同步處理。這能確保 Webhook 不會被誤判為失敗而觸發重試。

## 冪等處理

  同一事件可能因重試而被傳遞多次，請確保您的處理邏輯是冪等的。

使用 `event.id` 來避免重複處理：

```typescript
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' };
}
```

## 非同步處理

對於耗時操作，建議使用佇列進行非同步處理：

```typescript
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 路由**不需要認證**：

```typescript
// Next.js 範例 - 排除 webhook 路由
export const config = {
  matcher: ['/((?!api/webhooks).*)'],
};
```

### 404 Not Found

確認：
1. URL 路徑正確
2. 端點已部署
3. 使用 POST 方法

### 簽章驗證失敗

確認：
1. 使用正確的 Webhook Secret
2. 使用原始 Request Body（未解析）
3. Secret 正確編碼（不需要 Base64 解碼）

### 請求逾時

優化端點效能：
1. 使用非同步處理
2. 避免在回應前執行耗時操作
3. 考慮增加伺服器資源

## 下一步

- [事件類型](/guides/webhooks/events) - 查看所有事件的 Payload 格式
- [設定端點](/guides/webhooks/endpoints) - 建立和管理 Webhook 端點
