Recur
開發者指南Examples

Webhook 處理範例

處理訂閱狀態變更通知

Webhook 處理範例

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

對帳警告:處理 checkout.completed 時,絕對不要只用 customer_emailcustomer_id 來對帳!同一個用戶可能同時有多筆待處理的交易。請使用 checkout.idmetadata 中的交易 ID 進行精確對帳。

什麼是 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 =
  | 'checkout.created'
  | 'checkout.completed'
  | 'subscription.created'
  | 'subscription.updated'
  | 'subscription.cancelled'
  | 'subscription.renewed'
  | '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;
};

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 '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)
  );
}

// ============================================
// 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 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']) {
  // 使用 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. 驗證簽名

始終驗證 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 '{
    "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"
    }
  }'

監控和除錯

記錄所有事件

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

下一步

On this page