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