# 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-CN/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-CN/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-CN/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-CN/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-CN/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-CN/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-CN/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-CN/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-CN/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-CN/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-CN/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 恢复 |
---
---