---
title: Promotion Codes API
description: 優惠碼 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"
  }
}
```

---

## 列出優惠碼

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

### 端點

```
GET /promotion-codes
```

### Query 參數

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

### 請求範例

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

---

## 查詢優惠碼

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

### 端點

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

### 請求範例

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

---

## 更新優惠碼

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

### 端點

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

### 請求參數

| 參數 | 類型 | 說明 |
|------|------|------|
| `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 |

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

### 請求範例

```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`）。已兌換的記錄不受影響。

### 端點

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

### 請求範例

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

---

## 查詢兌換記錄

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

### 端點

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

### Query 參數

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

### 請求範例

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

### 回應範例

```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
  }
}
```

---

## 驗證優惠碼

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

### 端點

```
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 |
| 錯誤碼 | 模糊化（防止碼列舉） | 詳細錯誤碼 |

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

### 請求參數

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

  `external_id` 和 `customer_id` 擇一即可。使用 Publishable Key 時至少需要一個；使用 Secret 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();
```

```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();
```

### 回應格式

#### 驗證成功

```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
  }
}
```

#### 驗證失敗

```json
{
  "valid": false,
  "error_code": "PROMO_CODE_INVALID",
  "error_message": "此優惠碼無效"
}
```

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

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

```json
{
  "valid": false,
  "error_code": "PROMO_CODE_EXPIRED",
  "error_message": "此優惠碼已過期"
}
```

Secret Key 回傳詳細錯誤碼，方便後端除錯。

### 回應欄位

| 欄位 | 類型 | 說明 |
|------|------|------|
| `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）

| 錯誤碼 | 說明 |
|--------|------|
| `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

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>
  )
}
```

  `usePromoCode` hook 即將推出，敬請期待。

