# Coinepay OpenAPI — 全文索引
> Coinepay OpenAPI v1.1 — 虛擬卡接入對外文件:HMAC 鑑權、冪等性、Webhook、錯誤碼、程式碼樣例。
每個段落以 `` 標記起始,便於 LLM 切片。
---
# HMAC 鑑權
Coinepay OpenAPI 使用 **HMAC-SHA256** 對每次請求籤名。客戶必須在每次請求頭中提供 4 個值;伺服器據此校驗**身份 + 時間窗 + 防重放 + 防篡改**。
## 必需的 4 個請求頭
| Header | 說明 | 示例 |
|---|---|---|
| `X-App-Id` | 憑證標識,固定 31 字元(`cp_` + 28 hex) | `cp_a1b2c3d4e5f6071829304a5b6c7d8e9f` |
| `X-Timestamp` | Unix **秒**(不是毫秒),ASCII 十進位制 | `1714377600` |
| `X-Nonce` | 8~64 位字元,10 分鐘內對同一 AppID 唯一 | `8f7e6d5c4b3a29180a1b2c3d4e5f6071` |
| `X-Signature` | HMAC-SHA256 lowercase hex,64 字元 | `9b8e7f6d5c4b3a...` |
::: warning 時間單位
`X-Timestamp` 必須是**秒**,不是毫秒。`Math.floor(Date.now() / 1000)` 不是 `Date.now()`。
:::
## 簽名輸入構造
```text
signInput = METHOD + LF + PATH + LF + RAW_QUERY + LF + TIMESTAMP + LF + NONCE + LF + BODY_SHA256_HEX
```
| 符號 | 含義 |
|---|---|
| `LF` | 字元 `\n`(單位元組 0x0A),**不是 `\r\n`** |
| `+` | 字串拼接 |
## 各欄位定義
| 欄位 | 取值 | 注意 |
|---|---|---|
| `METHOD` | 全大寫 HTTP 方法(OpenAPI 全部為 `POST`) | ASCII |
| `PATH` | 請求路徑含開頭 `/`,**不含**查詢串 | 不要 URL decode/encode |
| `RAW_QUERY` | 查詢串(不含 `?`),無則空字串 | 通常為空 |
| `TIMESTAMP` | 與 `X-Timestamp` 頭**完全相同**字串 | ASCII |
| `NONCE` | 與 `X-Nonce` 頭完全相同 | ASCII |
| `BODY_SHA256_HEX` | `sha256(請求體位元組)` 的 lowercase hex | 64 字元 |
::: tip 空 body 的 sha256
固定為 `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。
空物件 `{}` 的 sha256 是 `44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a`(注意區別)。
:::
## 簽名計算
```text
signature = lowercase_hex( HMAC_SHA256( secret_bytes, signInput_bytes ) )
```
::: warning Secret 編碼
`secret_bytes` 是 secret 字串的 **UTF-8 位元組**(直接是 64 個 hex 字元的 ASCII 位元組)。
**不要**把 hex decode 成 32 位元組再做 HMAC。
:::
簽名長度固定為 **64 字元 hex**。
## 完整請求示例
```http
POST /api/v1/openapi/card_headers/list HTTP/1.1
Host: api.coinepay.net
X-App-Id: cp_a1b2c3d4e5f6071829304a5b6c7d8e9f
X-Timestamp: 1714377600
X-Nonce: 8f7e6d5c4b3a29180a1b2c3d4e5f6071
X-Signature: 9b8e7f6d5c4b3a2918273645d4e3c2b1a0f9e8d7c6b5a4938271605f4e3d2c1b
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
Content-Length: 24
{"page":1,"page_size":20}
```
## 防重放視窗
| 項 | 值 |
|---|---|
| 時間戳容差 | `[server_now - 300s, server_now + 300s]` (±5 分鐘)|
| Nonce 唯一視窗 | 同一 `(app_id, nonce)` 在 **10 分鐘**內不允許重複 |
| 推薦 Nonce 生成 | `crypto/rand` 16 位元組 → 32 hex 字元 |
## 極端情況速查
| 場景 | 處理 |
|---|---|
| body 是空物件 `{}` | `BODY_SHA256_HEX = 44136fa3...8a` |
| body 是空字串 | 用空 body sha256 常量 `e3b0c44...855` |
| body 含中文 | 按 **UTF-8 位元組**計算 sha256 |
| body 是陣列 `[1,2,3]` | 按位元組字面計算(key 排序、空格不影響雜湊)|
| 網路中介軟體改 body | 簽名失敗;避免任何中間層改 body |
## 實現注意事項
::: warning 客戶端 stringify 必須確定性
不同語言的 JSON 序列化可能有鍵序、空格、轉義差異。**客戶端 stringify 出來的位元組是什麼,就用什麼計算 sha256,傳給伺服器的也是同一份位元組**。不要先 stringify 計算 hash、又用另一種序列化傳送。
:::
## 401 錯誤處理
伺服器對所有鑑權失敗原因(AppID 不存在 / 簽名錯 / 時間漂移 / 憑證停用 / 使用者不活躍)都返回**相同的** `openapi_invalid_credentials`。這是**反列舉設計**,客戶端無需根據具體原因區分。
排查步驟:
1. 確認伺服器時間與客戶端時間差 < 5 分鐘(用 `date +%s` 對一下)
2. 列印 `signInput` 的位元組,逐行確認 `\n` 是 `0x0A`、沒有 `\r`
3. 確認 `X-Timestamp` 頭與 signInput 中的 `TIMESTAMP` 完全一致(不是各算一次)
4. 確認 `BODY_SHA256_HEX` 的 body 是**實際傳送的位元組**(不是 stringify 又改過的)
5. 重置一對憑證再試
## 下一步
- [冪等鍵](./idempotency) — 寫介面的安全重試
- [程式碼樣例](/zh-TW/examples/) — 複製即用的客戶端實現
---
# 錯誤碼
Coinepay OpenAPI 錯誤響應同時包含:
- `code`:與 HTTP 狀態碼相同(200/400/401/404/409/429/500)
- `message`:根據請求頭 `Accept-Language` 返回中文或英文
- `message_key`:穩定的英文 key,**程式判斷用這個**
```json
{
"code": 400,
"message": "Idempotency-Key 衝突",
"message_key": "openapi_idempotency_key_conflict",
"data": null
}
```
## HTTP 狀態碼語義
| HTTP | 含義 |
|---|---|
| 200 | 成功(含"冪等返回"和"已入隊") |
| 400 | 引數錯 / 業務規則不滿足(看 `message_key` 區分) |
| 401 | 鑑權失敗(一律 `openapi_invalid_credentials`,不區分原因) |
| 403 | 鑑權透過但所請求的特性受限(如敏感卡資訊未開通、卡未啟用) |
| 404 | 資源不存在 / 不屬於當前賬號 |
| 409 | 冪等鍵衝突(同 key 不同 body) |
| 429 | 限流 |
| 500 | 服務端異常 |
## 完整 message_key 字典
### 鑑權與憑證
| message_key | zh-CN | en-US | HTTP |
|---|---|---|---|
| `openapi_invalid_credentials` | 無效的 OpenAPI 憑證 | Invalid OpenAPI credentials | 401 |
### ID 校驗
| message_key | zh-CN | en-US | HTTP |
|---|---|---|---|
| `openapi_invalid_header_id` | header_id 不合法(缺字首 / 錯字首 / 不存在) | Invalid header_id | 400 |
| `openapi_invalid_package_id` | package_id 不合法 | Invalid package_id | 400 |
| `openapi_invalid_card_id` | card_id 不合法 | Invalid card_id | 400 |
### 冪等
| message_key | zh-CN | en-US | HTTP |
|---|---|---|---|
| `openapi_idempotency_key_required` | 缺少 Idempotency-Key 請求頭 | Missing Idempotency-Key header | 400 |
| `openapi_idempotency_key_too_long` | Idempotency-Key 超長(最大 128 字元) | Idempotency-Key too long | 400 |
| `openapi_idempotency_key_invalid_chars` | Idempotency-Key 含非法字元 | Invalid characters | 400 |
| `openapi_idempotency_key_conflict` | Idempotency-Key 衝突 | Idempotency-Key conflict | 409 |
### 業務規則
| message_key | zh-CN | en-US | HTTP |
|---|---|---|---|
| `openapi_card_type_not_supported` | 暫不支援該卡型別,僅虛擬卡 | Not supported (virtual cards only) | 400 |
| `amount_required` | amount 為必填 | amount is required | 400 |
| `card_not_found` | 卡片不存在 / 不屬於當前賬號 | Card not found | 404 |
| `insufficient_balance` | 餘額不足 | Insufficient balance | 400 |
| `kyc_required` | 請先完成實名認證 | Please complete identity verification first | 400 |
| `kyc_not_approved` | KYC 未透過 | KYC not approved | 400 |
### 首充(自定義金額)
由 [`/card/apply`](../api/card-apply)(作為 `message_key`,HTTP 400)與 [`/card/first_deposit/preview`](../api/first-deposit-preview)(作為 `200` 響應體內的 `invalid_reason`)返回,表示自定義 `first_deposit_amount` 不可受理。
| message_key | zh-CN | en-US | HTTP |
|---|---|---|---|
| `invalid_first_deposit_amount` | first_deposit_amount 必須是非負整數 | first_deposit_amount must be a non-negative integer | 400 |
| `first_recharge_below_base` | 首充低於配置底額 | First deposit below the config base | 400 |
| `first_recharge_exceeds_max` | 超額超過最大充值 | Excess exceeds max recharge | 400 |
| `first_recharge_limit_exceeded` | 超額超過充值限額 | Excess exceeds the recharge limit | 400 |
| `first_recharge_asset_mismatch` | 開卡費資產與充值資產不一致 | Open-fee asset differs from recharge asset | 400 |
| `first_recharge_excess_too_small` | 超額扣費取整後過小 | Excess too small after fee + rounding | 400 |
### 敏感卡片資訊
下列錯誤碼由 [`/openapi/card/info`](../api/card-info#敏感卡片資訊) 在 `with_sensitive=true` 但前提不滿足時返回。詳見該介面文件說明的雙前提(憑證已開通 + 卡處於 `status=2 active`)。
| message_key | zh-CN | en-US | HTTP |
|---|---|---|---|
| `openapi_sensitive_card_info_disabled` | 敏感卡片資訊訪問未啟用,請聯絡管理員為該 API 憑證開啟 | Sensitive card info access is disabled. Contact administrator to enable it for your API credential. | 403 |
| `openapi_sensitive_card_only_active` | 僅啟用狀態的卡片可獲取敏感資訊 | Sensitive card info is only available for active cards. | 403 |
### 卡列表過濾
| message_key | zh-CN | en-US | HTTP |
|---|---|---|---|
| `openapi_conflicting_card_filters` | usable_only 與 statuses 不能同時傳 | usable_only and statuses cannot be used together | 400 |
| `openapi_invalid_status_value` | statuses 含非法狀態值(僅允許 1-6) | statuses contains an invalid value (only 1-6 allowed) | 400 |
| `invalid_min_balance` | 最小余額必須是合法非負數字(如 0 或 10.5) | min_balance must be a valid non-negative number | 400 |
| `invalid_max_balance` | 最大余額必須是合法非負數字 | max_balance must be a valid non-negative number | 400 |
| `min_balance_exceeds_max_balance` | 最小余額不能大於最大余額 | min_balance must not exceed max_balance | 400 |
| `invalid_date_format` | 日期格式必須是 YYYY-MM-DD | date must be in YYYY-MM-DD format | 400 |
### 交易明細過濾
由 [`/transactions/list`](../api/transactions-list) 與 [`/card/transactions/list`](../api/card-transactions-list) 返回。
| message_key | zh-CN | en-US | HTTP |
|---|---|---|---|
| `invalid_date_format` | 交易時間格式必須為 `YYYY-MM-DD HH:MM:SS` | transaction_time_from/to must be `YYYY-MM-DD HH:MM:SS` | 400 |
| `invalid_params` | 引數無效(如金額格式錯誤) | Invalid parameters (e.g. bad amount format) | 400 |
### 凍結 / 解凍
由 [`/card/freeze`](../api/card-freeze) 與 [`/card/unfreeze`](../api/card-unfreeze) 返回。
| message_key | zh-CN | en-US | HTTP |
|---|---|---|---|
| `card_already_frozen` | 卡片已被凍結 | Card is already frozen | 400 |
| `card_status_cannot_freeze` | 當前卡狀態不可凍結 | Current card status does not allow freezing | 400 |
| `card_not_frozen` | 卡片未被凍結 | Card is not frozen | 400 |
| `card_type_mismatch` | 卡型別與配置不匹配 | Card type does not match | 400 |
| `operation_not_supported` | 該操作不支援 | Operation not supported for this card type | 400 |
| `recharge_unfreeze_disabled` | 該卡不允許透過充值自動解凍 | Recharge-based unfreeze is not available | 400 |
| `unauthorized_unfreeze_admin` | 該卡片被管理員凍結,使用者無法自行解凍 | Frozen by admin — cannot self-unfreeze | 403 |
| `unauthorized_unfreeze_risk` | 該卡片被風控凍結 | Frozen by risk control — cannot self-unfreeze | 403 |
| `unauthorized_unfreeze_system` | 該卡片被系統凍結 | Frozen by system — cannot self-unfreeze | 403 |
### 服務端異常
| message_key | zh-CN | en-US | HTTP |
|---|---|---|---|
| `openapi_internal_error` | OpenAPI 內部錯誤 | OpenAPI internal error | 500 |
| `openapi_list_card_headers_failed` | 查詢卡頭列表失敗 | Failed to list card headers | 500 |
| `openapi_list_card_configs_failed` | 查詢卡配置列表失敗 | Failed to list card configs | 500 |
| `openapi_apply_card_failed` | 申請虛擬卡失敗 | Failed to apply virtual card | 500 |
| `openapi_first_deposit_preview_failed` | 首充預覽失敗 | Failed to preview first deposit | 500 |
| `openapi_recharge_failed` | 充值失敗 | Failed to recharge | 500 |
| `openapi_get_card_info_failed` | 獲取卡片資訊失敗 | Failed to get card info | 500 |
| `openapi_list_cards_failed` | 查詢卡列表失敗 | Failed to list cards | 500 |
| `openapi_list_webhook_events_failed` | 查詢 Webhook 事件歷史失敗 | Failed to list webhook events | 500 |
| `openapi_list_transactions_failed` | 查詢交易明細失敗 | Failed to list transactions | 500 |
| `openapi_freeze_card_failed` | 凍結卡片失敗 | Failed to freeze card | 500 |
| `openapi_unfreeze_card_failed` | 解凍卡片失敗 | Failed to unfreeze card | 500 |
## 程式處理建議
```python
def handle_response(resp):
code = resp.get('code', 500)
key = resp.get('message_key', '')
if code == 200:
return resp['data']
if code == 401:
# 永遠是 openapi_invalid_credentials
raise AuthError("憑證無效或已過期,請檢查 AppID/Secret/時鐘")
if code == 403:
# 鑑權透過但所請求的特性受限
if key == 'openapi_sensitive_card_info_disabled':
raise PermissionError("請聯絡客戶經理為該 API 憑證開通敏感欄位訪問")
if key == 'openapi_sensitive_card_only_active':
raise RuntimeError("卡未啟用,不能請求敏感欄位")
raise PermissionError(f"特性受限: {key}")
if key == 'insufficient_balance':
raise BalanceError("餘額不足")
if key == 'kyc_not_approved' or key == 'kyc_required':
raise KYCError("KYC 未透過")
if key.startswith('openapi_invalid_') and key.endswith('_id'):
raise ValueError(f"ID 格式錯或不存在: {key}")
if key == 'openapi_idempotency_key_conflict':
# 不應自動重試 —— 業務側需檢查 body
raise IdempotencyConflict()
if code == 429:
raise RateLimited()
if code >= 500:
raise ServerError(resp.get('message', '內部錯誤'))
raise ApiError(code, key, resp.get('message'))
```
## 多語言
透過請求頭 `Accept-Language` 控制 `message` 的語言:
```http
Accept-Language: en-US
```
| 取值 | 行為 |
|---|---|
| 不傳 | 預設中文 |
| `zh-CN` / `zh` | 中文 |
| `en-US` / `en` | 英文 |
| 其他 | 回退到預設 |
`message_key` 不受 `Accept-Language` 影響,永遠穩定。
---
# 冪等鍵
為避免網路重試導致**重複開卡 / 重複扣款**,寫介面要求客戶端在每次請求中提供 `Idempotency-Key` 頭。
## 哪些介面需要
| 介面 | 是否需要冪等鍵 |
|---|---|
| `/api/v1/openapi/card/apply` | ✅ 必填 |
| `/api/v1/openapi/card/recharge` | ✅ 必填 |
| 所有 `/list` 介面、`/info` 介面 | ❌ 不需要 |
## 請求頭
```http
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
```
| 約束 | 值 |
|---|---|
| 長度 | ≤ **128** 字元 |
| 字元集 | URL-safe(推薦 UUID v4 / ULID) |
| 去重視窗 | **24 小時** |
| 缺失返回 | 400 `openapi_idempotency_key_required` |
| 超長返回 | 400 `openapi_idempotency_key_too_long` |
| 非法字元 | 400 `openapi_idempotency_key_invalid_chars` |
| 同 key 不同 body | 409 `openapi_idempotency_key_conflict` |
## 服務端行為
### 同 key + 同 body
返回**之前那次響應的副本**(原狀態碼 + 原 `data`),即使第一次還在處理中也會等待並返回最終結果。**不會**觸發第二次開卡。
回放的響應會附帶一個額外的響應頭,便於客戶端區分是否是回放:
```http
X-Idempotent-Replay: true
```
::: tip 識別回放
`X-Idempotent-Replay: true` 僅用於提示,無論有沒有這個頭,響應體本身才是權威結果。該頭主要用於打點和排錯("這次請求其實已經到達服務端了,是網路抖動導致客戶端重試")。
:::
::: info 只有 2xx 響應會被快取
服務端**只**快取原始呼叫返回 **2xx** 的響應體。若首次呼叫返回 `4xx`(如 `insufficient_balance`)或 `5xx`,相同 key + 相同 body 的下次請求會**重新執行**,不存在"回放"。
換句話說:冪等保護的是"成功響應丟失後的安全重試",**不是**"反覆重試已經業務失敗的請求"。
:::
::: warning 響應快取上限
服務端最多快取 **256 KB** 的響應體。當前所有 OpenAPI 響應都遠低於此上限,正常使用不需關心 —— 僅作完整性披露。
:::
### 同 key + 不同 body
返回 `409 openapi_idempotency_key_conflict`,**拒絕**處理。客戶端應換 key 重試或修正 body。
### 不同 key + 同 body
**視為兩次獨立請求**,會觸發兩次開卡 / 兩次扣款。**這是設計如此** —— 冪等基於 key,不是 body。
::: warning 不要用 body hash 當 key
不要用"請求體 hash"作為 Idempotency-Key —— 這會讓"使用者連續兩次請求同金額充值"被去重為一次。**key 應代表一次業務意圖**,每次新業務請求生成新 key。
:::
## 推薦用法
### 客戶端 SDK 模式
```python
import uuid
def apply_card(header_id, package_id):
key = str(uuid.uuid4()) # 每次新業務一個新 key
return call("/api/v1/openapi/card/apply",
{"header_id": header_id, "package_id": package_id},
idempotency_key=key)
```
### 非同步任務模式
如果你的"開卡任務"會在網路失敗後被任務佇列重試,**把 key 與任務記錄繫結**:
```python
def open_card_task(task_id, header_id, package_id):
# 同一任務的所有重試用同一個 key
key = f"openapi:apply:{task_id}"
return call("/api/v1/openapi/card/apply", {...}, idempotency_key=key)
```
這樣即使任務被重試 N 次,Coinepay 端也只會真正開卡一次。
## 與簽名的關係
`Idempotency-Key` 頭**不參與**簽名輸入構造(簽名只覆蓋 method/path/query/timestamp/nonce/body sha256)。但每次請求仍需新的 nonce,因為 nonce 是用來防簽名重放的,與冪等是**兩套機制**:
| 機制 | 防的是 |
|---|---|
| Nonce | 攻擊者**抓到**簽名後**重放**請求 |
| Idempotency-Key | **客戶自己**網路失敗後**安全重試** |
二者協同:客戶重試同一筆業務時,**用同一個 Idempotency-Key + 新的 Nonce + 新的 Timestamp + 新的 Signature**。
## 錯誤響應示例
### 缺失(400)
```json
{
"code": 400,
"message": "缺少 Idempotency-Key 請求頭",
"message_key": "openapi_idempotency_key_required",
"data": null
}
```
### 衝突(409)
```json
{
"code": 409,
"message": "Idempotency-Key 衝突",
"message_key": "openapi_idempotency_key_conflict",
"data": null
}
```
---
# ID 與字首
Coinepay OpenAPI 所有對外資源 ID 都是**帶字首的字串**,不直接暴露內部數字主鍵。客戶回傳時**必須保留字首**。
## 字首總表
| 資源 | 字首 | 示例 | 出現位置 |
|---|---|---|---|
| 卡 | `card_` | `card_12345` | apply 響應 / 後續介面的 `card_id` 欄位 |
| 套餐 | `pkg_` | `pkg_67` | card_configs/list 返回 / apply 請求傳入 |
| 卡頭 | `hdr_` | `hdr_5` | card_headers/list 返回 / apply 請求傳入 |
| 充值訂單 | `txn_` | `txn_OO20260429110012abc` | recharge 響應 / webhook payload |
| Webhook 事件 | `evt_` | `evt_550e8400-e29b-41d4-a716-446655440000` | webhook_events/list 返回 |
::: tip Webhook 中也保持一致
Webhook payload 中所有 ID 同樣帶字首。`Webhook-Id` 頭去掉 `evt_` 即原始 UUID。
:::
::: warning `transaction_id` 是不透明字串
把 `txn_<...>` 當作**不透明字串**用來儲存、查詢、對賬。`txn_` 字首之後的內部結構由服務端生成,**不保證跨版本 / 跨 provider 穩定** —— 不要解析、切割或正則匹配。內部訂單引用故意不對外暴露。
:::
## 校驗規則
| 錯誤情況 | 返回 |
|---|---|
| 缺字首(如傳 `5` 而非 `hdr_5`) | 400 `openapi_invalid_header_id` |
| ID 不存在 / 不屬於當前賬號 | 400 `openapi_invalid_*_id`(與"缺字首"返回相同 message_key —— 防洩漏)|
::: warning 不區分"格式錯"和"不存在"
`openapi_invalid_*_id` 同時覆蓋:
- 字首缺失
- ID 不屬於當前 AppID 的賬號
- ID 不存在
伺服器**故意**不區分 — 防止列舉攻擊者透過響應差異探測有效 ID 範圍。
:::
## 不會暴露的內部欄位
每個介面文件中列出的響應欄位表即完整契約。內部資料庫主鍵、提供方訂單引用、中間計算欄位和內部標籤等不會透過 OpenAPI 返回。
如發現響應中出現已記錄欄位表之外的內容,請[反饋給 Coinepay 團隊](mailto:admin@coinepay.cc)。
## 使用建議
- 客戶端儲存時**包含字首**,回傳時也**保持原樣**(不要拆出數字部分)
- 日誌/告警中帶上完整 ID(`card_12345`),便於排查
- 與你的內部 ID 區分:`card_xxx` 是 Coinepay 的,建議你的內部 ID 用其他字首(如 `kart_xxx`)避免混淆
---
# 概述
Coinepay OpenAPI v1.2 是一組 HMAC 鑑權的 HTTPS REST API,專注於**虛擬卡**的開卡、充值與狀態查詢。所有介面僅支援 `POST` 方法,請求與響應均為 `application/json`。
## 核心特徵
- **統一 POST 介面**:所有 endpoint 都是 `POST`,便於前後端中介軟體統一攔截。
- **HMAC-SHA256 鑑權**:4 個請求頭(`X-App-Id` / `X-Timestamp` / `X-Nonce` / `X-Signature`),無需 OAuth/JWT。
- **冪等性**:寫介面要求傳 `Idempotency-Key`,24 小時去重。
- **業務字首 ID**:所有對外資源 ID 都有字首(如 `card_12345`、`pkg_67`),不洩漏內部 DB 主鍵。
- **Webhook 非同步推送**:開卡 / 充值 / 銷卡完成時主動推送,簽名同樣為 HMAC-SHA256。
- **國際化錯誤**:透過 `Accept-Language` 切換中英文 message,同時返回穩定的 `message_key` 用於程式判斷。
## 業務範圍(v1.2)
::: tip 當前版本支援
僅 **虛擬卡**:`virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g`
:::
::: warning 暫不支援
- 實體卡(`master_e` / `visa_h`)
- 轉賬介面(`transfer`)
:::
## 推薦閱讀順序
1. [快速開始](./quickstart) — 5 分鐘跑通第一個請求
2. [HMAC 鑑權](./authentication) — 必讀:簽名輸入構造規範
3. [ID 與字首](./ids-and-prefixes) — 資源識別符號約定
4. [冪等鍵](./idempotency) — 寫介面的安全重試
5. [錯誤碼](./error-codes) — message_key 字典
6. [Webhook 規範](./webhooks) — 接收非同步事件
7. [API 參考](/zh-TW/api/) — 12 個具體 endpoint 的請求/響應欄位表
## 介面字首
```
{base_url}/api/v1/openapi/{endpoint}
```
| 環境 | base_url |
|---|---|
| 生產 | `https://api.coinepay.net` |
| 開發 | `http://localhost:8801` |
::: warning 僅使用 HTTPS
生產環境必須透過 **HTTPS** 呼叫 `https://api.coinepay.net`。API Key 與 HMAC 簽名透過請求頭傳輸,明文 HTTP 會在傳輸途中洩露憑證。請精確固定主機名:`base_url` 末尾**不要**加斜槓,也絕不要使用相似域名。
:::
::: info 沙箱
v1.2 不提供獨立沙箱。請用真實賬號 + 小額測試。詳見 [沙箱與測試](./sandbox)。
:::
---
# 快速開始
跑通"列出虛擬卡卡頭"是驗證鑑權鏈路最快的方式(無副作用、不需要冪等鍵)。
## 1. 準備憑證
透過使用者控制台生成一對憑證:
- `APP_ID`:`cp_` + 28 位 hex 字元(共 31 字元)
- `SECRET`:64 位 hex 字元
::: warning 妥善保管
`SECRET` **僅在生成時返回一次**,丟失只能 reset。建議儲存到金鑰管理服務(KMS / Vault / GCP Secret Manager)。
:::
## 2. 複製下面的指令碼
::: code-group
```bash [cURL]
APP_ID="cp_a1b2c3d4..."
SECRET="f1e2d3c4..."
BASE="https://api.coinepay.net"
PATH_VAL="/api/v1/openapi/card_headers/list"
BODY='{"page":1,"page_size":20}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(echo -n "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
SIGN_INPUT=$(printf 'POST\n%s\n\n%s\n%s\n%s' "$PATH_VAL" "$TS" "$NONCE" "$BODY_HASH")
SIG=$(echo -n "$SIGN_INPUT" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -X POST "$BASE$PATH_VAL" \
-H "X-App-Id: $APP_ID" \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Signature: $SIG" \
-H "Content-Type: application/json" \
-d "$BODY"
```
```python [Python]
import time, hmac, hashlib, secrets, json, requests
APP_ID = "cp_a1b2c3d4..."
SECRET = "f1e2d3c4..."
BASE = "https://api.coinepay.net"
PATH = "/api/v1/openapi/card_headers/list"
BODY = json.dumps({"page": 1, "page_size": 20}, separators=(",", ":")).encode()
ts = str(int(time.time()))
nonce = secrets.token_hex(16)
body_hash = hashlib.sha256(BODY).hexdigest()
sign_input = f"POST\n{PATH}\n\n{ts}\n{nonce}\n{body_hash}"
sig = hmac.new(SECRET.encode(), sign_input.encode(), hashlib.sha256).hexdigest()
r = requests.post(BASE + PATH, headers={
"X-App-Id": APP_ID, "X-Timestamp": ts, "X-Nonce": nonce, "X-Signature": sig,
"Content-Type": "application/json",
}, data=BODY)
print(r.json())
```
```js [Node.js]
import crypto from 'node:crypto'
const APP_ID = 'cp_a1b2c3d4...'
const SECRET = 'f1e2d3c4...'
const BASE = 'https://api.coinepay.net'
const PATH = '/api/v1/openapi/card_headers/list'
const body = JSON.stringify({ page: 1, page_size: 20 })
const ts = Math.floor(Date.now() / 1000).toString()
const nonce = crypto.randomBytes(16).toString('hex')
const bodyHash = crypto.createHash('sha256').update(body).digest('hex')
const signInput = `POST\n${PATH}\n\n${ts}\n${nonce}\n${bodyHash}`
const sig = crypto.createHmac('sha256', SECRET).update(signInput).digest('hex')
const res = await fetch(BASE + PATH, {
method: 'POST',
headers: {
'X-App-Id': APP_ID, 'X-Timestamp': ts, 'X-Nonce': nonce, 'X-Signature': sig,
'Content-Type': 'application/json',
},
body,
})
console.log(await res.json())
```
:::
## 3. 期望響應
```json
{
"code": 200,
"message": "成功",
"data": {
"list": [
{
"header_id": "hdr_5",
"card_bin": "424242",
"card_brand": "VISA",
"card_area": "美國",
"business_scene": "境外消費",
"description": { "zh-CN": "適合電商訂閱", "en-US": "For e-commerce subscriptions" },
"features": ["3DS", "EMV"],
"require_phone": false,
"require_email": true
}
],
"total": 12,
"page": 1,
"page_size": 20,
"total_pages": 1,
"has_next": false,
"has_prev": false
}
}
```
## 4. 完整開卡流程
```mermaid
sequenceDiagram
participant C as 你的服務
participant API as Coinepay API
participant W as 你的 Webhook 接收器
C->>API: POST /card_headers/list
API-->>C: 返回 hdr_5 / hdr_8 ...
C->>API: POST /card_configs/list { header_id: "hdr_5" }
API-->>C: 返回 pkg_12 / pkg_13 ...
C->>API: POST /card/apply { header_id, package_id, ... }
+ Idempotency-Key
API-->>C: 200 { card_id: "card_12345", status: 1 (pending) }
Note over API,W: 幾秒後開卡完成
API->>W: POST card.opened webhook
W-->>API: 200 OK (5 秒內)
C->>API: POST /card/info { card_id } (可選輪詢)
API-->>C: status: 2 (active), masked_card_no
```
## 5. 常見錯誤
| 現象 | 原因 |
|---|---|
| 401 invalid_credentials | 簽名錯 / 時間戳過期 / nonce 重放 / AppID 不存在 |
| 400 openapi_idempotency_key_required | 寫介面未傳 `Idempotency-Key` 頭 |
| 409 openapi_idempotency_key_conflict | 同 key 不同 body |
| 429 | 觸發限流(600 次/分鐘)|
詳見 [錯誤碼字典](./error-codes)。
## 下一步
- [HMAC 鑑權](./authentication) — 把簽名規範讀完,避免奇怪的 401
- [Webhook 規範](./webhooks) — 接收開卡完成等非同步事件
- [程式碼樣例](/zh-TW/examples/) — Python / Go / Java / PHP 完整客戶端
---
# 限流
## 預設配額
| 維度 | 配額 |
|---|---|
| 每個 `(AppID, 客戶端 IP)` 每分鐘 | **600** 次請求 |
| 視窗 | 60 秒(滾動) |
| 超出返回 | HTTP `429` |
實際配額可能根據賬號等級調整,請以你賬號實際值為準。
## 429 響應
```json
{
"code": 429,
"message": "請求過於頻繁,請稍後再試",
"data": null
}
```
部分情況下還會附帶 `Retry-After` 響應頭,單位秒。
## 客戶端建議
### 1. 退避重試(針對 429 / 5xx)
```python
import time, random
def call_with_retry(fn, *args, max_attempts=4):
for attempt in range(max_attempts):
try:
resp = fn(*args)
if resp.get('code') == 429:
wait = (2 ** attempt) + random.random()
time.sleep(min(wait, 30))
continue
return resp
except Exception:
if attempt == max_attempts - 1:
raise
time.sleep(2 ** attempt)
```
::: warning 不要無限重試
對 4xx(除 429)**不要重試** —— 是請求本身的問題,重試只會重複失敗。
:::
### 2. 控制併發
如果你的業務側需要批次開卡,建議:
- 單 worker 順序請求(< 10 QPS)
- 多 worker 時透過佇列控制總併發,留出餘量給其他業務
### 3. 不要觸發限流來"測試"
每次 429 都計入限流計數,可能讓你後續真實請求被拒。**用小流量手工驗證**即可。
## 與冪等鍵的配合
如果你因 429 重試,請**保持同一個 Idempotency-Key**(針對寫介面)。這樣即使有一次請求實際到達後端但響應丟失,重試也不會重複開卡。
---
# 沙箱與測試
## 是否有獨立沙箱?
**v1.2 暫不提供**獨立沙箱環境。請用你的**真實賬號**在生產環境進行小額測試。
| 項 | 值 |
|---|---|
| 生產 base URL | `https://api.coinepay.net` |
| 沙箱 base URL | 暫無 |
| 開發自建 | `http://localhost:8801`(僅本地) |
## 推薦測試流程
### 1. 準備一對**專用測試**憑證
不要用生產憑證測試。在控制台為測試場景生成獨立的 AppID/Secret。
### 2. 用 webhook.site 接收 webhook
[https://webhook.site](https://webhook.site) 提供免費的臨時 webhook 接收 URL,能看到完整請求頭/體。
```bash
# 瀏覽器開啟 webhook.site,複製頂部的 unique URL,例如:
WEBHOOK_URL="https://webhook.site/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# 透過控制台或 set_webhook 介面設定
```
### 3. 跑通"列卡頭 → 列配置 → apply → webhook → info"
這是最完整的 happy path。詳見 [快速開始](./quickstart#完整開卡流程)。
### 4. 測試金額建議
| 項 | 建議 |
|---|---|
| 首充 | 選擇最便宜的套餐(通常 $5-10) |
| 測試充值 | 最小金額(套餐裡的 `min_recharge`) |
| 多次測試 | 每次開新卡 —— 銷卡有費用 |
## 當前可用的 card_type
| card_type | 說明 |
|---|---|
| `virtual_l` | 虛擬卡 - L 類 |
| `virtual_p` | 虛擬卡 - P 類 |
| `virtual_v` | 虛擬卡 - V 類(VISA) |
| `virtual_r` | 虛擬卡 - R 類 |
| `virtual_g` | 虛擬卡 - G 類 |
::: info 實際可用 card_type 由賬號決定
不是所有賬號都開通了所有 card_type。**呼叫 `card_headers/list` 看到的 `header_id` 才是你能 apply 的範圍**,不要硬編碼。
:::
## 憑證管理
| 項 | 說明 |
|---|---|
| 憑證有效期 | 不過期(除非主動 reset / disable) |
| 憑證數量 | 每個使用者最多 1 條(v1) |
| Reset 後 | 舊 SECRET **立即失效**,AppID 不變 |
| Disable 後 | 401 invalid_credentials;可隨時 enable |
## 測試 webhook 驗籤
把 `webhook.site` 收到的請求複製下來,本地用 [Webhook 驗籤程式碼](./webhooks#驗籤程式碼示例)走一遍,確認 `verify_ok = true`。
## 何時聯絡支援
| 現象 | 聯絡前自查 |
|---|---|
| 401 一直返回 | 見 [鑑權頁 401 錯誤處理](./authentication#_401-錯誤處理) |
| Webhook 一直收不到 | 檢查 webhook URL 是否公網 HTTPS、防火牆、5 秒響應、看 `webhook_events/list` 狀態 |
| 開卡卡在 `pending` | 是非同步的,等 webhook;超過 30 分鐘未變更聯絡支援 |
| 充值無響應 | 看 `webhook_events/list` 狀態;查 `transaction_id` |
支援郵箱:admin@coinepay.cc
---
# Webhook 規範
Coinepay 在非同步事件完成時(開卡成功、充值成功、銷卡完成等),主動向你配置的 URL 推送 HTTP `POST`。客戶必須**驗籤 + 在超時視窗內返回 2xx**。
## 客戶收到的 HTTP 請求
```http
POST https://customer-server.example/your-webhook-path HTTP/1.1
Content-Type: application/json
Webhook-Id: 550e8400-e29b-41d4-a716-446655440000
Webhook-Timestamp: 1714377612
Webhook-Signature: v1,9b8e7f6d5c4b3a2918273645d4e3c2b1a0f9e8d7c6b5a4938271605f4e3d2c1b
Webhook-Type: card.opened
User-Agent: Coinepay-Webhook/1.0
```
## 驗籤演算法
```text
signInput = WebhookTimestamp + "." + raw_body_bytes
expected = "v1," + lowercase_hex( HMAC_SHA256(webhook_secret_bytes, signInput) )
verify_ok = constant_time_compare(Webhook-Signature, expected)
```
::: warning 必須用 raw body 位元組
**不能** `JSON.parse` 後 `re-stringify` —— 可能丟空格 / 改鍵序,導致簽名失敗。務必用框架提供的"原始 body"(Express 的 `bodyParser.raw`、Go 的 `io.ReadAll(r.Body)` 等)。
:::
::: tip webhook_secret 與 API secret 不同
- API `SECRET` —— 用於客戶端 → 伺服器簽名
- `webhook_secret` —— 用於伺服器 → 客戶端 webhook 簽名
呼叫 `set_webhook` 設定 URL 時返回的 `webhook_secret` 是另外一份。
:::
## 時間戳防重放
```text
abs(server_now_unix - WebhookTimestamp) <= 300 # ±5 分鐘
```
超出視窗的請求請視為可疑並拒絕。
## 客戶端必須做到
| 項 | 要求 |
|---|---|
| 響應狀態 | 必須 2xx,否則觸發重試 |
| 響應頭超時 | **5 秒** —— TLS 握手完成後 5 秒內必須把響應狀態行 + 頭部發給服務端 |
| 整請求總超時 | **10 秒** —— 含響應體在內整請求 10 秒內必須完成;響應體慢吞吐也算失敗 |
| 冪等 | 同一 `Webhook-Id` 可能被多次投遞(重試導致),客戶端需根據 `Webhook-Id` 去重 |
::: tip 實操建議
把重活丟到後臺佇列,立即返回 `200`。webhook 入口端到端目標 **< 200 ms**,給網路抖動留緩衝。
:::
::: warning 冪等性
即使你 200 OK 了,因網路丟包,Coinepay 可能沒收到 ack 而重試。**務必基於 `Webhook-Id` 去重**,避免重複處理。
:::
## 事件型別
| event_type | 觸發時機 | payload 概要 | 卡型別覆蓋 |
|---|---|---|---|
| `webhook.test` | 控制台點測試 / `set_webhook` 後非同步觸發一次 | `{message, sent_at, acknowledge_to_complete_verification}` | 全部 |
| `card.opened` | 虛擬卡開卡成功 | `{card_id, card_type, status: "opened", opened_at, last_four?}` | virtual_l/p/v/r/g |
| `card.open_failed` | 開卡失敗 | `{card_id, card_type, status: "open_failed", fail_reason, failed_at}` | virtual_l/p/v/r/g |
| `card.recharged` | 充值成功 | `{transaction_id, card_id, status: 2, amount, currency, completed_at}` | 全部 |
| `card.recharge_failed` | 充值失敗 | `{transaction_id, card_id, status: 3, amount, currency, fail_reason, failed_at}` | 全部 |
| `card.closed` | 銷卡完成 | `{card_id, card_type, status: "closed", closed_at, last_four?}` | virtual_l/p/v/r/g |
| `card.status_changed` | 其他狀態變更(手動凍結/解凍、風控臨時凍結等) | `{card_id, card_type, from_status, to_status, changed_at, last_four?}` | virtual_l/p/v/r/g |
::: info v1.2 狀態碼約定
- 開卡 / 銷卡的 `status` 是**字串**:`"opened"` / `"open_failed"` / `"closed"`
- 充值的 `status` 是**數字**:`2` = 成功 / `3` = 失敗
- `card_type` 全小寫:`virtual_v` 而非 `VIRTUAL_V`
:::
## 完整 payload 示例
### `webhook.test`
```json
{
"message": "This is a test event from Coinepay OpenAPI",
"sent_at": "2026-04-29T11:00:00Z",
"acknowledge_to_complete_verification": true
}
```
### `card.opened`
```json
{
"card_id": "card_12345",
"card_type": "virtual_v",
"status": "opened",
"opened_at": "2026-04-29T11:00:12Z",
"last_four": "4242"
}
```
### `card.open_failed`
```json
{
"card_id": "card_12345",
"card_type": "virtual_v",
"status": "open_failed",
"fail_reason": "kyc rejected",
"failed_at": "2026-04-29T11:00:12Z"
}
```
### `card.recharged`
```json
{
"transaction_id": "txn_RO20260429120000xyz",
"card_id": "card_12345",
"status": 2,
"amount": "100.00",
"currency": "USD",
"completed_at": "2026-04-29T12:00:00Z"
}
```
### `card.recharge_failed`
```json
{
"transaction_id": "txn_RO20260429120000xyz",
"card_id": "card_12345",
"status": 3,
"amount": "100.00",
"currency": "USD",
"fail_reason": "provider declined",
"failed_at": "2026-04-29T12:00:01Z"
}
```
### `card.closed`
```json
{
"card_id": "card_12345",
"card_type": "virtual_v",
"status": "closed",
"closed_at": "2026-04-29T13:00:00Z",
"last_four": "4242"
}
```
### `card.status_changed`
卡片在**非終態之間**的狀態切換 —— 通常是使用者手動凍結/解凍或風控臨時凍結。`card.opened` / `card.closed` / `card.open_failed` 這些專門事件覆蓋的終態變更**不**走此事件。
```json
{
"card_id": "card_12345",
"card_type": "virtual_v",
"from_status": "active",
"to_status": "frozen",
"changed_at": "2026-04-29T14:30:00Z",
"last_four": "4242"
}
```
`from_status` / `to_status` 是小寫字串(`active` / `frozen` / `pending` / `closing`)。對於銷卡 (`closed`) / 開卡失敗 (`failed`) 等終態切換,優先使用更具體的 `card.closed` / `card.open_failed` 事件。
## 重試退避
| 第 N 次失敗 | 下次投遞間隔 |
|---|---|
| 1 | 1 分鐘 |
| 2 | 5 分鐘 |
| 3 | 15 分鐘 |
| 4 | 1 小時 |
| 5 | 6 小時 |
| 6 | 24 小時 |
| 7 | `dead_letter` —— 不再重試 |
::: info 總投遞次數
每個事件共 **7 次投遞機會**(1 次首投 + 6 次重試)。第 7 次失敗後進入 `dead_letter`,不再重試。
:::
死信狀態可透過 [Webhook 事件歷史介面](/zh-TW/api/webhook-events-list) 查詢。
## fail_reason 服務端脫敏
`card.open_failed` / `card.recharge_failed` payload 裡的 `fail_reason` 是**經過脫敏**的簡短可讀描述。服務端會剝除:
- Provider URL / IPv4 地址 / 郵箱 / 域名
- Go 傳輸層模板(如 `Post`、`dial tcp ...`)
- HTML 與特殊字元
- 截斷到 **120 字元**
| 失敗模式 | `fail_reason` 內容 |
|---|---|
| **同步**失敗(API 呼叫立即拒絕) | `""`(空字串)—— 客戶端走通用文案 |
| **非同步**失敗(已入隊但 provider 返回錯誤) | 清洗後的簡短描述,如 `"Insufficient funds"`、`"Card blocked"` —— **不會**含 provider 域名 / IP / 內部 trace id / 完整堆疊 |
::: tip 想看原始 provider 錯誤?
調 [`/api/v1/openapi/webhook_events/list`](/zh-TW/api/webhook-events-list) 看 `last_error`(運營級別的診斷資訊)。完整 provider 響應在服務端保留;如需深度排查請聯絡運營。
:::
## URL 配置約束
::: info 驗證狀態生命週期
每次呼叫 `set_webhook` 都會**輪換 `webhook_secret`** 並**清空已驗證標誌**。當下一條出站事件(通常是自動派發的 `webhook.test`)成功收到 2xx 響應後,標誌被重新置上,表示"新地址可達"。可透過 `get_webhook` 讀取該標誌,用於發現"已配置但從未收到投遞"的異常情況。
:::
呼叫 `set_webhook` 時:
| 約束 | 說明 |
|---|---|
| 協議 | 生產環境強制 **HTTPS**(dev 環境 `AllowHTTP=true` 時方可放行 HTTP) |
| 埠 | 生產環境**僅允許 443**,其他埠在投遞階段被拒 |
| IP | 必須**公網 IP**,拒絕 `127.0.0.0/8` / `10/8` / `192.168/16` / `172.16-31/12` / `169.254/16` 等私網與 link-local |
| 域名 | 解析必須落到允許的 IP 範圍 |
| URL 長度 | 推薦 < 1024 字元 |
::: warning SSRF 防護
拒絕內網 / cloud-metadata(`169.254.169.254`)/ loopback 是**強制約束**,不可繞過。如果你的接收器在內網,請透過反向代理暴露公網 HTTPS。
:::
## 驗籤程式碼示例
::: code-group
```python [Python]
import hmac, hashlib
def verify_webhook(sig_header, ts_header, raw_body, webhook_secret):
if not sig_header.startswith("v1,"):
return False
sig = sig_header[3:]
expected = hmac.new(
webhook_secret.encode(),
f"{ts_header}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sig, expected)
```
```js [Node.js (Express)]
import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.post('/webhook',
express.raw({ type: 'application/json' }), // 關鍵:raw body
(req, res) => {
const sigHeader = req.header('Webhook-Signature') || ''
const tsHeader = req.header('Webhook-Timestamp') || ''
if (!sigHeader.startsWith('v1,')) return res.sendStatus(401)
const sig = sigHeader.slice(3)
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${tsHeader}.`).update(req.body)
.digest('hex')
const ok =
Buffer.byteLength(sig) === Buffer.byteLength(expected) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
if (!ok) return res.sendStatus(401)
// 處理 ...
res.sendStatus(200)
})
```
```go [Go]
func verifyWebhook(sigHeader, tsHeader string, rawBody []byte, webhookSecret string) bool {
const prefix = "v1,"
if !strings.HasPrefix(sigHeader, prefix) {
return false
}
sig := sigHeader[len(prefix):]
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(tsHeader))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}
```
```php [PHP]
function verifyWebhook(string $sigHeader, string $tsHeader, string $rawBody, string $webhookSecret): bool {
if (strpos($sigHeader, 'v1,') !== 0) return false;
$sig = substr($sigHeader, 3);
$expected = hash_hmac('sha256', $tsHeader . '.' . $rawBody, $webhookSecret);
return hash_equals($sig, $expected);
}
```
:::
## 常見整合陷阱
| 陷阱 | 解決 |
|---|---|
| 用 JSON parse 後的物件計算簽名 | 改用 raw body 位元組 |
| 把 `webhook_secret` 與 API `SECRET` 混淆 | 它們是兩個獨立 secret |
| 用 `==` 比較簽名 | 改用恆定時間比較,防 timing attack |
| 超時未返回 2xx | 把"重活"丟到佇列,立即返回 200。整請求 **10 s** 內必須完成(響應頭超時 5 s)|
| 沒有 `Webhook-Id` 去重 | 加冪等表,重複 `Webhook-Id` 直接 200 不再處理 |
---
# API 參考
## 介面總覽
| # | 介面 | 路徑 | 冪等鍵 | 說明 |
|---|---|---|---|---|
| 1 | [列卡頭](./card-headers-list) | `/api/v1/openapi/card_headers/list` | — | 檢視可申請的卡頭列表 |
| 2 | [列卡配置](./card-configs-list) | `/api/v1/openapi/card_configs/list` | — | 檢視卡頭下的套餐 |
| 3 | [列出銀行卡](./cards-list) | `/api/v1/openapi/cards/list` | — | 列出商戶名下的卡(狀態/日期/餘額過濾) |
| 4 | [申請虛擬卡](./card-apply) | `/api/v1/openapi/card/apply` | ✅ 必填 | 非同步開卡 |
| 5 | [首充預覽](./first-deposit-preview) | `/api/v1/openapi/card/first_deposit/preview` | — | 試算首充手續費 / 凍結額 |
| 6 | [建立充值](./card-recharge) | `/api/v1/openapi/card/recharge` | ✅ 必填 | 非同步充值 |
| 7 | [查詢卡片資訊](./card-info) | `/api/v1/openapi/card/info` | — | 狀態輪詢 |
| 8 | [Webhook 事件歷史](./webhook-events-list) | `/api/v1/openapi/webhook_events/list` | — | 排查 webhook 投遞 |
| 9 | [查詢所有交易明細](./transactions-list) | `/api/v1/openapi/transactions/list` | — | 名下所有卡的交易明細 |
| 10 | [查詢單卡交易明細](./card-transactions-list) | `/api/v1/openapi/card/transactions/list` | — | 單張卡的交易明細 |
| 11 | [凍結卡片](./card-freeze) | `/api/v1/openapi/card/freeze` | — | 凍結一張活躍虛擬卡 |
| 12 | [解凍卡片](./card-unfreeze) | `/api/v1/openapi/card/unfreeze` | — | 解凍使用者主動凍結的卡 |
## 通用約定
- **方法**:所有介面都是 `POST`
- **Content-Type**:`application/json`(請求與響應均是)
- **鑑權**:每次請求 4 個簽名頭([詳見 HMAC 鑑權](/zh-TW/guide/authentication))
- **國際化**:透過 `Accept-Language: zh-CN | en-US` 切換 message 文案
- **響應包絡**:
```json
{
"code": 200, // HTTP 狀態碼同步
"message": "成功",
"message_key": "...", // 僅錯誤時返回
"data": { ... } // 業務資料
}
```
## 分頁約定
所有 `*/list` 介面(除 `/card_configs/list`)支援分頁:
| 欄位 | 型別 | 預設 | 限制 |
|---|---|---|---|
| `page` | int | 1 | ≥ 1 |
| `page_size` | int | 20 | ≤ 100 |
響應包含:
```json
{
"list": [...],
"total": 12,
"page": 1,
"page_size": 20,
"total_pages": 1,
"has_next": false,
"has_prev": false
}
```
---
# 申請虛擬卡
**非同步開卡**介面。請求成功只代表"已入隊",開卡完成結果透過 [`card.opened` / `card.open_failed` webhook](/zh-TW/guide/webhooks#事件型別) 推送,或透過 [card/info](./card-info) 輪詢。
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/apply |
| 鑑權 | HMAC |
| 冪等鍵 | 必填 `Idempotency-Key` 頭 |
## 請求欄位
::: warning 不要傳 card_type
本介面**不需要**傳 `card_type`。伺服器從 `header_id` 自動推導,並校驗是否在虛擬卡範圍內。
:::
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `header_id` | string | ✅ | `hdr_` 格式([列卡頭](./card-headers-list))|
| `package_id` | string | ✅ | `pkg_` 格式([列卡配置](./card-configs-list))|
| `first_name` | string | ⚠️ | 持卡人名(看 header.require_phone/email) |
| `last_name` | string | ⚠️ | 持卡人姓 |
| `phone_code` | string | ⚠️ | 國家區號(如 `86` / `1`) |
| `phone` | string | ⚠️ | 手機號(不含區號) |
| `email` | string | ⚠️ | 郵箱 |
| `use_bound_email` | bool | 否 | 預設 `false`,使用請求中傳的 `email`。傳 `true` 時回退到賬號繫結的郵箱。 |
| `use_bound_phone` | bool | 否 | 預設 `false`,使用請求中傳的 `phone` + `phone_code`。傳 `true` 時回退到賬號繫結的手機號。 |
| `first_deposit_amount` | string | 否 | 自定義首充總額,**非負整數**(支付/充值資產單位,如 USDT)。空 = 用配置底額。超出 `base` 的部分按充值口徑收手續費,並與開卡費一併凍結。可用[首充預覽](./first-deposit-preview)精確試算。 |
::: tip 持卡人欄位是否必填
- `header.require_phone == true` 時需要 `phone_code` + `phone`(或顯式傳 `use_bound_phone == true` 讓服務端讀賬號繫結的手機號)
- `header.require_email == true` 時需要 `email`(或顯式傳 `use_bound_email == true` 讓服務端讀賬號繫結的郵箱)
- `first_name` / `last_name` 多數情況下需要
- **OpenAPI 與 H5 使用者端的預設值相反**:本介面的 `use_bound_phone` / `use_bound_email` 預設為 `false` —— 服務端使用你傳的值。商戶的終端使用者一般沒有在我們這邊繫結過手機號/郵箱,回退到"繫結值"會失敗。只有當你確實需要使用賬號繫結值時才顯式傳 `true`。
:::
### 自定義首充
預設按配置底額開卡。如需加充,傳 `first_deposit_amount`(非負整數,且 ≥ `base`)。底額按 1:1 免費進卡;超出底額的**超額**按充值口徑收手續費,扣費後按 1:1 進卡(USDT/USD,不走匯率),發給提供方的首充取整為整數。開卡費 + 完整計算出的凍結額在開卡時一併從錢包凍結。
::: tip 開卡前先預覽
校驗邏輯(格式 / `>= base` / 最大充值 / 充值限額 / 餘額)與[首充預覽](./first-deposit-preview)共用。先調預覽即可向客戶展示精確的 `total_freeze` / `first_recharge_card`——數值與本介面 1:1 一致。非法金額在本介面同樣被拒,錯誤 key 與預覽的 `invalid_reason` 相同(見[典型錯誤](#典型錯誤))。
:::
### G 卡(virtual_g)特殊約束
申請 `virtual_g` 時,服務端會在 header 配置之外**強制**以下約束:
| 約束 | 規則 | 失敗 `message_key` |
|---|---|---|
| 郵箱 | 始終必填(服務端強制覆蓋 header 設定) | `bank_card_email_required` |
| 手機號 | `phone_code` + `phone` 始終必填(服務端強制覆蓋 header 設定) | `bank_card_phone_required` |
| 持卡人姓名 | `first_name` + `last_name` 拼接後必須匹配 `^[A-Za-z]+(?: [A-Za-z]+)*$`,總長度 ≤ 40 字元(僅 ASCII 字母 + 單個空格分隔;不含數字、符號、非 ASCII 字元) | `virtual_g_name_invalid` |
| 生日與賬單地址 | **不要傳** —— 服務端自動生成 | `virtual_g_required_fields_missing`(僅當服務端自動填充失敗時返回) |
::: warning 申請前先校驗姓名
真實世界的持卡人姓名常帶變音符號、連字元或撇號——這些字元會被提供方拒絕。請在前端引導終端使用者輸入只含 ASCII 字母 + 單空格分隔的姓名。
:::
### 請求示例
```json
{
"header_id": "hdr_5",
"package_id": "pkg_12",
"first_name": "John",
"last_name": "Doe"
}
```
請求頭必須含:
```http
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
```
## 響應欄位
| 欄位 | 型別 | 說明 |
|---|---|---|
| `card_id` | string | `card_` 格式(用於後續查詢) |
| `status` | int | 1=pending 2=active 3=failed 4=closing 5=closed 6=frozen |
| `status_desc` | string | 英文描述 |
| `created_at` | string | RFC3339 時間 |
### 響應示例(成功入隊)
```json
{
"code": 200,
"message": "成功",
"data": {
"card_id": "card_12345",
"status": 1,
"status_desc": "pending",
"created_at": "2026-04-29T11:00:12Z"
}
}
```
::: info status=1 不代表卡已可用
返回 `status=1 (pending)` 只是"已入隊"。等 `card.opened` webhook 到達,或輪詢 [card/info](./card-info) 看到 `status=2 (active)` 才能用。
:::
## 後續流程
```mermaid
sequenceDiagram
autonumber
participant C as 客戶
participant API as Coinepay
participant W as 客戶 Webhook 接收器
C->>API: POST /card/apply (Idempotency-Key)
API-->>C: 200 { card_id, status: 1 }
Note over API: 非同步開卡(幾秒~幾分鐘)
alt 開卡成功
API->>W: card.opened
W-->>API: 200 OK
else 開卡失敗
API->>W: card.open_failed
W-->>API: 200 OK
end
Note right of C: 也可主動輪詢 /card/info
```
## 典型錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 400 | `insufficient_balance` | 餘額不足(開卡費 + 首充) |
| 400 | `invalid_first_deposit_amount` | `first_deposit_amount` 不是純非負整數(小數 / 符號 / 科學計數法 / 位數過多) |
| 400 | `first_recharge_below_base` | `first_deposit_amount` 低於配置底額 |
| 400 | `first_recharge_exceeds_max` | 超額超過 `max_recharge_amount` |
| 400 | `first_recharge_limit_exceeded` | 超額超過賬號充值限額 |
| 400 | `first_recharge_asset_mismatch` | 開卡費資產 ≠ 充值資產;該配置不支援自定義超額 |
| 400 | `first_recharge_excess_too_small` | 扣費 + 取整後到卡為 0,請加大金額 |
| 400 | `kyc_required` | 請先完成實名認證 |
| 400 | `kyc_not_approved` | 賬號 KYC 未透過 |
| 400 | `bank_card_email_required` | 卡型別需要郵箱;請傳 `email` 或顯式設 `use_bound_email=true`(僅當賬號已繫結時) |
| 400 | `bank_card_phone_required` | 卡型別需要手機號;請傳 `phone_code`+`phone` 或顯式設 `use_bound_phone=true`(僅當賬號已繫結時) |
| 400 | `openapi_card_type_not_supported` | header 對應的卡型別不在虛擬卡範圍 |
| 400 | `virtual_g_name_invalid` | (G 卡)持卡人姓名不滿足 ASCII 字母 / 單空格 / ≤40 字元規則 |
| 400 | `virtual_g_required_fields_missing` | (G 卡)服務端無法組裝完整的開卡請求(罕見,通常是配置或地址池缺失)|
| 400 | `openapi_invalid_header_id` | `header_id` 缺字首或不存在 |
| 400 | `openapi_invalid_package_id` | `package_id` 缺字首 / 不屬於該 header |
| 400 | `openapi_idempotency_key_required` | 缺冪等鍵 |
| 400 | `openapi_idempotency_key_too_long` | 冪等鍵超 128 字元 |
| 400 | `openapi_idempotency_key_invalid_chars` | 冪等鍵含非法字元 |
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 409 | `openapi_idempotency_key_conflict` | 同 key 不同 body |
| 500 | `openapi_apply_card_failed` | 服務端異常 |
## 注意事項
- **開卡費 + 首充**會在請求時一併扣款 / 凍結。傳自定義 `first_deposit_amount` 時,超額手續費也會一併凍結(見[自定義首充](#自定義首充))。失敗的開卡會自動退還
- 非同步任務最長可能跑幾分鐘。**不要**因為客戶端 30 秒內沒收到 webhook 就重試 apply(重試需帶相同 `Idempotency-Key`)
- `card_id` 一旦返回就**永久**指向這次請求的卡(即使最終失敗)
---
# 列卡配置
返回某個卡頭下可選的**套餐配置**(費率、首充、月費等)。客戶端根據費用選擇最合適的套餐。
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card_configs/list |
| 鑑權 | HMAC |
| 冪等鍵 | 不需要 |
## 請求欄位
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `header_id` | string | ✅ | `hdr_` 格式(來自 [列卡頭](./card-headers-list)) |
### 請求示例
```json
{ "header_id": "hdr_5" }
```
## 響應欄位
`data.list[]` 中每項:
| 欄位 | 型別 | 說明 |
|---|---|---|
| `package_id` | string | `pkg_` 格式(apply 時回傳) |
| `name` | object | i18n 名稱 |
| `currency` | string | 卡內幣種 ISO 4217(如 `USD`) |
| `open_card_fee` | string (decimal) | 開卡費金額 |
| `open_card_fee_asset` | string | 開卡費資產符號(如 `USDT`) |
| `initial_deposit` | string (decimal) | 首充金額(開卡時一併凍結) |
| `min_recharge` | string (decimal) | 最小單次充值 |
| `max_recharge` | string (decimal) | 最大單次充值 |
| `monthly_fee` | string (decimal) | 月費 |
| `monthly_fee_asset` | string | 月費資產符號 |
| `recharge_fee` | [`FeeSpec`](#feespec) | 充值手續費 |
| `close_fee` | [`FeeSpec`](#feespec) | 銷卡手續費 |
| `authorization_fee` | [`FeeSpec`](#feespec) | 授權交易費 |
| `cross_border_fee` | [`FeeSpec`](#feespec) | 跨境交易費 |
| `refund_fee` | [`FeeSpec`](#feespec) | 退款交易費 |
| `is_default` | bool | 是否為推薦預設套餐 |
| `description` | object | i18n 套餐說明 |
### `FeeSpec`
| 欄位 | 型別 | 說明 |
|---|---|---|
| `type` | string | `fixed` / `percent` / `fixed_plus_percent` / `unknown` |
| `rate` | string (decimal) | 百分比費率(type=percent / fixed_plus_percent) |
| `fixed` | string (decimal) | 固定費用(type=fixed / fixed_plus_percent) |
| `asset_symbol` | string | 費用資產符號(如 `USDT`) |
::: tip 預設套餐選擇規則
1. 優先 `is_default == true`
2. 否則按 `(open_card_fee ASC, min_recharge ASC)` 排序,取第一個
:::
### 響應示例
```json
{
"code": 200,
"message": "成功",
"data": {
"list": [
{
"package_id": "pkg_12",
"name": { "zh-CN": "基礎套餐", "en-US": "Basic" },
"currency": "USD",
"open_card_fee": "5.00",
"open_card_fee_asset": "USDT",
"initial_deposit": "10.00",
"min_recharge": "10.00",
"max_recharge": "10000.00",
"monthly_fee": "0",
"monthly_fee_asset": "USDT",
"recharge_fee": {
"type": "percent",
"rate": "0.02",
"fixed": "0",
"asset_symbol": "USDT"
},
"close_fee": {
"type": "fixed",
"rate": "0",
"fixed": "1.00",
"asset_symbol": "USDT"
},
"authorization_fee": {
"type": "fixed", "rate": "0", "fixed": "0.20", "asset_symbol": "USDT"
},
"cross_border_fee": {
"type": "percent", "rate": "0.015", "fixed": "0", "asset_symbol": "USDT"
},
"refund_fee": {
"type": "fixed", "rate": "0", "fixed": "0", "asset_symbol": "USDT"
},
"is_default": true,
"description": {
"zh-CN": "適合個人小額消費",
"en-US": "For personal small spending"
}
}
],
"total": 3,
"page": 1,
"page_size": 3,
"total_pages": 1,
"has_next": false,
"has_prev": false
}
}
```
## 典型錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 400 | `openapi_invalid_header_id` | header_id 格式錯或不存在 |
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 500 | `openapi_list_card_configs_failed` | 服務端異常 |
---
# 凍結卡片
凍結一張 **active(已啟用)** 的虛擬卡。凍結後,該卡在你[解凍](./card-unfreeze)之前無法用於任何支付/授權。
- 該凍結會被記錄為 **使用者發起的凍結**,可透過 [解凍卡片](./card-unfreeze) 介面撤銷。
- 僅接受屬於當前 `AppID` 賬號、且卡型別為 **虛擬卡** 的卡。
- 不涉及任何資金變動 —— 凍結/解凍均無手續費,也不影響卡內餘額或你的錢包。
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/freeze |
| 鑑權 | HMAC |
| 冪等鍵 | 不需要 |
::: tip 無需 Idempotency-Key
凍結天然具備冪等安全性:對已凍結的卡重試會返回 `400 card_already_frozen`,而不會重複執行。此操作沒有資金影響需要防護,因此無需 `Idempotency-Key` 請求頭。
:::
## 請求欄位
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `card_id` | string | ✅ | `card_` —— 必須屬於你的賬號且為虛擬卡 |
| `reason` | string | 可選 | 隨凍結一併記錄的自由文本原因(最長 255 字元) |
### 請求示例
```json
{
"card_id": "card_12345",
"reason": "suspected fraud on merchant side"
}
```
## 響應欄位
| 欄位 | 型別 | 說明 |
|---|---|---|
| `card_id` | string | 回顯示卡 ID |
| `status` | int | 操作**之後**的卡狀態 —— 成功時恆為 `6`(frozen) |
| `status_desc` | string | 英文狀態描述 —— `frozen` |
| `success` | bool | 成功時為 `true` |
### 響應示例
```json
{
"code": 200,
"message": "OK",
"data": {
"card_id": "card_12345",
"status": 6,
"status_desc": "frozen",
"success": true
}
}
```
## 前提與規則
1. **歸屬** —— `card_id` 必須屬於已鑑權的賬號,否則返回 `404 card_not_found`(與"不存在"不作區分,以防列舉探測)。
2. **卡型別** —— 僅虛擬卡(`virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g`)可透過 OpenAPI 操作。
3. **狀態** —— 僅 `status=2 (active)` 的卡可被凍結。`pending` / `failed` / `closing` / `closed` / 已 `frozen` 的卡會被拒絕。
## 常見錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 400 | `openapi_invalid_card_id` | `card_id` 缺失/非法 |
| 400 | `openapi_card_type_not_supported` | 該卡不是虛擬卡型別 |
| 400 | `card_already_frozen` | 卡已處於凍結狀態 |
| 400 | `card_status_cannot_freeze` | 卡不是 `active`,無法凍結 |
| 400 | `operation_not_supported` | 該提供商/卡型別不支援凍結 |
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 404 | `card_not_found` | 卡不存在 / 不屬於當前賬號 |
| 500 | `openapi_freeze_card_failed` | 服務端異常 |
## 說明
- 凍結成功後,[`/card/info`](./card-info) 會返回 `status=6`。
- 如需撤銷,呼叫 [解凍卡片](./card-unfreeze)。只有使用者發起的凍結(即本介面)才可由你自行解凍;由風控/管理員施加的凍結不可自行解凍。
## 整合常見問題與最佳實踐
::: warning 接入前必讀
以下是最容易導致你係統內「卡狀態 / 記賬」與真實狀態不一致的失敗模式。
:::
1. **同步呼叫、依賴上游提供商,最長約 60 秒。** 凍結會內聯呼叫上游髮卡提供商。通常幾秒,但可能達到 10–15 秒,服務端最長允許 **60 秒**。**本介面的 HTTP 客戶端超時請設為 ≥ 60 秒**;設成 10–30 秒會誘發第 2 條問題。
2. **客戶端超時 ≠ 凍結失敗。** 若提供商已凍結成功、而你的客戶端此時超時(或連線斷開),你會收到錯誤,但卡其實**已凍結**——形成靜默的狀態錯位。**切勿把超時記為「未凍結」。** 遇到任何超時/網路錯誤,用 [`/card/info`](./card-info) 對賬:`status=6` → 凍結已成功(繼續);`status=2` → 未生效(可安全重試)。
3. **凍結/解凍沒有 webhook。** 同步響應是**唯一**訊號——本操作不會發出任何非同步 `card.*` webhook。不要等待回撥;以響應(或 `/card/info`)為準。
4. **無 `Idempotency-Key`,但可安全重試。** 重試不做去重(每次都會打到提供商),但卡狀態機會保護你:對已凍結的卡再次凍結返回 `400 card_already_frozen`。**應把 `card_already_frozen` 視為「已處於目標狀態」,而非硬錯誤。** 優先用「`/card/info` 對賬」而非盲目重試。
5. **凍結只攔截*新的*支付。** 它不會撤銷凍結前已授權的交易,也不發生任何資金變動(無手續費、餘額不變)。
---
# 列卡頭
返回當前賬號可申請的**虛擬卡**卡頭列表。卡頭(card header)由 BIN + 卡組織 + 地區 + 業務場景共同定義。
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card_headers/list |
| 鑑權 | HMAC(4 個簽名頭) |
| 冪等鍵 | 不需要 |
| 限流 | 600 / min |
## 請求欄位
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `page` | int | 否 | 預設 1 |
| `page_size` | int | 否 | 預設 20,最大 100 |
| `card_brand` | string | 否 | 卡組織程式碼,如 `VISA` / `MASTER` |
| `card_area` | string | 否 | 髮卡地區程式碼,如 `US` |
| `currency` | string | 否 | 幣種過濾,如 `USD` |
::: tip 不需要傳 card_type
本介面僅返回**虛擬卡**卡頭,無需傳 `card_type`。
:::
### 請求示例
```json
{
"page": 1,
"page_size": 20,
"card_brand": "VISA"
}
```
## 響應欄位
`data.list[]` 中每項:
| 欄位 | 型別 | 說明 |
|---|---|---|
| `header_id` | string | `hdr_` 格式(apply 時回傳) |
| `card_bin` | string | 6 位 BIN |
| `card_type` | string | 全小寫:`virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g`(僅資訊展示,apply **不需要**回傳) |
| `card_brand` | string | 卡組織中文(如 `VISA`) |
| `card_area` | string | 地區中文(如 `美國`) |
| `business_scene` | string | 業務場景中文 |
| `description` | object | i18n: `{"zh-CN": "...", "en-US": "..."}` |
| `features` | string[] | 特性標籤(如 `["3DS", "EMV"]`) |
| `require_phone` | bool | apply 是否需要傳 phone/phone_code |
| `require_email` | bool | apply 是否需要傳 email |
`data` 包含分頁欄位(`total` / `page` / `page_size` / `total_pages` / `has_next` / `has_prev`)。
### 響應示例
```json
{
"code": 200,
"message": "成功",
"data": {
"list": [
{
"header_id": "hdr_5",
"card_bin": "424242",
"card_type": "virtual_v",
"card_brand": "VISA",
"card_area": "美國",
"business_scene": "境外消費",
"description": {
"zh-CN": "適合電商訂閱",
"en-US": "For e-commerce subscriptions"
},
"features": ["3DS", "EMV"],
"require_phone": false,
"require_email": true
}
],
"total": 12,
"page": 1,
"page_size": 20,
"total_pages": 1,
"has_next": false,
"has_prev": false
}
}
```
## 典型錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 500 | `openapi_list_card_headers_failed` | 服務端異常 |
## 整合提示
- 客戶端**不要硬編碼** `header_id`,每次接入新賬號都應先調本介面
- `description` 的 i18n 字典直接渲染給使用者,不需要客戶端 i18n
- `require_phone` / `require_email` 決定 apply 時是否要補傳相關欄位
---
# 查詢卡片資訊
預設返回卡的**狀態、餘額、脫敏卡號**。
當請求體傳 **`with_sensitive=true`** 且**你的 API 憑證已被授權獲取敏感欄位**時,同一介面會在響應裡**額外**返回 **完整 PAN / CVV / 到期日 / 持卡人姓名**。該能力**預設關閉**,需顯式開通 —— 詳見下文 [敏感卡片資訊](#敏感卡片資訊)。
常用於:
- 非同步開卡後輪詢,直到 `status=2 (active)`
- 業務側定期同步餘額
- 排查 webhook 是否漏投遞
- (已授權敏感訪問後)在自有收銀臺 / 錢包 UI 中展示 PAN / CVV
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/info |
| 鑑權 | HMAC |
| 冪等鍵 | 不需要 |
## 請求欄位
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `card_id` | string | ✅ | `card_` 格式 |
| `with_sensitive` | bool | 可選 | 傳 `true` 表示請求完整 PAN / CVV / 到期日 / 持卡人姓名。預設 `false`。需同時滿足"已開通"和"卡片為 active"。 |
### 請求示例 — 預設(僅狀態與餘額)
```json
{ "card_id": "card_12345" }
```
### 請求示例 — 請求敏感欄位
```json
{
"card_id": "card_12345",
"with_sensitive": true
}
```
## 響應欄位
### 始終返回
| 欄位 | 型別 | 說明 |
|---|---|---|
| `card_id` | string | 回顯 |
| `card_type` | string | 全小寫:`virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g` |
| `card_brand` | string | 卡組織 |
| `currency` | string | 卡內幣種 |
| `status` | int | 1=pending 2=active 3=failed 4=closing 5=closed 6=frozen |
| `status_desc` | string | 英文描述 |
| `masked_card_no` | string | 脫敏卡號(前 6 後 4),如 `424242******1234` |
| `last_four` | string | 卡號末 4 位 |
| `balance` | string (decimal) | 卡內可用餘額 |
| `frozen_balance` | string (decimal) | 卡內凍結餘額 |
| `activated_at` | string nullable | 啟用時間(成功後才有) |
| `created_at` | string | 建立時間 |
### 僅當 `with_sensitive=true`、已開通授權、且 `status=2` 時返回
| 欄位 | 型別 | 說明 |
|---|---|---|
| `card_number` | string | 完整 PAN(無空格 / 無橫線) |
| `cvv` | string | 卡安全碼(Visa / Mastercard 為 3 位) |
| `expiry_date` | string | 到期日,格式 `MM/YY` |
| `first_name` | string | 持卡人名(開卡時提供) |
| `last_name` | string | 持卡人姓 |
任一前提不滿足時,這些欄位會**整體省略**(不存在、不為 `null` 也不為空串)。
### 響應示例 — 預設
```json
{
"code": 200,
"message": "成功",
"data": {
"card_id": "card_12345",
"card_type": "virtual_v",
"card_brand": "VISA",
"currency": "USD",
"status": 2,
"status_desc": "active",
"masked_card_no": "424242******1234",
"last_four": "1234",
"balance": "85.50",
"frozen_balance": "0",
"activated_at": "2026-04-29T11:00:30Z",
"created_at": "2026-04-29T11:00:12Z"
}
}
```
### 響應示例 — 含敏感欄位
```json
{
"code": 200,
"message": "成功",
"data": {
"card_id": "card_12345",
"card_type": "virtual_v",
"card_brand": "VISA",
"currency": "USD",
"status": 2,
"status_desc": "active",
"masked_card_no": "424242******1234",
"last_four": "1234",
"balance": "85.50",
"frozen_balance": "0",
"activated_at": "2026-04-29T11:00:30Z",
"created_at": "2026-04-29T11:00:12Z",
"card_number": "4242420000001234",
"cvv": "123",
"expiry_date": "12/28",
"first_name": "JOHN",
"last_name": "DOE"
}
}
```
## 狀態碼(status)
| 值 | 含義 | 說明 |
|---|---|---|
| 1 | pending | 開卡中 |
| 2 | active | 已啟用,可用 |
| 3 | failed | 開卡失敗(終態) |
| 4 | closing | 銷卡中 |
| 5 | closed | 已銷卡(終態) |
| 6 | frozen | 已凍結(使用者 / 風控) |
## 敏感卡片資訊
::: warning 預設關閉 — 需顯式審批後開通
敏感欄位訪問(`card_number` / `cvv` / `expiry_date` / `first_name` / `last_name`)對**每一份 API 憑證預設關閉**。未開通時即使傳 `with_sensitive=true` 也會得到 `403 openapi_sensitive_card_info_disabled`,敏感欄位不會下發。
如需開通,請**聯絡你的客戶經理**或提交工單。開通時需要:
- 說明業務用途(如在自有收銀臺展示卡號)
- 確認你的環境符合 PCI-DSS 對 PAN 儲存 / 展示的要求
- 提供你的 `AppID`(`cp_xxxx...`),授權將下發到正確的憑證
:::
### 訪問前提
下列三個條件必須**同時滿足**,敏感欄位才會下發:
1. **請求** — 呼叫方顯式傳 `with_sensitive: true`
2. **授權** — 你的 API 憑證已被授予敏感欄位訪問權(且平臺級策略允許)
3. **卡狀態** — 卡必須處於 `status=2 (active)`;`pending` / `failed` / `closing` / `closed` / `frozen` 一律拒絕
任一不滿足,響應為 `403` 加對應的 `message_key`。欄位不會部分返回 —— 單次請求是"全有或全無"。
### 合規與處理建議
::: danger 把返回的 PAN 視為高敏感 PCI 資料
- **不要日誌**:完整 `card_number` / `cvv` 不要寫到應用日誌或 APM
- **不要落庫**:PAN / CVV 不要持久化到自有資料庫或分析數倉
- **僅透過 TLS** 透傳到你的前端,渲染後立即丟棄
- 非展示狀態下務必脫敏;優先使用 `masked_card_no`
- 一旦懷疑憑證洩漏,立即在使用者後臺 Reset OpenAPI Secret
:::
### 運維說明
- 敏感訪問可被**回收**。一旦被回收,後續 `with_sensitive=true` 請求將在 ~5 分鐘內(快取傳播視窗)返回 `403`。
- 這兩個 `403` 錯誤碼與鑑權中介軟體返回的 `401` **不同** —— `403` 表示鑑權透過但所請求的特性受限。
- 此介面絕**不**會透過 webhook payload 推送敏感欄位;敏感欄位**只能**透過本同步介面獲取。
## 典型錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 400 | `openapi_invalid_card_id` | 卡 ID 錯誤 |
| 400 | `openapi_card_type_not_supported` | 對非虛擬卡型別請求敏感欄位 |
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 403 | `openapi_sensitive_card_info_disabled` | 當前憑證未開通敏感欄位訪問(或平臺策略關閉) |
| 403 | `openapi_sensitive_card_only_active` | 卡不在 `status=2 (active)`,敏感欄位拒絕下發 |
| 404 | `card_not_found` | 卡不存在 / 不屬於當前賬號 |
| 500 | `openapi_get_card_info_failed` | 服務端異常 |
| 500 | `openapi_internal_error` | 載入憑證上下文失敗 |
## 推薦輪詢策略
```python
import time
def wait_card_active(card_id, max_minutes=10):
"""非同步開卡後輪詢,直到 active 或失敗"""
deadline = time.time() + max_minutes * 60
backoff = 2
while time.time() < deadline:
info = call("/api/v1/openapi/card/info", {"card_id": card_id})
status = info['data']['status']
if status == 2:
return info['data'] # active
if status in (3, 5):
raise CardOpenFailed(info['data'])
time.sleep(backoff)
backoff = min(backoff * 2, 30) # 指數退避,最多 30 秒
raise TimeoutError(f"Card {card_id} not active in {max_minutes} min")
def fetch_pan_for_display(card_id):
"""一次性拿敏感欄位渲染前端,絕不落庫"""
resp = call("/api/v1/openapi/card/info", {
"card_id": card_id,
"with_sensitive": True,
})
if resp.get('code') == 403:
key = resp.get('message_key')
if key == 'openapi_sensitive_card_info_disabled':
raise PermissionError("請聯絡客戶經理為該 API 憑證開通敏感欄位訪問")
if key == 'openapi_sensitive_card_only_active':
raise RuntimeError("卡未啟用,敏感欄位暫不可獲取")
if resp.get('code') != 200:
raise RuntimeError(f"獲取卡資訊失敗:{resp.get('message_key')}")
return resp['data'] # 含 card_number / cvv / expiry_date / first_name / last_name
```
::: tip 優先用 webhook
能用 webhook 就用 webhook。`card/info` 輪詢是 fallback —— 僅用於 webhook 接收器臨時不可用、或對賬場景。
:::
---
# 建立充值
**非同步充值**介面。返回 `transaction_id` 與初始 `status: 1 (processing)`;最終結果透過 [`card.recharged` / `card.recharge_failed` webhook](/zh-TW/guide/webhooks#事件型別) 推送。
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/recharge |
| 鑑權 | HMAC |
| 冪等鍵 | 必填 `Idempotency-Key` 頭 |
## 請求欄位
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `card_id` | string | ✅ | `card_` 格式 |
| `amount` | string (decimal) | ✅ | 充值金額(卡內幣種單位) |
### 請求示例
```json
{ "card_id": "card_12345", "amount": "100.00" }
```
請求頭必須含:
```http
Idempotency-Key: 7b3e9d5c-1a2b-4f3e-8c7d-6f5e4a3b2c1d
```
## 響應欄位
| 欄位 | 型別 | 說明 |
|---|---|---|
| `transaction_id` | string | `txn_<...>` 格式 |
| `card_id` | string | 回顯 |
| `amount` | string | 客戶請求金額 |
| `currency` | string | 卡內幣種 |
| `status` | int | 1=processing 2=success 3=failed |
| `status_desc` | string | 英文描述 |
| `created_at` | string | RFC3339 時間 |
### 響應示例
```json
{
"code": 200,
"message": "成功",
"data": {
"transaction_id": "txn_OO20260429120000xyz",
"card_id": "card_12345",
"amount": "100.00",
"currency": "USD",
"status": 1,
"status_desc": "processing",
"created_at": "2026-04-29T12:00:00Z"
}
}
```
## 典型錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 400 | `amount_required` | amount 缺失 |
| 400 | `insufficient_balance` | 餘額不足 |
| 400 | `openapi_invalid_card_id` | 卡 ID 錯誤 |
| 400 | `openapi_idempotency_key_required` | 缺冪等鍵 |
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 404 | `card_not_found` | 卡不存在 |
| 409 | `openapi_idempotency_key_conflict` | 同 key 不同 body |
| 500 | `openapi_recharge_failed` | 服務端異常 |
## 注意事項
- **不要**在前端用同一個 `Idempotency-Key` 重複發起多筆不同金額充值(會 409)
- 同 key 的安全重試(網路重試)→ 返回原響應
- `transaction_id` 是 `txn_<...>`(視為不透明字串),請用此值與 webhook 中的 `transaction_id` 對賬
---
# 卡片交易列表
返回 **單張虛擬卡的交易明細**,支援分頁,篩選條件與 [交易列表](./transactions-list) 相同。唯一區別:`card_id` 為 **必填**,且結果限定於該單張卡。
- `card_id` 必須屬於你的賬號且為 **虛擬卡**,否則返回 `404` / `400`。
- 採用與 [交易列表](./transactions-list) 相同的嚴格脫敏和相同的響應結構。
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/transactions/list |
| 鑑權 | HMAC |
| 冪等鍵 | 不需要 |
## 請求欄位
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `card_id` | string | ✅ | `card_` —— 要查詢的卡(必須是你的虛擬卡) |
| `transaction_time_from` | string | 可選 | 範圍起點,`YYYY-MM-DD HH:MM:SS` |
| `transaction_time_to` | string | 可選 | 範圍終點,`YYYY-MM-DD HH:MM:SS` |
| `type` | string | 可選 | 型別篩選 —— `PURCHASE` / `AUTHORIZATION` / `REFUND` / `REVERSAL` / `TOPUP` / `WITHDRAW` / `FEE` |
| `status` | string | 可選 | `PENDING` / `APPROVED` / `FAILED` / `REVERSED` |
| `amount_from` | string | 可選 | 金額下限,如 `"10.00"` |
| `amount_to` | string | 可選 | 金額上限,如 `"1000.00"` |
| `merchant_name` | string | 可選 | 商戶名稱(模糊) |
| `keyword` | string | 可選 | 自由文本關鍵詞(模糊) |
| `page` | int | 可選 | 頁碼(預設 1) |
| `page_size` | int | 可選 | 每頁數量(預設 20,最大 100) |
### 請求示例
```json
{
"card_id": "card_12345",
"type": "PURCHASE",
"page": 1,
"page_size": 50
}
```
## 響應
與 [交易列表](./transactions-list#響應) 完全相同 —— 一個分頁信封,其 `list` 項為交易物件。完整欄位表、`type` 取值、分類以及示例負載請見該頁面。返回的每一行都歸屬於所請求的 `card_id`。
## 常見錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 400 | `openapi_invalid_card_id` | `card_id` 缺失或非法(此處必填) |
| 400 | `openapi_card_type_not_supported` | `card_id` 不是虛擬卡 |
| 400 | `invalid_date_format` | `transaction_time_from/to` 不符合 `YYYY-MM-DD HH:MM:SS` |
| 400 | `invalid_params` | 金額格式錯誤 |
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 404 | `card_not_found` | 卡不存在 / 不屬於當前賬號 |
| 500 | `openapi_list_transactions_failed` | 服務端異常 |
## 說明
- 本介面是針對常見"檢視某張卡歷史"場景的便捷封裝;功能上等價於設定了 `card_id` 的 [交易列表](./transactions-list)。選用在你的整合中更清晰的那個即可。
- 資料來自 Coinepay 的本地賬本;不會同步呼叫提供商。
---
# 解凍卡片
將一張 **frozen(已凍結)** 的虛擬卡恢復為 `active`,使其重新可用。
- 僅可解凍以 **使用者發起的凍結**(即透過 [凍結卡片](./card-freeze))凍結的卡。
- 由 **風控**、**管理員** 或 **系統** 凍結的卡無法在此解凍,會返回 `403` —— 請聯絡你的客戶經理。
- 不涉及任何資金變動 —— 解凍無手續費。
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/unfreeze |
| 鑑權 | HMAC |
| 冪等鍵 | 不需要 |
::: tip 無需 Idempotency-Key
對未處於凍結狀態的卡重試會返回 `400 card_not_frozen`,而不會重複執行。此操作沒有資金影響,因此無需 `Idempotency-Key` 請求頭。
:::
## 請求欄位
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `card_id` | string | ✅ | `card_` —— 必須屬於你的賬號且為虛擬卡 |
### 請求示例
```json
{ "card_id": "card_12345" }
```
## 響應欄位
| 欄位 | 型別 | 說明 |
|---|---|---|
| `card_id` | string | 回顯示卡 ID |
| `status` | int | 操作**之後**的卡狀態 —— 成功時恆為 `2`(active) |
| `status_desc` | string | 英文狀態描述 —— `active` |
| `success` | bool | 成功時為 `true` |
### 響應示例
```json
{
"code": 200,
"message": "OK",
"data": {
"card_id": "card_12345",
"status": 2,
"status_desc": "active",
"success": true
}
}
```
## 前提與規則
1. **歸屬** —— `card_id` 必須屬於已鑑權的賬號,否則返回 `404 card_not_found`。
2. **卡型別** —— 僅限虛擬卡。
3. **狀態** —— 僅 `status=6 (frozen)` 的卡可被解凍。
4. **凍結來源** —— 僅 *使用者發起* 的凍結可在此撤銷。若卡是由風控 / 管理員 / 系統凍結的,請求會以對應的 `403` 被拒絕。
## 常見錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 400 | `openapi_invalid_card_id` | `card_id` 缺失/非法 |
| 400 | `openapi_card_type_not_supported` | 該卡不是虛擬卡型別 |
| 400 | `card_not_frozen` | 卡當前未處於凍結狀態 |
| 400 | `recharge_unfreeze_disabled` | 該卡只能透過充值解凍(風控凍結),而該路徑已被停用 |
| 400 | `operation_not_supported` | 該提供商/卡型別不支援解凍 |
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 403 | `unauthorized_unfreeze_admin` | 由管理員凍結 —— 無法自行解凍 |
| 403 | `unauthorized_unfreeze_risk` | 由風控凍結 —— 無法自行解凍 |
| 403 | `unauthorized_unfreeze_system` | 由系統凍結 —— 無法自行解凍 |
| 404 | `card_not_found` | 卡不存在 / 不屬於當前賬號 |
| 500 | `openapi_unfreeze_card_failed` | 服務端異常 |
## 說明
- 解凍成功後,[`/card/info`](./card-info) 會返回 `status=2`。
- 此處返回 `403` 表示凍結是由你以外的一方施加的;卡將保持凍結,你需聯絡客服解除。
## 整合常見問題與最佳實踐
1. **同步呼叫、依賴上游提供商,最長約 60 秒。** 解凍會內聯呼叫上游提供商(延遲特徵與凍結相同——通常幾秒,服務端最長約 60 秒)。**本介面的 HTTP 客戶端超時請設為 ≥ 60 秒。**
2. **客戶端超時 ≠ 解凍失敗。** 遇到任何超時/網路錯誤,用 [`/card/info`](./card-info) 對賬:`status=2` → 解凍已成功(繼續);`status=6` → 未生效(可安全重試)。
3. **凍結/解凍沒有 webhook。** 同步響應是**唯一**訊號,不會發出任何非同步 `card.*` 回撥。以響應(或 `/card/info`)為準。
4. **無 `Idempotency-Key`,但可安全重試。** 重試不做去重,但卡狀態機會保護你:對已是活躍狀態的卡再次解凍返回 `400 card_not_frozen`。**應把 `card_not_frozen` 視為「已處於目標狀態」,而非硬錯誤。**
5. **`403` 是終態——不要迴圈重試。** 本介面只能解凍*使用者主動發起*的凍結(即經 [凍結卡片](./card-freeze) 施加的凍結)。若卡被風控/管理員/系統凍結(或事後又被重新凍結),解凍會返回 `403 unauthorized_unfreeze_*`,且重試只會一直返回 `403`;只能由施加方/客服解除。請把該結果透傳給使用者,而非重試迴圈。
---
# 列出銀行卡
商戶查詢當前 AppID 名下所有銀行卡,支援狀態過濾、日期範圍過濾、餘額範圍過濾和分頁。
**關鍵特性:**
- **僅虛擬卡**:響應已強制過濾為虛擬卡(`virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g`),物理卡不會透過 OpenAPI 暴露。
- **預設行為**:未傳 `statuses` 也未傳 `usable_only` 時,預設返回 `status ∈ [1, 2]`(pending + active),自動隱藏失敗/已登出/已凍結的歷史卡。
- **列表介面不下發敏感欄位**:本介面的響應永遠不含完整 PAN / CVV / 到期日 / 持卡人姓名,無論憑證許可權如何。如需獲取這些欄位,請改用 [`/openapi/card/info`](./card-info#敏感卡片資訊) 並傳 `with_sensitive=true`(按卡選擇性獲取,預設關閉,需先開通)。
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/cards/list |
| 鑑權 | HMAC |
| 冪等鍵 | 不需要 |
## 請求欄位
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `statuses` | int[] | 否 | 狀態過濾陣列,可選值 `1` `2` `3` `4` `5` `6`;與 `usable_only` 互斥 |
| `usable_only` | bool | 否 | 簡便過濾:`true` 等價 `statuses=[2]`;與 `statuses` 互斥 |
| `start_date` | string | 否 | 建立日期下界,格式 `YYYY-MM-DD` |
| `end_date` | string | 否 | 建立日期上界,格式 `YYYY-MM-DD`(含當日) |
| `min_balance` | string | 否 | 最小余額(字串數字,如 `"10.5"`) |
| `max_balance` | string | 否 | 最大余額(字串數字) |
| `page` | int | 否 | 頁碼,最小 1,預設 1 |
| `page_size` | int | 否 | 每頁數量,1–100,預設 20 |
### 卡狀態值
| status | 含義 | 預設包含? |
|---|---|---|
| 1 | pending(開卡處理中 / KYC 稽核中) | ✅ |
| 2 | active(正常) | ✅ |
| 3 | failed(開卡失敗) | ❌(需顯式 `statuses=[3]`) |
| 4 | closing(登出中) | ❌ |
| 5 | closed(已登出) | ❌ |
| 6 | frozen(已凍結) | ❌ |
::: tip 為何預設隱藏失敗/已登出/已凍結
為了防止商戶誤把廢卡當可用卡。如需做對賬或審計,請顯式傳 `statuses` 引數。
:::
### 請求示例
#### 1. 預設查詢(僅可用卡:pending + active)
```bash
curl -X POST "${baseUrl}/api/v1/openapi/cards/list" \
-H "Content-Type: application/json" \
-H "X-App-Id: cp_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-Timestamp: 1715251200" \
-H "X-Nonce: 9b5603a21d2d4e1d" \
-H "X-Signature: " \
-d '{}'
```
#### 2. 僅查可立即使用的卡(active)
```json
{ "usable_only": true }
```
#### 3. 自定義狀態(含歷史卡)
```json
{
"statuses": [2, 5, 6],
"page": 1,
"page_size": 50
}
```
#### 4. 餘額 + 日期範圍篩選
```json
{
"min_balance": "10",
"max_balance": "1000",
"start_date": "2026-01-01",
"end_date": "2026-12-31"
}
```
## 響應欄位
### 頂層包絡
| 欄位 | 型別 | 說明 |
|---|---|---|
| `list` | `CardListItemOut[]` | 卡陣列(見下) |
| `total` | int64 | 總記錄數 |
| `page` | int | 當前頁碼 |
| `page_size` | int | 每頁數量 |
| `total_pages` | int | 總頁數 |
| `has_next` | bool | 是否有下一頁 |
| `has_prev` | bool | 是否有上一頁 |
### `CardListItemOut`
| 欄位 | 型別 | 說明 |
|---|---|---|
| `card_id` | string | `card_` 格式,可作為其他介面(`/card/info`、`/card/recharge`)的 `card_id` 引數 |
| `card_type` | string | 全小寫:`virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g` |
| `card_brand` | string | 卡組織:`Visa` / `Mastercard` |
| `card_holder_name` | string | 持卡人姓名(apply 時由商戶提供) |
| `currency` | string | 卡內幣種 ISO 4217(如 `USD`) |
| `status` | int | 1=pending 2=active 3=failed 4=closing 5=closed 6=frozen |
| `status_desc` | string | 英文描述:`pending` / `active` / `failed` / `closing` / `closed` / `frozen` |
| `masked_card_no` | string | 脫敏卡號(前 6 後 4) |
| `last_four` | string | 卡號末 4 位 |
| `balance` | string (decimal) | 卡內可用餘額 |
| `frozen_balance` | string (decimal) | 卡內凍結餘額 |
| `freeze_type` | int | 凍結型別:`0`=未凍結 `1`=使用者凍結 `2`=系統凍結 `3`=管理員凍結 `4`=風控凍結 |
| `nickname` | string | 卡暱稱(可選) |
| `activated_at` | string nullable | 啟用時間(RFC3339) |
| `created_at` | string | 建立時間(RFC3339) |
::: warning 列表響應中不含敏感持卡人資料
本介面的響應永遠不包含完整卡號、CVV、卡有效期或持卡人姓名 —— 這些欄位在任何情況下都不屬於本介面的契約。內部欄位(DB 主鍵、提供方引用、累計統計、內部標籤等)同樣不會暴露。
如需為單張已啟用的卡獲取完整 PAN / CVV / 到期日 / 持卡人姓名,請改用 [`/openapi/card/info`](./card-info#敏感卡片資訊) 並傳 `with_sensitive=true`(要求憑證已開通敏感欄位訪問,預設關閉)。
:::
### 響應示例
```json
{
"code": 200,
"message": "成功",
"data": {
"list": [
{
"card_id": "card_133",
"card_type": "virtual_r",
"card_brand": "Visa",
"card_holder_name": "John Doe",
"currency": "USD",
"status": 2,
"status_desc": "active",
"masked_card_no": "493724******4245",
"last_four": "4245",
"balance": "37.72",
"frozen_balance": "0.00",
"freeze_type": 0,
"nickname": "我的主卡",
"activated_at": "2026-05-09T01:06:25Z",
"created_at": "2026-05-08T10:55:12Z"
}
],
"total": 1,
"page": 1,
"page_size": 20,
"total_pages": 1,
"has_next": false,
"has_prev": false
}
}
```
### 空列表響應
```json
{
"code": 200,
"message": "成功",
"data": {
"list": [],
"total": 0,
"page": 1,
"page_size": 20,
"total_pages": 0,
"has_next": false,
"has_prev": false
}
}
```
## 典型錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 400 | `openapi_conflicting_card_filters` | `usable_only` 與 `statuses` 不能同時傳 |
| 400 | `openapi_invalid_status_value` | `statuses` 只能填 1–6 |
| 400 | `invalid_min_balance` | `min_balance` 必須是合法非負數字字串 |
| 400 | `invalid_max_balance` | `max_balance` 必須是合法非負數字字串 |
| 400 | `min_balance_exceeds_max_balance` | `min_balance` 不能大於 `max_balance` |
| 400 | `invalid_date_format` | 日期必須為 `YYYY-MM-DD` 格式 |
| 401 | `openapi_invalid_credentials` | HMAC 簽名 / 時間戳 / nonce / 憑證狀態任一失敗 |
| 500 | `openapi_list_cards_failed` | 服務端內部錯誤,建議帶退避重試 |
## 補充說明
1. **資源歸屬**:僅返回當前 `X-App-Id` 關聯的商戶 user_id 名下的卡。即便重置過 secret,憑證按 user_id 隔離,多次 reset 後看到的卡保持一致。
2. **物理卡過濾**:本介面強制只返回虛擬卡。商戶即便透過使用者端 portal 開過物理卡,本介面也不會返回。
3. **歷史卡查詢**:預設隱藏 status ∈ {3, 4, 5, 6} 的卡。需要做對賬時顯式傳 `statuses=[5]` 等查詢。
4. **與其他介面的關聯**:
- 拿到 `card_id` 後,可調 [`/openapi/card/info`](./card-info) 查單卡詳情。
- 可調 [`/openapi/card/recharge`](./card-recharge) 充值。
- **重要**:發起充值前請確保 `status=2`,否則會被拒(`invalid_card_status`)。
5. **不要把 `card_id` 當固定主鍵存**:建議商戶在自己側也用字串儲存 `card_id`,未來如果遷移到不同 ID 編碼方案時減少破壞面。
---
# 首充預覽
[開卡](./card-apply)首充的**只讀試算**。給定 `first_deposit_amount`,它計算 [`/card/apply`](./card-apply) 將要收取的明細——超額部分的手續費、到卡金額、錢包凍結合計——但**不**開卡、不寫庫、不凍結資金。
用於在客戶確認前展示「將要支付多少」的明細,數值與真正開卡 1:1 一致。
::: tip 什麼是首充
卡配置定義了一個**底額**(`base_amount`),按 1:1 進卡、**不收手續費**。商戶可在[開卡](./card-apply)時傳更大的 `first_deposit_amount` 加充;超出底額的部分(**超額**)按充值口徑收取手續費,扣費後按 1:1 進卡(USDT/USD,不走匯率)。最終發給提供方的首充始終為整數。
:::
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/first_deposit/preview |
| 鑑權 | HMAC |
| 冪等 | 無需(只讀) |
## 請求欄位
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `header_id` | string | ✅ | `hdr_` 格式([列卡頭](./card-headers-list))|
| `package_id` | string | ✅ | `pkg_` 格式([列卡配置](./card-configs-list))|
| `first_deposit_amount` | string | 可選 | 首充總額,**非負整數**,單位為支付/充值資產(如 USDT)。空 = 用配置底額。必須 `>= base_amount`。科學計數法、小數、符號一律拒絕。 |
### 請求示例
```json
{
"header_id": "hdr_119",
"package_id": "pkg_33",
"first_deposit_amount": "16"
}
```
## 響應欄位
::: info 始終返回 200(優雅語義)
即便金額不可提交,本介面也返回 `200`。用 `is_valid` 判斷[開卡](./card-apply)是否會成功,用 `invalid_reason` 取穩定原因碼。僅當 ID 非法、卡型別不支援或服務端故障時才返回非 200。
:::
| 欄位 | 型別 | 說明 |
|---|---|---|
| `package_id` | string | 回顯,`pkg_` |
| `card_type` | string | 小寫業務碼,如 `virtual_v` |
| `currency` | string | 卡內幣種(如 `USD`) |
| `pay_asset_symbol` | string | 支付/充值資產符號(如 `USDT`) |
| `open_card_fee` | string(小數) | 開卡費(支付資產) |
| `base_amount` | string(小數) | 底額——1:1 進卡、不收費 |
| `request_amount` | string(小數) | 本次試算的首充總額 |
| `excess_amount` | string(小數) | 超出底額的部分(`request_amount − base_amount`) |
| `excess_fee_amount` | string(小數) | 超額手續費(手續費資產) |
| `excess_fee_asset_symbol` | string | 手續費資產符號(無超額時省略) |
| `excess_settle_amount` | string(小數) | 超額扣費後金額(取整進卡前) |
| `excess_card_amount` | string(小數) | 超額實際到卡金額(取整後) |
| `exchange_rate` | string | 相容欄位;USDT/USD 1:1,恆為空 |
| `first_recharge_card` | string(小數) | 卡內首充合計(`底額 + 到卡超額`);整數 |
| `total_freeze` | string(小數) | 錢包凍結合計(`開卡費 + 卡內到賬 + 超額手續費`);取整尾差**不**收取 |
| `fee_type` | int | 充值手續費型別:`1`=固定 `2`=百分比 `3`=混合 |
| `fee_rate` | string | 費率(百分比/混合時);否則省略 |
| `fee_fixed` | string | 固定費(固定/混合時);否則省略 |
| `min_recharge_amount` | string(小數) | 配置最小充值(展示用) |
| `max_recharge_amount` | string(小數) | 配置最大充值——**超額**受此上限約束 |
| `wallet_balance` | string(小數) | 你賬號在支付資產下的可用餘額 |
| `wallet_balance_sufficient` | bool | 餘額(含 USD 1:1 補足)是否夠凍結 `total_freeze` |
| `usd_balance` | string(小數) | 你的 USD 餘額(僅用於 USDT→USD 1:1 補足判定) |
| `will_use_usd` | bool | 是否會用到 USD 1:1 補足 |
| `usd_needed` | string(小數) | 經補足會動用的 USD 金額 |
| `is_valid` | bool | 用此金額[開卡](./card-apply)是否會被受理 |
| `invalid_reason` | string | `is_valid=false` 時的穩定原因碼(有效時省略) |
| `warnings` | string[] | 可選提示碼 |
### 響應示例(可提交)
```json
{
"code": 200,
"message": "成功",
"data": {
"package_id": "pkg_33",
"card_type": "virtual_v",
"currency": "USD",
"pay_asset_symbol": "USDT",
"open_card_fee": "1.000000000000000000",
"base_amount": "10",
"request_amount": "16",
"excess_amount": "6",
"excess_fee_amount": "1.22",
"excess_fee_asset_symbol": "USDT",
"excess_settle_amount": "4.78",
"excess_card_amount": "4",
"first_recharge_card": "14",
"total_freeze": "16.22",
"fee_type": 3,
"fee_rate": "0.020000",
"fee_fixed": "1.100000000000000000",
"min_recharge_amount": "10.000000000000000000",
"max_recharge_amount": "100.000000000000000000",
"wallet_balance": "70.82999088",
"wallet_balance_sufficient": true,
"usd_balance": "0",
"will_use_usd": false,
"usd_needed": "0",
"is_valid": true
}
}
```
本例中:`base=10` 的配置上首充 `16`,`10` 按 1:1 免費進卡,超額 `6` 收取 `1.22` 手續費、剩 `4` 進卡——故卡內到賬 `14`,錢包凍結 `16.22`。
### 響應示例(不可提交)
```json
{
"code": 200,
"message": "成功",
"data": {
"package_id": "pkg_33",
"card_type": "virtual_v",
"is_valid": false,
"invalid_reason": "first_recharge_exceeds_max",
"base_amount": "10",
"request_amount": "9999",
"max_recharge_amount": "100.000000000000000000"
}
}
```
## `invalid_reason` 原因碼
`is_valid=false` 時會附帶以下穩定原因碼之一(與[開卡](./card-apply)返回的 `message_key` 同 key):
| invalid_reason | 含義 |
|---|---|
| `invalid_first_deposit_amount` | 不是純非負整數(小數 / 符號 / 科學計數法 / 位數過多) |
| `first_recharge_below_base` | 金額低於 `base_amount` |
| `first_recharge_exceeds_max` | 超額超過 `max_recharge_amount` |
| `first_recharge_limit_exceeded` | 超額超過賬號充值限額 |
| `first_recharge_asset_mismatch` | 開卡費資產 ≠ 充值資產;該配置不支援自定義超額 |
| `first_recharge_excess_too_small` | 扣費 + 取整後到卡為 0,請加大金額 |
| `insufficient_balance` | 錢包(含 USD 補足)不足以凍結 `total_freeze` |
| `bank_card_config_not_found` | 套餐未啟用 / 不存在 |
| `bank_card_header_not_found` | 卡頭未啟用 / 不存在 |
## 典型錯誤
非 200 響應(入參非法 / 卡型別不支援 / 服務端故障):
| HTTP | message_key | 說明 |
|---|---|---|
| 400 | `openapi_invalid_header_id` | `header_id` 缺失 / 字首錯誤 / 不存在 |
| 400 | `openapi_invalid_package_id` | `package_id` 缺失 / 字首錯誤 |
| 400 | `openapi_card_type_not_supported` | header 對應的卡型別不在虛擬卡範圍 |
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 500 | `openapi_first_deposit_preview_failed` | 服務端異常 |
## 注意事項
- **數值與開卡完全一致**:預覽複用開卡的計算邏輯,相同 `first_deposit_amount` 下 `total_freeze` / `first_recharge_card` 與[開卡](./card-apply)實收一致
- **無資金移動**:本介面不凍結、不寫庫,可按需多次呼叫(受[限流](/zh-TW/guide/rate-limit)約束)
- `wallet_balance` 是**你自己**賬號的餘額——不暴露任何第三方資料
- 超額手續費與[建立充值](./card-recharge)使用同一套充值費率公式;費率也可經[列卡配置](./card-configs-list)按套餐獲取
---
# 交易列表
返回當前 `AppID` 賬號下 **所有虛擬卡** 的 **交易明細**(消費、退款、撤銷、手續費等),支援分頁和豐富的篩選條件。
- 範圍始終限於你自己的賬號 —— 服務端會根據你的 HMAC 憑證繫結賬號,無法查詢其他賬號的資料。
- 嚴格 **脫敏**:不含完整 PAN、不含持卡人 PII、不含提供商側 / 內部訂單引用。每條記錄都攜帶 `card_id` + `last_four`,便於你歸因到具體卡片。
- 如需查詢 **單張** 卡,請使用 [卡片交易列表](./card-transactions-list)(或在此處以可選篩選項傳入 `card_id`)。
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/transactions/list |
| 鑑權 | HMAC |
| 冪等鍵 | 不需要 |
## 請求欄位
所有欄位均為可選。
| 欄位 | 型別 | 預設值 | 說明 |
|---|---|---|---|
| `card_id` | string | — | 可選篩選項 —— `card_`。傳入後結果僅限該卡(會校驗歸屬 + 虛擬卡型別)。 |
| `transaction_time_from` | string | — | 交易時間範圍起點,格式 `YYYY-MM-DD HH:MM:SS` |
| `transaction_time_to` | string | — | 交易時間範圍終點,格式 `YYYY-MM-DD HH:MM:SS` |
| `type` | string | — | 型別篩選 —— 見 [型別取值](#type-values) |
| `status` | string | — | 狀態篩選 —— 取 `PENDING` / `APPROVED` / `FAILED` / `REVERSED` 之一 |
| `amount_from` | string | — | 交易金額下限,如 `"10.00"` |
| `amount_to` | string | — | 交易金額上限,如 `"1000.00"` |
| `merchant_name` | string | — | 商戶名稱(模糊匹配) |
| `keyword` | string | — | 自由文本關鍵詞(在商戶 / 描述 / 城市 等上模糊匹配) |
| `page` | int | 1 | 頁碼(≥ 1) |
| `page_size` | int | 20 | 每頁數量(≤ 100) |
### 請求示例
```json
{
"status": "APPROVED",
"transaction_time_from": "2026-04-01 00:00:00",
"transaction_time_to": "2026-04-30 23:59:59",
"page": 1,
"page_size": 20
}
```
## 響應
一個分頁信封(`list` / `total` / `page` / `page_size` / `total_pages` / `has_next` / `has_prev`)。`list` 中的每一項都是一筆交易:
### 交易欄位
| 欄位 | 型別 | 說明 |
|---|---|---|
| `transaction_id` | string | `txn_` —— 不透明令牌,請勿解析 |
| `card_id` | string | 該交易所屬的 `card_` |
| `card_type` | string | 小寫業務程式碼(`virtual_v` …) |
| `card_brand` | string | 卡組織展示名(如 `VISA`) |
| `last_four` | string | 卡號末 4 位(絕不含完整 PAN) |
| `type` | string | 歸一化後的大寫型別(`PURCHASE` / `REFUND` / `AUTHORIZATION` / `REVERSAL` / `FEE` …) |
| `type_category` | string | 用於標籤配色的歸一化分類 —— 見 [分類](#type-categories) |
| `type_i18n` | object | `{ "en-US": …, "zh-CN": …, "zh-HK": … }` 展示文案 |
| `status` | string | `PENDING` / `APPROVED` / `FAILED` / `REVERSED` |
| `transaction_time` | string nullable | 交易發生時間(提供商時鐘)。未知時為 `null` |
| `transaction_currency` | string | 交易幣種(ISO 4217) |
| `transaction_amount` | string | 交易金額(小數字符串,2 位小數) |
| `billing_currency` | string | 賬單 / 卡內幣種 |
| `billing_amount` | string | 賬單金額(小數字符串,2 位小數) |
| `merchant_name` | string | 商戶名稱 |
| `merchant_id` | string | 商戶 ID(由卡組織上報) |
| `merchant_category` | string | 商戶分類 / MCC 標籤 |
| `merchant_country` | string | 商戶國家(如 `US`) |
| `merchant_city` | string | 商戶城市 |
| `merchant_logo_url` | string | 品牌 Logo URL(解析完成前可能為空) |
| `approval_code` | string | 批准碼(對賬用) |
| `auth_code` | string | 授權碼(對賬用) |
| `cross_border_type` | string | `0` = 境內,`1` = 跨境 |
| `decline_reason` | string | 失敗 / 拒付原因(如適用) |
| `description` | string | 交易描述 |
| `remark` | string | 備註 |
| `created_at` | string | 記錄入庫時間 |
::: tip 省略欄位是刻意的
為空的可選字串欄位會從 JSON 中整體省略(而非 `null`)。文件所述結構即完整契約 —— 內部 DB ID、提供商交易 ID、關聯訂單號、使用者身份以及完整 PAN **絕不**返回。參見 [ID 與字首](/zh-TW/guide/ids-and-prefixes)。
:::
### 響應示例
```json
{
"code": 200,
"message": "OK",
"data": {
"list": [
{
"transaction_id": "txn_9087654",
"card_id": "card_12345",
"card_type": "virtual_v",
"card_brand": "VISA",
"last_four": "1234",
"type": "PURCHASE",
"type_category": "consumption",
"type_i18n": { "en-US": "Purchase", "zh-CN": "消費", "zh-HK": "消費" },
"status": "APPROVED",
"transaction_time": "2026-04-12T08:31:20Z",
"transaction_currency": "USD",
"transaction_amount": "12.90",
"billing_currency": "USD",
"billing_amount": "12.90",
"merchant_name": "OPENAI",
"merchant_country": "US",
"merchant_logo_url": "https://img.logo.dev/openai.com",
"approval_code": "091234",
"cross_border_type": "0",
"created_at": "2026-04-12T08:31:25Z"
}
],
"total": 1,
"page": 1,
"page_size": 20,
"total_pages": 1,
"has_next": false,
"has_prev": false
}
}
```
## 型別取值 {#type-values}
`type` 請求篩選項接受以下標準列舉(服務端會對映到各提供商的原始值):
| 值 | 含義 |
|---|---|
| `PURCHASE` | 消費 / 已結算購買 |
| `AUTHORIZATION` | 預授權(佔用,未結算) |
| `REFUND` | 退款 |
| `REVERSAL` | 撤銷 |
| `TOPUP` | 充值 / 充值入賬 |
| `WITHDRAW` | 提現 |
| `FEE` | 手續費 |
未知值會做精確匹配(通常返回空結果)。響應中的 `type` 欄位是歸一化後的大寫原始型別;請在你的一側對映用於展示,或使用 `type_i18n`。
## 型別分類 {#type-categories}
`type_category` 取以下之一:`consumption` · `refund` · `reversal` · `topup` · `fee` · `close` · `transfer` · `withdraw` · `interest` · `3ds` · `unknown`。
## 隱藏的交易型別
內部記賬型別(如系統追加的跨境手續費行、銷卡記賬條目)會從本介面隱藏,與面向客戶的 App 一致。卡片銷卡時間之後的交易同樣被排除。
## 常見錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 400 | `openapi_invalid_card_id` | `card_id` 篩選項錯誤 |
| 400 | `openapi_card_type_not_supported` | `card_id` 篩選項指向非虛擬卡 |
| 400 | `invalid_date_format` | `transaction_time_from/to` 不符合 `YYYY-MM-DD HH:MM:SS` |
| 400 | `invalid_params` | 金額格式錯誤(`amount_from` / `amount_to`) |
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 404 | `card_not_found` | `card_id` 篩選項不存在 / 不屬於當前賬號 |
| 500 | `openapi_list_transactions_failed` | 服務端異常 |
## 說明
- 資料來自 Coinepay 的本地賬本(由提供商 webhook 填充);本介面**不會**同步呼叫上游提供商。
- 即時流水優先使用 webhook(`card.*`);本介面用於定期對賬與歷史查詢。
---
# Webhook 事件歷史
查詢當前賬號的 webhook **投遞歷史**。常用場景:
- 排查"為什麼我的 webhook 沒收到"
- 監控 dead_letter 狀態的死信
- 與本地業務記錄對賬
## 端點
| 項 | 值 |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/webhook_events/list |
| 鑑權 | HMAC |
| 冪等鍵 | 不需要 |
## 請求欄位
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
| `event_type` | string | 否 | 過濾事件型別,如 `card.opened` |
| `status` | int | 否 | 過濾投遞狀態(見下表) |
| `page` | int | 否 | 預設 1 |
| `page_size` | int | 否 | 預設 20,最大 100 |
### 投遞狀態
| status | status_desc | 含義 |
|---|---|---|
| 0 | `pending` | 等待首次投遞 |
| 1 | `delivered` | 投遞成功 |
| 2 | `failed_retry` | 失敗但仍在重試 |
| 3 | `dead_letter` | 已死信,不再重試 |
| 4 | `skipped` | 跳過(如 webhook URL 未配置)|
### 請求示例
```json
{ "event_type": "card.opened", "status": 1, "page": 1, "page_size": 20 }
```
## 響應欄位
`data.list[]` 中每項:
| 欄位 | 型別 | 說明 |
|---|---|---|
| `event_id` | string | `evt_`(與 `Webhook-Id` 頭去字首後一致) |
| `event_type` | string | 事件型別 |
| `status` | int | 見上表 |
| `status_desc` | string | 英文描述 |
| `attempt_count` | int | 已嘗試次數 |
| `next_attempt_at` | string nullable | 下次重試時間(RFC3339) |
| `last_error` | string | 最近一次失敗原因 |
| `last_response_status` | int | 最近一次 HTTP 狀態碼(0 = 網路層失敗) |
| `delivered_at` | string nullable | 投遞成功時間 |
| `created_at` | string | 事件建立時間 |
### 響應示例
```json
{
"code": 200,
"message": "成功",
"data": {
"list": [
{
"event_id": "evt_550e8400-e29b-41d4-a716-446655440000",
"event_type": "card.opened",
"status": 1,
"status_desc": "delivered",
"attempt_count": 1,
"next_attempt_at": null,
"last_error": "",
"last_response_status": 200,
"delivered_at": "2026-04-29T11:00:13Z",
"created_at": "2026-04-29T11:00:12Z"
}
],
"total": 12,
"page": 1,
"page_size": 20,
"total_pages": 1,
"has_next": false,
"has_prev": false
}
}
```
## 典型錯誤
| HTTP | message_key | 說明 |
|---|---|---|
| 401 | `openapi_invalid_credentials` | 鑑權失敗 |
| 500 | `openapi_list_webhook_events_failed` | 服務端異常 |
## 排查 Webhook 投遞問題
### 場景 1:本地一直沒收到 webhook
```bash
# 看是不是根本沒投遞
{ "status": 0 } # 仍 pending → 系統側延遲,等幾秒再看
{ "status": 4 } # skipped → 你 webhook URL 沒配 / 已停用
# 或失敗重試中
{ "status": 2 } # 看 last_error / last_response_status 判斷
```
### 場景 2:懷疑漏事件
```bash
# 列你的 card_id 相關事件
{ "event_type": "card.opened", "page_size": 100 }
# 與本地接收記錄對賬
```
### 場景 3:確認死信
```bash
{ "status": 3 }
# 死信通常是:URL 永久 4xx / 永久 timeout / DNS 失敗
# 修好接收器後,聯絡支援手動重投或忽略
```
---
# 程式碼樣例
下面 6 種語言的樣例**全部已測試**,包含:
- HMAC 簽名生成
- HTTP 呼叫封裝
- Webhook 驗籤
::: tip 選哪個
- **快速驗證** → [cURL](./curl) 單行命令即可
- **生產服務** → 選你的棧:[Python](./python) / [Node](./nodejs) / [Go](./go) / [Java](./java) / [PHP](./php)
:::
## 各語言依賴
| 語言 | 依賴 | 備註 |
|---|---|---|
| Python | `requests`(或 `httpx` / `urllib3`) | 標準庫已含 `hmac` / `hashlib` / `secrets` |
| Node.js | 僅 Node.js ≥ 18(含 native fetch) | `crypto` 是內建 |
| Go | 標準庫 `crypto/hmac` / `crypto/sha256` / `net/http` | 無第三方依賴 |
| Java | JDK 11+ 標準庫(`HttpClient` / `Mac` / `MessageDigest`) | 無第三方依賴 |
| PHP | PHP 7.4+ 含 `curl` 擴充套件 | `hash_hmac` / `random_bytes` 是內建 |
| cURL | `openssl` + `awk` | 通用 shell 工具 |
## 共同變數
```
APP_ID = "cp_a1b2c3d4..." # 31 字元,cp_ + 28 hex
SECRET = "f1e2d3c4..." # 64 hex 字元
BASE = "https://api.coinepay.net"
```
::: warning 別 hardcode
樣例裡 `SECRET` 是為演示直接寫死的;**生產程式碼請從環境變數 / Vault / Secret Manager 讀取**。
:::
---
# cURL
適合**快速驗證鑑權鏈路**或在 CI 裡跑健康檢查。
## 依賴
- `bash` / `zsh`
- `openssl`
- `awk`
::: tip 不要在 cURL 命令上構建生產業務
shell 轉義複雜、錯誤處理弱。生產請用 [Python](./python) / [Node](./nodejs) / [Go](./go) 等。
:::
## 列卡頭(最簡單的成功路徑)
```bash
APP_ID="cp_a1b2c3d4..."
SECRET="f1e2d3c4..."
BASE="https://api.coinepay.net"
PATH_VAL="/api/v1/openapi/card_headers/list"
BODY='{"page":1,"page_size":20}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(echo -n "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
SIGN_INPUT=$(printf 'POST\n%s\n\n%s\n%s\n%s' "$PATH_VAL" "$TS" "$NONCE" "$BODY_HASH")
SIG=$(echo -n "$SIGN_INPUT" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -X POST "$BASE$PATH_VAL" \
-H "X-App-Id: $APP_ID" \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Signature: $SIG" \
-H "Content-Type: application/json" \
-d "$BODY"
```
## 申請虛擬卡(帶冪等鍵)
```bash
PATH_VAL="/api/v1/openapi/card/apply"
BODY='{"header_id":"hdr_5","package_id":"pkg_12","first_name":"John","last_name":"Doe"}'
IDEMPOTENCY_KEY=$(uuidgen) # macOS/Linux 通用
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(echo -n "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
SIGN_INPUT=$(printf 'POST\n%s\n\n%s\n%s\n%s' "$PATH_VAL" "$TS" "$NONCE" "$BODY_HASH")
SIG=$(echo -n "$SIGN_INPUT" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -X POST "$BASE$PATH_VAL" \
-H "X-App-Id: $APP_ID" \
-H "X-Timestamp: $TS" \
-H "X-Nonce: $NONCE" \
-H "X-Signature: $SIG" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d "$BODY"
```
## 通用簽名指令碼
把以下儲存為 `~/sign.sh` 後 `chmod +x ~/sign.sh`:
```bash
#!/usr/bin/env bash
# 用法:sign.sh
# 輸出:4 行 header(X-App-Id / X-Timestamp / X-Nonce / X-Signature)
# 呼叫前需 export APP_ID 和 SECRET
set -euo pipefail
METHOD="${1:-POST}"
PATH_VAL="$2"
BODY="${3:-{}}"
: "${APP_ID:?APP_ID env var required}"
: "${SECRET:?SECRET env var required}"
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(echo -n "$BODY" | openssl dgst -sha256 -hex 2>/dev/null | awk '{print $2}')
SIGN_INPUT=$(printf '%s\n%s\n\n%s\n%s\n%s' "$METHOD" "$PATH_VAL" "$TS" "$NONCE" "$BODY_HASH")
SIG=$(echo -n "$SIGN_INPUT" | openssl dgst -sha256 -hmac "$SECRET" -hex 2>/dev/null | awk '{print $2}')
cat <
# Go
::: info 相容性
Go **1.21+**。無第三方依賴。
:::
## 客戶端實現
```go
package coinepay
import (
"bytes"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const (
AppID = "cp_a1b2c3d4..."
Secret = "f1e2d3c4..."
Base = "https://api.coinepay.net"
)
var httpClient = &http.Client{Timeout: 30 * time.Second}
// Call 通用 OpenAPI 呼叫
func Call(method, path string, body any, idempotencyKey string) ([]byte, error) {
var bodyBytes []byte
if body != nil {
var err error
bodyBytes, err = json.Marshal(body)
if err != nil {
return nil, err
}
} else {
bodyBytes = []byte("{}")
}
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return nil, err
}
nonce := hex.EncodeToString(nonceBytes)
ts := fmt.Sprintf("%d", time.Now().Unix())
sum := sha256.Sum256(bodyBytes)
bodyHash := hex.EncodeToString(sum[:])
signInput := fmt.Sprintf("%s\n%s\n\n%s\n%s\n%s", method, path, ts, nonce, bodyHash)
mac := hmac.New(sha256.New, []byte(Secret))
mac.Write([]byte(signInput))
sig := hex.EncodeToString(mac.Sum(nil))
req, err := http.NewRequest(method, Base+path, bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("X-App-Id", AppID)
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", sig)
req.Header.Set("Content-Type", "application/json")
if idempotencyKey != "" {
req.Header.Set("Idempotency-Key", idempotencyKey)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// VerifyWebhook Webhook 驗籤
func VerifyWebhook(sigHeader, tsHeader string, rawBody []byte, webhookSecret string) bool {
const prefix = "v1,"
if len(sigHeader) <= len(prefix) || sigHeader[:len(prefix)] != prefix {
return false
}
sig := sigHeader[len(prefix):]
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(tsHeader))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}
```
## 使用示例
```go
package main
import (
"encoding/json"
"fmt"
"log"
"github.com/google/uuid"
"yourapp/coinepay"
)
type ApplyResp struct {
Code int `json:"code"`
Data struct {
CardID string `json:"card_id"`
Status int `json:"status"`
StatusDesc string `json:"status_desc"`
} `json:"data"`
}
func main() {
// 1. 申請虛擬卡(帶冪等鍵)
body := map[string]any{
"header_id": "hdr_5",
"package_id": "pkg_12",
"first_name": "John",
"last_name": "Doe",
}
raw, err := coinepay.Call("POST", "/api/v1/openapi/card/apply", body, uuid.New().String())
if err != nil {
log.Fatal(err)
}
var resp ApplyResp
if err := json.Unmarshal(raw, &resp); err != nil {
log.Fatal(err)
}
fmt.Printf("Created card: %s, status=%d\n", resp.Data.CardID, resp.Data.Status)
}
```
## Webhook 接收(net/http)
```go
package main
import (
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"time"
"yourapp/coinepay"
)
var webhookSecret = os.Getenv("COINEPAY_WEBHOOK_SECRET")
func webhookHandler(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
sig := r.Header.Get("Webhook-Signature")
ts := r.Header.Get("Webhook-Timestamp")
eventID := r.Header.Get("Webhook-Id")
eventType := r.Header.Get("Webhook-Type")
if !coinepay.VerifyWebhook(sig, ts, raw, webhookSecret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// 時間戳 ±5 分鐘
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil || abs(time.Now().Unix()-tsInt) > 300 {
http.Error(w, "timestamp expired", http.StatusUnauthorized)
return
}
// 基於 event_id 去重
if alreadyProcessed(eventID) {
w.WriteHeader(http.StatusOK)
return
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if err := enqueue(eventType, payload); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
markProcessed(eventID)
w.WriteHeader(http.StatusOK)
}
func abs(n int64) int64 {
if n < 0 {
return -n
}
return n
}
func main() {
http.HandleFunc("/webhook", webhookHandler)
http.ListenAndServe(":3000", nil)
}
```
## Webhook 接收(Gin)
```go
import (
"github.com/gin-gonic/gin"
"io"
)
func setupRouter() *gin.Engine {
r := gin.Default()
r.POST("/webhook", func(c *gin.Context) {
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
c.AbortWithStatus(400)
return
}
sig := c.GetHeader("Webhook-Signature")
ts := c.GetHeader("Webhook-Timestamp")
if !coinepay.VerifyWebhook(sig, ts, raw, webhookSecret) {
c.AbortWithStatus(401)
return
}
// ...處理
c.Status(200)
})
return r
}
```
---
# Java
::: info 相容性
JDK **11+**(原生 `HttpClient`)。無第三方依賴。
:::
## 客戶端實現
```java
package com.example.coinepay;
import java.net.URI;
import java.net.http.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.UUID;
public class coinepayClient {
private static final String APP_ID = "cp_a1b2c3d4...";
private static final String SECRET = "f1e2d3c4...";
private static final String BASE = "https://api.coinepay.net";
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
public static String call(String path, String body, String idempotencyKey) throws Exception {
if (body == null) body = "{}";
String ts = String.valueOf(System.currentTimeMillis() / 1000);
byte[] nonceBytes = new byte[16];
new SecureRandom().nextBytes(nonceBytes);
String nonce = bytesToHex(nonceBytes);
String bodyHash = sha256Hex(body.getBytes("UTF-8"));
String signInput = "POST\n" + path + "\n\n" + ts + "\n" + nonce + "\n" + bodyHash;
String sig = hmacHex(SECRET, signInput);
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.timeout(Duration.ofSeconds(30))
.header("X-App-Id", APP_ID)
.header("X-Timestamp", ts)
.header("X-Nonce", nonce)
.header("X-Signature", sig)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
if (idempotencyKey != null) b.header("Idempotency-Key", idempotencyKey);
HttpResponse resp = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
return resp.body();
}
/** Webhook 驗籤 */
public static boolean verifyWebhook(String sigHeader, String tsHeader,
byte[] rawBody, String webhookSecret) throws Exception {
if (sigHeader == null || !sigHeader.startsWith("v1,")) return false;
String sig = sigHeader.substring(3);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(webhookSecret.getBytes("UTF-8"), "HmacSHA256"));
mac.update((tsHeader + ".").getBytes("UTF-8"));
mac.update(rawBody);
String expected = bytesToHex(mac.doFinal());
return constantTimeEquals(sig, expected);
}
private static String hmacHex(String key, String data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes("UTF-8"), "HmacSHA256"));
return bytesToHex(mac.doFinal(data.getBytes("UTF-8")));
}
private static String sha256Hex(byte[] b) throws Exception {
return bytesToHex(MessageDigest.getInstance("SHA-256").digest(b));
}
private static String bytesToHex(byte[] b) {
StringBuilder sb = new StringBuilder(b.length * 2);
for (byte x : b) sb.append(String.format("%02x", x));
return sb.toString();
}
private static boolean constantTimeEquals(String a, String b) {
if (a.length() != b.length()) return false;
int diff = 0;
for (int i = 0; i < a.length(); i++) diff |= a.charAt(i) ^ b.charAt(i);
return diff == 0;
}
}
```
## 使用示例
```java
public class Demo {
public static void main(String[] args) throws Exception {
// 1. 列卡頭
String resp = coinepayClient.call(
"/api/v1/openapi/card_headers/list",
"{\"page\":1,\"page_size\":20}",
null);
System.out.println(resp);
// 2. 申請虛擬卡(帶冪等鍵)
String idempotencyKey = UUID.randomUUID().toString();
String body = """
{"header_id":"hdr_5","package_id":"pkg_12",
"first_name":"John","last_name":"Doe"}
""";
String applyResp = coinepayClient.call(
"/api/v1/openapi/card/apply", body, idempotencyKey);
System.out.println(applyResp);
}
}
```
## Webhook 接收(Spring Boot)
```java
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
import javax.servlet.http.HttpServletRequest;
@RestController
public class WebhookController {
private static final String WEBHOOK_SECRET = System.getenv("COINEPAY_WEBHOOK_SECRET");
@PostMapping(value = "/webhook", consumes = "application/json")
public ResponseEntity handleWebhook(
@RequestBody byte[] rawBody,
@RequestHeader(value = "Webhook-Signature", required = false) String sig,
@RequestHeader(value = "Webhook-Timestamp", required = false) String ts,
@RequestHeader(value = "Webhook-Id", required = false) String eventId,
@RequestHeader(value = "Webhook-Type", required = false) String eventType
) throws Exception {
if (!coinepayClient.verifyWebhook(sig, ts, rawBody, WEBHOOK_SECRET)) {
return ResponseEntity.status(401).body("invalid signature");
}
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - Long.parseLong(ts)) > 300) {
return ResponseEntity.status(401).body("timestamp expired");
}
if (alreadyProcessed(eventId)) return ResponseEntity.ok("");
String payload = new String(rawBody, "UTF-8");
enqueue(eventType, payload); // 重活丟佇列
markProcessed(eventId);
return ResponseEntity.ok("");
}
}
```
::: warning Spring Boot 接收 raw body
預設 Spring 會基於 `@RequestBody` 反序列化。**用 `byte[]` 型別**才能拿到原始位元組,避免被 JSON parse + re-serialize。
:::
## 注意事項
- `secretSpec` 用 `getBytes("UTF-8")` —— 不要用平臺預設編碼
- Java 中字串拼接 `\n` 在 source code 裡就是 `0x0A`,無需特殊處理
- 不要用 `MessageDigest.isEqual` 之外的常量時間比較;Java 字串 `equals` 不是常量時間
---
# Node.js
::: info 相容性
Node.js **≥ 18**(原生 `fetch`)。無第三方依賴。
:::
## 客戶端實現
```js
import crypto from 'node:crypto'
const APP_ID = 'cp_a1b2c3d4...'
const SECRET = 'f1e2d3c4...'
const BASE = 'https://api.coinepay.net'
function sign(method, path, body) {
const ts = Math.floor(Date.now() / 1000).toString()
const nonce = crypto.randomBytes(16).toString('hex')
const bodyHash = crypto.createHash('sha256').update(body).digest('hex')
const signInput = `${method}\n${path}\n\n${ts}\n${nonce}\n${bodyHash}`
const sig = crypto.createHmac('sha256', SECRET).update(signInput).digest('hex')
return {
'X-App-Id': APP_ID,
'X-Timestamp': ts,
'X-Nonce': nonce,
'X-Signature': sig,
}
}
export async function call(path, bodyDict, idempotencyKey) {
const body = JSON.stringify(bodyDict ?? {})
const headers = {
...sign('POST', path, body),
'Content-Type': 'application/json',
}
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey
const res = await fetch(BASE + path, { method: 'POST', headers, body })
return res.json()
}
// Webhook 驗籤
export function verifyWebhook(sigHeader, tsHeader, rawBody, webhookSecret) {
if (!sigHeader || !sigHeader.startsWith('v1,')) return false
const sig = sigHeader.slice(3)
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(`${tsHeader}.`)
.update(rawBody)
.digest('hex')
// 恆定時間比較
return (
Buffer.byteLength(sig) === Buffer.byteLength(expected) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
)
}
```
## 使用示例
```js
import { randomUUID } from 'node:crypto'
import { call } from './coinepay.js'
// 1. 列卡頭
let resp = await call('/api/v1/openapi/card_headers/list', { page: 1, page_size: 20 })
console.log(resp.data.list[0].header_id)
// 2. 列卡配置
resp = await call('/api/v1/openapi/card_configs/list', { header_id: 'hdr_5' })
const packageId = resp.data.list[0].package_id
// 3. 申請虛擬卡(帶冪等鍵)
resp = await call(
'/api/v1/openapi/card/apply',
{
header_id: 'hdr_5',
package_id: packageId,
first_name: 'John',
last_name: 'Doe',
},
randomUUID(),
)
const cardId = resp.data.card_id
console.log(`Created card: ${cardId}`)
// 4. 建立充值
resp = await call(
'/api/v1/openapi/card/recharge',
{ card_id: cardId, amount: '100.00' },
randomUUID(),
)
console.log(`Transaction: ${resp.data.transaction_id}`)
```
## Webhook 接收(Express)
```js
import express from 'express'
import { verifyWebhook } from './coinepay.js'
const app = express()
const WEBHOOK_SECRET = process.env.COINEPAY_WEBHOOK_SECRET
// 關鍵:raw body,不是 JSON parse 後的物件
app.post('/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.header('Webhook-Signature') || ''
const ts = req.header('Webhook-Timestamp') || ''
const eventId = req.header('Webhook-Id') || ''
const eventType = req.header('Webhook-Type') || ''
if (!verifyWebhook(sig, ts, req.body, WEBHOOK_SECRET)) {
return res.status(401).send('invalid signature')
}
// 時間戳 ±5 分鐘
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) {
return res.status(401).send('timestamp expired')
}
// event_id 去重
if (await alreadyProcessed(eventId)) return res.sendStatus(200)
const payload = JSON.parse(req.body.toString('utf8'))
await enqueue(eventType, payload) // 重活丟佇列
await markProcessed(eventId)
res.sendStatus(200)
})
app.listen(3000)
```
## Webhook 接收(Fastify)
```js
import Fastify from 'fastify'
import { verifyWebhook } from './coinepay.js'
const fastify = Fastify()
// 保留 raw body
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(req, body, done) => {
req.rawBody = body
try {
done(null, JSON.parse(body.toString('utf8')))
} catch (e) {
done(e)
}
},
)
fastify.post('/webhook', async (req, reply) => {
const sig = req.headers['webhook-signature'] || ''
const ts = req.headers['webhook-timestamp'] || ''
if (!verifyWebhook(sig, ts, req.rawBody, process.env.COINEPAY_WEBHOOK_SECRET)) {
return reply.code(401).send('invalid signature')
}
await enqueue(req.headers['webhook-type'], req.body)
return { ok: true }
})
fastify.listen({ port: 3000 })
```
## TypeScript 型別
```ts
export interface ApiResponse {
code: number
message: string
message_key?: string
data: T | null
}
export interface PageData {
list: T[]
total: number
page: number
page_size: number
total_pages: number
has_next: boolean
has_prev: boolean
}
export interface CardInfo {
card_id: string
card_type: 'virtual_l' | 'virtual_p' | 'virtual_v' | 'virtual_r' | 'virtual_g'
card_brand: string
currency: string
status: 1 | 2 | 3 | 4 | 5 | 6
status_desc: string
masked_card_no: string
last_four: string
balance: string
frozen_balance: string
activated_at: string | null
created_at: string
}
```
---
# PHP
::: info 相容性
PHP **7.4+**。僅依賴 `curl` 擴充套件(預設開啟)。
:::
## 客戶端實現
```php
true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $bodyStr,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
]);
$resp = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($resp === false) throw new RuntimeException("HTTP error: $err");
$decoded = json_decode($resp, true);
if ($decoded === null) throw new RuntimeException("Invalid JSON: $resp");
return $decoded;
}
function verifyWebhook(string $sigHeader, string $tsHeader, string $rawBody, string $webhookSecret): bool {
if (strpos($sigHeader, 'v1,') !== 0) return false;
$sig = substr($sigHeader, 3);
$expected = hash_hmac('sha256', $tsHeader . '.' . $rawBody, $webhookSecret);
return hash_equals($sig, $expected);
}
```
## 使用示例
```php
1, 'page_size' => 20]);
$headerId = $resp['data']['list'][0]['header_id'];
// 2. 列卡配置
$resp = callOpenAPI('/api/v1/openapi/card_configs/list', ['header_id' => $headerId]);
$packageId = $resp['data']['list'][0]['package_id'];
// 3. 申請虛擬卡(帶冪等鍵)
function uuidv4(): string {
$data = random_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
$resp = callOpenAPI('/api/v1/openapi/card/apply', [
'header_id' => $headerId,
'package_id' => $packageId,
'first_name' => 'John',
'last_name' => 'Doe',
], uuidv4());
$cardId = $resp['data']['card_id'];
echo "Created card: $cardId\n";
// 4. 建立充值
$resp = callOpenAPI('/api/v1/openapi/card/recharge', [
'card_id' => $cardId,
'amount' => '100.00',
], uuidv4());
echo "Transaction: " . $resp['data']['transaction_id'] . "\n";
```
## Webhook 接收(原生 PHP)
```php
300) {
http_response_code(401);
exit('timestamp expired');
}
if (alreadyProcessed($eventId)) {
http_response_code(200);
exit;
}
$payload = json_decode($rawBody, true);
enqueue($eventType, $payload); // 重活丟佇列
markProcessed($eventId);
http_response_code(200);
echo 'ok';
```
## Webhook 接收(Laravel)
```php
getContent();
$sig = $request->header('Webhook-Signature', '');
$ts = $request->header('Webhook-Timestamp', '');
if (!verifyWebhook($sig, $ts, $rawBody, env('COINEPAY_WEBHOOK_SECRET'))) {
abort(401, 'invalid signature');
}
if (abs(time() - intval($ts)) > 300) {
abort(401, 'timestamp expired');
}
$eventId = $request->header('Webhook-Id');
if (\Cache::has("webhook:$eventId")) return response()->noContent();
ProcessWebhook::dispatch($request->header('Webhook-Type'), json_decode($rawBody, true));
\Cache::put("webhook:$eventId", true, now()->addDays(7));
return response()->noContent();
}
}
```
## 注意事項
- `JSON_UNESCAPED_UNICODE` 讓中文不轉義成 `\uXXXX`,與伺服器期望一致
- 用 `hash_equals` 而非 `===` 比較簽名(防 timing attack)
- Laravel 預設會讀兩次 `php://input` 失敗 —— 用 `$request->getContent()`
---
# Python
::: info 相容性
Python 3.6+。僅依賴 `requests`,其他都是標準庫。
:::
## 安裝依賴
```bash
pip install requests
```
## 客戶端實現
```python
import time, hmac, hashlib, secrets, json
import requests
APP_ID = "cp_a1b2c3d4e5f6071829304a5b6c7d8e9f"
SECRET = "f1e2d3c4b5a69788776655443322110099aabbccddeeff00112233445566778899"
BASE = "https://api.coinepay.net"
def call(method: str, path: str, body_dict: dict | None = None,
idempotency_key: str | None = None) -> dict:
"""統一呼叫 OpenAPI"""
body = json.dumps(body_dict or {}, separators=(",", ":")).encode("utf-8")
body_hash = hashlib.sha256(body).hexdigest()
ts = str(int(time.time()))
nonce = secrets.token_hex(16)
sign_input = f"{method}\n{path}\n\n{ts}\n{nonce}\n{body_hash}"
sig = hmac.new(SECRET.encode(), sign_input.encode(), hashlib.sha256).hexdigest()
headers = {
"X-App-Id": APP_ID,
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": sig,
"Content-Type": "application/json",
}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
return requests.request(method, BASE + path, headers=headers, data=body, timeout=30).json()
# Webhook 驗籤
def verify_webhook(signature_header: str, timestamp_header: str,
raw_body: bytes, webhook_secret: str) -> bool:
if not signature_header.startswith("v1,"):
return False
sig = signature_header[3:]
expected = hmac.new(
webhook_secret.encode(),
f"{timestamp_header}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sig, expected)
```
## 使用示例
```python
import uuid
# 1. 列卡頭
resp = call("POST", "/api/v1/openapi/card_headers/list", {"page": 1, "page_size": 20})
print(resp["data"]["list"][0]["header_id"])
# 2. 列卡配置
resp = call("POST", "/api/v1/openapi/card_configs/list", {"header_id": "hdr_5"})
package_id = resp["data"]["list"][0]["package_id"]
# 3. 申請虛擬卡(帶冪等鍵)
resp = call("POST", "/api/v1/openapi/card/apply",
{"header_id": "hdr_5", "package_id": package_id,
"first_name": "John", "last_name": "Doe"},
idempotency_key=str(uuid.uuid4()))
card_id = resp["data"]["card_id"]
print(f"Created card: {card_id}, status={resp['data']['status']}")
# 4. 建立充值
resp = call("POST", "/api/v1/openapi/card/recharge",
{"card_id": card_id, "amount": "100.00"},
idempotency_key=str(uuid.uuid4()))
print(f"Transaction: {resp['data']['transaction_id']}")
```
## Webhook 接收(FastAPI)
```python
from fastapi import FastAPI, Request, HTTPException
import os
app = FastAPI()
WEBHOOK_SECRET = os.environ["COINEPAY_WEBHOOK_SECRET"]
@app.post("/webhook")
async def webhook(request: Request):
raw = await request.body()
sig = request.headers.get("Webhook-Signature", "")
ts = request.headers.get("Webhook-Timestamp", "")
event_id = request.headers.get("Webhook-Id", "")
event_type = request.headers.get("Webhook-Type", "")
if not verify_webhook(sig, ts, raw, WEBHOOK_SECRET):
raise HTTPException(401, "invalid signature")
# 時間戳 ±5 分鐘內
if abs(int(time.time()) - int(ts)) > 300:
raise HTTPException(401, "timestamp expired")
# 基於 event_id 去重
if already_processed(event_id):
return {"ok": True}
payload = json.loads(raw)
handle_event(event_type, payload)
mark_processed(event_id)
return {"ok": True}
```
::: warning 重活丟佇列
`handle_event` 應只做"持久化 + 入隊"。**不要**在 webhook handler 裡做耗時操作(資料庫慢查詢、外部呼叫),5 秒不返回 2xx 就會重投。
:::
## 錯誤處理與重試
```python
import time
class coinepayError(Exception): ...
class RateLimited(coinepayError): ...
class IdempotencyConflict(coinepayError): ...
def call_with_retry(method, path, body=None, idempotency_key=None,
max_attempts=4):
for attempt in range(max_attempts):
resp = call(method, path, body, idempotency_key)
code = resp.get("code", 500)
if code == 200:
return resp["data"]
if code == 429 and attempt < max_attempts - 1:
time.sleep(min(2 ** attempt, 30))
continue
if code >= 500 and attempt < max_attempts - 1:
time.sleep(2 ** attempt)
continue
if code == 409:
raise IdempotencyConflict(resp.get("message"))
raise coinepayError(f"{code}: {resp.get('message')}")
raise coinepayError("max attempts exceeded")
```
---
# 更新日誌
所有破壞性變更(刪欄位、改語義、改型別)都會**升 major 版本**並提前公告。新增欄位是非破壞性變更。
## v1.2(當前)
### 新增
- **2026-07-07**:新增卡片**凍結 / 解凍**與**交易明細**端點(4 個)。非破壞性變更——不改動任何已有端點。
- `POST /api/v1/openapi/transactions/list` —— [查詢名下所有虛擬卡的交易明細](../api/transactions-list)
- `POST /api/v1/openapi/card/transactions/list` —— [查詢單張卡的交易明細](../api/card-transactions-list)
- `POST /api/v1/openapi/card/freeze` —— [凍結一張活躍虛擬卡](../api/card-freeze)
- `POST /api/v1/openapi/card/unfreeze` —— [解凍使用者主動凍結的卡](../api/card-unfreeze)
- 交易物件嚴格脫敏:僅返回 `last_four`(絕不返回完整 PAN),不含持卡人 PII,不含供應商側 / 內部訂單引用。
- 凍結 / 解凍**無需** `Idempotency-Key`——不涉及資金變動,且被卡狀態機保護(重複凍結 → `card_already_frozen`;重複解凍 → `card_not_frozen`)。僅*使用者主動凍結*可透過 `/card/unfreeze` 解凍;風控 / 管理員 / 系統凍結返回 `403`。
## v1.1
### 新增
- **2026-06-15**:[`/card/apply`](../api/card-apply) 透過可選的 `first_deposit_amount` 支援**自定義首充金額**,並新增只讀的 [`/card/first_deposit/preview`](../api/first-deposit-preview) 試算端點。非破壞性變更——`first_deposit_amount` 可選(空 = 配置底額);預覽是新增端點,不改動任何已有端點。
- **2026-05-26**:新增受支援的 `card_type` 取值 `virtual_g`(虛擬卡 - G 類)。非破壞性變更——無新增端點、無 schema 變化;未整合 G 卡的客戶端不受影響。
- 8 個 OpenAPI endpoint:
- `POST /api/v1/openapi/card_headers/list`
- `POST /api/v1/openapi/card_configs/list`
- `POST /api/v1/openapi/cards/list`
- `POST /api/v1/openapi/card/apply`
- `POST /api/v1/openapi/card/first_deposit/preview`
- `POST /api/v1/openapi/card/recharge`
- `POST /api/v1/openapi/card/info`
- `POST /api/v1/openapi/webhook_events/list`
- HMAC-SHA256 鑑權(4 個 header)
- 冪等鍵支援(`/card/apply` / `/card/recharge`)
- Webhook 推送:`webhook.test` / `card.opened` / `card.open_failed` / `card.recharged` / `card.recharge_failed` / `card.closed` / `card.status_changed`
- [`/card/info`](../api/card-info#敏感卡片資訊) 透過 `with_sensitive=true` 選擇性返回完整 PAN / CVV / 到期日 / 持卡人姓名 —— 需憑證被授予該訪問權且卡處於 active 狀態。預設關閉,開通需審批。
### 欄位封裝規則
- 所有對外 ID 是帶業務字首的字串(`card_` / `pkg_` / `hdr_` / `txn_` / `evt_`),當作不透明 token 使用。
- 資產引用使用 ISO 4217 幣種程式碼 / 資產符號(字串,不暴露數字 ID)。
- `card_type` 全小寫業務程式碼(如 `virtual_v`)。
- 充值 `status` 用業務碼:`2` 成功 / `3` 失敗。
- 開卡 / 銷卡 webhook `status` 用字串:`opened` / `open_failed` / `closed`。
- 內部欄位(DB 主鍵、提供方訂單引用、中間計算值、內部標籤等)不會透過響應返回。
### 範圍
- 僅虛擬卡:`virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g`
- 不含實體卡 / 轉賬介面(計劃在後續版本)
## 計劃(後續版本)
::: info 僅供參考,實際以發版為準
- 獨立沙箱環境
- 暴露 `GET /openapi.json`(自動生成的 OpenAPI 3.0 spec)
- 暴露 `GET /openapi.postman.json`(自動生成的 Postman Collection)
:::
## 相容性承諾
- 同 major 版本(v1.x)內:**只新增、不刪欄位**
- 預設值、約束(長度、型別)**不會向後不相容地變**
- `message_key` 一旦釋出**永久穩定**
- 刪除欄位或改語義會升 major 版本並保留舊版至少 6 個月
## 反饋
- 郵箱:admin@coinepay.cc
---
# 常量字典
下面是 OpenAPI v1.2 所有"不會變"的值。任何破壞性變更都會**透過 changelog + 版本號公告**,並提前通知整合方。
## Base URL
| 環境 | URL |
|---|---|
| 生產 | `https://api.coinepay.net` |
| 沙箱 | 暫無(v1.2 不提供) |
| 本地開發 | `http://localhost:8801` |
## API 字首
```
/api/v1/openapi
```
## HTTP 約定
| 項 | 值 |
|---|---|
| Method | 全部 `POST`(專案硬性約束) |
| 請求 Content-Type | `application/json` |
| 響應 Content-Type | `application/json; charset=utf-8` |
| 最大請求體 | **4 MB**(4194304 位元組) |
| 字元集 | UTF-8 |
## 鑑權
| 項 | 值 |
|---|---|
| 演算法 | HMAC-SHA256 |
| 必需 header | `X-App-Id` / `X-Timestamp` / `X-Nonce` / `X-Signature` |
| AppID 格式 | `cp_<28 hex>`,固定長度 31 |
| Secret 格式 | 64 hex 字元 |
| Secret 字首展示 | `<前 4 字元>****` |
| Timestamp 單位 | **秒**(不是毫秒) |
| 時間戳容差 | ±300 秒 |
| Nonce 長度 | 8~64 字元 |
| Nonce 防重放視窗 | 600 秒 |
| 簽名格式 | lowercase hex |
| 簽名長度 | 64 字元 |
## 冪等
| 項 | 值 |
|---|---|
| Header | `Idempotency-Key` |
| 必填介面 | `/api/v1/openapi/card/apply` / `/api/v1/openapi/card/recharge` |
| 最大長度 | 128 字元 |
| 去重視窗 | **24 小時** |
| 衝突狀態碼 | 409 |
## 限流
| 項 | 值 |
|---|---|
| 維度 | (AppID, IP) |
| 配額 | 600 / 分鐘 |
| 視窗 | 60 秒(滾動) |
| 超出 | HTTP 429 |
## Webhook 推送
| 項 | 值 |
|---|---|
| 簽名 header | `Webhook-Signature` |
| 簽名格式 | `v1,` |
| 簽名輸入 | `{timestamp}.{raw_body}` |
| 事件 ID header | `Webhook-Id` |
| 時間戳 header | `Webhook-Timestamp` |
| 型別 header | `Webhook-Type` |
| 重試退避(秒) | `[60, 300, 900, 3600, 21600, 86400]` |
| 總投遞次數 | **7**(1 次首投 + 6 次重試,之後 `dead_letter`)|
| 接收方響應頭超時 | 5 秒 |
| 接收方整請求總超時 | 10 秒 |
| 必須 HTTPS | ✅(生產;dev 環境 `AllowHTTP=true` 時方可放行 HTTP) |
| 必須公網 IP | ✅(拒絕私網 / loopback / link-local) |
| 允許埠 | 生產環境**僅 443** |
## 卡型別(v1.2 OpenAPI 範圍)
| card_type | 說明 |
|---|---|
| `virtual_l` | 虛擬卡 - L 類 |
| `virtual_p` | 虛擬卡 - P 類 |
| `virtual_v` | 虛擬卡 - V 類 |
| `virtual_r` | 虛擬卡 - R 類 |
| `virtual_g` | 虛擬卡 - G 類 |
::: info OpenAPI 不接受非虛擬卡
`master_e` / `visa_h` / `transfer` 等不在範圍內,會返回 400 `openapi_card_type_not_supported`。
:::
::: warning apply 介面不傳 card_type
[`/card/apply`](/zh-TW/api/card-apply) 不需要傳 `card_type`,由 `header_id` 自動推導。
:::
## 業務字首
| 資源 | 字首 | 示例 |
|---|---|---|
| 卡 | `card_` | `card_12345` |
| 套餐 | `pkg_` | `pkg_67` |
| 卡頭 | `hdr_` | `hdr_5` |
| 充值訂單 | `txn_` | `txn_OO20260429110012abc` |
| Webhook 事件 | `evt_` | `evt_550e8400-e29b-41d4-a716-446655440000` |
## 常用 SHA-256 常量
| 輸入 | sha256 hex |
|---|---|
| 空字串 | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` |
| `{}` | `44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a` |
## 國際化
| Header | 行為 |
|---|---|
| `Accept-Language` 不傳 | 預設中文 |
| `Accept-Language: zh-CN` | 中文 |
| `Accept-Language: en-US` | 英文 |
`message_key` 不受 `Accept-Language` 影響,永遠穩定(用作程式判斷)。
## 憑證
| 項 | 值 |
|---|---|
| 憑證有效期 | 不過期(除非 reset / disable) |
| 憑證數量上限 | 每使用者 1 條(v1) |
| Reset 後舊 secret | 立即失效 |
| Disable 後 | 401 invalid_credentials;可 enable 恢復 |
---
---