Z Zise Developers 简体中文

Top up a card (two asynchronous stages; the webhook confirms credit)

POST /v1/cards/{id}/topups scope: cards:write
On behalf of a member · x-on-behalf-of required Requires x-idempotency-key Moves funds · Non-platform-operated accounts pay from merchant prepaid funds; platform-operated accounts use member wallets.
This endpoint moves funds

See the response table below for failure handling. Retry timeouts (504) with the same idempotency key — we may already have processed the request; use a new key after a business failure; the same key replays that failure.

Funding restriction: merchant_hosted accounts must explicitly send funding_source: merchant. Sending member or omitting the field returns 400 invalid_fields with fields[0].key = funding_source, without debiting funds or creating a business order. member and its default behavior are available only to platform_hosted accounts.

── Success from this endpoint does not mean the card has been credited ── An upstream recharge result of succeed means only that the order was received. Actual card credit is confirmed by the card.topup.credited webhook. The status returned here is therefore usually processing; do not use it to authorize any downstream action for the user. Card limits must likewise be applied only after credit is confirmed; applying them earlier opens spending capacity before funds arrive.

── Failure and an unknown outcome are different ── An explicit upstream rejection causes immediate release of the hold. You receive a business error and no funds are lost. A network failure or timeout leaves the order in processing for reconciliation; we never release the hold in this case. Do not retry as a second payment. Resend with the same idempotency key to retrieve the same order.

── Idempotency replays the original outcome; it does not guarantee success ── With the same x-idempotency-key, an order that failed initially still returns failure on the second request. Use a new key to initiate a new attempt. Previously this endpoint always replayed success, causing the second click to show success even though the card was not credited and the transaction record showed failure.

⚠ Merchant-hosted accounts use merchant funding and hold merchant prepaid funds; platform-operated accounts use member funding and debit the available member balance. If merchant prepaid funds are insufficient, the member sees only temporary unavailability. Do not expose the merchant funding reason to end users.

Prerequisites

  • The card is in a top-up-eligible status (active / frozen / pending; all transitional states are excluded).
  • The member is not in funds-protection mode; we block this path first when card limits and upstream state diverge.
  • Platform-operated member funding: available member balance in the asset must cover customer_total.
  • Your prepaid balance covers this order's wholesale cost.

Path Parameters

FieldTypeRequiredDescription
id string Required Card ID
FieldTypeRequiredDescription
x-on-behalf-of string Required The member on whose behalf to call.

Request Body

FieldTypeRequiredDescription
funding_source "member" | "merchant" Optional Funding source: merchant_hosted accounts must explicitly send merchant, which debits only merchant prepaid funds. Sending member or omitting the field returns invalid_fields. member is available only to platform_hosted accounts.
amount string Required Base amount, a decimal string in the source asset, using the same convention as the estimate. The actual debit is the estimate's customer_total.USDT allows up to 6 decimal places: "100.00".
source_asset string Optional Debit asset. If omitted or unavailable, selected automatically by payment priority; as with the estimate, this is a preference rather than an instruction.

Response

201Accepted. status: processing (order accepted, outcome awaiting reconciliation, or queued) · completed (only when a shared-limit card settles immediately). 201 does not mean the card has been credited.
{
  "id": "ctp_4a8e5d21-90bc-4f37-b1e2-8d0c6a3f5719",
  "status": "processing"
}
400state_invalid: the card status does not allow top-ups. product_not_available: product or issuer unavailable. service_unavailable: card issuing is not enabled or restrictions have been applied to your funding account. idempotency_key_required / idempotency_key_invalid. ⚠ Insufficient balance, below-minimum amounts, amounts exceeding the per-transaction limit, upstream rejection, and unavailable exchange rates currently return 500 api_error; their internal codes are not registered in the public catalog. For 500, retry once with the same key to confirm the outcome. If it is still 500, report it as a business failure rather than retrying in a loop.
409idempotency_key_reused · idempotency_in_progress
504upstream_timeout: outcome unknown. Retry with the same idempotency key; we may already have processed it.

Emitted Events

Green = successful terminal state · Red = terminal state requiring action · Purple = intermediate state. Open an event for its payload and signature verification details.

Request
curl -X POST 'https://api.zinfra.vip/v1/cards/{id}/topups' \
  -H 'x-auth-token: Bearer $TOKEN' \
  -H 'x-on-behalf-of: $MEMBER_ID' \
  -H 'x-idempotency-key: $IDEMPOTENCY_KEY' \
  -H 'content-type: application/json' \
  -d '{
    "funding_source": "merchant",
    "amount": "100.00",
    "source_asset": "USDT"
  }'
const res = await fetch("https://api.zinfra.vip/v1/cards/{id}/topups", {
  method: "POST",
  headers: {
    "x-auth-token": "Bearer $TOKEN",
    "x-on-behalf-of": "$MEMBER_ID",
    "x-idempotency-key": "$IDEMPOTENCY_KEY",
    "content-type": "application/json",
  },
  body: JSON.stringify({
    "funding_source": "merchant",
    "amount": "100.00",
    "source_asset": "USDT"
  }),
});
// Keep monetary amounts as strings, never numbers.
const data = await res.json();
import requests

res = requests.post(
    "https://api.zinfra.vip/v1/cards/{id}/topups",
    headers={
        "x-auth-token": "Bearer $TOKEN",
        "x-on-behalf-of": "$MEMBER_ID",
        "x-idempotency-key": "$IDEMPOTENCY_KEY",
        "content-type": "application/json",
    },
    json={
      "funding_source": "merchant",
      "amount": "100.00",
      "source_asset": "USDT"
    },
)
# Use Decimal(str(...)) for amounts, not float.
data = res.json()
req, _ := http.NewRequest("POST", "https://api.zinfra.vip/v1/cards/{id}/topups",
    strings.NewReader(`{
  "funding_source": "merchant",
  "amount": "100.00",
  "source_asset": "USDT"
}`))
req.Header.Set("x-auth-token", "Bearer $TOKEN")
req.Header.Set("x-on-behalf-of", "$MEMBER_ID")
req.Header.Set("x-idempotency-key", "$IDEMPOTENCY_KEY")
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
// Decode amount fields as string, not float64.
HttpRequest req = HttpRequest.newBuilder()
    .uri(URI.create("https://api.zinfra.vip/v1/cards/{id}/topups"))
    .header("x-auth-token", "Bearer $TOKEN")
    .header("x-on-behalf-of", "$MEMBER_ID")
    .header("x-idempotency-key", "$IDEMPOTENCY_KEY")
    .header("content-type", "application/json")
    .method("POST", HttpRequest.BodyPublishers.ofString("""
{
  "funding_source": "merchant",
  "amount": "100.00",
  "source_asset": "USDT"
}
"""))
    .build();
// Use String / BigDecimal for amounts, not double.
$ch = curl_init('https://api.zinfra.vip/v1/cards/{id}/topups');
curl_setopt_array($ch, [
  CURLOPT_CUSTOMREQUEST => 'POST',
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    'x-auth-token: Bearer $TOKEN',
    'x-on-behalf-of: $MEMBER_ID',
    'x-idempotency-key: $IDEMPOTENCY_KEY',
    'content-type: application/json',
  ],
  CURLOPT_POSTFIELDS => <<<'JSON'
{
  "funding_source": "merchant",
  "amount": "100.00",
  "source_asset": "USDT"
}
JSON,
]);
$res = curl_exec($ch);
// Use bcmath / strings for amounts, not floatval.
201
{
  "id": "ctp_4a8e5d21-90bc-4f37-b1e2-8d0c6a3f5719",
  "status": "processing"
}