# Webhook 處理範例 (/guides/examples/webhook-handling)



Webhook 處理範例 [#webhook-處理範例]

學習如何設定和處理 Recur 的 Webhook 通知，以追蹤訂閱狀態變更。



<Callout type="error">
  **對帳警告**：處理 `checkout.completed` 時，絕對不要只用 `customer_email` 或 `customer_id` 來對帳！同一個用戶可能同時有多筆待處理的交易。請使用 `checkout.id` 或 `metadata` 中的交易 ID 進行精確對帳。
</Callout>

什麼是 Webhook？ [#什麼是-webhook]

Webhook 是一種即時通知機制，當訂閱狀態發生變更時（如訂閱成功、取消、續訂等），Recur 會自動發送 HTTP POST 請求到你指定的 URL。

設定 Webhook URL [#設定-webhook-url]

在 [Recur 儀表板](https://app.recur.tw) 中設定你的 Webhook URL：

1. 進入「設定」→「Webhooks」
2. 新增 Webhook URL（例如：`https://yourdomain.com/api/webhooks/recur`）
3. 選擇要接收的事件類型
4. 儲存設定

基本 Webhook 處理器 [#基本-webhook-處理器]

Next.js API Route [#nextjs-api-route]

```typescript
// app/api/webhooks/recur/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

// Webhook 事件類型
type WebhookEventType =
  | 'checkout.created'
  | 'checkout.completed'
  | 'subscription.created'
  | 'subscription.activated'
  | 'subscription.updated'
  | 'subscription.cancelled'
  | 'subscription.renewed'
  | 'subscription.past_due'
  | 'subscription.payment_method_required'
  | 'refund.created'
  | 'refund.succeeded'
  | 'refund.failed';

type SubscriptionData = {
  subscription_id: string;
  organization_id: string;
  product_id: string;
  subscriber_id: string;
  status: 'active' | 'cancelled' | 'expired';
  current_period_start: string;
  current_period_end: string;
  // 007-coupon-system: 優惠相關欄位
  coupon: {
    id: string;
    name: string;
    discount_type: 'FIXED_AMOUNT' | 'PERCENTAGE' | 'FIRST_PERIOD_PRICE';
    discount_amount: number;
    duration: 'ONCE' | 'REPEATING' | 'FOREVER';
  } | null;
  coupon_remaining_cycles: number | null; // REPEATING 類型剩餘期數
  discount_amount: number;
  promotion_code: string | null;
  metadata: Record<string, any> | null; // 開發者自訂 key-value 資料
};

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 CheckoutData = {
  id: string;
  status: string;
  amount: number;
  currency: string;
  product_id: string;
  customer_id: string | null;
  customer_email: string | null;
  created_at: string;
  completed_at: string | null;
  metadata: Record<string, any> | null;
  // 007-coupon-system: 優惠相關欄位
  discount_amount: number;
  promotion_code: string | null;
};

type WebhookEvent = {
  id: string;  // Event ID (e.g., 'evt_abc123...')
  type: WebhookEventType;
  timestamp: string;
  data: SubscriptionData | RefundData | CheckoutData;
};

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) {
      // Checkout 事件
      case 'checkout.completed':
        await handleCheckoutCompleted(event.data as CheckoutData);
        break;
      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 'subscription.payment_method_required':
        await handlePaymentMethodRequired(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('base64');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

// ============================================
// Checkout 事件處理
// ============================================

// 處理結帳完成
async function handleCheckoutCompleted(data: CheckoutData) {
  console.log('Checkout completed:', data.id);

  // ⚠️ 重要：使用 checkout.id 或 metadata 進行對帳
  // 絕對不要只用 customer_email 對帳！

  // 方法一：使用 checkout.id 對帳
  const pendingTransaction = await db.pendingTransaction.findUnique({
    where: { checkoutId: data.id }
  });

  if (pendingTransaction) {
    // 找到對應的待處理交易，進行處理
    await db.$transaction([
      db.user.update({
        where: { id: pendingTransaction.userId },
        data: { credits: { increment: pendingTransaction.credits } }
      }),
      db.pendingTransaction.update({
        where: { id: pendingTransaction.id },
        data: {
          status: 'COMPLETED',
          completedAt: new Date(data.completed_at!),
        }
      })
    ]);
    return;
  }

  // 方法二：使用 metadata 對帳（推薦）
  const { transaction_id, user_id, credits } = data.metadata || {};

  if (transaction_id) {
    const transaction = await db.transaction.findUnique({
      where: { id: transaction_id }
    });

    if (transaction && transaction.status === 'PENDING') {
      await db.$transaction([
        db.user.update({
          where: { id: transaction.userId },
          data: { credits: { increment: transaction.credits } }
        }),
        db.transaction.update({
          where: { id: transaction.id },
          data: {
            status: 'COMPLETED',
            recurCheckoutId: data.id,
            completedAt: new Date(data.completed_at!),
          }
        })
      ]);
    }
  }
}

// ============================================
// Subscription 事件處理
// ============================================

// 處理訂閱建立
async function handleSubscriptionCreated(data: SubscriptionData) {
  console.log('New subscription created:', data.subscription_id);

  // 更新資料庫
  await db.subscription.create({
    data: {
      id: data.subscription_id,
      subscriberId: data.subscriber_id,
      productId: data.product_id,
      status: data.status,
      currentPeriodStart: new Date(data.current_period_start),
      currentPeriodEnd: new Date(data.current_period_end),
    },
  });

  // 發送歡迎郵件
  await sendWelcomeEmail(data.subscriber_id);
}

// 處理訂閱更新
async function handleSubscriptionUpdated(data: SubscriptionData) {
  console.log('Subscription updated:', data.subscription_id);

  await db.subscription.update({
    where: { id: data.subscription_id },
    data: {
      status: data.status,
      currentPeriodEnd: new Date(data.current_period_end),
    },
  });
}

// 處理訂閱取消
async function handleSubscriptionCancelled(data: SubscriptionData) {
  console.log('Subscription cancelled:', data.subscription_id);

  await db.subscription.update({
    where: { id: data.subscription_id },
    data: { status: 'cancelled' },
  });

  // 發送取消確認郵件
  await sendCancellationEmail(data.subscriber_id);
}

// 處理訂閱續訂
async function handleSubscriptionRenewed(data: SubscriptionData) {
  console.log('Subscription renewed:', data.subscription_id);

  await db.subscription.update({
    where: { id: data.subscription_id },
    data: {
      currentPeriodStart: new Date(data.current_period_start),
      currentPeriodEnd: new Date(data.current_period_end),
    },
  });

  // 發送續訂通知
  await sendRenewalNotification(data.subscriber_id);
}

// 處理訂閱缺少付款方式（匯入的訂閱到期時觸發）
async function handlePaymentMethodRequired(data: SubscriptionData) {
  console.log('Payment method required:', data.subscription_id);

  // 通知客戶需要綁定付款方式
  await sendPaymentMethodReminder(data.subscriber_id);
}

// 處理退款建立
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 範例 [#expressjs-範例]

```javascript
// 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('base64');

  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 小時後

<Callout type="info">
  確保你的 Webhook 處理器返回 2xx 狀態碼，否則會觸發重試。
</Callout>

冪等性處理 [#冪等性處理]

由於可能會收到重複的 Webhook 事件，建議實作冪等性處理：

```typescript
async function handleSubscriptionCreated(data: WebhookEvent['data']) {
  // 使用 subscription_id 作為唯一識別
  const existing = await db.subscription.findUnique({
    where: { id: data.subscription_id },
  });

  // 如果已存在，跳過處理（冪等性）
  if (existing) {
    console.log('Subscription already processed:', data.subscription_id);
    return;
  }

  // 建立新訂閱
  await db.subscription.create({
    data: {
      id: data.subscription_id,
      // ... 其他欄位
    },
  });
}
```

安全性最佳實踐 [#安全性最佳實踐]

1\. 驗證簽名 [#1-驗證簽名]

**始終**驗證 Webhook 簽名以確保請求來自 Recur：

```typescript
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('base64');

  // 使用時間安全比較避免時間攻擊
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}
```

2\. 使用 HTTPS [#2-使用-https]

確保你的 Webhook URL 使用 HTTPS 協議。

3\. 保護環境變數 [#3-保護環境變數]

將 Webhook Secret 儲存在環境變數中，不要硬編碼在程式碼裡：

```bash
# .env
RECUR_WEBHOOK_SECRET=your_webhook_secret_here
```

測試 Webhook [#測試-webhook]

本地測試 [#本地測試]

使用 ngrok 將本地伺服器暴露到網際網路：

```bash
# 啟動 ngrok
ngrok http 3000

# 使用 ngrok 提供的 URL 作為 Webhook URL
# https://abc123.ngrok.io/api/webhooks/recur
```

模擬 Webhook 事件 [#模擬-webhook-事件]

```bash
curl -X POST https://yourdomain.com/api/webhooks/recur \
  -H "Content-Type: application/json" \
  -H "x-recur-signature: your_signature_here" \
  -d '{
    "id": "evt_test_123456",
    "type": "subscription.created",
    "timestamp": "2024-01-01T00:00:00Z",
    "data": {
      "id": "sub_123",
      "product_id": "prod_789",
      "customer": {
        "id": "cust_abc",
        "email": "user@example.com",
        "name": "Test User"
      },
      "status": "active",
      "amount": 990,
      "interval": "month",
      "current_period_start": "2024-01-01T00:00:00Z",
      "current_period_end": "2024-02-01T00:00:00Z",
      "metadata": null
    }
  }'
```

監控和除錯 [#監控和除錯]

記錄所有事件 [#記錄所有事件]

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

設定錯誤警報 [#設定錯誤警報]

```typescript
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 安全性](/guides/webhook-security)
* 📊 設定 [事件監控](/guides/event-monitoring)
* 🛠️ 查看 [Webhook API 參考](/api-reference/webhooks)
