Apply Virtual Card
Async card-open endpoint. A successful response means "queued"; the final result is pushed via card.opened / card.open_failed webhook or polled via card/info.
Endpoint
| Item | Value |
|---|---|
| Method | POST |
| Path | /api/v1/openapi/card/apply |
| Auth | HMAC |
| Idempotency Key | Required via Idempotency-Key header |
Request Fields
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_<id> (List Card Headers) |
package_id | string | ✅ | pkg_<id> (List Card Configs) |
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 | ⚠️ | |
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. Must be ≥ the package's min_first_deposit (List Card Configs); when that minimum is above base, this field is required (the empty default is rejected with first_recharge_below_min). 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. |
When holder fields are required
- If
header.require_phone == true: sendphone_code+phone(or setuse_bound_phone == trueto fall back to the account's bound phone) - If
header.require_email == true: sendemail(or setuse_bound_email == trueto fall back to the account's bound email) first_name/last_nameare usually required (always required forvirtual_g/virtual_a, see below)- OpenAPI default differs from the H5 app:
use_bound_phoneanduse_bound_emaildefault tofalsehere — 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. Sendtrueonly 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.
A package may also enforce a minimum first deposit above the base (min_first_deposit in List Card Configs, min_first_deposit_amount in the preview). In that case first_deposit_amount is required and must be ≥ that minimum; only the base part stays fee-free (e.g. base 10, minimum 500 → deposit ≥ 500, 10 fee-free, 490 charged).
Preview before applying
The validation (format / >= base / max recharge / recharge limit / balance) is shared with Preview First Deposit. 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).
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 |
|---|---|---|
| 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) |
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.
Virtual-A specific constraints
virtual_a (A card) is issued through the same provider family as V / R / G and follows the provider's own application protocol. The server enforces these rules regardless of what header.require_* says — and the header list already reports the effective values (require_email=true, require_phone=false) for A-card headers:
| Constraint | Rule | Failure message_key |
|---|---|---|
Always required — send email (or use_bound_email=true if the account has a bound email) | bank_card_email_required | |
| Phone | Not collected — phone_code / phone / use_bound_phone are ignored; the provider protocol has no phone field | — |
| Cardholder name | first_name and last_name are both required and must be non-blank (whitespace-only values are rejected); checked before any funds are frozen | virtual_a_required_fields_missing |
| Birthday & billing address | Do not send — generated server-side automatically | virtual_a_required_fields_missing (only if server-side fill fails) |
Everything else (custom first deposit, idempotency, async result via card.opened / card.open_failed, recharge, freeze / unfreeze, close, transactions, sensitive card info) works exactly as for the other virtual card types.
Example Request
{
"header_id": "hdr_5",
"package_id": "pkg_12",
"first_name": "John",
"last_name": "Doe"
}Required header:
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000Response Fields
| Field | Type | Description |
|---|---|---|
card_id | string | card_<id> (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)
{
"code": 200,
"message": "OK",
"data": {
"card_id": "card_12345",
"status": 1,
"status_desc": "pending",
"created_at": "2026-04-29T11:00:12Z"
}
}status=1 doesn't mean ready
status=1 (pending) only means "queued". Wait for the card.opened webhook, or poll card/info until you see status=2 (active).
Subsequent Flow
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_below_min | first_deposit_amount (or the empty default = base) is below the package's minimum first deposit min_first_deposit |
| 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 | account_manager_bind_required | Account-manager-controlled header and your account has no account manager bound — only use header_id values returned by /card_headers/list |
| 400 | account_manager_unavailable | The account manager bound to your account is no longer active; contact platform support |
| 400 | account_manager_open_disabled | Your account manager has not opened this header / package for your account; use only headers and packages returned by the list endpoints (they already omit closed ones) |
| 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 | virtual_a_required_fields_missing | (A card) first_name / last_name missing or blank, or the server could not assemble a complete apply payload (header / 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). 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_idpermanently identifies the open attempt (even if it ultimately fails).