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