Recur
開發者指南結帳整合

Hosted Checkout

使用 Recur 託管結帳頁面快速接受付款

Hosted Checkout

Hosted Checkout 是最簡單的整合方式。您只需在後端建立 Checkout Session,然後將用戶導向 Recur 託管的結帳頁面。

適合場景

  • 快速上線,無需開發前端 UI
  • 想使用 Recur 最佳化的結帳體驗
  • 不需要完全客製化結帳頁面

運作流程

1. 用戶點擊「訂閱」按鈕

2. 您的後端呼叫 POST /checkout/sessions

3. Recur 返回 Checkout Session URL

4. 前端導向該 URL

5. 用戶在 Recur 結帳頁面完成付款

6. 重新導向回 successUrl 或 cancelUrl

建立 Checkout Session

API 端點

POST https://api.recur.tw/v1/checkout/sessions

請求參數

參數必填說明
productId✅*商品 ID(與 productSlug 二擇一)
productSlug✅*商品 Slug(與 productId 二擇一)
modePAYMENT(一次性)、SUBSCRIPTION(訂閱)、SETUP(僅儲存卡片)
successUrl成功後的重新導向 URL
cancelUrl取消時的重新導向 URL
customerEmail預填客戶 Email(結帳時必填,但建立 session 時選填)
customerName預填客戶姓名
promotionCode預先套用的優惠碼(建立時即計算折扣)
collectPaymentMethod信用卡收集策略:always(預設)或 if_required。詳見零元結帳
externalId外部客戶 ID(您系統中的用戶 ID)
metadata自訂 metadata(僅 Secret Key 可用)

程式碼範例

// app/api/create-checkout/route.ts
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const body = await request.json();

  const response = await fetch('https://api.recur.tw/v1/checkout/sessions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      productId: body.productId,
      mode: 'SUBSCRIPTION',
      successUrl: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancelUrl: `${process.env.NEXT_PUBLIC_URL}/pricing`,
      customerEmail: body.email,
      metadata: {
        userId: body.userId,
      },
    }),
  });

  const data = await response.json();

  if (!response.ok) {
    return NextResponse.json({ error: data.error }, { status: response.status });
  }

  return NextResponse.json({ url: data.url });
}
// routes/checkout.js
const express = require('express');
const router = express.Router();

router.post('/create-checkout', async (req, res) => {
  try {
    const response = await fetch('https://api.recur.tw/v1/checkout/sessions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        productId: req.body.productId,
        mode: 'SUBSCRIPTION',
        successUrl: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
        cancelUrl: `${process.env.APP_URL}/pricing`,
      }),
    });

    const data = await response.json();

    if (!response.ok) {
      return res.status(response.status).json({ error: data.error });
    }

    res.json({ url: data.url });
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

module.exports = router;
# routes/checkout.py
from flask import Blueprint, request, jsonify
import requests
import os

checkout = Blueprint('checkout', __name__)

@checkout.route('/create-checkout', methods=['POST'])
def create_checkout():
    data = request.get_json()

    response = requests.post(
        'https://api.recur.tw/v1/checkout/sessions',
        headers={
            'Authorization': f'Bearer {os.environ["RECUR_SECRET_KEY"]}',
            'Content-Type': 'application/json',
        },
        json={
            'productId': data['productId'],
            'mode': 'SUBSCRIPTION',
            'successUrl': f'{os.environ["APP_URL"]}/success?session_id={{CHECKOUT_SESSION_ID}}',
            'cancelUrl': f'{os.environ["APP_URL"]}/pricing',
        }
    )

    result = response.json()

    if not response.ok:
        return jsonify({'error': result.get('error')}), response.status_code

    return jsonify({'url': result['url']})

前端導向

在前端呼叫您的後端 API,然後導向返回的 URL:

// components/SubscribeButton.tsx
'use client';

