# Subscriptions API (/api/subscriptions)





Subscriptions API [#subscriptions-api]

訂閱相關的 API 端點，用於查詢和管理訂閱狀態。

查詢訂閱狀態 [#查詢訂閱狀態]

查詢訂閱狀態，支援列出所有訂閱或按客戶篩選。此端點需要 Secret Key 認證，適合後端使用。

端點 [#端點]

```
GET /subscriptions
```

認證 [#認證]

<Callout type="warning">
  此端點需要 **Secret Key** 認證，僅限後端使用。
</Callout>

支援以下認證方式：

```bash
# 方式 1: Authorization Header（推薦）
Authorization: Bearer sk_test_xxx

# 方式 2: 專用 Header
X-Recur-Secret-Key: sk_test_xxx
```

請求參數 [#請求參數]

客戶篩選（選填） [#客戶篩選選填]

| 參數            | 類型     | 說明                |
| ------------- | ------ | ----------------- |
| `email`       | string | 客戶 Email          |
| `external_id` | string | 您系統中的客戶 ID（外部 ID） |
| `customer_id` | string | Recur 內部客戶 ID     |

<Callout type="info">
  若不提供任何客戶識別參數，將返回該組織下所有訂閱。每筆訂閱會包含對應的客戶資訊。
</Callout>

商品篩選（選填） [#商品篩選選填]

| 參數             | 類型     | 說明      |
| -------------- | ------ | ------- |
| `product_id`   | string | 商品 ID   |
| `product_slug` | string | 商品 Slug |

<Callout type="info">
  `product_id` 和 `product_slug` 為選填。若不提供，將返回該客戶的所有訂閱。
</Callout>

狀態篩選（選填） [#狀態篩選選填]

| 參數       | 類型      | 說明                                        |
| -------- | ------- | ----------------------------------------- |
| `active` | boolean | 設為 `true` 僅返回有效訂閱（ACTIVE、TRIAL、PAST\_DUE） |
| `status` | string  | 篩選特定狀態（如 `ACTIVE`、`CANCELED`）             |

分頁參數（選填） [#分頁參數選填]

| 參數               | 類型     | 說明                   |
| ---------------- | ------ | -------------------- |
| `limit`          | number | 每頁返回數量（預設 10，最大 100） |
| `starting_after` | string | 分頁游標，傳入上一頁最後一筆訂閱的 ID |

回應格式 [#回應格式]

按客戶篩選（有訂閱） [#按客戶篩選有訂閱]

```json
{
  "object": "list",
  "has_active_subscription": true,
  "data": [
    {
      "object": "subscription",
      "id": "sub_xxxxx",
      "status": "ACTIVE",
      "product_id": "prod_xxxxx",
      "product_slug": "pro-monthly",
      "product_name": "Pro Plan",
      "amount": 299,
      "interval": "month",
      "interval_count": 1,
      "current_period_start": "2025-01-01T00:00:00.000Z",
      "current_period_end": "2025-02-01T00:00:00.000Z",
      "canceled_at": null,
      "started_at": "2024-12-01T00:00:00.000Z",
      "next_billing_date": "2025-02-01T00:00:00.000Z",
      "metadata": null,
      "coupon": null,
      "coupon_remaining_cycles": null,
      "discount_amount": 0,
      "promotion_code": null
    }
  ],
  "customer": {
    "id": "cus_xxxxx",
    "email": "user@example.com",
    "name": "王小明",
    "external_id": "user_123"
  },
  "has_more": false,
  "next_cursor": null,
  "livemode": false
}
```

列出所有訂閱（無客戶篩選） [#列出所有訂閱無客戶篩選]

當不提供任何客戶識別參數時，每筆訂閱會包含對應的客戶資訊：

```json
{
  "object": "list",
  "has_active_subscription": true,
  "data": [
    {
      "object": "subscription",
      "id": "sub_xxxxx",
      "status": "ACTIVE",
      "product_id": "prod_xxxxx",
      "product_slug": "pro-monthly",
      "product_name": "Pro Plan",
      "amount": 299,
      "interval": "month",
      "interval_count": 1,
      "current_period_start": "2025-01-01T00:00:00.000Z",
      "current_period_end": "2025-02-01T00:00:00.000Z",
      "canceled_at": null,
      "started_at": "2024-12-01T00:00:00.000Z",
      "next_billing_date": "2025-02-01T00:00:00.000Z",
      "metadata": null,
      "customer": {
        "id": "cus_xxxxx",
        "email": "user@example.com",
        "name": "王小明",
        "external_id": "user_123"
      }
    },
    {
      "object": "subscription",
      "id": "sub_yyyyy",
      "status": "ACTIVE",
      "product_id": "prod_xxxxx",
      "product_slug": "pro-monthly",
      "product_name": "Pro Plan",
      "amount": 299,
      "interval": "month",
      "interval_count": 1,
      "current_period_start": "2025-01-05T00:00:00.000Z",
      "current_period_end": "2025-02-05T00:00:00.000Z",
      "canceled_at": null,
      "started_at": "2024-12-05T00:00:00.000Z",
      "next_billing_date": "2025-02-05T00:00:00.000Z",
      "metadata": null,
      "customer": {
        "id": "cus_yyyyy",
        "email": "another@example.com",
        "name": "李小華",
        "external_id": "user_456"
      }
    }
  ],
  "customer": null,
  "has_more": true,
  "next_cursor": "sub_yyyyy",
  "livemode": false
}
```

**含優惠的訂閱範例：**

```json
{
  "object": "list",
  "has_active_subscription": true,
  "data": [
    {
      "object": "subscription",
      "id": "sub_xxxxx",
      "status": "ACTIVE",
      "product_id": "prod_xxxxx",
      "product_slug": "pro-monthly",
      "product_name": "Pro Plan",
      "amount": 299,
      "interval": "month",
      "interval_count": 1,
      "current_period_start": "2025-01-01T00:00:00.000Z",
      "current_period_end": "2025-02-01T00:00:00.000Z",
      "canceled_at": null,
      "started_at": "2024-12-01T00:00:00.000Z",
      "next_billing_date": "2025-02-01T00:00:00.000Z",
      "metadata": null,
      "coupon": {
        "id": "cpn_xxxxx",
        "name": "新年優惠 8 折",
        "discount_type": "PERCENTAGE",
        "discount_amount": 2000,
        "duration": "REPEATING"
      },
      "coupon_remaining_cycles": 2,
      "discount_amount": 60,
      "promotion_code": "NEWYEAR2025"
    }
  ],
  "customer": {
    "id": "cus_xxxxx",
    "email": "user@example.com",
    "name": "王小明",
    "external_id": "user_123"
  },
  "has_more": false,
  "next_cursor": null,
  "livemode": false
}
```

無有效訂閱的客戶 [#無有效訂閱的客戶]

```json
{
  "object": "list",
  "has_active_subscription": false,
  "data": [
    {
      "object": "subscription",
      "id": "sub_xxxxx",
      "status": "CANCELED",
      "product_id": "prod_xxxxx",
      "product_slug": "pro-monthly",
      "product_name": "Pro Plan",
      "amount": 299,
      "interval": "month",
      "interval_count": 1,
      "current_period_start": "2024-12-01T00:00:00.000Z",
      "current_period_end": "2025-01-01T00:00:00.000Z",
      "canceled_at": "2025-01-01T00:00:00.000Z",
      "started_at": "2024-12-01T00:00:00.000Z",
      "next_billing_date": null,
      "metadata": null,
      "coupon": null,
      "coupon_remaining_cycles": null,
      "discount_amount": 0,
      "promotion_code": null
    }
  ],
  "customer": {
    "id": "cus_xxxxx",
    "email": "user@example.com",
    "name": "王小明",
    "external_id": null
  },
  "has_more": false,
  "next_cursor": null,
  "livemode": false
}
```

找不到客戶 [#找不到客戶]

當提供客戶篩選參數但找不到對應客戶時：

```json
{
  "object": "list",
  "has_active_subscription": false,
  "data": [],
  "customer": null,
  "has_more": false,
  "next_cursor": null,
  "livemode": false
}
```

回應欄位說明 [#回應欄位說明]

| 欄位                        | 類型             | 說明                              |
| ------------------------- | -------------- | ------------------------------- |
| `object`                  | string         | 固定為 `"list"`                    |
| `has_active_subscription` | boolean        | 是否有有效訂閱（ACTIVE、TRIAL、PAST\_DUE） |
| `data`                    | array          | 所有符合條件的訂閱（按建立時間降冪排序）            |
| `customer`                | object \| null | 客戶資訊（僅在按客戶篩選時有值）                |
| `has_more`                | boolean        | 是否有更多結果（用於分頁）                   |
| `next_cursor`             | string \| null | 下一頁的游標（傳入 `starting_after` 參數）  |
| `livemode`                | boolean        | 是否為正式環境                         |

訂閱物件欄位 [#訂閱物件欄位]

| 欄位                        | 類型             | 說明                        |
| ------------------------- | -------------- | ------------------------- |
| `object`                  | string         | 固定為 `"subscription"`      |
| `id`                      | string         | 訂閱 ID                     |
| `status`                  | string         | 訂閱狀態                      |
| `product_id`              | string         | 商品 ID                     |
| `product_slug`            | string         | 商品 Slug                   |
| `product_name`            | string         | 商品名稱                      |
| `amount`                  | number         | 金額                        |
| `interval`                | string         | 週期單位（month、year）          |
| `interval_count`          | number         | 週期數量                      |
| `current_period_start`    | string         | 當期開始時間                    |
| `current_period_end`      | string         | 當期結束時間                    |
| `canceled_at`             | string \| null | 取消時間                      |
| `started_at`              | string         | 開始時間                      |
| `next_billing_date`       | string \| null | 下次扣款時間                    |
| `metadata`                | object \| null | 開發者自訂 key-value 資料        |
| `customer`                | object         | 客戶資訊（僅在無客戶篩選時出現）          |
| `coupon`                  | object \| null | 套用的優惠資訊（見下方說明）            |
| `coupon_remaining_cycles` | number \| null | 週期性折扣剩餘期數（僅 REPEATING 類型） |
| `discount_amount`         | number         | 本期折扣金額                    |
| `promotion_code`          | string \| null | 使用的推廣代碼                   |

優惠物件結構（coupon） [#優惠物件結構coupon]

| 欄位                | 類型     | 說明                                                    |
| ----------------- | ------ | ----------------------------------------------------- |
| `id`              | string | 優惠 ID                                                 |
| `name`            | string | 優惠名稱                                                  |
| `discount_type`   | string | 折扣類型：`FIXED_AMOUNT`、`PERCENTAGE`、`FIRST_PERIOD_PRICE` |
| `discount_amount` | number | 折扣數值（固定金額為分、百分比為 basis points）                        |
| `duration`        | string | 折扣期間：`ONCE`、`REPEATING`、`FOREVER`                     |

訂閱狀態說明 [#訂閱狀態說明]

以下狀態視為「有效訂閱」（`has_active_subscription: true`）：

| 狀態         | 說明           |
| ---------- | ------------ |
| `ACTIVE`   | 正常訂閱中        |
| `TRIAL`    | 試用期中         |
| `PAST_DUE` | 逾期但尚未取消（仍有效） |

以下狀態視為「無效訂閱」：

| 狀態         | 說明  |
| ---------- | --- |
| `CANCELED` | 已取消 |
| `EXPIRED`  | 已過期 |
| `PAUSED`   | 已暫停 |

程式碼範例 [#程式碼範例]

<Tabs items="['Node.js', 'Python', 'cURL']">
  <Tab value="Node.js">
    ```typescript
    // 使用 email 查詢所有訂閱
    const response = await fetch(
      'https://api.recur.tw/v1/subscriptions?' +
      new URLSearchParams({
        email: 'user@example.com',
      }),
      {
        headers: {
          'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
        },
      }
    );

    const result = await response.json();

    if (result.has_active_subscription) {
      console.log('用戶有有效訂閱');
      console.log('訂閱數:', result.data.length);
      console.log('第一筆訂閱狀態:', result.data[0].status);
    } else {
      console.log('用戶沒有有效訂閱');
    }

    // 使用 external_id 查詢特定方案
    const response2 = await fetch(
      'https://api.recur.tw/v1/subscriptions?' +
      new URLSearchParams({
        external_id: 'user_123',
        product_slug: 'pro-monthly',
      }),
      {
        headers: {
          'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
        },
      }
    );
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import requests
    import os

    # 使用 email 查詢所有訂閱
    response = requests.get(
        'https://api.recur.tw/v1/subscriptions',
        headers={
            'Authorization': f'Bearer {os.environ["RECUR_SECRET_KEY"]}',
        },
        params={
            'email': 'user@example.com',
        }
    )

    result = response.json()

    if result['has_active_subscription']:
        print('用戶有有效訂閱')
        print(f'訂閱數: {len(result["data"])}')
        print(f'第一筆訂閱狀態: {result["data"][0]["status"]}')
    else:
        print('用戶沒有有效訂閱')

    # 使用 external_id 查詢
    response2 = requests.get(
        'https://api.recur.tw/v1/subscriptions',
        headers={
            'Authorization': f'Bearer {os.environ["RECUR_SECRET_KEY"]}',
        },
        params={
            'external_id': 'user_123',
            'active': 'true',
        }
    )
    ```
  </Tab>

  <Tab value="cURL">
    ```bash
    # 列出所有有效訂閱（不需指定客戶）
    curl -X GET "https://api.recur.tw/v1/subscriptions?active=true&limit=50" \
      -H "Authorization: Bearer sk_test_xxx"

    # 分頁：取得下一頁
    curl -X GET "https://api.recur.tw/v1/subscriptions?active=true&limit=50&starting_after=sub_xxxxx" \
      -H "Authorization: Bearer sk_test_xxx"

    # 使用 email 查詢特定客戶的訂閱
    curl -X GET "https://api.recur.tw/v1/subscriptions?email=user@example.com" \
      -H "Authorization: Bearer sk_test_xxx"

    # 使用 external_id 查詢特定方案
    curl -X GET "https://api.recur.tw/v1/subscriptions?external_id=user_123&product_slug=pro-monthly" \
      -H "X-Recur-Secret-Key: sk_test_xxx"

    # 使用 customer_id 查詢僅有效訂閱
    curl -X GET "https://api.recur.tw/v1/subscriptions?customer_id=cus_xxxxx&active=true" \
      -H "Authorization: Bearer sk_test_xxx"

    # 篩選特定狀態
    curl -X GET "https://api.recur.tw/v1/subscriptions?email=user@example.com&status=CANCELED" \
      -H "Authorization: Bearer sk_test_xxx"
    ```
  </Tab>
</Tabs>

使用案例 [#使用案例]

1\. 權限控制（使用 external_id） [#1-權限控制使用-external_id]

在您的後端驗證用戶是否有權限存取付費功能：

```typescript
// middleware/checkSubscription.ts
import { NextRequest, NextResponse } from 'next/server';

export async function checkSubscription(req: NextRequest, userId: string) {
  // 使用您系統的 userId 作為 external_id 查詢
  const response = await fetch(
    `${process.env.RECUR_API_URL}/v1/subscriptions?` +
    new URLSearchParams({
      external_id: userId,
      active: 'true',  // 只查詢有效訂閱
    }),
    {
      headers: {
        'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
      },
    }
  );

  const { has_active_subscription } = await response.json();

  if (!has_active_subscription) {
    return NextResponse.json(
      { error: '此功能需要付費訂閱' },
      { status: 403 }
    );
  }

  return null; // 允許通過
}
```

2\. 查詢用戶所有訂閱 [#2-查詢用戶所有訂閱]

不指定方案，取得用戶的所有訂閱歷史：

```typescript
// app/api/user/subscriptions/route.ts
export async function GET(req: NextRequest) {
  const session = await getSession();

  const response = await fetch(
    `${process.env.RECUR_API_URL}/v1/subscriptions?` +
    new URLSearchParams({
      email: session.user.email,
      // 不指定 product_slug，返回所有訂閱
    }),
    {
      headers: {
        'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
      },
    }
  );

  const result = await response.json();

  // Find active subscription
  const activeSubscription = result.data.find(
    (sub: { status: string }) => ['ACTIVE', 'TRIAL', 'PAST_DUE'].includes(sub.status)
  );

  return NextResponse.json({
    hasActiveSubscription: result.has_active_subscription,
    currentProduct: activeSubscription?.product_name,
    allSubscriptions: result.data.map((sub: { product_name: string; status: string; started_at: string; current_period_end: string }) => ({
      product: sub.product_name,
      status: sub.status,
      startedAt: sub.started_at,
      expiresAt: sub.current_period_end,
    })),
  });
}
```

3\. 顯示訂閱資訊 [#3-顯示訂閱資訊]

在用戶設定頁面顯示當前訂閱狀態：

```typescript
// app/api/user/subscription/route.ts
export async function GET(req: NextRequest) {
  const session = await getSession();

  const response = await fetch(
    `${process.env.RECUR_API_URL}/v1/subscriptions?` +
    new URLSearchParams({
      email: session.user.email,
      product_slug: 'pro',
    }),
    {
      headers: {
        'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
      },
    }
  );

  const result = await response.json();

  // Get the first subscription (sorted by createdAt desc)
  const subscription = result.data[0];

  return NextResponse.json({
    isSubscribed: result.has_active_subscription,
    product: subscription?.product_name,
    renewsAt: subscription?.current_period_end,
    status: subscription?.status,
  });
}
```

錯誤處理 [#錯誤處理]

| HTTP 狀態 | 錯誤碼            | 說明                                         |
| ------- | -------------- | ------------------------------------------ |
| 401     | `unauthorized` | 需要 Secret Key 認證或 API Key 無效               |
| 404     | `not_found`    | 找不到指定的商品（僅在提供 product\_id/product\_slug 時） |

注意事項 [#注意事項]

<Callout type="warning">
  **安全提醒**：此端點使用 Secret Key 認證，請勿在前端調用。所有查詢應透過您的後端進行。
</Callout>

<Callout type="info">
  **快取建議**：為了效能考量，建議在您的後端實作適當的快取機制，避免頻繁查詢相同用戶的訂閱狀態。
</Callout>

***

避免重複訂閱 [#避免重複訂閱]

Recur 在多個層級防止同一客戶重複訂閱同一商品：

| 檢查時機         | 端點                                                | 條件                       |
| ------------ | ------------------------------------------------- | ------------------------ |
| Checkout 建立時 | `POST /v1/checkouts`、`POST /v1/checkout/sessions` | 帶入 `customerEmail` 時自動檢查 |
| 訂閱建立時        | `POST /v1/subscriptions`                          | 永遠檢查                     |
| 付款完成後        | Webhook handler                                   | 最終防線                     |

若偵測到重複，API 回傳 `409 conflict`，`details` 中包含 `existing_subscription_id` 和 `status`。

<Callout type="info">
  **建議做法：建立前先查詢**

  在觸發結帳前，先用 `GET /v1/subscriptions` 確認客戶是否已有訂閱，可以提供更好的 UX：

  ```typescript
  const res = await fetch(
    `https://api.recur.tw/v1/subscriptions?email=${email}&product_id=${productId}&active=true`,
    { headers: { 'Authorization': `Bearer ${secretKey}` } }
  );
  const { has_active_subscription } = await res.json();

  if (has_active_subscription) {
    // 顯示「您已訂閱此方案」，提供管理訂閱或切換方案的選項
  } else {
    // 建立 Checkout Session
  }
  ```
</Callout>

***

建立訂閱 [#建立訂閱]

透過 API 建立新訂閱。支援兩種使用方式：

* **標準流程**：建立 PENDING 訂閱，等待付款完成（用於 UNi Embed 流程）
* **匯入流程**：直接建立 ACTIVE 或 TRIAL 訂閱（用於從其他平台遷移或為既有客戶建立訂閱）

端點 [#端點-1]

```
POST /subscriptions
```

認證 [#認證-1]

<Callout type="info">
  此端點需要 **API Key** 認證。
</Callout>

請求參數 [#請求參數-1]

基本參數 [#基本參數]

| 參數               | 類型     | 必填 | 說明                   |
| ---------------- | ------ | -- | -------------------- |
| `product_id`     | string | 是  | 商品 ID（或使用 `plan_id`） |
| `customer_email` | string | 是  | 客戶 Email             |
| `customer_name`  | string | 否  | 客戶名稱                 |
| `external_id`    | string | 否  | 您系統中的客戶 ID（用於關聯）     |

匯入參數（選填） [#匯入參數選填]

| 參數                    | 類型           | 預設值       | 說明                                                                     |
| --------------------- | ------------ | --------- | ---------------------------------------------------------------------- |
| `status`              | string       | `PENDING` | 訂閱初始狀態：`PENDING`、`ACTIVE`、`TRIAL`                                      |
| `billing_anchor_date` | ISO datetime | —         | 計費起算日，系統從此日期推算當前計費週期（與 `next_billing_date` 互斥）                         |
| `next_billing_date`   | ISO datetime | —         | 下次扣款日，系統反推當前計費週期（與 `billing_anchor_date` 互斥）                           |
| `trial_end`           | ISO datetime | —         | 試用期結束時間（`status` 為 `TRIAL` 時必填）                                        |
| `amount`              | number       | 商品價格      | 覆寫訂閱金額（整數，單位為 TWD）                                                     |
| `metadata`            | object       | —         | 開發者自訂 key-value 資料（如 `{"source": "stripe", "original_id": "sub_xxx"}`） |
| `skip_webhooks`       | boolean      | `false`   | 跳過 Webhook 觸發（適用於批量匯入）                                                 |

<Callout type="info">
  **匯入既有訂閱**

  當 `status` 設為 `ACTIVE` 或 `TRIAL` 時，訂閱會立即生效，客戶馬上獲得產品權限。由於沒有綁定信用卡，訂閱到期時系統會觸發 `subscription.payment_method_required` Webhook，並將訂閱轉為 PAST\_DUE，提醒客戶綁卡。
</Callout>

回應格式 [#回應格式-1]

成功建立 [#成功建立]

```json
{
  "subscription": {
    "id": "sub_xxxxx",
    "status": "PENDING",
    "product_id": "prod_xxxxx",
    "product_name": "Pro Plan",
    "amount": 299,
    "interval": "month",
    "interval_count": 1,
    "trial_days": 7,
    "current_period_start": "2025-01-01T00:00:00.000Z",
    "current_period_end": "2025-02-01T00:00:00.000Z",
    "next_billing_date": "2025-02-01T00:00:00.000Z",
    "metadata": null
  },
  "customer": {
    "id": "cus_xxxxx",
    "email": "user@example.com",
    "name": "王小明",
    "external_id": "user_123"
  },
  "next_steps": {
    "get_sdk_token": "/api/v1/subscriptions/sdk-token",
    "complete_subscription": "/api/v1/subscriptions/sub_xxxxx/complete"
  },
  "livemode": false
}
```

匯入 ACTIVE 訂閱 [#匯入-active-訂閱]

```json
{
  "subscription": {
    "id": "sub_xxxxx",
    "status": "ACTIVE",
    "product_id": "prod_xxxxx",
    "product_name": "Pro Plan",
    "amount": 299,
    "interval": "month",
    "interval_count": 1,
    "trial_days": null,
    "current_period_start": "2025-01-01T00:00:00.000Z",
    "current_period_end": "2025-02-01T00:00:00.000Z",
    "next_billing_date": "2025-02-01T00:00:00.000Z",
    "metadata": {
      "source": "stripe",
      "original_id": "sub_xxx"
    }
  },
  "customer": {
    "id": "cus_xxxxx",
    "email": "user@example.com",
    "name": "王小明",
    "external_id": "user_123"
  },
  "livemode": false
}
```

<Callout type="info">
  當 `status` 為 `ACTIVE` 或 `TRIAL` 時，回應不包含 `next_steps`，因為不需要走付款流程。
</Callout>

錯誤處理 [#錯誤處理-1]

| HTTP 狀態 | 錯誤碼            | 說明           |
| ------- | -------------- | ------------ |
| 400     | `bad_request`  | 缺少必要參數或參數無效  |
| 401     | `unauthorized` | API Key 無效   |
| 404     | `not_found`    | 找不到指定的商品     |
| 409     | `conflict`     | 客戶已有該商品的有效訂閱 |

重複訂閱錯誤（409） [#重複訂閱錯誤409]

當客戶已有該商品的有效訂閱（ACTIVE、TRIAL 或 PAST\_DUE）時，API 會返回 409 錯誤：

```json
{
  "error": {
    "code": "conflict",
    "message": "Customer already has an active subscription to this product",
    "details": [
      {
        "existing_subscription_id": "sub_xxxxx",
        "status": "ACTIVE"
      }
    ]
  }
}
```

<Callout type="info">
  **處理重複訂閱錯誤**

  當收到 409 錯誤時，您可以：

  1. 使用 `details[0].existing_subscription_id` 查詢現有訂閱詳情
  2. 引導客戶到 Customer Portal 管理現有訂閱
  3. 如需切換方案，使用[訂閱方案切換](#訂閱方案切換) API
</Callout>

程式碼範例 [#程式碼範例-1]

<Tabs items="['Node.js', 'Python', 'cURL']">
  <Tab value="Node.js">
    ```typescript
    const response = await fetch('https://api.recur.tw/v1/subscriptions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.RECUR_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        product_id: 'prod_xxxxx',
        customer_email: 'user@example.com',
        customer_name: '王小明',
        external_id: 'user_123',
      }),
    });

    const data = await response.json();

    if (response.status === 409) {
      // 客戶已有訂閱
      const existingSubId = data.error.details[0].existing_subscription_id;
      console.log(`客戶已有訂閱: ${existingSubId}`);
    } else if (response.ok) {
      console.log('訂閱建立成功:', data.subscription.id);
    }
    ```
  </Tab>

  <Tab value="Python">
    ```python
    import requests
    import os

    response = requests.post(
        'https://api.recur.tw/v1/subscriptions',
        headers={
            'Authorization': f'Bearer {os.environ["RECUR_API_KEY"]}',
            'Content-Type': 'application/json',
        },
        json={
            'product_id': 'prod_xxxxx',
            'customer_email': 'user@example.com',
            'customer_name': '王小明',
            'external_id': 'user_123',
        }
    )

    data = response.json()

    if response.status_code == 409:
        # 客戶已有訂閱
        existing_sub_id = data['error']['details'][0]['existing_subscription_id']
        print(f'客戶已有訂閱: {existing_sub_id}')
    elif response.ok:
        print(f'訂閱建立成功: {data["subscription"]["id"]}')
    ```
  </Tab>

  <Tab value="cURL">
    ```bash
    curl -X POST "https://api.recur.tw/v1/subscriptions" \
      -H "Authorization: Bearer pk_test_xxx" \
      -H "Content-Type: application/json" \
      -d '{
        "product_id": "prod_xxxxx",
        "customer_email": "user@example.com",
        "customer_name": "王小明",
        "external_id": "user_123"
      }'

    # 匯入 ACTIVE 訂閱（從其他平台遷移）
    curl -X POST "https://api.recur.tw/v1/subscriptions" \
      -H "Authorization: Bearer sk_test_xxx" \
      -H "Content-Type: application/json" \
      -d '{
        "product_id": "prod_xxxxx",
        "customer_email": "user@example.com",
        "status": "ACTIVE",
        "next_billing_date": "2025-04-01T00:00:00Z",
        "metadata": { "source": "stripe", "original_id": "sub_xxx" },
        "skip_webhooks": true
      }'
    ```
  </Tab>
</Tabs>

***

訂閱方案切換 [#訂閱方案切換]

允許您代替客戶執行訂閱方案的升級、降級或週期變更。

<Callout type="warning">
  所有訂閱切換 API 都需要 **Secret Key** 認證，僅限後端使用。
</Callout>

切換類型說明 [#切換類型說明]

| 類型              | 說明              | 執行方式            |
| --------------- | --------------- | --------------- |
| `UPGRADE`       | 升級到更高價方案        | 立即執行，按比例計算差額    |
| `DOWNGRADE`     | 降級到較低價方案        | 排程至當期結束時執行      |
| `PERIOD_CHANGE` | 變更計費週期（月→年或年→月） | 月→年立即執行；年→月排程執行 |
| `CROSSGRADE`    | 切換到同價方案         | 立即執行，無需補差額      |

預覽切換 [#預覽切換]

在執行切換前，預覽切換結果和費用計算。

端點 [#端點-2]

```
GET /subscriptions/{subscription_id}/switch-preview
```

請求參數 [#請求參數-2]

| 參數                  | 類型     | 必填 | 說明      |
| ------------------- | ------ | -- | ------- |
| `target_product_id` | string | 是  | 目標方案 ID |

回應範例 [#回應範例]

```json
{
  "object": "switch_preview",
  "subscription_id": "sub_xxxxx",
  "switch_type": "UPGRADE",
  "execution_mode": "immediate",
  "current_plan": {
    "product_id": "prod_basic",
    "product_name": "Basic Plan",
    "amount": 299,
    "currency": "TWD",
    "interval": "month",
    "interval_count": 1,
    "monthly_equivalent": 299
  },
  "new_plan": {
    "product_id": "prod_pro",
    "product_name": "Pro Plan",
    "amount": 599,
    "currency": "TWD",
    "interval": "month",
    "interval_count": 1,
    "monthly_equivalent": 599
  },
  "proration": {
    "credit_amount": 150,
    "charge_amount": 599,
    "net_amount": 449,
    "unused_days": 15,
    "total_days_in_period": 30,
    "credit_description": "15 天未使用的 Basic Plan"
  },
  "effective_date": "2025-01-15T10:00:00.000Z",
  "next_billing_date": "2025-02-15T00:00:00.000Z",
  "requires_payment": true,
  "can_proceed": true,
  "is_in_trial": false
}
```

回應欄位說明 [#回應欄位說明-1]

| 欄位                     | 類型             | 說明                                                |
| ---------------------- | -------------- | ------------------------------------------------- |
| `switch_type`          | string         | 切換類型（UPGRADE、DOWNGRADE、PERIOD\_CHANGE、CROSSGRADE） |
| `execution_mode`       | string         | 執行方式（`immediate` 立即執行、`scheduled` 排程執行）           |
| `proration`            | object \| null | 按比例計算結果（降級時為 null）                                |
| `proration.net_amount` | number         | 客戶需支付的淨額（charge - credit）                         |
| `requires_payment`     | boolean        | 是否需要付款                                            |
| `can_proceed`          | boolean        | 是否可執行切換                                           |
| `blocking_reason`      | string \| null | 無法切換的原因（如有）                                       |

程式碼範例 [#程式碼範例-2]

<Tabs items="['Node.js', 'cURL']">
  <Tab value="Node.js">
    ```typescript
    const response = await fetch(
      `https://api.recur.tw/v1/subscriptions/${subscriptionId}/switch-preview?` +
      new URLSearchParams({
        target_product_id: 'prod_pro',
      }),
      {
        headers: {
          'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
        },
      }
    );

    const preview = await response.json();

    if (preview.can_proceed) {
      console.log(`切換類型: ${preview.switch_type}`);
      console.log(`執行方式: ${preview.execution_mode}`);
      if (preview.proration) {
        console.log(`需付金額: ${preview.proration.net_amount}`);
      }
    }
    ```
  </Tab>

  <Tab value="cURL">
    ```bash
    curl -X GET "https://api.recur.tw/v1/subscriptions/sub_xxxxx/switch-preview?target_product_id=prod_pro" \
      -H "Authorization: Bearer sk_test_xxx"
    ```
  </Tab>
</Tabs>

***

執行切換 [#執行切換]

執行訂閱方案切換。根據切換類型，會立即執行或排程至當期結束。

端點 [#端點-3]

```
POST /subscriptions/{subscription_id}/switch
```

請求參數 [#請求參數-3]

| 參數                   | 類型     | 必填 | 說明                                     |
| -------------------- | ------ | -- | -------------------------------------- |
| `target_product_id`  | string | 是  | 目標方案 ID                                |
| `proration_behavior` | string | 否  | 按比例計算行為：`create_prorations`（預設）、`none` |

回應範例（立即執行） [#回應範例立即執行]

升級或月轉年會立即執行：

```json
{
  "object": "switch_result",
  "execution_mode": "immediate",
  "subscription": {
    "id": "sub_xxxxx",
    "product_id": "prod_pro",
    "status": "ACTIVE",
    "amount": 599,
    "currency": "TWD",
    "interval": "month",
    "interval_count": 1,
    "current_period_start": "2025-01-15T10:00:00.000Z",
    "current_period_end": "2025-02-15T00:00:00.000Z",
    "next_billing_date": "2025-02-15T00:00:00.000Z",
    "previous_product_id": "prod_basic",
    "switched_at": "2025-01-15T10:00:00.000Z",
    "switch_type": "UPGRADE"
  },
  "invoice": {
    "id": "inv_xxxxx",
    "invoice_number": "INV-20250115-ABC123",
    "amount": 449,
    "currency": "TWD",
    "status": "PAID",
    "billing_reason": "SUBSCRIPTION_UPDATE",
    "billing_entries": [
      {
        "id": "entry_1",
        "type": "PRORATION_CREDIT",
        "direction": "CREDIT",
        "amount": 150,
        "description": "15 天未使用的 Basic Plan"
      },
      {
        "id": "entry_2",
        "type": "SUBSCRIPTION",
        "direction": "CHARGE",
        "amount": 599,
        "description": "Pro Plan (月繳)"
      }
    ]
  },
  "schedule": null,
  "switch_type": "UPGRADE",
  "proration": {
    "credit_amount": 150,
    "charge_amount": 599,
    "net_amount": 449,
    "unused_days": 15,
    "total_days_in_period": 30,
    "credit_description": "15 天未使用的 Basic Plan"
  },
  "effective_date": "2025-01-15T10:00:00.000Z"
}
```

回應範例（排程執行） [#回應範例排程執行]

降級或年轉月會排程至當期結束：

```json
{
  "object": "switch_result",
  "execution_mode": "scheduled",
  "subscription": {
    "id": "sub_xxxxx"
  },
  "invoice": null,
  "schedule": {
    "id": "sched_xxxxx",
    "target_product_id": "prod_basic",
    "switch_type": "DOWNGRADE",
    "effective_at": "2025-02-15T00:00:00.000Z",
    "status": "PENDING",
    "created_at": "2025-01-15T10:00:00.000Z"
  },
  "switch_type": "DOWNGRADE",
  "proration": null,
  "effective_date": "2025-02-15T00:00:00.000Z"
}
```

程式碼範例 [#程式碼範例-3]

<Tabs items="['Node.js', 'cURL']">
  <Tab value="Node.js">
    ```typescript
    // 執行升級
    const response = await fetch(
      `https://api.recur.tw/v1/subscriptions/${subscriptionId}/switch`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          target_product_id: 'prod_pro',
        }),
      }
    );

    const result = await response.json();

    if (result.execution_mode === 'immediate') {
      console.log('切換已立即生效');
      console.log(`新方案: ${result.subscription.product_id}`);
      if (result.invoice) {
        console.log(`帳單金額: ${result.invoice.amount}`);
      }
    } else {
      console.log('切換已排程');
      console.log(`生效日期: ${result.schedule.effective_at}`);
    }
    ```
  </Tab>

  <Tab value="cURL">
    ```bash
    curl -X POST "https://api.recur.tw/v1/subscriptions/sub_xxxxx/switch" \
      -H "Authorization: Bearer sk_test_xxx" \
      -H "Content-Type: application/json" \
      -d '{"target_product_id": "prod_pro"}'
    ```
  </Tab>
</Tabs>

***

查詢排程切換 [#查詢排程切換]

查詢訂閱是否有待執行的排程切換。

端點 [#端點-4]

```
GET /subscriptions/{subscription_id}/schedule
```

回應範例（有排程） [#回應範例有排程]

```json
{
  "object": "schedule",
  "has_pending_schedule": true,
  "schedule": {
    "id": "sched_xxxxx",
    "subscription_id": "sub_xxxxx",
    "target_product_id": "prod_basic",
    "target_product_name": "Basic Plan",
    "switch_type": "DOWNGRADE",
    "effective_at": "2025-02-15T00:00:00.000Z",
    "status": "PENDING",
    "created_at": "2025-01-15T10:00:00.000Z"
  }
}
```

回應範例（無排程） [#回應範例無排程]

```json
{
  "object": "schedule",
  "has_pending_schedule": false,
  "schedule": null
}
```

***

取消排程切換 [#取消排程切換]

取消待執行的排程切換，訂閱將維持當前方案。

端點 [#端點-5]

```
DELETE /subscriptions/{subscription_id}/schedule
```

回應範例 [#回應範例-1]

```json
{
  "object": "schedule_cancellation",
  "cancelled": true,
  "subscription_id": "sub_xxxxx"
}
```

程式碼範例 [#程式碼範例-4]

<Tabs items="['Node.js', 'cURL']">
  <Tab value="Node.js">
    ```typescript
    // 取消排程的降級
    const response = await fetch(
      `https://api.recur.tw/v1/subscriptions/${subscriptionId}/schedule`,
      {
        method: 'DELETE',
        headers: {
          'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
        },
      }
    );

    const result = await response.json();
    if (result.cancelled) {
      console.log('排程切換已取消，訂閱將維持當前方案');
    }
    ```
  </Tab>

  <Tab value="cURL">
    ```bash
    curl -X DELETE "https://api.recur.tw/v1/subscriptions/sub_xxxxx/schedule" \
      -H "Authorization: Bearer sk_test_xxx"
    ```
  </Tab>
</Tabs>

***

使用案例 [#使用案例-1]

1\. 客服協助升級 [#1-客服協助升級]

當客服需要幫客戶升級方案時：

```typescript
async function upgradeSubscription(subscriptionId: string, newProductId: string) {
  // 1. 先預覽切換
  const previewRes = await fetch(
    `${RECUR_API_URL}/v1/subscriptions/${subscriptionId}/switch-preview?` +
    new URLSearchParams({ target_product_id: newProductId }),
    {
      headers: { 'Authorization': `Bearer ${RECUR_SECRET_KEY}` },
    }
  );
  const preview = await previewRes.json();

  if (!preview.can_proceed) {
    throw new Error(`無法切換: ${preview.blocking_reason}`);
  }

  // 2. 確認後執行切換
  const switchRes = await fetch(
    `${RECUR_API_URL}/v1/subscriptions/${subscriptionId}/switch`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${RECUR_SECRET_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ target_product_id: newProductId }),
    }
  );

  return switchRes.json();
}
```

2\. 顯示待執行的降級 [#2-顯示待執行的降級]

在用戶設定頁面顯示排程中的降級：

```typescript
async function getPendingDowngrade(subscriptionId: string) {
  const res = await fetch(
    `${RECUR_API_URL}/v1/subscriptions/${subscriptionId}/schedule`,
    {
      headers: { 'Authorization': `Bearer ${RECUR_SECRET_KEY}` },
    }
  );
  const data = await res.json();

  if (data.has_pending_schedule) {
    return {
      hasPendingChange: true,
      newPlan: data.schedule.target_product_name,
      effectiveDate: data.schedule.effective_at,
      canCancel: true,
    };
  }

  return { hasPendingChange: false };
}
```

錯誤處理 [#錯誤處理-2]

| HTTP 狀態 | 錯誤碼                       | 說明               |
| ------- | ------------------------- | ---------------- |
| 401     | `unauthorized`            | 需要 Secret Key 認證 |
| 404     | `subscription_not_found`  | 找不到訂閱            |
| 404     | `product_not_found`       | 找不到目標方案          |
| 400     | `same_product`            | 目標方案與當前相同        |
| 400     | `subscription_not_active` | 訂閱非有效狀態          |
| 400     | `past_due_blocks_switch`  | 訂閱逾期中，無法切換       |
| 402     | `payment_required`        | 需要付款但付款失敗        |

***

下一步 [#下一步]

* [Webhook 整合](/guides/webhooks) - 接收訂閱狀態變更通知
* [API 認證](/getting-started/authentication) - 了解 API Key 使用方式
* [Customer Portal](/guides/portal) - 讓客戶自助管理訂閱
