# Portal Sessions API (/api/portal)





Portal Sessions API [#portal-sessions-api]

Customer Portal 讓您的客戶可以自助管理訂閱，包括查看訂閱詳情、更新付款方式、取消訂閱等。

建立 Portal Session [#建立-portal-session]

建立一個短期有效的 Portal Session，用於將客戶導向 Customer Portal。

端點 [#端點]

```
POST /portal/sessions
```

認證 [#認證]

<Callout type="warning">
  此端點需要 **Secret Key** 認證，僅限後端使用。Portal Session 包含敏感的客戶資訊，不應在前端建立。
</Callout>

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

# 或專用 Header
X-Recur-Secret-Key: sk_test_xxx
```

請求參數 [#請求參數]

**客戶識別（至少需要其中一個）：**

| 參數            | 必填 | 類型     | 說明                   |
| ------------- | -- | ------ | -------------------- |
| `customer_id` | ❌  | string | 客戶的內部 ID（優先順序最高）     |
| `external_id` | ❌  | string | 客戶的外部 ID（來自您的系統）     |
| `email`       | ❌  | string | 客戶的 email 地址（優先順序最低） |

<Callout type="info">
  **客戶識別優先順序**：`customer_id` > `external_id` > `email`

  您必須提供至少一個識別參數。如果同時提供多個，系統會按照優先順序選擇。
</Callout>

**其他參數：**

| 參數                 | 必填 | 類型     | 說明                   |
| ------------------ | -- | ------ | -------------------- |
| `return_url`       | ❌  | string | 客戶離開 Portal 後的返回 URL |
| `configuration_id` | ❌  | string | 指定 Portal 設定 ID      |
| `locale`           | ❌  | string | 語言偏好（`zh-TW` 或 `en`） |

<Callout type="info">
  如果未提供 `return_url`，將使用 Portal 設定中的預設返回 URL。若都未設定，會返回錯誤。
</Callout>

回應格式 [#回應格式]

```json
{
  "id": "portal_session_xxxxx",
  "object": "portal.session",
  "url": "https://portal.recur.tw/s/ps_xxxxx",
  "return_url": "https://your-site.com/account",
  "customer_id": "cus_xxxxx",
  "status": "active",
  "expires_at": "2025-01-15T12:00:00.000Z",
  "accessed_at": null,
  "created_at": "2025-01-15T11:00:00.000Z"
}
```

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

| 欄位            | 類型             | 說明                                       |
| ------------- | -------------- | ---------------------------------------- |
| `id`          | string         | Portal Session ID                        |
| `object`      | string         | 固定為 `portal.session`                     |
| `url`         | string         | 導向客戶的 Portal URL                         |
| `return_url`  | string         | 離開 Portal 後的返回 URL                       |
| `customer_id` | string         | 客戶 ID                                    |
| `status`      | string         | Session 狀態（`active`、`expired`、`revoked`） |
| `expires_at`  | string         | Session 過期時間（ISO 8601）                   |
| `accessed_at` | string \| null | 最後存取時間                                   |
| `created_at`  | string         | 建立時間                                     |

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

<Tabs items="['Recur SDK', 'Node.js', 'Python', 'cURL']">
  <Tab value="Recur SDK">
    ```typescript
    // 推薦：使用 Recur Server SDK
    import { Recur } from 'recur-tw/server';

    const recur = new Recur(process.env.RECUR_SECRET_KEY!);

    // 方式一：使用客戶 ID
    const session = await recur.portal.sessions.create({
      customer: 'cus_xxxxx',
      returnUrl: 'https://your-site.com/account',
    });

    // 方式二：使用 email
    const session = await recur.portal.sessions.create({
      email: 'customer@example.com',
      returnUrl: 'https://your-site.com/account',
    });

    // 方式三：使用外部 ID（您系統中的用戶 ID）
    const session = await recur.portal.sessions.create({
      externalId: 'user_123',
      returnUrl: 'https://your-site.com/account',
    });

    // 導向客戶
    redirect(session.url);
    ```
  </Tab>

  <Tab value="Node.js">
    ```typescript
    // app/api/portal/create/route.ts
    import { NextRequest, NextResponse } from 'next/server';

    export async function POST(request: NextRequest) {
      // 可以接收 customer_id、email 或 external_id
      const { customer_id, email, external_id } = await request.json();

      const response = await fetch('https://api.recur.tw/v1/portal/sessions', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          // 提供至少其中一個識別參數
          ...(customer_id && { customer_id }),
          ...(email && { email }),
          ...(external_id && { external_id }),
          return_url: `${process.env.NEXT_PUBLIC_URL}/account`,
        }),
      });

      const data = await response.json();

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

      return NextResponse.json({ url: data.url });
    }
    ```
  </Tab>

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

    def create_portal_session(
        customer_id: str = None,
        email: str = None,
        external_id: str = None
    ) -> dict:
        """
        建立 Portal Session
        至少需要提供 customer_id、email 或 external_id 其中之一
        """
        body = {
            'return_url': f'{os.environ["APP_URL"]}/account',
        }

        # 根據提供的參數選擇識別方式
        if customer_id:
            body['customer_id'] = customer_id
        elif external_id:
            body['external_id'] = external_id
        elif email:
            body['email'] = email
        else:
            raise ValueError('至少需要提供 customer_id、email 或 external_id')

        response = requests.post(
            'https://api.recur.tw/v1/portal/sessions',
            headers={
                'Authorization': f'Bearer {os.environ["RECUR_SECRET_KEY"]}',
                'Content-Type': 'application/json',
            },
            json=body
        )

        response.raise_for_status()
        return response.json()

    # 使用方式
    session = create_portal_session(customer_id='cus_xxxxx')
    session = create_portal_session(email='customer@example.com')
    session = create_portal_session(external_id='user_123')
    print(f'Portal URL: {session["url"]}')
    ```
  </Tab>

  <Tab value="cURL">
    ```bash
    # 使用客戶 ID
    curl -X POST "https://api.recur.tw/v1/portal/sessions" \
      -H "Authorization: Bearer sk_test_xxx" \
      -H "Content-Type: application/json" \
      -d '{
        "customer_id": "cus_xxxxx",
        "return_url": "https://your-site.com/account"
      }'

    # 使用 email
    curl -X POST "https://api.recur.tw/v1/portal/sessions" \
      -H "Authorization: Bearer sk_test_xxx" \
      -H "Content-Type: application/json" \
      -d '{
        "email": "customer@example.com",
        "return_url": "https://your-site.com/account"
      }'

    # 使用外部 ID
    curl -X POST "https://api.recur.tw/v1/portal/sessions" \
      -H "Authorization: Bearer sk_test_xxx" \
      -H "Content-Type: application/json" \
      -d '{
        "external_id": "user_123",
        "return_url": "https://your-site.com/account"
      }'
    ```
  </Tab>
</Tabs>

Session 生命週期 [#session-生命週期]

有效期限 [#有效期限]

* Portal Session 有效期為 **1 小時**
* 過期後 Session 狀態變為 `expired`
* 每個客戶最多同時擁有 **5 個** 有效 Session

狀態說明 [#狀態說明]

| 狀態        | 說明                     |
| --------- | ---------------------- |
| `active`  | Session 有效，可以存取 Portal |
| `expired` | Session 已過期            |
| `revoked` | Session 已被撤銷           |

錯誤處理 [#錯誤處理]

| HTTP 狀態 | 錯誤代碼                 | 說明                    |
| ------- | -------------------- | --------------------- |
| 400     | `invalid_param`      | 請求參數無效（需要至少一個客戶識別參數）  |
| 400     | `missing_return_url` | 未提供 return\_url 且無預設值 |
| 401     | `invalid_api_key`    | 需要 Secret Key 認證      |
| 404     | `customer_not_found` | 找不到指定的客戶              |

錯誤回應範例 [#錯誤回應範例]

```json
// 找不到客戶（使用 email）
{
  "error": {
    "type": "invalid_request_error",
    "code": "customer_not_found",
    "message": "Customer not found (email: customer@example.com)"
  }
}

// 找不到客戶（使用 external_id）
{
  "error": {
    "type": "invalid_request_error",
    "code": "customer_not_found",
    "message": "Customer not found (external_id: user_123)"
  }
}

// 缺少客戶識別參數
{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_param",
    "message": "At least one of customer_id, email, or external_id is required"
  }
}
```

使用案例 [#使用案例]

1\. 帳戶設定頁面的「管理訂閱」按鈕 [#1-帳戶設定頁面的管理訂閱按鈕]

```typescript
// app/account/page.tsx
'use client';

import { useState } from 'react';

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

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

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

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

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

      window.location.href = url;
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? '載入中...' : '管理訂閱'}
    </button>
  );
}
```

2\. 使用 Recur SDK Web Component [#2-使用-recur-sdk-web-component]

```html
<!-- 方式一：直接提供 Portal URL（server-rendered） -->
<recur-portal portal-url="https://portal.recur.tw/s/ps_xxxxx">
  管理訂閱
</recur-portal>

<!-- 方式二：透過 API 動態建立 Session -->
<recur-portal
  api-endpoint="/api/portal/create"
  customer-id="cus_xxxxx">
  管理訂閱
</recur-portal>
```

安全性考量 [#安全性考量]

<Callout type="warning">
  **重要安全提醒**

  * Portal Session URL 包含敏感存取權杖，請勿記錄或暴露
  * 務必驗證用戶身份後再建立對應客戶的 Portal Session
  * 使用 HTTPS 確保傳輸安全
</Callout>

下一步 [#下一步]

* [Customer Portal 整合指南](/guides/portal) - 完整整合教學
* [Webhook 事件](/guides/webhooks/events) - 監聽訂閱變更事件
* [Subscriptions API](/api/subscriptions) - 查詢訂閱狀態