export function SubscribeButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    setLoading(true);

    try {
      const response = await fetch('/api/create-checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ productId }),
      });

      const { url, error } = await response.json();

      if (error) {
        alert('發生錯誤:' + error.message);
        return;
      }

      // 導向 Recur 結帳頁面
      window.location.href = url;
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? '處理中...' : '立即訂閱'}
    </button>
  );
}

Success URL 處理

用戶完成付款後,會被導向您設定的 successUrl。URL 中會包含 {CHECKOUT_SESSION_ID} 佔位符的實際值:

https://your-site.com/success?session_id=cs_abc123xyz

您可以使用這個 Session ID 來:

  1. 驗證付款狀態 - 呼叫 GET /checkout/sessions/{id}
  2. 取得訂閱資訊 - 從 Session 中取得 subscriptionId
  3. 更新使用者狀態 - 在您的資料庫中標記用戶已訂閱
// app/success/page.tsx
import { notFound } from 'next/navigation';

export default async function SuccessPage({
  searchParams,
}: {
  searchParams: { session_id?: string };
}) {
  const sessionId = searchParams.session_id;

  if (!sessionId) {
    notFound();
  }

  // 驗證 Session 狀態
  const response = await fetch(
    `https://api.recur.tw/v1/checkout/sessions/${sessionId}`,
    {
      headers: {
        'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
      },
    }
  );

  const session = await response.json();

  if (session.status !== 'COMPLETE') {
    return <div>付款尚未完成</div>;
  }

  return (
    <div>
      <h1>感謝您的訂閱!</h1>
      <p>訂單編號:{session.orderId}</p>
      <p>訂閱編號:{session.subscriptionId}</p>
    </div>
  );
}

設定 Webhook

重要:不要只依賴 successUrl 來確認付款。用戶可能在付款完成前關閉瀏覽器。請務必設定 Webhook 來接收付款通知。

正確的付款對帳方式

嚴重警告:絕對不要只用 customer_emailcustomer_id 來對帳!同一個用戶可能同時有多筆待處理的交易,這會導致錯誤的歸因。

常見錯誤情境

T1: 用戶點「儲值 100 點」→ 建立 checkout A → 付款成功
T2: webhook A 還在傳送中...
T3: 用戶點「儲值 1000 點」→ 建立 checkout B(未付款)
T4: webhook A 抵達,但系統錯誤地將它歸因到 checkout B(因為只看 email)
結果:用戶只付了 100 元,卻獲得 1000 點!

正確做法:使用 checkout.idmetadata 中的交易 ID 進行精確對帳。

方法一:儲存 Checkout ID

// 1. 建立 checkout 時儲存 session ID
const response = await fetch('https://api.recur.tw/v1/checkout/sessions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    productId: 'prod_100_points',
    successUrl: 'https://yoursite.com/success',
    cancelUrl: 'https://yoursite.com/cancel',
    customerEmail: user.email,
  }),
});

const { id: checkoutId, url } = await response.json();

// ✅ 儲存 checkoutId 到待處理交易
await db.pendingTopups.create({
  data: {
    userId: user.id,
    checkoutId: checkoutId,  // ← 重要!
    points: 100,
    status: 'PENDING',
  }
});
// 2. Webhook 處理 - 用 checkout.id 對帳
app.post('/webhooks/recur', async (req, res) => {
  const event = req.body;

  if (event.type === 'checkout.completed') {
    const checkout = event.data;

    // ✅ 用 checkout ID 找到對應的待處理交易
    const pendingTopup = await db.pendingTopups.findUnique({
      where: { checkoutId: checkout.id }
    });

    if (pendingTopup) {
      await db.users.update({
        where: { id: pendingTopup.userId },
        data: { points: { increment: pendingTopup.points } }
      });
    }
  }

  res.json({ received: true });
});

方法二:使用 Metadata(推薦)

