# 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 恢復 | --- ---