# Coinepay OpenAPI — Full Text
> Coinepay OpenAPI v1.1 — Virtual card integration docs: HMAC auth, idempotency, webhooks, error codes, code samples.
Each section is preceded by `` for easy LLM slicing.
---
# HMAC Authentication
Coinepay OpenAPI uses **HMAC-SHA256** to sign every request. Clients send 4 headers; the server verifies **identity + time window + replay protection + payload integrity**.
## Required Headers
| Header | Description | Example |
|---|---|---|
| `X-App-Id` | Credential identifier, fixed 31 chars (`cp_` + 28 hex) | `cp_a1b2c3d4e5f6071829304a5b6c7d8e9f` |
| `X-Timestamp` | Unix **seconds** (not ms), ASCII decimal | `1714377600` |
| `X-Nonce` | 8–64 chars, unique per AppID within 10 minutes | `8f7e6d5c4b3a29180a1b2c3d4e5f6071` |
| `X-Signature` | HMAC-SHA256 lowercase hex, 64 chars | `9b8e7f6d5c4b3a...` |
::: warning Time unit
`X-Timestamp` must be **seconds**, not milliseconds. `Math.floor(Date.now() / 1000)`, not `Date.now()`.
:::
## Signing Input Construction
```text
signInput = METHOD + LF + PATH + LF + RAW_QUERY + LF + TIMESTAMP + LF + NONCE + LF + BODY_SHA256_HEX
```
| Symbol | Meaning |
|---|---|
| `LF` | Single byte 0x0A (`\n`), **not `\r\n`** |
| `+` | String concatenation |
## Field Definitions
| Field | Value | Notes |
|---|---|---|
| `METHOD` | Uppercase HTTP method (always `POST` for OpenAPI) | ASCII |
| `PATH` | Request path with leading `/`, **excluding** query string | Do not URL-decode/encode |
| `RAW_QUERY` | Query string without `?` (empty string if none) | Usually empty |
| `TIMESTAMP` | Same string as `X-Timestamp` header | ASCII |
| `NONCE` | Same as `X-Nonce` header | ASCII |
| `BODY_SHA256_HEX` | Lowercase hex of `sha256(body bytes)` | 64 chars |
::: tip SHA256 of empty body
Constant: `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`.
SHA256 of `{}` is `44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a` (note the difference).
:::
## Computing the Signature
```text
signature = lowercase_hex( HMAC_SHA256( secret_bytes, signInput_bytes ) )
```
::: warning Secret encoding
`secret_bytes` is the **UTF-8 bytes** of the secret string (i.e. ASCII bytes of the 64 hex chars).
**Do NOT** hex-decode the secret into 32 bytes before HMAC.
:::
Signature length is fixed at **64 hex chars**.
## Complete Request Example
```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}
```
## Replay Protection
| Check | Value |
|---|---|
| Timestamp tolerance | `[server_now - 300s, server_now + 300s]` (±5 min) |
| Nonce uniqueness window | Same `(app_id, nonce)` may NOT repeat within **10 minutes** |
| Recommended nonce | `crypto/rand` 16 bytes → 32 hex chars |
## Edge Cases
| Scenario | Handling |
|---|---|
| body is empty object `{}` | `BODY_SHA256_HEX = 44136fa3...8a` |
| body is empty string | Use empty body constant `e3b0c44...855` |
| body contains non-ASCII | sha256 over **UTF-8 bytes** |
| body is array `[1,2,3]` | Hash the literal bytes (key order/whitespace doesn't matter once the bytes are fixed) |
| Network middleware rewrites body | Signature fails; avoid any body-rewriting layer |
## Implementation Notes
::: warning Stringify must be deterministic
Different languages may serialize JSON differently (key order, whitespace, escapes). **Whatever bytes the client stringified are the bytes you must hash AND send**. Don't hash one serialization and send another.
:::
## Handling 401
The server returns the **same** `openapi_invalid_credentials` for all auth-failure causes (unknown AppID / bad signature / clock drift / disabled credential / inactive user). This is **anti-enumeration**; clients shouldn't try to distinguish causes.
Debugging steps:
1. Confirm server time vs client time within 5 min (`date +%s` on both sides).
2. Print signInput bytes; confirm `\n` is `0x0A` and there are no `\r`.
3. Confirm `X-Timestamp` header equals the `TIMESTAMP` in signInput exactly (don't compute twice).
4. Confirm `BODY_SHA256_HEX` is over the **actual sent bytes**, not a re-stringified version.
5. Reset credentials and retry.
## Next
- [Idempotency](./idempotency) — safe retries on write endpoints
- [Code Samples](/examples/) — copy-paste-ready clients
---
# Error Codes
Coinepay OpenAPI error responses contain:
- `code`: same as HTTP status (200/400/401/404/409/429/500)
- `message`: localized via `Accept-Language`
- `message_key`: stable English key, **use this for programmatic checks**
```json
{
"code": 400,
"message": "Idempotency-Key conflict",
"message_key": "openapi_idempotency_key_conflict",
"data": null
}
```
## HTTP Status Semantics
| HTTP | Meaning |
|---|---|
| 200 | Success (incl. "idempotent replay" and "queued") |
| 400 | Validation / business-rule failure (use `message_key` to differentiate) |
| 401 | Auth failure (always `openapi_invalid_credentials`, no further detail) |
| 403 | Authenticated but feature-gated (e.g. sensitive card info not enabled, or card not active) |
| 404 | Resource not found / not owned by current account |
| 409 | Idempotency-Key conflict (same key, different body) |
| 429 | Rate limited |
| 500 | Internal server error |
## Full message_key Dictionary
### Auth & Credentials
| message_key | en-US | zh-CN | HTTP |
|---|---|---|---|
| `openapi_invalid_credentials` | Invalid OpenAPI credentials | 无效的 OpenAPI 凭证 | 401 |
### ID Validation
| message_key | en-US | zh-CN | HTTP |
|---|---|---|---|
| `openapi_invalid_header_id` | Invalid header_id | header_id 不合法 | 400 |
| `openapi_invalid_package_id` | Invalid package_id | package_id 不合法 | 400 |
| `openapi_invalid_card_id` | Invalid card_id | card_id 不合法 | 400 |
### Idempotency
| message_key | en-US | zh-CN | HTTP |
|---|---|---|---|
| `openapi_idempotency_key_required` | Missing Idempotency-Key header | 缺少 Idempotency-Key 请求头 | 400 |
| `openapi_idempotency_key_too_long` | Idempotency-Key too long | Idempotency-Key 超长 | 400 |
| `openapi_idempotency_key_invalid_chars` | Invalid characters | 含非法字符 | 400 |
| `openapi_idempotency_key_conflict` | Idempotency-Key conflict | Idempotency-Key 冲突 | 409 |
### Business Rules
| message_key | en-US | zh-CN | HTTP |
|---|---|---|---|
| `openapi_card_type_not_supported` | Not supported (virtual cards only) | 暂不支持该卡类型 | 400 |
| `amount_required` | amount is required | amount 为必填 | 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 not approved | KYC 未通过 | 400 |
### First Deposit (Custom Amount)
Returned by [`/card/apply`](../api/card-apply) (as `message_key`, HTTP 400) and by [`/card/first_deposit/preview`](../api/first-deposit-preview) (as `invalid_reason` inside a `200` body) when a custom `first_deposit_amount` cannot be honored.
| message_key | en-US | zh-CN | HTTP |
|---|---|---|---|
| `invalid_first_deposit_amount` | first_deposit_amount must be a non-negative integer | first_deposit_amount 必须是非负整数 | 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 |
### Sensitive Card Information
These codes are returned by [`/openapi/card/info`](../api/card-info#sensitive-card-information) when `with_sensitive=true` cannot be honored. See the endpoint doc for the dual prerequisite (credential-level enablement + card must be `status=2 (active)`).
| message_key | en-US | zh-CN | HTTP |
|---|---|---|---|
| `openapi_sensitive_card_info_disabled` | Sensitive card info access is disabled. Contact administrator to enable it for your API credential. | 敏感卡片信息访问未启用,请联系管理员为该 API 凭证开启 | 403 |
| `openapi_sensitive_card_only_active` | Sensitive card info is only available for active cards. | 仅激活状态的卡片可获取敏感信息 | 403 |
### Card List Filters
| message_key | en-US | zh-CN | HTTP |
|---|---|---|---|
| `openapi_conflicting_card_filters` | usable_only and statuses cannot be used together | usable_only 与 statuses 不能同时传 | 400 |
| `openapi_invalid_status_value` | statuses contains an invalid value (only 1-6 allowed) | statuses 含非法状态值(仅允许 1-6) | 400 |
| `invalid_min_balance` | 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` | date must be in the expected format | 日期格式不正确 | 400 |
### Transaction Filters
Returned by [`/transactions/list`](../api/transactions-list) and [`/card/transactions/list`](../api/card-transactions-list).
| message_key | en-US | zh-CN | HTTP |
|---|---|---|---|
| `invalid_date_format` | transaction_time_from/to must be `YYYY-MM-DD HH:MM:SS` | 交易时间格式必须为 `YYYY-MM-DD HH:MM:SS` | 400 |
| `invalid_params` | Invalid parameters (e.g. bad amount format) | 参数无效(如金额格式错误) | 400 |
### Freeze / Unfreeze
Returned by [`/card/freeze`](../api/card-freeze) and [`/card/unfreeze`](../api/card-unfreeze).
| message_key | en-US | zh-CN | 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 for this card | 该卡不允许通过充值自动解冻 | 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 |
### Server Errors
| message_key | en-US | zh-CN | HTTP |
|---|---|---|---|
| `openapi_internal_error` | OpenAPI internal error | OpenAPI 内部错误 | 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` | Failed to list webhook events | 查询 Webhook 事件历史失败 | 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 |
## Recommended Handling
```python
def handle_response(resp):
code = resp.get('code', 500)
key = resp.get('message_key', '')
if code == 200:
return resp['data']
if code == 401:
# Always openapi_invalid_credentials
raise AuthError("invalid credentials — check AppID/Secret/clock")
if code == 403:
# Feature-gated: caller is authenticated but lacks permission for this specific request
if key == 'openapi_sensitive_card_info_disabled':
raise PermissionError("Contact support to enable sensitive card info access")
if key == 'openapi_sensitive_card_only_active':
raise RuntimeError("Card must be active before requesting sensitive fields")
raise PermissionError(f"Forbidden: {key}")
if key == 'insufficient_balance':
raise BalanceError()
if key == 'kyc_not_approved' or key == 'kyc_required':
raise KYCError()
if key.startswith('openapi_invalid_') and key.endswith('_id'):
raise ValueError(f"bad ID: {key}")
if key == 'openapi_idempotency_key_conflict':
# Don't auto-retry — caller must check the body
raise IdempotencyConflict()
if code == 429:
raise RateLimited()
if code >= 500:
raise ServerError(resp.get('message', 'internal error'))
raise ApiError(code, key, resp.get('message'))
```
## Localization
Set the `Accept-Language` header to control the `message` language:
```http
Accept-Language: en-US
```
| Value | Behavior |
|---|---|
| Not sent | Default Chinese |
| `zh-CN` / `zh` | Chinese |
| `en-US` / `en` | English |
| Other | Fallback to default |
`message_key` is **independent** of `Accept-Language` and always stable.
---
# Idempotency
To prevent retries from causing **duplicate card opens / duplicate charges**, write endpoints require an `Idempotency-Key` header.
## Which Endpoints Require It
| Endpoint | Required? |
|---|---|
| `/api/v1/openapi/card/apply` | ✅ Yes |
| `/api/v1/openapi/card/recharge` | ✅ Yes |
| All `/list` and `/info` endpoints | ❌ No |
## Header
```http
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
```
| Constraint | Value |
|---|---|
| Length | ≤ **128** chars |
| Charset | URL-safe (UUID v4 / ULID recommended) |
| Dedup window | **24 hours** |
| Missing | 400 `openapi_idempotency_key_required` |
| Too long | 400 `openapi_idempotency_key_too_long` |
| Bad characters | 400 `openapi_idempotency_key_invalid_chars` |
| Same key, different body | 409 `openapi_idempotency_key_conflict` |
## Server Behavior
### Same key + same body
Returns a **copy of the previous response** (the original status code and `data`). If the first call is still processing, the call waits and returns the final result. **Does not** trigger a second card open.
The replayed response carries an extra response header so you can tell it apart from a fresh call:
```http
X-Idempotent-Replay: true
```
::: tip Detecting replays
Treat the `X-Idempotent-Replay: true` header as informational. Either way the response body is the authoritative source of truth — but the header is useful for metrics and debugging ("this attempt actually hit the server but I retried after a network blip").
:::
::: info Only 2xx responses are cached
The server **only** caches the response body when the original call returned **2xx**. If the first call returned `4xx` (e.g. `insufficient_balance`) or `5xx`, the same key + same body **will be re-attempted** on the next request — there's nothing to replay.
In other words, idempotency protects you against losing a successful response, not against repeating a failed business call.
:::
::: warning Captured response size
The server caches at most **256 KB** of the response body. The current OpenAPI responses are well under that ceiling, so this should never be a concern in practice — flagged only for completeness.
:::
### Same key + different body
Returns `409 openapi_idempotency_key_conflict`. The server **refuses** to process. Client should switch keys or fix the body.
### Different keys + same body
**Treated as two independent requests** — opens two cards / charges twice. **By design** — idempotency is keyed by the header, not the body.
::: warning Don't use a body hash as the key
Using "request body hash" as the Idempotency-Key would deduplicate "user clicks recharge twice for the same amount" into a single charge. **The key represents one business intent**; generate a new key for each new business intent.
:::
## Recommended Patterns
### Client SDK pattern
```python
import uuid
def apply_card(header_id, package_id):
key = str(uuid.uuid4()) # new key per business call
return call("/api/v1/openapi/card/apply",
{"header_id": header_id, "package_id": package_id},
idempotency_key=key)
```
### Async-task pattern
If your "apply card" task is retried by a job queue on network failure, **bind the key to the task record**:
```python
def open_card_task(task_id, header_id, package_id):
# All retries of the same task use the same key
key = f"openapi:apply:{task_id}"
return call("/api/v1/openapi/card/apply", {...}, idempotency_key=key)
```
That way, even if the task is retried N times, Coinepay opens at most one card.
## Relationship with Signatures
`Idempotency-Key` is **not** part of the signing input (the signature covers method/path/query/timestamp/nonce/body sha256). Each request still needs a fresh nonce. They are **two independent mechanisms**:
| Mechanism | Protects against |
|---|---|
| Nonce | An attacker **capturing** a signature and **replaying** the request |
| Idempotency-Key | **You** safely retrying after a network failure |
Coordinating both: when the client retries the same business call, use the **same Idempotency-Key + new Nonce + new Timestamp + new Signature**.
## Error Responses
### Missing (400)
```json
{
"code": 400,
"message": "Missing Idempotency-Key header",
"message_key": "openapi_idempotency_key_required",
"data": null
}
```
### Conflict (409)
```json
{
"code": 409,
"message": "Idempotency-Key conflict",
"message_key": "openapi_idempotency_key_conflict",
"data": null
}
```
---
# IDs & Prefixes
All public resource IDs are **prefixed strings** rather than raw integer DB keys. When you send IDs back, **keep the prefix as-is**.
## Prefix Table
| Resource | Prefix | Example | Where it appears |
|---|---|---|---|
| Card | `card_` | `card_12345` | apply response / `card_id` field on subsequent endpoints |
| Package | `pkg_` | `pkg_67` | card_configs/list response / apply request |
| Card header | `hdr_` | `hdr_5` | card_headers/list response / apply request |
| Recharge transaction | `txn_` | `txn_OO20260429110012abc` | recharge response / webhook payload |
| Webhook event | `evt_` | `evt_550e8400-e29b-41d4-a716-446655440000` | webhook_events/list response |
::: tip Webhooks use the same convention
All IDs in webhook payloads are also prefixed. The `Webhook-Id` header value with `evt_` stripped is the original UUID.
:::
::: warning `transaction_id` is opaque
Treat `txn_<...>` as an **opaque string** for storage, lookup and reconciliation. The internal structure after the `txn_` prefix is server-generated and **not stable** across versions or providers — never parse, slice or pattern-match it. Internal order references are intentionally not exposed.
:::
## Validation Rules
| Error | Returns |
|---|---|
| Missing prefix (e.g. sending `5` instead of `hdr_5`) | 400 `openapi_invalid_header_id` |
| Resource doesn't exist / not owned by you | 400 `openapi_invalid_*_id` (same key as "missing prefix" — no leakage) |
::: warning No distinction between "format error" and "not found"
`openapi_invalid_*_id` covers all of:
- Missing prefix
- Wrong prefix
- ID not owned by current AppID's account
- ID does not exist
The server **deliberately** returns the same key — preventing enumeration attackers from probing valid ID ranges via response differences.
:::
## Internal Fields That Are Never Exposed
The response shape documented for each endpoint is the complete contract. Internal database identifiers, provider-side order references, intermediate computation values, and internal labels are never returned via OpenAPI.
If you see anything outside the documented response shape, please [report it to the Coinepay team](mailto:admin@coinepay.cc).
## Tips
- Store IDs **with** their prefix; send them back **as-is** (don't strip the digit part)
- Logs/alerts should include the full ID (`card_12345`) for debugging
- Differentiate from your internal IDs: Coinepay's IDs are `card_xxx`; your internal IDs should use a different prefix (e.g. `kart_xxx`) to avoid confusion.
---
# Overview
Coinepay OpenAPI v1.2 is a set of HMAC-authenticated HTTPS REST APIs focused on **virtual card** issuance, recharge and status queries. All endpoints are `POST` only; both requests and responses are `application/json`.
## Key Characteristics
- **POST-only**: every endpoint is `POST` — easy uniform middleware on both ends.
- **HMAC-SHA256 auth**: 4 headers (`X-App-Id` / `X-Timestamp` / `X-Nonce` / `X-Signature`) — no OAuth/JWT.
- **Idempotency**: write endpoints require `Idempotency-Key`, dedup window is 24 hours.
- **Prefixed resource IDs**: every public resource has a business prefix (e.g. `card_12345`, `pkg_67`); internal DB primary keys never leak.
- **Async webhook delivery**: card open / recharge / close completion are pushed actively; same HMAC-SHA256 signing.
- **i18n errors**: switch zh/en with `Accept-Language`, while `message_key` stays stable for programmatic checks.
## Scope (v1.2)
::: tip Currently supported
**Virtual cards only**: `virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g`
:::
::: warning Not supported yet
- Physical cards (`master_e` / `visa_h`)
- Transfer endpoints (`transfer`)
:::
## Recommended Reading Order
1. [Quick Start](./quickstart) — make your first request in 5 minutes
2. [HMAC Authentication](./authentication) — required reading: signature input format
3. [IDs & Prefixes](./ids-and-prefixes) — resource identifier convention
4. [Idempotency](./idempotency) — safe retries on write endpoints
5. [Error Codes](./error-codes) — `message_key` dictionary
6. [Webhook Spec](./webhooks) — receive async events
7. [API Reference](/api/) — request/response schemas for all 12 endpoints
## Endpoint Prefix
```
{base_url}/api/v1/openapi/{endpoint}
```
| Environment | base_url |
|---|---|
| Production | `https://api.coinepay.net` |
| Local dev | `http://localhost:8801` |
::: warning Always use HTTPS
Call the production API over **HTTPS only** at `https://api.coinepay.net`. Your API key and the HMAC signature travel in request headers — a plain-HTTP request would leak them in transit. Pin the host exactly: do **not** add a trailing slash to `base_url`, and never substitute a look-alike domain.
:::
::: info Sandbox
v1.2 does **not** ship a separate sandbox. Use a test account with small amounts in production. See [Sandbox & Testing](./sandbox).
:::
---
# Quick Start
Listing virtual card headers is the fastest way to verify your auth chain (no side effects, no idempotency key needed).
## 1. Get Credentials
Generate a credential pair from the user dashboard:
- `APP_ID`: `cp_` + 28 hex characters (31 total)
- `SECRET`: 64 hex characters
::: warning Store securely
`SECRET` is **only returned once** at generation. If lost you must reset. Save it to KMS / Vault / GCP Secret Manager.
:::
## 2. Copy a Snippet
::: 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. Expected Response
```json
{
"code": 200,
"message": "OK",
"data": {
"list": [
{
"header_id": "hdr_5",
"card_bin": "424242",
"card_brand": "VISA",
"card_area": "United States",
"business_scene": "Cross-border spending",
"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. Full Card-Issuance Flow
```mermaid
sequenceDiagram
participant C as Your service
participant API as Coinepay API
participant W as Your webhook receiver
C->>API: POST /card_headers/list
API-->>C: returns hdr_5 / hdr_8 ...
C->>API: POST /card_configs/list { header_id: "hdr_5" }
API-->>C: returns 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: Card opens after a few seconds
API->>W: POST card.opened webhook
W-->>API: 200 OK (within 5s)
C->>API: POST /card/info { card_id } (optional polling)
API-->>C: status: 2 (active), masked_card_no
```
## 5. Common Errors
| Symptom | Cause |
|---|---|
| 401 invalid_credentials | Bad signature / expired timestamp / nonce replay / unknown AppID |
| 400 openapi_idempotency_key_required | Write endpoint without `Idempotency-Key` header |
| 409 openapi_idempotency_key_conflict | Same key, different body |
| 429 | Rate limit hit (600 req/min) |
See the [error code dictionary](./error-codes).
## Next Steps
- [HMAC Authentication](./authentication) — read the signing spec end-to-end to avoid mysterious 401s
- [Webhook Spec](./webhooks) — receive async events like card-opened
- [Code Samples](/examples/) — full Python / Go / Java / PHP clients
---
# Rate Limit
## Default Quota
| Dimension | Quota |
|---|---|
| Per `(AppID, client IP)` per minute | **600** requests |
| Window | 60 seconds (rolling) |
| Exceeded | HTTP `429` |
Actual quotas may be adjusted based on your account tier.
## 429 Response
```json
{
"code": 429,
"message": "Too many requests, please retry later",
"data": null
}
```
May include a `Retry-After` response header (seconds).
## Client Recommendations
### 1. Backoff retry (for 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 Don't retry forever
Don't retry 4xx responses (except 429) — the request itself is wrong; retrying just repeats the failure.
:::
### 2. Cap concurrency
For batch card-issuance scenarios:
- Single worker, sequential (< 10 QPS)
- Multiple workers behind a queue with a global cap; leave headroom for other traffic
### 3. Don't trigger rate limits "to test"
Each 429 still counts toward the bucket and may block your real traffic. **Test with small manual flows** instead.
## With Idempotency
If you retry due to 429, **keep the same Idempotency-Key** (for write endpoints). If a request reached the backend but the response was lost, the retry will not duplicate the operation.
---
# Sandbox & Testing
## Is there a sandbox?
**v1.2 does not** ship a separate sandbox. Use your **real account** in production with small test amounts.
| Item | Value |
|---|---|
| Production base URL | `https://api.coinepay.net` |
| Sandbox base URL | None |
| Local dev | `http://localhost:8801` (your machine only) |
## Recommended Testing Flow
### 1. Create dedicated test credentials
Don't test with production credentials. Generate independent AppID/Secret in the dashboard for testing.
### 2. Receive webhooks via webhook.site
[https://webhook.site](https://webhook.site) provides free temporary receiver URLs that show full request headers/body.
```bash
# Open webhook.site in browser, copy the unique URL, e.g.:
WEBHOOK_URL="https://webhook.site/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Configure via dashboard or set_webhook endpoint
```
### 3. Walk through "list headers → list configs → apply → webhook → info"
The most complete happy path. See [Quick Start](./quickstart#full-card-issuance-flow).
### 4. Test amount tips
| Item | Recommendation |
|---|---|
| Initial deposit | Pick the cheapest package (typically $5-10) |
| Test recharge | Use the package's `min_recharge` |
| Multiple iterations | Open new cards each time — closing has fees |
## Available card_types
| card_type | Description |
|---|---|
| `virtual_l` | Virtual card class L |
| `virtual_p` | Virtual card class P |
| `virtual_v` | Virtual card class V (VISA) |
| `virtual_r` | Virtual card class R |
| `virtual_g` | Virtual card class G |
::: info Account-dependent
Not every account has every card_type enabled. **The `header_id` values returned from `card_headers/list` are your actual range** — don't hardcode.
:::
## Credential Lifecycle
| Item | Value |
|---|---|
| Validity | Indefinite (until reset / disabled) |
| Per-user limit | 1 active credential pair (v1) |
| After reset | Old SECRET **immediately** invalid; AppID unchanged |
| After disable | 401 invalid_credentials; can re-enable any time |
## Verify a Captured Webhook
Copy the request from webhook.site, run it through the [verification snippet](./webhooks#verification-code-snippets) locally, and confirm `verify_ok = true`.
## When to Contact Support
| Symptom | Self-check first |
|---|---|
| Persistent 401 | See [Auth — handling 401](./authentication#handling-401) |
| No webhook received | Verify URL is public HTTPS, firewall, 5s response, check `webhook_events/list` |
| Card stuck in `pending` | It's async; wait for webhook. Contact if > 30 min. |
| No recharge response | Check `webhook_events/list`; query by `transaction_id` |
Support email: admin@coinepay.cc
---
# Webhook Spec
When async events complete (card opened, recharge succeeded, card closed, etc.), Coinepay actively `POST`s to your configured URL. Clients must **verify the signature + return 2xx within the timeout window**.
## Inbound HTTP Request
```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
```
## Signature Verification
```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 Use the raw body bytes
**Do NOT** `JSON.parse` then `re-stringify` — whitespace / key-order may change and the signature will fail. Use your framework's raw body capability (`bodyParser.raw` in Express, `io.ReadAll(r.Body)` in Go, etc.).
:::
::: tip webhook_secret is NOT the API secret
- API `SECRET` — used for client → server signing.
- `webhook_secret` — used for server → client webhook signing.
The `webhook_secret` returned from `set_webhook` is independent.
:::
## Timestamp Replay Protection
```text
abs(server_now_unix - WebhookTimestamp) <= 300 # ±5 minutes
```
Reject anything outside the window.
## Receiver Requirements
| Item | Requirement |
|---|---|
| Response status | Must be 2xx; otherwise retry triggers |
| Response-header timeout | **5 seconds** — server must receive the response status line + headers within 5 s of finishing the TLS handshake |
| Total request timeout | **10 seconds** — full request including response body must complete within 10 s; slow body streaming counts as a failure |
| Idempotency | Same `Webhook-Id` may arrive multiple times (delivery retries) — dedupe by `Webhook-Id` |
::: tip Pratical guideline
Push heavy work to a background queue and respond `200` immediately. Aim for **< 200 ms** end-to-end on the webhook endpoint to leave headroom for network jitter.
:::
::: warning Idempotency required
Even after you respond 200, packet loss may make Coinepay retry. **Always dedupe by `Webhook-Id`** to avoid double-processing.
:::
## Event Types
| event_type | Trigger | Payload | Card-types covered |
|---|---|---|---|
| `webhook.test` | Console "test" / `set_webhook` async fire-and-test | `{message, sent_at, acknowledge_to_complete_verification}` | All |
| `card.opened` | Virtual card opened | `{card_id, card_type, status: "opened", opened_at, last_four?}` | virtual_l/p/v/r/g |
| `card.open_failed` | Open failed | `{card_id, card_type, status: "open_failed", fail_reason, failed_at}` | virtual_l/p/v/r/g |
| `card.recharged` | Recharge succeeded | `{transaction_id, card_id, status: 2, amount, currency, completed_at}` | All |
| `card.recharge_failed` | Recharge failed | `{transaction_id, card_id, status: 3, amount, currency, fail_reason, failed_at}` | All |
| `card.closed` | Card closed | `{card_id, card_type, status: "closed", closed_at, last_four?}` | virtual_l/p/v/r/g |
| `card.status_changed` | Other status transitions (manual freeze / unfreeze / risk-driven holds) | `{card_id, card_type, from_status, to_status, changed_at, last_four?}` | virtual_l/p/v/r/g |
::: info v1.2 status convention
- Open / close `status` is a **string**: `"opened"` / `"open_failed"` / `"closed"`
- Recharge `status` is a **number**: `2` = success / `3` = failure
- `card_type` is lowercase: `virtual_v`, not `VIRTUAL_V`
:::
## Full Payload Examples
### `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`
Fired when a card transitions between non-terminal states that aren't covered by the dedicated `card.opened` / `card.closed` events — typically **manual freeze / unfreeze** by the cardholder or risk-driven holds.
```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` are lowercase strings (`active` / `frozen` / `pending` / `closing`). For terminal `closed` / `failed` transitions, prefer the more specific `card.closed` / `card.open_failed` events.
## Retry Backoff
| Failure # | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
| 6 | 24 hours |
| 7 | `dead_letter` — no more retries |
::: info Total delivery attempts
Each event has **7 delivery attempts** (1 initial + 6 retries). After the 7th failure the event moves to `dead_letter` and stops retrying.
:::
Inspect dead-letter status via the [Webhook events history endpoint](/api/webhook-events-list).
## fail_reason — Server-Side Desensitization
`fail_reason` on `card.open_failed` and `card.recharge_failed` payloads is a **sanitized**, short, human-readable description. The server strips:
- Provider URLs / IPv4 addresses / email addresses / domain names
- Go-style transport templates (e.g. `Post`, `dial tcp ...`)
- HTML and special characters
- Truncates to **120 characters**
| Failure mode | What you'll see in `fail_reason` |
|---|---|
| **Synchronous** failure (API rejected the call immediately) | `""` (empty string) — render a generic message |
| **Asynchronous** failure (provider returned an error after queueing) | Cleaned short description like `"Insufficient funds"`, `"Card blocked"` — never contains provider domain / IP / internal trace IDs / stack traces |
::: tip Need the raw provider error?
Call [`/api/v1/openapi/webhook_events/list`](/api/webhook-events-list) and look at `last_error` for ops-level diagnostic info. The full provider response is retained server-side; contact support if you need it for an investigation.
:::
## URL Constraints (set_webhook)
::: info Verification lifecycle
Each call to `set_webhook` rotates `webhook_secret` and **clears the verified flag** on the credential. The flag is re-set the moment the next outbound delivery (typically the auto-fired `webhook.test`) returns 2xx, signaling that the new endpoint is reachable. You can read the flag via `get_webhook` to detect "configured but never reached" misconfigurations.
:::
| Constraint | Rule |
|---|---|
| Protocol | **HTTPS** in production (HTTP only allowed in dev when `AllowHTTP=true`) |
| Port | Production: **443 only**. Other ports rejected at delivery time. |
| IP | Must be public IP. Reject `127.0.0.0/8` / `10/8` / `192.168/16` / `172.16-31/12` / `169.254/16` etc. |
| Domain | DNS must resolve to allowed IP range |
| URL length | Recommend < 1024 chars |
::: warning SSRF protection
Rejection of internal / cloud-metadata (`169.254.169.254`) / loopback is **mandatory**. If your receiver is internal, expose it via a reverse proxy with public HTTPS.
:::
## Verification Code Snippets
::: 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)
// process...
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);
}
```
:::
## Common Pitfalls
| Pitfall | Fix |
|---|---|
| Signing over the parsed JSON object | Use raw body bytes |
| Confusing `webhook_secret` with API `SECRET` | They are two independent secrets |
| Comparing signatures with `==` | Use constant-time compare to prevent timing attacks |
| Not returning 2xx within 5 seconds | Push heavy work to a queue; respond 200 immediately. Total request must finish within **10 s** (response-header timeout 5 s) |
| No `Webhook-Id` dedup | Add an idempotency table; same `Webhook-Id` returns 200 without re-processing |
---
# API Reference
## Endpoint Overview
| # | Endpoint | Path | Idempotency Key | Description |
|---|---|---|---|---|
| 1 | [List Card Headers](./card-headers-list) | `/api/v1/openapi/card_headers/list` | — | List available card headers |
| 2 | [List Card Configs](./card-configs-list) | `/api/v1/openapi/card_configs/list` | — | List packages under a header |
| 3 | [List Cards](./cards-list) | `/api/v1/openapi/cards/list` | — | List the merchant's cards (status / date / balance filters) |
| 4 | [Apply Virtual Card](./card-apply) | `/api/v1/openapi/card/apply` | ✅ Required | Async card open |
| 5 | [Preview First Deposit](./first-deposit-preview) | `/api/v1/openapi/card/first_deposit/preview` | — | Dry-run first-deposit fee / freeze |
| 6 | [Create Recharge](./card-recharge) | `/api/v1/openapi/card/recharge` | ✅ Required | Async recharge |
| 7 | [Get Card Info](./card-info) | `/api/v1/openapi/card/info` | — | Status polling |
| 8 | [Webhook Events History](./webhook-events-list) | `/api/v1/openapi/webhook_events/list` | — | Debug webhook delivery |
| 9 | [List Transactions](./transactions-list) | `/api/v1/openapi/transactions/list` | — | Transaction details across all cards |
| 10 | [List Card Transactions](./card-transactions-list) | `/api/v1/openapi/card/transactions/list` | — | Transaction details for one card |
| 11 | [Freeze Card](./card-freeze) | `/api/v1/openapi/card/freeze` | — | Freeze an active virtual card |
| 12 | [Unfreeze Card](./card-unfreeze) | `/api/v1/openapi/card/unfreeze` | — | Unfreeze a user-frozen card |
## Common Conventions
- **Method**: all endpoints are `POST`
- **Content-Type**: `application/json` (both request and response)
- **Auth**: 4 signing headers per request ([HMAC Authentication](/guide/authentication))
- **Localization**: switch zh/en via `Accept-Language: zh-CN | en-US`
- **Response envelope**:
```json
{
"code": 200, // Mirrors HTTP status
"message": "OK",
"message_key": "...", // Only on errors
"data": { ... } // Business payload
}
```
## Pagination
All `*/list` endpoints (except `/card_configs/list`) support pagination:
| Field | Type | Default | Limit |
|---|---|---|---|
| `page` | int | 1 | ≥ 1 |
| `page_size` | int | 20 | ≤ 100 |
Response includes:
```json
{
"list": [...],
"total": 12,
"page": 1,
"page_size": 20,
"total_pages": 1,
"has_next": false,
"has_prev": false
}
```
---
# Apply Virtual Card
**Async** card-open endpoint. A successful response means "queued"; the final result is pushed via [`card.opened` / `card.open_failed` webhook](/guide/webhooks#event-types) or polled via [card/info](./card-info).
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/apply |
| Auth | HMAC |
| Idempotency Key | Required via `Idempotency-Key` header |
## Request Fields
::: warning Don't send card_type
This endpoint **does not** accept `card_type`. The server derives it from `header_id` and verifies it's within the virtual-card range.
:::
| Field | Type | Required | Description |
|---|---|---|---|
| `header_id` | string | ✅ | `hdr_` ([List Card Headers](./card-headers-list)) |
| `package_id` | string | ✅ | `pkg_` ([List Card Configs](./card-configs-list)) |
| `first_name` | string | ⚠️ | Cardholder first name (depends on header.require_phone/email) |
| `last_name` | string | ⚠️ | Last name |
| `phone_code` | string | ⚠️ | Country code (e.g. `86` / `1`) |
| `phone` | string | ⚠️ | Phone number (without country code) |
| `email` | string | ⚠️ | Email |
| `use_bound_email` | bool | No | Default `false` — server uses the `email` you sent. Set `true` to fall back to the account's bound email. |
| `use_bound_phone` | bool | No | Default `false` — server uses the `phone` + `phone_code` you sent. Set `true` to fall back to the account's bound phone. |
| `first_deposit_amount` | string | No | Custom total first deposit, **non-negative integer** (pay/recharge asset unit, e.g. USDT). Empty = config base. The part above `base` is charged a recharge-style fee and frozen alongside the open fee. Dry-run the exact numbers with [Preview First Deposit](./first-deposit-preview). |
::: tip When holder fields are required
- If `header.require_phone == true`: send `phone_code` + `phone` (or set `use_bound_phone == true` to fall back to the account's bound phone)
- If `header.require_email == true`: send `email` (or set `use_bound_email == true` to fall back to the account's bound email)
- `first_name` / `last_name` are usually required
- **OpenAPI default differs from the H5 app**: `use_bound_phone` and `use_bound_email` default to `false` here — the server uses what you sent. Merchants normally don't have bound phones/emails on our side, so falling back to bound values would fail. Send `true` only if you have a verified reason to read the bound value.
:::
### Custom first deposit
By default a card opens with the config's base initial deposit. To top it up, send `first_deposit_amount` (a non-negative integer ≥ `base`). The base is credited 1:1 with no fee; the **excess** above base is charged a recharge-style fee, the remainder is credited 1:1 (USDT/USD, no FX), and the first deposit sent to the provider is rounded to an integer. The open fee + the full computed freeze are held from your wallet at apply time.
::: tip Preview before applying
The validation (format / `>= base` / max recharge / recharge limit / balance) is shared with [Preview First Deposit](./first-deposit-preview). Call the preview first to show the customer the exact `total_freeze` / `first_recharge_card` — the numbers match this endpoint 1:1. Invalid amounts are rejected here with the same keys the preview reports as `invalid_reason` (see [Common Errors](#common-errors)).
:::
### Virtual-G specific constraints
`virtual_g` (G card) has extra constraints enforced server-side beyond what `header.require_*` may say:
| Constraint | Rule | Failure `message_key` |
|---|---|---|
| Email | Always required (server overrides header) | `bank_card_email_required` |
| Phone | `phone_code` + `phone` always required (server overrides header) | `bank_card_phone_required` |
| Cardholder name | `first_name` + `last_name` combined must match `^[A-Za-z]+(?: [A-Za-z]+)*$` and total length ≤ 40 chars (ASCII letters only; single-space separators; no digits, symbols, or non-ASCII) | `virtual_g_name_invalid` |
| Birthday & billing address | **Do not send** — generated server-side automatically | `virtual_g_required_fields_missing` (only if server-side fill fails) |
::: warning Trim and validate the name before calling apply
Many real-world cardholder names contain accents, hyphens, or apostrophes — those are rejected by the provider. Inform end users that G-card applications require an ASCII-letter-only name with single-space separators.
:::
### Example Request
```json
{
"header_id": "hdr_5",
"package_id": "pkg_12",
"first_name": "John",
"last_name": "Doe"
}
```
Required header:
```http
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
```
## Response Fields
| Field | Type | Description |
|---|---|---|
| `card_id` | string | `card_` (used for subsequent queries) |
| `status` | int | 1=pending 2=active 3=failed 4=closing 5=closed 6=frozen |
| `status_desc` | string | English description |
| `created_at` | string | RFC3339 timestamp |
### Example Response (Queued)
```json
{
"code": 200,
"message": "OK",
"data": {
"card_id": "card_12345",
"status": 1,
"status_desc": "pending",
"created_at": "2026-04-29T11:00:12Z"
}
}
```
::: info status=1 doesn't mean ready
`status=1 (pending)` only means "queued". Wait for the `card.opened` webhook, or poll [card/info](./card-info) until you see `status=2 (active)`.
:::
## Subsequent Flow
```mermaid
sequenceDiagram
autonumber
participant C as Client
participant API as Coinepay
participant W as Customer Webhook
C->>API: POST /card/apply (Idempotency-Key)
API-->>C: 200 { card_id, status: 1 }
Note over API: Async open (seconds to minutes)
alt Open succeeded
API->>W: card.opened
W-->>API: 200 OK
else Open failed
API->>W: card.open_failed
W-->>API: 200 OK
end
Note right of C: Or poll /card/info
```
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 400 | `insufficient_balance` | Insufficient balance (open fee + initial deposit) |
| 400 | `invalid_first_deposit_amount` | `first_deposit_amount` is not a plain non-negative integer (decimals / signs / scientific notation / too many digits) |
| 400 | `first_recharge_below_base` | `first_deposit_amount` is below the config base |
| 400 | `first_recharge_exceeds_max` | Excess above base exceeds `max_recharge_amount` |
| 400 | `first_recharge_limit_exceeded` | Excess exceeds the account's recharge limit |
| 400 | `first_recharge_asset_mismatch` | Open-fee asset ≠ recharge asset; custom excess unsupported for this config |
| 400 | `first_recharge_excess_too_small` | After fee + integer rounding nothing reaches the card; increase the amount |
| 400 | `kyc_required` | Please complete identity verification first |
| 400 | `kyc_not_approved` | Account KYC not approved |
| 400 | `bank_card_email_required` | Card type requires email; send `email` or set `use_bound_email=true` (only if the account has one bound) |
| 400 | `bank_card_phone_required` | Card type requires phone; send `phone_code`+`phone` or set `use_bound_phone=true` (only if the account has one bound) |
| 400 | `openapi_card_type_not_supported` | Header's card type not in virtual range |
| 400 | `virtual_g_name_invalid` | (G card) Cardholder name fails ASCII-letters / single-space / ≤40 chars rule |
| 400 | `virtual_g_required_fields_missing` | (G card) Server could not assemble a complete apply payload (rare; usually a missing config or address-pool issue) |
| 400 | `openapi_invalid_header_id` | header_id missing/wrong prefix or not found |
| 400 | `openapi_invalid_package_id` | package_id missing/wrong prefix or not under header |
| 400 | `openapi_idempotency_key_required` | Missing idempotency key |
| 400 | `openapi_idempotency_key_too_long` | Key over 128 chars |
| 400 | `openapi_idempotency_key_invalid_chars` | Bad characters in key |
| 401 | `openapi_invalid_credentials` | Auth failure |
| 409 | `openapi_idempotency_key_conflict` | Same key, different body |
| 500 | `openapi_apply_card_failed` | Server error |
## Notes
- **Open fee + initial deposit** are charged / held immediately. With a custom `first_deposit_amount`, the excess fee is held too (see [Custom first deposit](#custom-first-deposit)). Failed opens auto-refund.
- Async tasks can take a few minutes. **Don't** retry apply because no webhook arrived in 30 seconds (retries must use the same `Idempotency-Key`).
- A returned `card_id` **permanently** identifies the open attempt (even if it ultimately fails).
---
# List Card Configs
Returns the **packages** available under a card header (fees, deposits, monthly fee, etc.). The client picks one based on cost.
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card_configs/list |
| Auth | HMAC |
| Idempotency | Not required |
## Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `header_id` | string | ✅ | `hdr_` format (from [List Card Headers](./card-headers-list)) |
### Example Request
```json
{ "header_id": "hdr_5" }
```
## Response Fields
Each item in `data.list[]`:
| Field | Type | Description |
|---|---|---|
| `package_id` | string | `pkg_` format (used in apply) |
| `name` | object | i18n name |
| `currency` | string | Card currency (ISO 4217) |
| `open_card_fee` | string (decimal) | Card-open fee |
| `open_card_fee_asset` | string | Asset symbol for the fee (e.g. `USDT`) |
| `initial_deposit` | string (decimal) | Initial deposit (held at open) |
| `min_recharge` | string (decimal) | Min single recharge |
| `max_recharge` | string (decimal) | Max single recharge |
| `monthly_fee` | string (decimal) | Monthly fee |
| `monthly_fee_asset` | string | Monthly fee asset symbol |
| `recharge_fee` | [`FeeSpec`](#feespec) | Recharge fee |
| `close_fee` | [`FeeSpec`](#feespec) | Close fee |
| `authorization_fee` | [`FeeSpec`](#feespec) | Authorization transaction fee |
| `cross_border_fee` | [`FeeSpec`](#feespec) | Cross-border fee |
| `refund_fee` | [`FeeSpec`](#feespec) | Refund fee |
| `is_default` | bool | Recommended default package |
| `description` | object | i18n description |
### `FeeSpec`
| Field | Type | Description |
|---|---|---|
| `type` | string | `fixed` / `percent` / `fixed_plus_percent` / `unknown` |
| `rate` | string (decimal) | Percentage rate (when type=percent / fixed_plus_percent) |
| `fixed` | string (decimal) | Fixed fee (when type=fixed / fixed_plus_percent) |
| `asset_symbol` | string | Fee asset symbol (e.g. `USDT`) |
::: tip Default package selection
1. Prefer `is_default == true`
2. Otherwise sort by `(open_card_fee ASC, min_recharge ASC)` and take the first
:::
### Example Response
```json
{
"code": 200,
"message": "OK",
"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
}
}
```
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 400 | `openapi_invalid_header_id` | header_id format error or not found |
| 401 | `openapi_invalid_credentials` | Auth failure |
| 500 | `openapi_list_card_configs_failed` | Server error |
---
# Freeze Card
Freezes an **active** virtual card. Once frozen the card cannot be used for any payment/authorization until you [unfreeze](./card-unfreeze) it.
- The freeze is recorded as a **user-initiated freeze** and can be reversed by the [Unfreeze Card](./card-unfreeze) endpoint.
- Only cards owned by the current `AppID` account and of a **virtual** card type are accepted.
- No money moves — freezing/unfreezing has no fee and does not touch the card balance or your wallet.
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/freeze |
| Auth | HMAC |
| Idempotency | Not required |
::: tip No Idempotency-Key needed
Freeze is naturally idempotent-safe: retrying on an already-frozen card returns `400 card_already_frozen` rather than double-applying. There is no monetary effect to protect against, so no `Idempotency-Key` header is required.
:::
## Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `card_id` | string | ✅ | `card_` — must belong to your account and be a virtual card |
| `reason` | string | optional | Free-text reason recorded with the freeze (max 255 chars) |
### Example Request
```json
{
"card_id": "card_12345",
"reason": "suspected fraud on merchant side"
}
```
## Response Fields
| Field | Type | Description |
|---|---|---|
| `card_id` | string | Echo of the card ID |
| `status` | int | Card status **after** the operation — always `6` (frozen) on success |
| `status_desc` | string | English status description — `frozen` |
| `success` | bool | `true` on success |
### Example Response
```json
{
"code": 200,
"message": "OK",
"data": {
"card_id": "card_12345",
"status": 6,
"status_desc": "frozen",
"success": true
}
}
```
## Preconditions & Rules
1. **Ownership** — `card_id` must belong to the authenticated account, else `404 card_not_found` (no distinction from "not found", to prevent enumeration).
2. **Card type** — only virtual cards (`virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g`) may be operated via OpenAPI.
3. **State** — only `status=2 (active)` cards may be frozen. A `pending` / `failed` / `closing` / `closed` / already-`frozen` card is rejected.
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 400 | `openapi_invalid_card_id` | Missing/invalid `card_id` |
| 400 | `openapi_card_type_not_supported` | Card is not a virtual card type |
| 400 | `card_already_frozen` | Card is already frozen |
| 400 | `card_status_cannot_freeze` | Card is not `active`; cannot be frozen |
| 400 | `operation_not_supported` | This provider/card type does not support freeze |
| 401 | `openapi_invalid_credentials` | Auth failure |
| 404 | `card_not_found` | Card not found / not owned |
| 500 | `openapi_freeze_card_failed` | Server error |
## Notes
- After a successful freeze, [`/card/info`](./card-info) reports `status=6`.
- To reverse, call [Unfreeze Card](./card-unfreeze). Only user-initiated freezes (this endpoint) are reversible by you; freezes applied by risk-control/admin are not.
## Integration pitfalls & best practices
::: warning Read before integrating
These are the failure modes most likely to leave your system's card-state / bookkeeping out of sync with reality.
:::
1. **Synchronous, provider-bound call — allow up to ~60 s.** Freeze calls the upstream card provider inline. Latency is usually a few seconds but can reach 10–15 s, and the server permits up to **60 s**. **Set your HTTP client timeout to ≥ 60 s for this endpoint.** A short timeout (10–30 s) invites pitfall #2.
2. **A client-side timeout does NOT mean the freeze failed.** If your client times out (or the connection drops) *after* the provider already froze the card, you get an error but the card **is** frozen — a silent divergence. **Never record a timeout as "not frozen".** On any timeout / network error, reconcile via [`/card/info`](./card-info): `status=6` → freeze succeeded (proceed); `status=2` → not applied (safe to retry).
3. **No webhook for freeze/unfreeze.** The synchronous response is the **only** signal — this operation does not emit an asynchronous `card.*` webhook. Do not wait for a callback; treat the response (or `/card/info`) as the source of truth.
4. **No `Idempotency-Key`, but safe to retry.** Retries are not deduplicated (each one hits the provider), but the card state machine protects you: re-freezing an already-frozen card returns `400 card_already_frozen`. **Treat `card_already_frozen` as "already in the desired state", not a hard error.** Prefer the reconcile-via-`/card/info` pattern over blind retry loops.
5. **Freeze only blocks *new* payments.** It does not reverse authorizations already approved before the freeze, and moves no money (no fee, balance untouched).
---
# List Card Headers
Returns the **virtual card** headers your account can apply for. A "card header" is defined by BIN + brand + region + business scene.
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card_headers/list |
| Auth | HMAC (4 signing headers) |
| Idempotency | Not required |
| Rate limit | 600 / min |
## Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `page` | int | No | Default 1 |
| `page_size` | int | No | Default 20, max 100 |
| `card_brand` | string | No | Brand code, e.g. `VISA` / `MASTER` |
| `card_area` | string | No | Region code, e.g. `US` |
| `currency` | string | No | Currency filter, e.g. `USD` |
::: tip Don't send card_type
This endpoint only returns **virtual card** headers — `card_type` is not needed.
:::
### Example Request
```json
{
"page": 1,
"page_size": 20,
"card_brand": "VISA"
}
```
## Response Fields
Each item in `data.list[]`:
| Field | Type | Description |
|---|---|---|
| `header_id` | string | `hdr_` format (used in apply) |
| `card_bin` | string | 6-digit BIN |
| `card_type` | string | Lowercase: `virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g` (informational only — **not** sent on apply) |
| `card_brand` | string | Brand display name |
| `card_area` | string | Region display name |
| `business_scene` | string | Business scene |
| `description` | object | i18n: `{"zh-CN": "...", "en-US": "..."}` |
| `features` | string[] | Feature tags (e.g. `["3DS", "EMV"]`) |
| `require_phone` | bool | Whether `phone` / `phone_code` are required when applying |
| `require_email` | bool | Whether `email` is required when applying |
`data` also contains pagination fields (`total` / `page` / `page_size` / `total_pages` / `has_next` / `has_prev`).
### Example Response
```json
{
"code": 200,
"message": "OK",
"data": {
"list": [
{
"header_id": "hdr_5",
"card_bin": "424242",
"card_type": "virtual_v",
"card_brand": "VISA",
"card_area": "United States",
"business_scene": "Cross-border spending",
"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
}
}
```
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 401 | `openapi_invalid_credentials` | Auth failure |
| 500 | `openapi_list_card_headers_failed` | Server error |
## Integration Tips
- **Don't hardcode** `header_id`. Always call this endpoint when integrating a new account.
- The `description` i18n dict is server-rendered — no client-side translation needed.
- `require_phone` / `require_email` determine whether you must include those fields when applying.
---
# Get Card Info
Returns the card's **status, balance, masked PAN** by default.
When **`with_sensitive=true`** is sent AND your API credential has been granted sensitive-field access, this same endpoint additionally returns the **full PAN / CVV / expiry / cardholder name** in the same response. The access is **disabled by default** and must be explicitly granted — see [Sensitive Card Information](#sensitive-card-information) below.
Use cases:
- Poll after async open until `status=2 (active)`
- Periodically sync balance on the customer side
- Investigate missed webhooks
- (With sensitive access) Display PAN / CVV in your own checkout / wallet UI
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/info |
| Auth | HMAC |
| Idempotency | Not required |
## Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `card_id` | string | ✅ | `card_` |
| `with_sensitive` | bool | optional | When `true`, request full PAN / CVV / expiry / cardholder name. Defaults to `false`. Requires both server-side enablement and `status=2 (active)`. |
### Example Request — default (status & balance only)
```json
{ "card_id": "card_12345" }
```
### Example Request — sensitive fields
```json
{
"card_id": "card_12345",
"with_sensitive": true
}
```
## Response Fields
### Always returned
| Field | Type | Description |
|---|---|---|
| `card_id` | string | Echo |
| `card_type` | string | Lowercase: `virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g` |
| `card_brand` | string | Brand display name |
| `currency` | string | Card currency |
| `status` | int | 1=pending 2=active 3=failed 4=closing 5=closed 6=frozen |
| `status_desc` | string | English description |
| `masked_card_no` | string | Masked PAN (first 6 + last 4), e.g. `424242******1234` |
| `last_four` | string | Last 4 digits |
| `balance` | string (decimal) | Available balance |
| `frozen_balance` | string (decimal) | Frozen balance |
| `activated_at` | string nullable | Activation time (after success) |
| `created_at` | string | Creation time |
### Returned only when `with_sensitive=true` AND access is enabled AND `status=2`
| Field | Type | Description |
|---|---|---|
| `card_number` | string | Full PAN (no spaces / dashes) |
| `cvv` | string | Card verification value (3 digits for Visa/Mastercard) |
| `expiry_date` | string | Expiry, format `MM/YY` |
| `first_name` | string | Cardholder first name (as provided at apply time) |
| `last_name` | string | Cardholder last name |
These fields are entirely **omitted** from the response (not present at all, not `null` or empty) when any of the three preconditions is unmet.
### Example Response — default
```json
{
"code": 200,
"message": "OK",
"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"
}
}
```
### Example Response — with sensitive fields
```json
{
"code": 200,
"message": "OK",
"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 Codes
| Value | Name | Meaning |
|---|---|---|
| 1 | pending | Opening |
| 2 | active | Activated, usable |
| 3 | failed | Open failed (terminal) |
| 4 | closing | Closing |
| 5 | closed | Closed (terminal) |
| 6 | frozen | Frozen (user / risk) |
## Sensitive Card Information
::: warning Off by default — explicit approval required
Sensitive-field access (`card_number` / `cvv` / `expiry_date` / `first_name` / `last_name`) is **disabled by default for every API credential**. Sending `with_sensitive=true` without prior approval will return `403 openapi_sensitive_card_info_disabled` and the sensitive fields will be omitted.
To request enablement, **contact your account manager** or submit a support ticket. You will need to:
- State the business reason (e.g. displaying the card in your own checkout)
- Confirm your environment is PCI-DSS compliant for storing/displaying PAN
- Provide your `AppID` (`cp_xxxx...`) so the access can be granted to the correct credential
:::
### Access preconditions
All three must be satisfied for sensitive fields to be returned:
1. **Request** — caller explicitly sends `with_sensitive: true`
2. **Authorization** — sensitive access has been granted to your API credential (and the platform-wide policy permits it)
3. **Card state** — the card must be `status=2 (active)`; `pending` / `failed` / `closing` / `closed` / `frozen` are rejected
If any precondition fails, the response is `403` with the corresponding `message_key`. Fields are not partially returned — it is all-or-nothing per request.
### Compliance & handling recommendations
::: danger Handle returned PAN as out-of-scope PCI data
- **Do not log** the full `card_number` / `cvv` to your application logs or APM
- **Do not persist** PAN / CVV to your own database or analytics warehouse
- **Pass through** to your front-end via TLS only; render and discard
- **Mask before display** when not actively in use; prefer `masked_card_no`
- Rotate your OpenAPI secret immediately if you suspect compromise
:::
### Operational notes
- Sensitive access is **revocable**. If support revokes it, subsequent `with_sensitive=true` calls return `403` within ~5 minutes (propagation window).
- The two `403` error codes are distinct from the `401` returned by the auth middleware — `403` means the request was authenticated correctly but the requested feature is gated.
- This endpoint never returns CVV via webhook payloads; sensitive fields are only available through this synchronous call.
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 400 | `openapi_invalid_card_id` | Bad card ID |
| 400 | `openapi_card_type_not_supported` | Sensitive fields requested for a non-virtual card type |
| 401 | `openapi_invalid_credentials` | Auth failure |
| 403 | `openapi_sensitive_card_info_disabled` | Sensitive access not granted for this credential (or platform policy disabled) |
| 403 | `openapi_sensitive_card_only_active` | Card is not in `status=2 (active)`; sensitive fields refused |
| 404 | `card_not_found` | Card not found / not owned |
| 500 | `openapi_get_card_info_failed` | Server error |
| 500 | `openapi_internal_error` | Failed to load credential context for sensitive branch |
## Recommended Polling Strategy
```python
import time
def wait_card_active(card_id, max_minutes=10):
"""Poll after async open until active or failure"""
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) # exp backoff, max 30s
raise TimeoutError(f"Card {card_id} not active in {max_minutes} min")
def fetch_pan_for_display(card_id):
"""Fetch sensitive card data for one-shot UI rendering. Never persist."""
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("Contact support to enable sensitive access")
if key == 'openapi_sensitive_card_only_active':
raise RuntimeError("Card is not active; wait for activation")
if resp.get('code') != 200:
raise RuntimeError(f"Card info failed: {resp.get('message_key')}")
return resp['data'] # contains card_number / cvv / expiry_date / first_name / last_name
```
::: tip Prefer webhooks
Use webhooks where possible. `card/info` polling is a fallback for when your webhook receiver is temporarily down or for reconciliation.
:::
---
# Create Recharge
**Async** recharge endpoint. Returns `transaction_id` + initial `status: 1 (processing)`; final result is delivered via [`card.recharged` / `card.recharge_failed` webhook](/guide/webhooks#event-types).
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/recharge |
| Auth | HMAC |
| Idempotency Key | Required |
## Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `card_id` | string | ✅ | `card_` |
| `amount` | string (decimal) | ✅ | Recharge amount (in card currency) |
### Example Request
```json
{ "card_id": "card_12345", "amount": "100.00" }
```
Required header:
```http
Idempotency-Key: 7b3e9d5c-1a2b-4f3e-8c7d-6f5e4a3b2c1d
```
## Response Fields
| Field | Type | Description |
|---|---|---|
| `transaction_id` | string | `txn_<...>` |
| `card_id` | string | Echo |
| `amount` | string | Requested amount |
| `currency` | string | Card currency |
| `status` | int | 1=processing 2=success 3=failed |
| `status_desc` | string | English description |
| `created_at` | string | RFC3339 timestamp |
### Example Response
```json
{
"code": 200,
"message": "OK",
"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"
}
}
```
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 400 | `amount_required` | amount missing |
| 400 | `insufficient_balance` | Insufficient balance |
| 400 | `openapi_invalid_card_id` | Bad card ID |
| 400 | `openapi_idempotency_key_required` | Missing idempotency key |
| 401 | `openapi_invalid_credentials` | Auth failure |
| 404 | `card_not_found` | Card not found |
| 409 | `openapi_idempotency_key_conflict` | Same key, different body |
| 500 | `openapi_recharge_failed` | Server error |
## Notes
- **Don't** reuse the same `Idempotency-Key` for different amounts on the front-end (will 409)
- Same key used as a safety net for network retries → returns the original response
- `transaction_id` is `txn_<...>` — match it against the `transaction_id` in webhooks (treat as opaque)
---
# List Card Transactions
Returns **transaction details for a single virtual card**, with pagination and the same filters as [List Transactions](./transactions-list). The only difference: `card_id` is **required** and results are scoped to that one card.
- `card_id` must belong to your account and be a **virtual** card, otherwise `404` / `400`.
- Same strict desensitization and same response shape as [List Transactions](./transactions-list).
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/transactions/list |
| Auth | HMAC |
| Idempotency | Not required |
## Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `card_id` | string | ✅ | `card_` — the card to query (must be your virtual card) |
| `transaction_time_from` | string | optional | Start of range, `YYYY-MM-DD HH:MM:SS` |
| `transaction_time_to` | string | optional | End of range, `YYYY-MM-DD HH:MM:SS` |
| `type` | string | optional | Type filter — `PURCHASE` / `AUTHORIZATION` / `REFUND` / `REVERSAL` / `TOPUP` / `WITHDRAW` / `FEE` |
| `status` | string | optional | `PENDING` / `APPROVED` / `FAILED` / `REVERSED` |
| `amount_from` | string | optional | Minimum amount, e.g. `"10.00"` |
| `amount_to` | string | optional | Maximum amount, e.g. `"1000.00"` |
| `merchant_name` | string | optional | Merchant name (fuzzy) |
| `keyword` | string | optional | Free-text keyword (fuzzy) |
| `page` | int | optional | Page number (default 1) |
| `page_size` | int | optional | Page size (default 20, max 100) |
### Example Request
```json
{
"card_id": "card_12345",
"type": "PURCHASE",
"page": 1,
"page_size": 50
}
```
## Response
Identical to [List Transactions](./transactions-list#response) — a paginated envelope whose `list` items are transaction objects. See that page for the full field table, `type` values, categories, and the example payload. Every returned row belongs to the requested `card_id`.
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 400 | `openapi_invalid_card_id` | Missing or invalid `card_id` (required here) |
| 400 | `openapi_card_type_not_supported` | `card_id` is not a virtual card |
| 400 | `invalid_date_format` | `transaction_time_from/to` not in `YYYY-MM-DD HH:MM:SS` |
| 400 | `invalid_params` | Bad amount format |
| 401 | `openapi_invalid_credentials` | Auth failure |
| 404 | `card_not_found` | Card not found / not owned |
| 500 | `openapi_list_transactions_failed` | Server error |
## Notes
- This endpoint is a convenience wrapper for the common "show me one card's history" case; functionally it equals [List Transactions](./transactions-list) with `card_id` set. Choose whichever reads clearer in your integration.
- Data comes from Coinepay's local ledger; it does not call the provider synchronously.
---
# Unfreeze Card
Restores a **frozen** virtual card to `active`, making it usable again.
- Only cards frozen as a **user-initiated freeze** (i.e. via [Freeze Card](./card-freeze)) can be unfrozen through this endpoint.
- Cards frozen by **risk-control**, **admin**, or the **system** cannot be unfrozen here and return `403` — contact your account manager.
- No money moves — unfreezing has no fee.
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/unfreeze |
| Auth | HMAC |
| Idempotency | Not required |
::: tip No Idempotency-Key needed
Retrying on a card that is not frozen returns `400 card_not_frozen` rather than double-applying. No monetary effect, so no `Idempotency-Key` header is required.
:::
## Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `card_id` | string | ✅ | `card_` — must belong to your account and be a virtual card |
### Example Request
```json
{ "card_id": "card_12345" }
```
## Response Fields
| Field | Type | Description |
|---|---|---|
| `card_id` | string | Echo of the card ID |
| `status` | int | Card status **after** the operation — always `2` (active) on success |
| `status_desc` | string | English status description — `active` |
| `success` | bool | `true` on success |
### Example Response
```json
{
"code": 200,
"message": "OK",
"data": {
"card_id": "card_12345",
"status": 2,
"status_desc": "active",
"success": true
}
}
```
## Preconditions & Rules
1. **Ownership** — `card_id` must belong to the authenticated account, else `404 card_not_found`.
2. **Card type** — virtual cards only.
3. **State** — only `status=6 (frozen)` cards may be unfrozen.
4. **Freeze origin** — only *user-initiated* freezes are reversible here. If the card was frozen by risk-control / admin / system, the request is rejected with the corresponding `403`.
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 400 | `openapi_invalid_card_id` | Missing/invalid `card_id` |
| 400 | `openapi_card_type_not_supported` | Card is not a virtual card type |
| 400 | `card_not_frozen` | Card is not currently frozen |
| 400 | `recharge_unfreeze_disabled` | This card can only be unfrozen by recharging (risk freeze), and that path is disabled |
| 400 | `operation_not_supported` | This provider/card type does not support unfreeze |
| 401 | `openapi_invalid_credentials` | Auth failure |
| 403 | `unauthorized_unfreeze_admin` | Frozen by an administrator — cannot self-unfreeze |
| 403 | `unauthorized_unfreeze_risk` | Frozen by risk-control — cannot self-unfreeze |
| 403 | `unauthorized_unfreeze_system` | Frozen by the system — cannot self-unfreeze |
| 404 | `card_not_found` | Card not found / not owned |
| 500 | `openapi_unfreeze_card_failed` | Server error |
## Notes
- After a successful unfreeze, [`/card/info`](./card-info) reports `status=2`.
- A `403` here means the freeze was applied by a party other than you; the card stays frozen and you must contact support to lift it.
## Integration pitfalls & best practices
1. **Synchronous, provider-bound call — allow up to ~60 s.** Unfreeze calls the upstream provider inline (same latency profile as freeze — commonly a few seconds, up to ~60 s server-side). **Set your HTTP client timeout to ≥ 60 s for this endpoint.**
2. **A client-side timeout does NOT mean the unfreeze failed.** On any timeout / network error, reconcile via [`/card/info`](./card-info): `status=2` → unfreeze succeeded (proceed); `status=6` → not applied (safe to retry).
3. **No webhook for freeze/unfreeze.** The synchronous response is the **only** signal; no asynchronous `card.*` callback is emitted. Use the response (or `/card/info`) as the source of truth.
4. **No `Idempotency-Key`, but safe to retry.** Retries are not deduplicated, but the state machine protects you: re-unfreezing an already-active card returns `400 card_not_frozen`. **Treat `card_not_frozen` as "already in the desired state", not a hard error.**
5. **`403` is terminal — do not retry-loop.** Only *user-initiated* freezes (freezes made via [Freeze Card](./card-freeze)) are reversible here. If risk-control / admin / system froze — or later re-froze — the card, unfreeze returns `403 unauthorized_unfreeze_*` and retrying will keep returning `403`. The card can only be lifted by that party / support. Surface it to the user instead of looping.
---
# List Cards
List all bank cards owned by the current AppID. Supports status / date-range / balance-range filters and pagination.
**Key behaviors:**
- **Virtual cards only** — the response is force-filtered to virtual cards (`virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g`); physical cards are never exposed via OpenAPI.
- **Default behavior** — when neither `statuses` nor `usable_only` is provided, the server returns `status ∈ [1, 2]` (pending + active) by default, hiding failed / closed / frozen historical cards.
- **List endpoint does not carry sensitive fields** — the list response never includes full PAN / CVV / expiry / cardholder name, regardless of credential permissions. For those fields, use [`/openapi/card/info`](./card-info#sensitive-card-information) with `with_sensitive=true` (per-card, opt-in, off by default).
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/cards/list |
| Auth | HMAC |
| Idempotency | Not required |
## Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `statuses` | int[] | optional | Status filter array, allowed values `1` `2` `3` `4` `5` `6`; mutually exclusive with `usable_only` |
| `usable_only` | bool | optional | Convenience filter: `true` is equivalent to `statuses=[2]`; mutually exclusive with `statuses` |
| `start_date` | string | optional | Lower bound of creation date, format `YYYY-MM-DD` |
| `end_date` | string | optional | Upper bound of creation date, format `YYYY-MM-DD` (inclusive) |
| `min_balance` | string | optional | Minimum balance (decimal string, e.g. `"10.5"`) |
| `max_balance` | string | optional | Maximum balance (decimal string) |
| `page` | int | optional | Page number, min 1, default 1 |
| `page_size` | int | optional | Page size, 1–100, default 20 |
### Card Status Values
| status | Meaning | Included by default? |
|---|---|---|
| 1 | pending (opening / KYC review) | ✅ |
| 2 | active | ✅ |
| 3 | failed (open failed) | ❌ (must pass `statuses=[3]`) |
| 4 | closing | ❌ |
| 5 | closed | ❌ |
| 6 | frozen | ❌ |
::: tip Why hide failed/closed/frozen by default
To prevent merchants from accidentally treating dead cards as usable. For reconciliation or audit work, pass `statuses` explicitly.
:::
### Example Requests
#### 1. Default query (usable cards only: 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 cards only (immediately usable)
```json
{ "usable_only": true }
```
#### 3. Custom statuses (include historical cards)
```json
{
"statuses": [2, 5, 6],
"page": 1,
"page_size": 50
}
```
#### 4. Balance + date range filter
```json
{
"min_balance": "10",
"max_balance": "1000",
"start_date": "2026-01-01",
"end_date": "2026-12-31"
}
```
## Response Fields
### Envelope
| Field | Type | Description |
|---|---|---|
| `list` | `CardListItemOut[]` | Array of cards (see below) |
| `total` | int64 | Total record count |
| `page` | int | Current page number |
| `page_size` | int | Page size |
| `total_pages` | int | Total page count |
| `has_next` | bool | Whether a next page exists |
| `has_prev` | bool | Whether a previous page exists |
### `CardListItemOut`
| Field | Type | Description |
|---|---|---|
| `card_id` | string | `card_` — usable as input to `/card/info`, `/card/recharge`, etc. |
| `card_type` | string | Lowercase: `virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g` |
| `card_brand` | string | Brand display name: `Visa` / `Mastercard` |
| `card_holder_name` | string | Cardholder name (provided at apply time) |
| `currency` | string | Card currency, ISO 4217 (e.g. `USD`) |
| `status` | int | 1=pending 2=active 3=failed 4=closing 5=closed 6=frozen |
| `status_desc` | string | English description: `pending` / `active` / `failed` / `closing` / `closed` / `frozen` |
| `masked_card_no` | string | Masked PAN (first 6 + last 4) |
| `last_four` | string | Last 4 digits |
| `balance` | string (decimal) | Available balance |
| `frozen_balance` | string (decimal) | Frozen balance |
| `freeze_type` | int | Freeze type: `0`=none `1`=user `2`=system `3`=admin `4`=risk-control |
| `nickname` | string | Card nickname (optional) |
| `activated_at` | string nullable | Activation time (RFC3339) |
| `created_at` | string | Creation time (RFC3339) |
::: warning No sensitive cardholder data in the list response
The list response never includes the full card number, CVV, card expiry, or cardholder first/last name — these fields are not part of this endpoint's contract under any circumstances. Internal-only fields (DB identifiers, provider-side references, intermediate stats, internal labels) are likewise not exposed.
To retrieve full PAN / CVV / expiry / cardholder name for a single active card, call [`/openapi/card/info`](./card-info#sensitive-card-information) with `with_sensitive=true` (the credential must have sensitive access enabled — off by default).
:::
### Example Response
```json
{
"code": 200,
"message": "OK",
"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": "Primary card",
"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
}
}
```
### Empty Response
```json
{
"code": 200,
"message": "OK",
"data": {
"list": [],
"total": 0,
"page": 1,
"page_size": 20,
"total_pages": 0,
"has_next": false,
"has_prev": false
}
}
```
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 400 | `openapi_conflicting_card_filters` | Do not pass `usable_only` and `statuses` at the same time |
| 400 | `openapi_invalid_status_value` | `statuses` may only contain values 1–6 |
| 400 | `invalid_min_balance` | `min_balance` must be a valid non-negative decimal string |
| 400 | `invalid_max_balance` | `max_balance` must be a valid non-negative decimal string |
| 400 | `min_balance_exceeds_max_balance` | `min_balance` must not exceed `max_balance` |
| 400 | `invalid_date_format` | Dates must be in `YYYY-MM-DD` format |
| 401 | `openapi_invalid_credentials` | HMAC / timestamp / nonce / credential check failed |
| 500 | `openapi_list_cards_failed` | Server error — retry with backoff |
## Notes
1. **Resource ownership** — only cards belonging to the user_id behind the current `X-App-Id` are returned. Even after secret rotation, the list of cards a merchant sees stays consistent (credentials are isolated per user_id).
2. **Physical cards filtered** — this endpoint hard-filters to virtual cards only. Even if a merchant opened a physical card via the user portal, it won't appear here.
3. **Historical cards** — `status ∈ {3, 4, 5, 6}` is hidden by default. For reconciliation, pass `statuses=[5]` etc. explicitly.
4. **Relation to other endpoints**:
- With `card_id` in hand, call [`/openapi/card/info`](./card-info) for single-card detail.
- Call [`/openapi/card/recharge`](./card-recharge) to top up.
- **Important**: only `status=2` cards can be recharged; otherwise the server returns `invalid_card_status`.
5. **Treat `card_id` as an opaque string** — store it as a string on your side rather than parsing the numeric portion. Future ID encoding migrations should not require schema changes on your end.
---
# Preview First Deposit
**Read-only** dry-run for the [card-open](./card-apply) first deposit. It computes exactly what [`/card/apply`](./card-apply) would charge for a given `first_deposit_amount` — the fee on the excess, the amount credited to the card, and the total wallet freeze — **without** opening a card, writing anything, or freezing funds.
Use it to render a "what you'll pay" breakdown before the customer confirms, so the numbers match the real open 1:1.
::: tip What is the first deposit?
The card config defines a **base** initial deposit (`base_amount`) that is credited 1:1 with **no fee**. A merchant may top this up by sending a larger `first_deposit_amount` on [apply](./card-apply); the part above the base (the **excess**) is charged a recharge-style fee, and the remainder is credited to the card 1:1 (USDT/USD, no FX). The final first deposit sent to the provider is always an integer.
:::
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/first_deposit/preview |
| Auth | HMAC |
| Idempotency | Not required (read-only) |
## Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `header_id` | string | ✅ | `hdr_` ([List Card Headers](./card-headers-list)) |
| `package_id` | string | ✅ | `pkg_` ([List Card Configs](./card-configs-list)) |
| `first_deposit_amount` | string | optional | Total first deposit, **non-negative integer**, in the pay/recharge asset unit (e.g. USDT). Empty = use the config base. Must be `>= base_amount`. Scientific notation, decimals, and signs are rejected. |
### Example Request
```json
{
"header_id": "hdr_119",
"package_id": "pkg_33",
"first_deposit_amount": "16"
}
```
## Response Fields
::: info Always 200 (graceful)
This endpoint returns `200` even when the amount is not submittable. Read `is_valid` to decide whether [apply](./card-apply) would succeed, and `invalid_reason` for the stable reason code. Only malformed IDs, an unsupported card type, or a server fault return non-200.
:::
| Field | Type | Description |
|---|---|---|
| `package_id` | string | Echo, `pkg_` |
| `card_type` | string | Lowercase business code, e.g. `virtual_v` |
| `currency` | string | Card currency (e.g. `USD`) |
| `pay_asset_symbol` | string | Pay/recharge asset symbol (e.g. `USDT`) |
| `open_card_fee` | string (decimal) | Open-card fee (pay asset) |
| `base_amount` | string (decimal) | Base initial deposit — credited 1:1, no fee |
| `request_amount` | string (decimal) | The total first deposit being evaluated |
| `excess_amount` | string (decimal) | Excess above base (`request_amount − base_amount`) |
| `excess_fee_amount` | string (decimal) | Fee charged on the excess (fee asset) |
| `excess_fee_asset_symbol` | string | Fee asset symbol (omitted when no excess) |
| `excess_settle_amount` | string (decimal) | Excess after fee, before integer rounding |
| `excess_card_amount` | string (decimal) | Excess actually credited to the card (after rounding) |
| `exchange_rate` | string | Compatibility field; USDT/USD is 1:1, always empty |
| `first_recharge_card` | string (decimal) | Total credited to the card (`base + excess credited`); integer |
| `total_freeze` | string (decimal) | Total wallet freeze (`open_card_fee + amount credited + excess fee`); rounding remainder is **not** charged |
| `fee_type` | int | Recharge fee type: `1`=fixed `2`=percent `3`=mixed |
| `fee_rate` | string | Rate (percent / mixed); omitted otherwise |
| `fee_fixed` | string | Fixed fee (fixed / mixed); omitted otherwise |
| `min_recharge_amount` | string (decimal) | Config min recharge (display) |
| `max_recharge_amount` | string (decimal) | Config max recharge — the **excess** is capped by this |
| `wallet_balance` | string (decimal) | Your account's available balance in the pay asset |
| `wallet_balance_sufficient` | bool | Whether the balance (incl. USD 1:1 top-up) covers `total_freeze` |
| `usd_balance` | string (decimal) | Your USD balance (used only for the USDT→USD 1:1 top-up check) |
| `will_use_usd` | bool | Whether the USD 1:1 top-up would be used |
| `usd_needed` | string (decimal) | USD amount that would be drawn via the top-up |
| `is_valid` | bool | Whether [apply](./card-apply) would be accepted with this amount |
| `invalid_reason` | string | Stable reason code when `is_valid=false` (omitted when valid) |
| `warnings` | string[] | Optional advisory codes |
### Example Response (valid)
```json
{
"code": 200,
"message": "OK",
"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
}
}
```
In this example a `16` first deposit on a `base=10` config keeps `10` free (1:1), and the `6` excess is charged a `1.22` fee, leaving `4` credited — so the card receives `14` and the wallet is frozen `16.22`.
### Example Response (not submittable)
```json
{
"code": 200,
"message": "OK",
"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` Codes
`is_valid=false` is paired with one of these stable codes (same keys returned as `message_key` by [apply](./card-apply)):
| invalid_reason | Meaning |
|---|---|
| `invalid_first_deposit_amount` | Not a plain non-negative integer (decimals / signs / scientific notation / too many digits) |
| `first_recharge_below_base` | Amount below `base_amount` |
| `first_recharge_exceeds_max` | Excess exceeds `max_recharge_amount` |
| `first_recharge_limit_exceeded` | Excess exceeds the account's recharge limit |
| `first_recharge_asset_mismatch` | Open-fee asset ≠ recharge asset; custom excess unsupported for this config |
| `first_recharge_excess_too_small` | After fee + integer rounding nothing reaches the card; increase the amount |
| `insufficient_balance` | Wallet (incl. USD top-up) can't cover `total_freeze` |
| `bank_card_config_not_found` | Package inactive / not found |
| `bank_card_header_not_found` | Header inactive / not found |
## Common Errors
Non-200 responses (malformed input / unsupported type / server fault):
| HTTP | message_key | Description |
|---|---|---|
| 400 | `openapi_invalid_header_id` | `header_id` missing / wrong prefix / not found |
| 400 | `openapi_invalid_package_id` | `package_id` missing / wrong prefix |
| 400 | `openapi_card_type_not_supported` | Header's card type not in the virtual range |
| 401 | `openapi_invalid_credentials` | Auth failure |
| 500 | `openapi_first_deposit_preview_failed` | Server error |
## Notes
- **Numbers match apply exactly.** The preview shares the open-card computation, so `total_freeze` / `first_recharge_card` are the same values [apply](./card-apply) will charge for the same `first_deposit_amount`.
- **No money moves.** This call freezes nothing and writes nothing; safe to call as often as needed (subject to [rate limits](/guide/rate-limit)).
- `wallet_balance` is **your own** account balance — no other party's data is exposed.
- The excess fee uses the same recharge fee formula as [Create Recharge](./card-recharge); the formula is also available per package via [List Card Configs](./card-configs-list).
---
# List Transactions
Returns **transaction details** (purchases, refunds, reversals, fees, etc.) across **all virtual cards** under the current `AppID` account, with pagination and rich filters.
- Scope is always your own account — the server binds the account from your HMAC credential; there is no way to query another account's data.
- Strictly **desensitized**: no full PAN, no cardholder PII, no provider-side / internal order references. Each row carries `card_id` + `last_four` so you can attribute it to a card.
- To query a **single** card, use [List Card Transactions](./card-transactions-list) (or pass `card_id` here as an optional filter).
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/transactions/list |
| Auth | HMAC |
| Idempotency | Not required |
## Request Fields
All fields are optional.
| Field | Type | Default | Description |
|---|---|---|---|
| `card_id` | string | — | Optional filter — `card_`. When provided, results are limited to that card (ownership + virtual-type validated). |
| `transaction_time_from` | string | — | Start of transaction-time range, format `YYYY-MM-DD HH:MM:SS` |
| `transaction_time_to` | string | — | End of transaction-time range, format `YYYY-MM-DD HH:MM:SS` |
| `type` | string | — | Type filter — see [Type values](#type-values) |
| `status` | string | — | Status filter — one of `PENDING` / `APPROVED` / `FAILED` / `REVERSED` |
| `amount_from` | string | — | Minimum transaction amount, e.g. `"10.00"` |
| `amount_to` | string | — | Maximum transaction amount, e.g. `"1000.00"` |
| `merchant_name` | string | — | Merchant name (fuzzy match) |
| `keyword` | string | — | Free-text keyword (fuzzy match over merchant / description / city / etc.) |
| `page` | int | 1 | Page number (≥ 1) |
| `page_size` | int | 20 | Page size (≤ 100) |
### Example Request
```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
}
```
## Response
A paginated envelope (`list` / `total` / `page` / `page_size` / `total_pages` / `has_next` / `has_prev`). Each item in `list` is a transaction:
### Transaction Fields
| Field | Type | Description |
|---|---|---|
| `transaction_id` | string | `txn_` — opaque token, do not parse |
| `card_id` | string | `card_` the transaction belongs to |
| `card_type` | string | Lowercase business code (`virtual_v` …) |
| `card_brand` | string | Brand display name (e.g. `VISA`) |
| `last_four` | string | Last 4 digits of the card (never the full PAN) |
| `type` | string | Normalized upper-case type (`PURCHASE` / `REFUND` / `AUTHORIZATION` / `REVERSAL` / `FEE` …) |
| `type_category` | string | Normalized category for tag coloring — see [Categories](#type-categories) |
| `type_i18n` | object | `{ "en-US": …, "zh-CN": …, "zh-HK": … }` display labels |
| `status` | string | `PENDING` / `APPROVED` / `FAILED` / `REVERSED` |
| `transaction_time` | string nullable | When the transaction occurred (provider clock). `null` if unknown |
| `transaction_currency` | string | Transaction currency (ISO 4217) |
| `transaction_amount` | string | Transaction amount (decimal string, 2 dp) |
| `billing_currency` | string | Billing / card currency |
| `billing_amount` | string | Billing amount (decimal string, 2 dp) |
| `merchant_name` | string | Merchant name |
| `merchant_id` | string | Merchant ID (as reported by the network) |
| `merchant_category` | string | Merchant category / MCC label |
| `merchant_country` | string | Merchant country (e.g. `US`) |
| `merchant_city` | string | Merchant city |
| `merchant_logo_url` | string | Brand logo URL (may be empty until resolved) |
| `approval_code` | string | Approval code (reconciliation) |
| `auth_code` | string | Authorization code (reconciliation) |
| `cross_border_type` | string | `0` = domestic, `1` = cross-border |
| `decline_reason` | string | Failure / decline reason, when applicable |
| `description` | string | Transaction description |
| `remark` | string | Remark |
| `created_at` | string | When the record was stored |
::: tip Omitted fields are intentional
Empty optional string fields are omitted from the JSON entirely (not `null`). The documented shape is the complete contract — internal DB IDs, provider transaction IDs, related order numbers, user identity and full PAN are **never** returned. See [IDs & Prefixes](/guide/ids-and-prefixes).
:::
### Example Response
```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-values}
The `type` request filter accepts these standard enums (mapped server-side to each provider's raw values):
| Value | Meaning |
|---|---|
| `PURCHASE` | Consumption / settled purchase |
| `AUTHORIZATION` | Pre-authorization (held, not settled) |
| `REFUND` | Refund |
| `REVERSAL` | Reversal |
| `TOPUP` | Top-up / recharge posting |
| `WITHDRAW` | Withdrawal |
| `FEE` | Fee |
Unknown values are matched exactly (and will typically return no rows). The response `type` field is the normalized upper-case raw type; map it on your side for display, or use `type_i18n`.
## Type categories {#type-categories}
`type_category` is one of: `consumption` · `refund` · `reversal` · `topup` · `fee` · `close` · `transfer` · `withdraw` · `interest` · `3ds` · `unknown`.
## Hidden transaction types
Internal bookkeeping types (e.g. system-added cross-border fee lines and card-close entries) are hidden from this endpoint, matching the customer-facing app. Transactions after a card's close time are also excluded.
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 400 | `openapi_invalid_card_id` | Bad `card_id` filter |
| 400 | `openapi_card_type_not_supported` | `card_id` filter points to a non-virtual card |
| 400 | `invalid_date_format` | `transaction_time_from/to` not in `YYYY-MM-DD HH:MM:SS` |
| 400 | `invalid_params` | Bad amount format (`amount_from` / `amount_to`) |
| 401 | `openapi_invalid_credentials` | Auth failure |
| 404 | `card_not_found` | `card_id` filter not found / not owned |
| 500 | `openapi_list_transactions_failed` | Server error |
## Notes
- Data is served from Coinepay's local ledger (populated from provider webhooks); this endpoint does **not** call the upstream provider synchronously.
- Prefer webhooks (`card.*`) for real-time flow; use this endpoint for periodic reconciliation and history.
---
# Webhook Events History
Query the **webhook delivery history** for the current account. Common uses:
- Diagnose "why didn't I receive a webhook"
- Monitor dead-letter status
- Reconcile against your local records
## Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/webhook_events/list |
| Auth | HMAC |
| Idempotency | Not required |
## Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
| `event_type` | string | No | Filter by event type, e.g. `card.opened` |
| `status` | int | No | Filter by delivery status (see table) |
| `page` | int | No | Default 1 |
| `page_size` | int | No | Default 20, max 100 |
### Delivery Status
| status | status_desc | Meaning |
|---|---|---|
| 0 | `pending` | Awaiting first delivery |
| 1 | `delivered` | Delivered |
| 2 | `failed_retry` | Failed but still retrying |
| 3 | `dead_letter` | Dead-lettered, no more retries |
| 4 | `skipped` | Skipped (e.g. webhook URL not set) |
### Example Request
```json
{ "event_type": "card.opened", "status": 1, "page": 1, "page_size": 20 }
```
## Response Fields
Each item in `data.list[]`:
| Field | Type | Description |
|---|---|---|
| `event_id` | string | `evt_` (matches `Webhook-Id` header without prefix) |
| `event_type` | string | Event type |
| `status` | int | See table |
| `status_desc` | string | English description |
| `attempt_count` | int | Attempts so far |
| `next_attempt_at` | string nullable | Next retry time (RFC3339) |
| `last_error` | string | Most recent failure reason |
| `last_response_status` | int | Most recent HTTP status (0 = network failure) |
| `delivered_at` | string nullable | Delivery time on success |
| `created_at` | string | Event creation time |
### Example Response
```json
{
"code": 200,
"message": "OK",
"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
}
}
```
## Common Errors
| HTTP | message_key | Description |
|---|---|---|
| 401 | `openapi_invalid_credentials` | Auth failure |
| 500 | `openapi_list_webhook_events_failed` | Server error |
## Diagnosing Webhook Issues
### Scenario 1: Never received a webhook
```bash
# Was it ever queued?
{ "status": 0 } # still pending → system delay, wait a few seconds
{ "status": 4 } # skipped → URL not set / disabled
# Or in retry loop
{ "status": 2 } # check last_error / last_response_status
```
### Scenario 2: Suspect missing events
```bash
# List all events for the time range
{ "event_type": "card.opened", "page_size": 100 }
# Reconcile with your local receiver records
```
### Scenario 3: Confirm dead-letter
```bash
{ "status": 3 }
# Dead-letters typically come from: permanent 4xx / permanent timeout / DNS failure
# After fixing your receiver, contact support to manually re-deliver or ignore
```
---
# Code Samples
The 6 samples below are **all tested** and include:
- HMAC signature generation
- HTTP call wrappers
- Webhook signature verification
::: tip Pick the right one
- **Quick check** → [cURL](./curl) one-liner
- **Production service** → pick your stack: [Python](./python) / [Node](./nodejs) / [Go](./go) / [Java](./java) / [PHP](./php)
:::
## Dependencies
| Language | Dependencies | Notes |
|---|---|---|
| Python | `requests` (or `httpx` / `urllib3`) | `hmac` / `hashlib` / `secrets` are stdlib |
| Node.js | Node.js ≥ 18 (native fetch) | `crypto` is built-in |
| Go | stdlib `crypto/hmac` / `crypto/sha256` / `net/http` | No third-party deps |
| Java | JDK 11+ stdlib (`HttpClient` / `Mac` / `MessageDigest`) | No third-party deps |
| PHP | PHP 7.4+ with `curl` extension | `hash_hmac` / `random_bytes` are built-in |
| cURL | `openssl` + `awk` | Generic shell tools |
## Common Variables
```
APP_ID = "cp_a1b2c3d4..." # 31 chars, cp_ + 28 hex
SECRET = "f1e2d3c4..." # 64 hex chars
BASE = "https://api.coinepay.net"
```
::: warning Don't hardcode
Samples have `SECRET` inline for clarity; **production code should read from env vars / Vault / Secret Manager**.
:::
---
# cURL
Best for **verifying the auth chain quickly** or running a CI healthcheck.
## Dependencies
- `bash` / `zsh`
- `openssl`
- `awk`
::: tip Don't build production on cURL
Shell quoting gets ugly fast and error handling is weak. Use [Python](./python) / [Node](./nodejs) / [Go](./go) for production.
:::
## List Card Headers (simplest happy path)
```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"
```
## Apply Virtual Card (with idempotency key)
```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"
```
## Reusable Sign Script
Save the following as `~/sign.sh` and `chmod +x ~/sign.sh`:
```bash
#!/usr/bin/env bash
# Usage: sign.sh
# Output: 4 lines (X-App-Id / X-Timestamp / X-Nonce / X-Signature)
# Prerequisite: export APP_ID and 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 Compatibility
Go **1.21+**. No third-party dependencies.
:::
## Client Implementation
```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 invokes an OpenAPI endpoint
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 verifies a webhook signature
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))
}
```
## Usage
```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() {
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 Receiver (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
}
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil || abs(time.Now().Unix()-tsInt) > 300 {
http.Error(w, "timestamp expired", http.StatusUnauthorized)
return
}
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 Receiver (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
}
// ... handle ...
c.Status(200)
})
return r
}
```
---
# Java
::: info Compatibility
JDK **11+** (native `HttpClient`). No third-party dependencies.
:::
## Client Implementation
```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 signature verification */
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;
}
}
```
## Usage
```java
public class Demo {
public static void main(String[] args) throws Exception {
// 1. List card headers
String resp = coinepayClient.call(
"/api/v1/openapi/card_headers/list",
"{\"page\":1,\"page_size\":20}",
null);
System.out.println(resp);
// 2. Apply virtual card
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 Receiver (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 deserializes `@RequestBody` by default. **Use `byte[]`** to receive raw bytes — otherwise the body is parsed and re-serialized, breaking the signature.
:::
## Notes
- `secretSpec` uses `getBytes("UTF-8")` — never use platform default encoding
- Java string `\n` literals in source are `0x0A` — no special handling needed
- Use `MessageDigest.isEqual` or your own constant-time compare; `String.equals` is **not** constant-time
---
# Node.js
::: info Compatibility
Node.js **≥ 18** (native `fetch`). No third-party dependencies.
:::
## Client Implementation
```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 signature verification
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')
// Constant-time compare
return (
Buffer.byteLength(sig) === Buffer.byteLength(expected) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
)
}
```
## Usage
```js
import { randomUUID } from 'node:crypto'
import { call } from './coinepay.js'
// 1. List card headers
let resp = await call('/api/v1/openapi/card_headers/list', { page: 1, page_size: 20 })
console.log(resp.data.list[0].header_id)
// 2. List card configs
resp = await call('/api/v1/openapi/card_configs/list', { header_id: 'hdr_5' })
const packageId = resp.data.list[0].package_id
// 3. Apply virtual card
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. Create recharge
resp = await call(
'/api/v1/openapi/card/recharge',
{ card_id: cardId, amount: '100.00' },
randomUUID(),
)
console.log(`Transaction: ${resp.data.transaction_id}`)
```
## Webhook Receiver (Express)
```js
import express from 'express'
import { verifyWebhook } from './coinepay.js'
const app = express()
const WEBHOOK_SECRET = process.env.COINEPAY_WEBHOOK_SECRET
// Important: raw body, NOT a parsed JSON object
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')
}
if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) {
return res.status(401).send('timestamp expired')
}
if (await alreadyProcessed(eventId)) return res.sendStatus(200)
const payload = JSON.parse(req.body.toString('utf8'))
await enqueue(eventType, payload) // push to queue
await markProcessed(eventId)
res.sendStatus(200)
})
app.listen(3000)
```
## Webhook Receiver (Fastify)
```js
import Fastify from 'fastify'
import { verifyWebhook } from './coinepay.js'
const fastify = Fastify()
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 Types
```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 Compatibility
PHP **7.4+**. Only depends on the `curl` extension (enabled by default).
:::
## Client Implementation
```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);
}
```
## Usage
```php
1, 'page_size' => 20]);
$headerId = $resp['data']['list'][0]['header_id'];
// 2. List card configs
$resp = callOpenAPI('/api/v1/openapi/card_configs/list', ['header_id' => $headerId]);
$packageId = $resp['data']['list'][0]['package_id'];
// 3. Apply virtual card
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. Create recharge
$resp = callOpenAPI('/api/v1/openapi/card/recharge', [
'card_id' => $cardId,
'amount' => '100.00',
], uuidv4());
echo "Transaction: " . $resp['data']['transaction_id'] . "\n";
```
## Webhook Receiver (Plain 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 Receiver (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();
}
}
```
## Notes
- `JSON_UNESCAPED_UNICODE` keeps non-ASCII characters as-is (not `\uXXXX`), matching the server's expectation
- Use `hash_equals` for signature comparison, not `===` (timing-attack safe)
- Laravel's default request body parsing reads `php://input` once — use `$request->getContent()` instead
---
# Python
::: info Compatibility
Python 3.6+. Only depends on `requests`; everything else is stdlib.
:::
## Install
```bash
pip install requests
```
## Client Implementation
```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:
"""Unified OpenAPI call"""
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 signature verification
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)
```
## Usage
```python
import uuid
# 1. List card headers
resp = call("POST", "/api/v1/openapi/card_headers/list", {"page": 1, "page_size": 20})
print(resp["data"]["list"][0]["header_id"])
# 2. List card configs
resp = call("POST", "/api/v1/openapi/card_configs/list", {"header_id": "hdr_5"})
package_id = resp["data"]["list"][0]["package_id"]
# 3. Apply virtual card (with idempotency key)
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. Create recharge
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 Receiver (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")
# Timestamp ±5 min
if abs(int(time.time()) - int(ts)) > 300:
raise HTTPException(401, "timestamp expired")
# Dedupe by 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 Defer heavy work
`handle_event` should only do "persist + enqueue". **Don't** do slow operations (DB queries, external calls) in the webhook handler — anything over 5 seconds will be re-delivered.
:::
## Errors & Retry
```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")
```
---
# Changelog
Breaking changes (removed fields, changed semantics, changed types) bump the **major** version with advance notice. New fields are non-breaking.
## v1.2 (current)
### Added
- **2026-07-07**: Card **freeze / unfreeze** and **transaction details** endpoints (4 new). Non-breaking — no existing endpoint changed.
- `POST /api/v1/openapi/transactions/list` — [transaction details across all your virtual cards](../api/transactions-list)
- `POST /api/v1/openapi/card/transactions/list` — [transaction details for one card](../api/card-transactions-list)
- `POST /api/v1/openapi/card/freeze` — [freeze an active virtual card](../api/card-freeze)
- `POST /api/v1/openapi/card/unfreeze` — [unfreeze a user-frozen card](../api/card-unfreeze)
- Transaction objects are strictly desensitized: `last_four` only (never the full PAN), no cardholder PII, no provider-side / internal order references.
- Freeze/unfreeze require **no** `Idempotency-Key` — they carry no monetary effect and are guarded by the card state machine (repeat freeze → `card_already_frozen`; repeat unfreeze → `card_not_frozen`). Only *user-initiated* freezes are reversible via `/card/unfreeze`; risk/admin/system freezes return `403`.
## v1.1
### Added
- **2026-06-15**: Custom **first-deposit amount** on [`/card/apply`](../api/card-apply) via the optional `first_deposit_amount`, plus a new read-only [`/card/first_deposit/preview`](../api/first-deposit-preview) dry-run endpoint. Non-breaking — `first_deposit_amount` is optional (empty = config base); the preview is a new endpoint and changes no existing one.
- **2026-05-26**: New supported `card_type` value `virtual_g` (Virtual card class G). Non-breaking — no new endpoints, no schema changes; existing integrations that don't onboard G see no impact.
- 8 OpenAPI endpoints:
- `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 authentication (4 headers)
- Idempotency-Key support (`/card/apply` / `/card/recharge`)
- Webhook events: `webhook.test` / `card.opened` / `card.open_failed` / `card.recharged` / `card.recharge_failed` / `card.closed` / `card.status_changed`
- Opt-in sensitive card fields on [`/card/info`](../api/card-info#sensitive-card-information) via `with_sensitive=true` — returns full PAN / CVV / expiry / cardholder name when access is granted to the credential and the card is active. Off by default; enablement is approval-gated.
### Field-encapsulation rules
- All public IDs are prefixed strings (`card_` / `pkg_` / `hdr_` / `txn_` / `evt_`); use them as opaque tokens.
- Asset references use ISO 4217 currency codes / asset symbols (strings, never numeric IDs).
- `card_type` values are lowercase business codes (e.g. `virtual_v`).
- Recharge `status` uses business codes: `2` success / `3` failed.
- Open / close webhook `status` uses strings: `opened` / `open_failed` / `closed`.
- Internal-only fields (DB identifiers, provider-side order references, intermediate computation values, internal labels) are never returned.
### Scope
- Virtual cards only: `virtual_l` / `virtual_p` / `virtual_v` / `virtual_r` / `virtual_g`
- No physical card / transfer endpoints (planned for later)
## Planned (future)
::: info Subject to change
- Independent sandbox environment
- Expose `GET /openapi.json` (auto-generated OpenAPI 3.0 spec)
- Expose `GET /openapi.postman.json` (auto-generated Postman Collection)
:::
## Compatibility Promise
- Within the same major version (v1.x): **fields are added, never removed**
- Defaults & constraints (length, type) **never change in a breaking way**
- `message_key` values are **permanently stable** once published
- Removing fields or changing semantics bumps major version, with the previous major preserved for at least 6 months
## Feedback
- Email: admin@coinepay.cc
---
# Constants
The values below are **stable** in v1.2. Any breaking changes will ship via a changelog entry + version bump, with advance notice to integrators.
## Base URL
| Environment | URL |
|---|---|
| Production | `https://api.coinepay.net` |
| Sandbox | None (not provided in v1.2) |
| Local dev | `http://localhost:8801` |
## API Prefix
```
/api/v1/openapi
```
## HTTP Convention
| Item | Value |
|---|---|
| Method | All `POST` (project hard rule) |
| Request Content-Type | `application/json` |
| Response Content-Type | `application/json; charset=utf-8` |
| Max body | **4 MB** (4194304 bytes) |
| Charset | UTF-8 |
## Authentication
| Item | Value |
|---|---|
| Algorithm | HMAC-SHA256 |
| Required headers | `X-App-Id` / `X-Timestamp` / `X-Nonce` / `X-Signature` |
| AppID format | `cp_<28 hex>`, fixed length 31 |
| Secret format | 64 hex characters |
| Secret prefix display | `****` |
| Timestamp unit | **Seconds** (not milliseconds) |
| Timestamp tolerance | ±300 seconds |
| Nonce length | 8–64 characters |
| Nonce replay window | 600 seconds |
| Signature format | lowercase hex |
| Signature length | 64 characters |
## Idempotency
| Item | Value |
|---|---|
| Header | `Idempotency-Key` |
| Required endpoints | `/api/v1/openapi/card/apply` / `/api/v1/openapi/card/recharge` |
| Max length | 128 characters |
| Dedup window | **24 hours** |
| Conflict status | 409 |
## Rate Limit
| Item | Value |
|---|---|
| Dimension | (AppID, IP) |
| Quota | 600 / minute |
| Window | 60 seconds (rolling) |
| Exceeded | HTTP 429 |
## Webhook Delivery
| Item | Value |
|---|---|
| Signature header | `Webhook-Signature` |
| Signature format | `v1,` |
| Signature input | `{timestamp}.{raw_body}` |
| Event ID header | `Webhook-Id` |
| Timestamp header | `Webhook-Timestamp` |
| Type header | `Webhook-Type` |
| Retry backoff (seconds) | `[60, 300, 900, 3600, 21600, 86400]` |
| Total delivery attempts | **7** (1 initial + 6 retries; then `dead_letter`) |
| Receiver response-header timeout | 5 seconds |
| Receiver total request timeout | 10 seconds |
| HTTPS required | ✅ (production; HTTP only when `AllowHTTP=true` in dev) |
| Public IP required | ✅ (rejects private / loopback / link-local) |
| Allowed ports | **443** in production |
## Card Types (v1.2 OpenAPI scope)
| card_type | Description |
|---|---|
| `virtual_l` | Virtual card class L |
| `virtual_p` | Virtual card class P |
| `virtual_v` | Virtual card class V |
| `virtual_r` | Virtual card class R |
| `virtual_g` | Virtual card class G |
::: info OpenAPI rejects non-virtual types
`master_e` / `visa_h` / `transfer` etc. are out of scope and return 400 `openapi_card_type_not_supported`.
:::
::: warning apply does not accept card_type
[`/card/apply`](/api/card-apply) doesn't take `card_type`; it's derived from `header_id`.
:::
## Business Prefixes
| Resource | Prefix | Example |
|---|---|---|
| Card | `card_` | `card_12345` |
| Package | `pkg_` | `pkg_67` |
| Card header | `hdr_` | `hdr_5` |
| Recharge transaction | `txn_` | `txn_OO20260429110012abc` |
| Webhook event | `evt_` | `evt_550e8400-e29b-41d4-a716-446655440000` |
## Useful SHA-256 Constants
| Input | sha256 hex |
|---|---|
| Empty string | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` |
| `{}` | `44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a` |
## Localization
| Header | Behavior |
|---|---|
| `Accept-Language` not set | Default Chinese |
| `Accept-Language: zh-CN` | Chinese |
| `Accept-Language: en-US` | English |
`message_key` is **not affected** by `Accept-Language` — always stable, use it for programmatic checks.
## Credentials
| Item | Value |
|---|---|
| Validity | Indefinite (until reset / disabled) |
| Per-user limit | 1 active credential pair (v1) |
| After reset | Old secret immediately invalid |
| After disable | 401 invalid_credentials; can re-enable |
---
---