// 1. 先建立內部交易記錄,再建立 checkout
const transaction = await db.transactions.create({
  data: {
    id: generateId(),  // 例如 "txn_abc123"
    userId: user.id,
    type: 'TOPUP',
    points: 100,
    status: 'PENDING',
  }
});

const response = await fetch('https://api.recur.tw/v1/checkout/sessions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    productId: 'prod_100_points',
    successUrl: 'https://yoursite.com/success',
    cancelUrl: 'https://yoursite.com/cancel',
    customerEmail: user.email,
    metadata: {
      transaction_id: transaction.id,  // ← 你的內部交易 ID
      user_id: user.id,
      points: 100,
    }
  }),
});
// 2. Webhook 處理 - 用 metadata.transaction_id 對帳
app.post('/webhooks/recur', async (req, res) => {
  const event = req.body;

  if (event.type === 'checkout.completed') {
    const checkout = event.data;
    const { transaction_id } = checkout.metadata || {};

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

      if (transaction && transaction.status === 'PENDING') {
        // ✅ 精確對帳,不會搞混
        await db.$transaction([
          db.users.update({
            where: { id: transaction.userId },
            data: { points: { increment: transaction.points } }
          }),
          db.transactions.update({
            where: { id: transaction.id },
            data: { status: 'COMPLETED' }
          })
        ]);
      }
    }
  }

  res.json({ received: true });
});

對帳欄位參考

欄位可用於對帳?說明
checkout.id✅ 是每個 checkout session 唯一
metadata.transaction_id✅ 是你的內部交易 ID
metadata.order_id✅ 是你的內部訂單 ID
customer_email❌ 否同一用戶可能有多筆交易
customer_id❌ 否同一客戶可能有多筆交易
product_id❌ 否同一商品可能被購買多次

結帳模式

SUBSCRIPTION(訂閱)

{
  "productId": "prod_monthly",
  "mode": "SUBSCRIPTION",
  "successUrl": "https://example.com/welcome",
  "cancelUrl": "https://example.com/pricing"
}
  • 建立週期性訂閱
  • 自動綁定付款方式
  • 後續會自動扣款

PAYMENT(一次性付款)

{
  "productId": "prod_ebook",
  "mode": "PAYMENT",
  "successUrl": "https://example.com/download",
  "cancelUrl": "https://example.com/shop"
}
  • 單次付款
  • 不建立訂閱關係
  • 適合數位商品、實體商品

SETUP(僅儲存卡片)

{
  "productId": "prod_xxx",
  "mode": "SETUP",
  "successUrl": "https://example.com/card-saved",
  "cancelUrl": "https://example.com/settings"
}
  • 不扣款,僅儲存付款方式
  • 用於升級流程、試用結束後收費
  • 返回 setupIntentId 而非 paymentIntentId

客製化選項

預填客戶資訊

Email 和姓名都是選填的。如果未預填,用戶會在結帳頁面輸入(Email 在結帳時為必填,用於寄送收據)。

{
  "customerEmail": "user@example.com",
  "customerName": "王小明"
}

預先套用優惠碼

在建立 Checkout Session 時帶入 promotionCode,結帳頁面會自動套用折扣:

{
  "productId": "prod_monthly",
  "mode": "SUBSCRIPTION",
  "promotionCode": "SAVE100",
  "successUrl": "https://example.com/success",
  "cancelUrl": "https://example.com/pricing"
}

優惠碼會在建立 Session 時立即驗證。若優惠碼無效、已過期或已達使用上限,API 會回傳 400 錯誤,不會建立 Session。

用戶也可以在結帳頁面手動輸入優惠碼。預先套用適合以下場景:

  • 行銷活動:透過專屬連結自動帶入優惠碼
  • 合作夥伴:為特定管道預設折扣
  • 客服補償:直接發送已套用優惠的結帳連結

使用 Metadata

{
  "metadata": {
    "userId": "user_123",
    "plan": "pro",
    "referrer": "homepage"
  }
}

