# Promotion Codes API (/api/promotion-codes)





Promotion Codes API [#promotion-codes-api]

Promotion Code（優惠碼）是面向顧客的折扣代碼，每個優惠碼連結到一個 Coupon（優惠券），由 Coupon 定義折扣規則。

除了驗證端點可使用 Publishable Key 外，其餘端點皆需 **Secret Key** 認證。

***

建立優惠碼 [#建立優惠碼]

建立一個新的優惠碼，連結到指定的 Coupon。

端點 [#端點]

```
POST /promotion-codes
```

請求參數 [#請求參數]

| 參數                             | 類型      | 必填 | 說明                                             |
| ------------------------------ | ------- | -- | ---------------------------------------------- |
| `coupon_id`                    | string  | 是  | 連結的優惠券 ID                                      |
| `code`                         | string  | 是  | 優惠碼（3-20 字元，僅大寫英文與數字）                          |
| `valid_from`                   | string  | 否  | 生效時間（ISO 8601）                                 |
| `valid_until`                  | string  | 否  | 到期時間（ISO 8601，需晚於 `valid_from`）                |
| `max_redemptions`              | integer | 否  | 全域兌換上限                                         |
| `max_redemptions_per_customer` | integer | 否  | 每位顧客兌換上限（預設 `1`）                               |
| `minimum_amount`               | integer | 否  | 最低消費金額（TWD）                                    |
| `customer_eligibility`         | string  | 否  | `ALL`（預設）、`NEW_CUSTOMERS`、`EXISTING_CUSTOMERS` |
| `restricted_to_customer_id`    | string  | 否  | 限定特定顧客使用                                       |
| `active`                       | boolean | 否  | 是否啟用（預設 `true`）                                |
| `metadata`                     | object  | 否  | 自訂 metadata                                    |

請求範例 [#請求範例]

```typescript
const response = await fetch('https://api.recur.tw/v1/promotion-codes', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk_test_xxx',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    coupon_id: 'cpn_xxx',
    code: 'SAVE100',
    max_redemptions: 100,
    valid_until: '2026-06-30T23:59:59.000Z',
    customer_eligibility: 'ALL',
  }),
});
```

回應範例 [#回應範例]

```json
{
  "success": true,
  "data": {
    "id": "promo_xxx",
    "code": "SAVE100",
    "coupon_id": "cpn_xxx",
    "active": true,
    "valid_from": null,
    "valid_until": "2026-06-30T23:59:59.000Z",
    "max_redemptions": 100,
    "max_redemptions_per_customer": 1,
    "current_redemptions": 0,
    "minimum_amount": null,
    "customer_eligibility": "ALL",
    "restricted_to_customer_id": null,
    "metadata": null,
    "created_at": "2026-03-29T10:00:00.000Z",
    "updated_at": "2026-03-29T10:00:00.000Z"
  }
}
```

***

列出優惠碼 [#列出優惠碼]

列出組織下的所有優惠碼。

端點 [#端點-1]

```
GET /promotion-codes
```

Query 參數 [#query-參數]

| 參數          | 類型      | 必填 | 說明                       |
| ----------- | ------- | -- | ------------------------ |
| `coupon_id` | string  | 否  | 依優惠券篩選                   |
| `active`    | string  | 否  | `true` 或 `false`，依啟用狀態篩選 |
| `limit`     | integer | 否  | 回傳筆數（預設 `20`，上限 `100`）   |
| `offset`    | integer | 否  | 分頁偏移量（預設 `0`）            |

請求範例 [#請求範例-1]

```typescript
const response = await fetch(
  'https://api.recur.tw/v1/promotion-codes?active=true&limit=10',
  {
    headers: { 'Authorization': 'Bearer sk_test_xxx' },
  }
);
```

***

查詢優惠碼 [#查詢優惠碼]

取得單一優惠碼的詳細資訊。

端點 [#端點-2]

```
GET /promotion-codes/:id
```

請求範例 [#請求範例-2]

```typescript
const response = await fetch('https://api.recur.tw/v1/promotion-codes/promo_xxx', {
  headers: { 'Authorization': 'Bearer sk_test_xxx' },
});
```

***

更新優惠碼 [#更新優惠碼]

更新優惠碼的設定。`code` 和 `coupon_id` 建立後不可修改。

端點 [#端點-3]

```
PATCH /promotion-codes/:id
```

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

| 參數                             | 類型      | 說明                                         |
| ------------------------------ | ------- | ------------------------------------------ |
| `valid_from`                   | string  | 生效時間（ISO 8601）                             |
| `valid_until`                  | string  | 到期時間（ISO 8601）                             |
| `max_redemptions`              | integer | 全域兌換上限                                     |
| `max_redemptions_per_customer` | integer | 每位顧客兌換上限                                   |
| `minimum_amount`               | integer | 最低消費金額（TWD）                                |
| `customer_eligibility`         | string  | `ALL`、`NEW_CUSTOMERS`、`EXISTING_CUSTOMERS` |
| `active`                       | boolean | 是否啟用                                       |
| `metadata`                     | object  | 自訂 metadata                                |

所有欄位皆為選填，僅傳送需要更新的欄位。

請求範例 [#請求範例-3]

```typescript
const response = await fetch('https://api.recur.tw/v1/promotion-codes/promo_xxx', {
  method: 'PATCH',
  headers: {
    'Authorization': 'Bearer sk_test_xxx',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    max_redemptions: 200,
    valid_until: '2026-12-31T23:59:59.000Z',
  }),
});
```

***

停用優惠碼 [#停用優惠碼]

停用優惠碼（設為 `active: false`）。已兌換的記錄不受影響。

端點 [#端點-4]

```
DELETE /promotion-codes/:id
```

請求範例 [#請求範例-4]

```typescript
const response = await fetch('https://api.recur.tw/v1/promotion-codes/promo_xxx', {
  method: 'DELETE',
  headers: { 'Authorization': 'Bearer sk_test_xxx' },
});
```

***

查詢兌換記錄 [#查詢兌換記錄]

列出某個優惠碼的兌換記錄。

端點 [#端點-5]

```
GET /promotion-codes/:id/redemptions
```

Query 參數 [#query-參數-1]

| 參數           | 類型      | 必填 | 說明                     |
| ------------ | ------- | -- | ---------------------- |
| `status`     | string  | 否  | `ACTIVE` 或 `VOIDED`    |
| `customerId` | string  | 否  | 依顧客 ID 篩選              |
| `limit`      | integer | 否  | 回傳筆數（預設 `20`，上限 `100`） |
| `offset`     | integer | 否  | 分頁偏移量（預設 `0`）          |

請求範例 [#請求範例-5]

```typescript
const response = await fetch(
  'https://api.recur.tw/v1/promotion-codes/promo_xxx/redemptions?status=ACTIVE',
  {
    headers: { 'Authorization': 'Bearer sk_test_xxx' },
  }
);
```

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

```json
{
  "success": true,
  "data": {
    "redemptions": [
      {
        "id": "red_xxx",
        "promotion_code_id": "promo_xxx",
        "customer_id": "cus_xxx",
        "order_id": "ord_xxx",
        "discount_amount": 100,
        "status": "ACTIVE",
        "redeemed_at": "2026-03-15T10:30:00.000Z"
      }
    ],
    "total": 42,
    "active_count": 40,
    "voided_count": 2
  }
}
```

***

驗證優惠碼 [#驗證優惠碼]

驗證優惠碼是否有效，並回傳折扣內容與適用產品。

端點 [#端點-6]

```
POST /promotion-codes/validate
```

認證 [#認證]

支援 **Publishable Key**（前端）和 **Secret Key**（後端），行為有所不同：

|            | Publishable Key                       | Secret Key         |
| ---------- | ------------------------------------- | ------------------ |
| 客戶身份       | **必填**（`external_id` 或 `customer_id`） | 選填                 |
| Rate Limit | 5/min per IP + 10/hr per customer     | 60/min per API key |
| 錯誤碼        | 模糊化（防止碼列舉）                            | 詳細錯誤碼              |

<Callout type="warning">
  使用 Publishable Key 時，必須提供 `external_id` 或 `customer_id` 來識別顧客。這是為了防止匿名暴力列舉優惠碼。
</Callout>

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

| 參數            | 類型     | 必填   | 說明            |
| ------------- | ------ | ---- | ------------- |
| `code`        | string | 是    | 優惠碼（不區分大小寫）   |
| `external_id` | string | 條件必填 | 您系統中的客戶 ID    |
| `customer_id` | string | 條件必填 | Recur 內部客戶 ID |
| `product_id`  | string | 否    | 驗證是否適用於特定產品   |
| `amount`      | number | 否    | 訂單金額（用於計算折後價） |

<Callout type="info">
  `external_id` 和 `customer_id` 擇一即可。使用 Publishable Key 時至少需要一個；使用 Secret Key 時兩者皆為選填。
</Callout>

請求範例 [#請求範例-6]

<Tabs items="['前端（Publishable Key）', '後端（Secret Key）']">
  <Tab value="前端（Publishable Key）">
    ```typescript
    const response = await fetch('https://api.recur.tw/v1/promotion-codes/validate', {
      method: 'POST',
      headers: {
        'X-Recur-Publishable-Key': 'pk_test_xxx',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        code: 'SAVE100',
        external_id: 'user_123',     // 必填：您系統的用戶 ID
        product_id: 'prod_xxx',      // 選填：驗證特定產品
        amount: 499,                 // 選填：計算折後價
      }),
    });

    const result = await response.json();
    ```
  </Tab>

  <Tab value="後端（Secret Key）">
    ```typescript
    const response = await fetch('https://api.recur.tw/v1/promotion-codes/validate', {
      method: 'POST',
      headers: {
        'Authorization': 'Bearer sk_test_xxx',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        code: 'SAVE100',
        product_id: 'prod_xxx',
        amount: 499,
      }),
    });

    const result = await response.json();
    ```
  </Tab>
</Tabs>

回應格式 [#回應格式]

驗證成功 [#驗證成功]

```json
{
  "valid": true,
  "applies_to_all_products": false,
  "applicable_products": [
    {
      "id": "prod_xxx",
      "name": "超級方案",
      "price": 499,
      "price_after_discount": 399,
      "interval": "month",
      "interval_count": 1
    }
  ],
  "discount": {
    "type": "FIXED_AMOUNT",
    "amount": 100,
    "discounted_amount": 399,
    "label": "NT$100 off",
    "bonus_trial_days": 7
  },
  "coupon": {
    "name": "春季優惠",
    "discount_type": "FIXED_AMOUNT",
    "discount_amount": 100,
    "duration": "ONCE",
    "applies_to_all_products": false
  },
  "promotion_code": {
    "code": "SAVE100",
    "valid_until": "2026-06-30T23:59:59.000Z",
    "max_redemptions": 100,
    "current_redemptions": 42
  }
}
```

驗證失敗 [#驗證失敗]

<Tabs items="['Publishable Key', 'Secret Key']">
  <Tab value="Publishable Key">
    ```json
    {
      "valid": false,
      "error_code": "PROMO_CODE_INVALID",
      "error_message": "此優惠碼無效"
    }
    ```

    Publishable Key 統一回傳 `PROMO_CODE_INVALID`，不揭露碼是否存在、已過期或已停用，防止暴力列舉。

    唯一例外：`PROMO_CODE_ALREADY_USED` 會保留，因為這是顧客自身的使用狀態。
  </Tab>

  <Tab value="Secret Key">
    ```json
    {
      "valid": false,
      "error_code": "PROMO_CODE_EXPIRED",
      "error_message": "此優惠碼已過期"
    }
    ```

    Secret Key 回傳詳細錯誤碼，方便後端除錯。
  </Tab>
</Tabs>

回應欄位 [#回應欄位]

| 欄位                           | 類型      | 說明                                               |
| ---------------------------- | ------- | ------------------------------------------------ |
| `valid`                      | boolean | 優惠碼是否有效                                          |
| `applies_to_all_products`    | boolean | 是否適用所有產品                                         |
| `applicable_products`        | array   | 適用產品清單（僅限定產品時回傳）                                 |
| `discount`                   | object  | 折扣資訊                                             |
| `discount.type`              | string  | `PERCENTAGE`、`FIXED_AMOUNT`、`FIRST_PERIOD_PRICE` |
| `discount.amount`            | number  | 折扣金額（需搭配 `amount` 參數）                            |
| `discount.discounted_amount` | number  | 折後金額                                             |
| `discount.label`             | string  | 人類可讀的折扣描述（如 `"20% off"`）                         |
| `discount.bonus_trial_days`  | number  | 額外試用天數                                           |
| `discount.bonus_months`      | number  | 額外贈送月數                                           |
| `coupon`                     | object  | 優惠券詳細資訊                                          |
| `promotion_code`             | object  | 推廣代碼詳細資訊                                         |
| `error_code`                 | string  | 錯誤碼（驗證失敗時）                                       |
| `error_message`              | string  | 錯誤訊息（驗證失敗時）                                      |

錯誤碼（Secret Key） [#錯誤碼secret-key]

| 錯誤碼                                  | 說明          |
| ------------------------------------ | ----------- |
| `PROMO_CODE_NOT_FOUND`               | 優惠碼不存在      |
| `PROMO_CODE_INACTIVE`                | 優惠碼或優惠券已停用  |
| `PROMO_CODE_EXPIRED`                 | 優惠碼已過期      |
| `PROMO_CODE_NOT_STARTED`             | 優惠碼尚未生效     |
| `PROMO_CODE_EXHAUSTED`               | 優惠碼已達使用上限   |
| `PROMO_CODE_ALREADY_USED`            | 此顧客已使用過此優惠碼 |
| `PROMO_CODE_PRODUCT_MISMATCH`        | 優惠碼不適用於此產品  |
| `PROMO_CODE_MINIMUM_NOT_MET`         | 未達最低消費金額    |
| `PROMO_CODE_RESTRICTED`              | 優惠碼限定特定顧客使用 |
| `PROMO_CODE_NEW_CUSTOMERS_ONLY`      | 僅限新顧客使用     |
| `PROMO_CODE_EXISTING_CUSTOMERS_ONLY` | 僅限現有顧客使用    |

***

安全機制 [#安全機制]

防暴力列舉 [#防暴力列舉]

使用 Publishable Key 時，API 實施三層防護：

1. **客戶身份必填** — 無法匿名發送請求
2. **雙重 Rate Limiting** — IP 限制（5 次/分鐘）+ 客戶限制（10 次/小時）
3. **錯誤碼模糊化** — 攻擊者無法區分「碼不存在」與「碼已過期」

使用 Secret Key 時，Rate Limit 改為 **60 次/分鐘 per API key**，不再以 IP 為單位。這讓後端伺服器即使共用 NAT IP，也不會因為多個用戶同時驗證而被限流。

環境隔離 [#環境隔離]

`external_id` 在 Sandbox 和 Production 環境之間完全隔離。同一個 `external_id` 在兩個環境中對應不同的客戶記錄，不會互相影響。

***

搭配 React SDK [#搭配-react-sdk]

Recur React SDK 提供 `usePromoCode` hook，封裝了上述 API 呼叫：

```tsx
import { usePromoCode } from 'recur-tw'

function PricingPage() {
  const promo = usePromoCode({ customerId: 'user_123' })

  return (
    <div>
      <input
        placeholder="輸入折扣碼"
        onBlur={(e) => promo.apply(e.target.value)}
      />
      {promo.isValid && <span>{promo.discount.label}</span>}
      {promo.error && <span>{promo.error}</span>}
    </div>
  )
}
```

<Callout type="info">
  `usePromoCode` hook 即將推出，敬請期待。
</Callout>
