Recur
Recur
Recur 文件中心
開發者指南
範例基本結帳範例Webhook 處理範例自訂樣式範例
開發者指南Examples

Webhook 處理範例

處理訂閱狀態變更通知

Webhook 處理範例

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

什麼是 Webhook?

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

設定 Webhook URL

在 Recur 儀表板 中設定你的 Webhook URL:

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

基本 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 參考

基本結帳範例

實作最簡單的訂閱結帳流程

自訂樣式範例

客製化結帳頁面的外觀以符合品牌風格

On this page

Webhook 處理範例什麼是 Webhook?設定 Webhook URL基本 Webhook 處理器Next.js API RouteExpress.js 範例重試機制冪等性處理安全性最佳實踐1. 驗證簽名2. 使用 HTTPS3. 保護環境變數測試 Webhook本地測試模擬 Webhook 事件監控和除錯記錄所有事件設定錯誤警報下一步