metadata 僅在使用 Secret Key 時可用。Publishable Key 無法設定 metadata。

零元結帳(免綁卡兌換)

當優惠碼折扣 100% 時,您可以讓用戶不需要輸入信用卡就完成訂閱。這透過 collectPaymentMethod 參數控制。

使用情境

  • 教育優惠:學生憑校園信箱兌換免費方案
  • 員工帳號:企業為員工開通訂閱
  • 合作夥伴:與其他平台交叉推廣的免費體驗
  • 補償方案:客服發送全額折扣碼給需要補償的客戶
  • Beta 測試:邀請早期用戶免費使用

collectPaymentMethod 參數

collectPaymentMethod折扣結果行為
always(預設)0 元仍需輸入信用卡(驗證 NT$2)
if_required0 元跳過信用卡,直接完成訂閱
if_required部分折扣(> $0)正常收信用卡
always無折扣正常收信用卡

collectPaymentMethod: 'if_required' 只有在最終金額為 $0 時才會跳過信用卡。如果優惠碼只是部分折扣(如 8 折),仍然會正常收取信用卡。

程式碼範例

const response = await fetch('https://api.recur.tw/v1/checkout/sessions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    productId: 'prod_pro_monthly',
    mode: 'SUBSCRIPTION',
    promotionCode: 'FREE100',                // 100% 折扣優惠碼
    collectPaymentMethod: 'if_required',     // 金額 $0 時跳過信用卡
    successUrl: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancelUrl: `${process.env.APP_URL}/pricing`,
    customerEmail: 'student@university.edu',
  }),
});

const data = await response.json();

// Response 範例:
// {
//   "id": "cs_xxx",
//   "url": "https://checkout.recur.tw/cs_xxx",
//   "expires_at": "2025-01-01T00:30:00Z",
//   "amount": 0,
//   "requires_payment_method": false,
//   "discount": {
//     "code": "FREE100",
//     "discount_amount": 799,
//     "final_amount": 0
//   }
// }

結帳頁面體驗

結帳頁面會根據 collectPaymentMethod 和折扣結果自動調整 UI:

if_required + 金額 $0requires_payment_method: false):

  1. 隱藏信用卡輸入欄位
  2. 顯示綠色提示:「您的優惠碼已抵扣全額,無需輸入信用卡資訊」
  3. 按鈕文字變為「確認兌換」
  4. 用戶只需填寫 Email 即可完成

always + 金額 $0requires_payment_method: true):

  1. 仍顯示信用卡輸入欄位
  2. 顯示藍色提示:「您的優惠碼已抵扣全額,但仍需驗證信用卡以供後續帳單使用」
  3. 按鈕文字變為「驗證卡片」
  4. 提交後進行 NT$2 驗證扣款(立即退款),綁定信用卡供未來續約使用

續約注意事項

免綁卡訂閱的續約行為取決於優惠碼的持續期間:

  • 永久折扣(FOREVER):每期都免費,不需要信用卡。訂閱可持續自動續訂。
  • 限期折扣(ONCE / REPEATING):折扣期結束後,系統會嘗試扣款。由於沒有綁定信用卡,訂閱將進入 PAST_DUE 狀態,並通知用戶補綁卡片。

若您的 100% 折扣碼是一次性(ONCE),建議在折扣到期前提醒用戶綁定付款方式。

錯誤處理

常見錯誤和處理方式:

錯誤代碼原因解決方式
resource_not_found商品不存在確認 productId 正確
invalid_requestURL 格式錯誤確認 successUrl/cancelUrl 為 HTTPS
unauthorizedAPI Key 無效檢查 Secret Key
bad_request優惠碼無效或已過期確認優惠碼狀態,或移除 promotionCode 參數
conflict客戶已有此商品的有效訂閱引導用戶至訂閱管理頁面,詳見錯誤處理指南

下一步

Last updated on

On this page