Sandbox: simulate an incoming upstream card transaction
x-on-behalf-of required
Requires x-idempotency-key
Moves funds · Moves member.card and card.pool according to the scenario, using the same journal types as production.
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.
Available only in the sandbox; returns 404 in live mode. Only card products with transaction simulation support are eligible. Unsupported cards return product_not_available without writing transactions or callback inbox entries.
── Why this endpoint is necessary ──
/v1/sandbox/upstream-behavior injects behavior into our outbound upstream call. Card purchases, refunds, reversals, settlements, and chargebacks are instead pushed to us by the upstream, with no outbound call on this path, so such injection is a no-op here. Sandbox cards also cannot make real purchases because there is no acquiring side. Without this endpoint, all your card-transaction handling code for posting, reconciliation, limit recovery, and chargeback disputes would go live without ever running, and its first real execution would involve real money in production. This endpoint moves that first execution into the sandbox.
── The payload follows the real processing path ──
We construct an upstream-shaped callback and feed it through the exact production pipeline: inbox deduplication → normalization → direction determination → posting only on succeed → ledger → limits → risk counters → penalties/card freezing. Only signature verification is skipped, because we do not have the upstream's private key. A simulation that merely inserts a transaction row would bypass every one of these steps and produce a misleading pass.
── Where to observe results ──
Simulated and normal card-transaction callbacks share the same processing path. Once persisted, they emit card.transaction.updated with object card_transaction, a ctx_ transaction ID, a crd_ card ID, and the actual status. Query transaction details through the transaction list after receiving it. Inspect three places:
GET /v1/cards/{id}/transactions: the new transaction row. **Also readdirection**, becauseamountis absolute;auth_codematches the value in this response.GET /v1/cards/{id}: changes tobalanceandlimits.- The
card.status.updatedevent: when repeatedchargebackscenarios exceed the freeze threshold, this event is actually delivered. It is the only event pushed to you on this line.
── Repeated scenarios ──
repeat runs the same scenario N times, up to 20, to exceed free-event allowances and freeze thresholds (the chargeback threshold is 3). Each transaction uses a different transaction ID. Reusing an ID triggers inbox deduplication, and a single counted event could be mistaken for risk controls not working.
── Transaction IDs ──
txn_id applies only when repeat is omitted or 1. We prepend the merchant identifier (SBX-<你的沙盒商户号>-<你给的值>) before using it. This is necessary because transaction-ID deduplication is database-wide: unrestricted IDs could claim another merchant's identifier, causing their transaction to be silently treated as a duplicate.
── Insufficient balance ──
Our ledger rejects the transaction and returns insufficient_balance, including completed, the number of earlier transactions in this batch already processed. This is not a platform failure; it reminds you that a real upstream would send a decline rather than a successful authorization in this situation. Use the decline_insufficient scenario to test that branch.
Prerequisites
- The current token resolves to a sandbox shadow entity; live credentials always return 404.
- The card belongs to this member and merchant and already has an upstream card identifier.
Path Parameters
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Required | Card ID. Accepted with or without the crd_ prefix. |
Request Headers
| Field | Type | Required | Description |
|---|---|---|---|
x-on-behalf-of |
string | Required | The member on whose behalf to call. Must own this card; otherwise returns 404. |
x-idempotency-key |
string | Required | UUID v4. A replay returns the same response body without generating another transaction. Use a new key to generate another transaction. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
scenario |
string | Required | Scenario key; see GET /v1/sandbox/card-scenarios.
An unrecognized value returns invalid_fields, with all valid values listed in fields[0].reason. |
amount |
string | Optional | Transaction amount, a decimal string, such as "12.99". Decimal places must not exceed the card's
card_scale. Defaults to "10.00". Maximum per transaction: 1,000,000 whole currency units."12.99" |
repeat |
integer | Optional | Number of repetitions: 1~20, default 1; higher values are capped at 20. |
txn_id |
string | Optional | Your own reference, used only when repeat is 1. We prepend the merchant identifier
and return the complete value in the response. |
origin_txn_id |
string | Optional | Related original transaction ID, used for refund / reversal / chargeback. |
merchant_name |
string | Optional | Acquiring merchant name; defaults to SANDBOX MERCHANT / LONDON. |
mcc |
string | Optional | Merchant category code; defaults to 5812. |
country |
string | Optional | Two-letter country code; defaults to GB. |
Response
events[].deduplicated being true means the inbox identified a redelivery;
this transaction was not processed again, exactly as in production.
Use auth_code to identify this transaction in GET /v1/cards/{id}/transactions.
Card-status scenarios (card_frozen / card_active) have no transaction,
so this field is an empty string.{
"card_id": "crd_2c9e4b10-77af-4d3a-8e51-b0d6c2f9a134",
"scenario": "auth_ok",
"expect": "过⑤号凭证 · 已使用额度 +金额 · 可用额度 −金额 · 不计风控",
"events": [
{
"txn_id": "SBX-acme_sbx-9F2C7A1B4D6E8035",
"auth_code": "6E8035",
"deduplicated": false
}
],
"note": "载荷经与生产逐字相同的回调管线落地(含收件箱去重),只跳过验签。"
}invalid_fields: scenario not in the list. invalid_request: body is not JSON
or the amount is invalid. limit_exceeded: per-transaction amount exceeds 1,000,000 whole currency units.
state_invalid: the card has no upstream card identifier yet.
insufficient_balance: insufficient card balance; includes completed.not_found: the card does not exist or does not belong to this member and merchant.idempotency_key_reused: same key with a different body.
idempotency_in_progress: the original request is still being processed.Emitted Events
Green = successful terminal state · Red = terminal state requiring action · Purple = intermediate state. Open an event for its payload and signature verification details.
curl -X POST 'https://api.zinfra.vip/v1/sandbox/cards/{id}/simulate' \
-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 '{
"scenario": "auth_ok",
"amount": "12.99",
"merchant_name": "NETFLIX.COM",
"mcc": "4899"
}'const res = await fetch("https://api.zinfra.vip/v1/sandbox/cards/{id}/simulate", {
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({
"scenario": "auth_ok",
"amount": "12.99",
"merchant_name": "NETFLIX.COM",
"mcc": "4899"
}),
});
// Keep monetary amounts as strings, never numbers.
const data = await res.json();import requests
res = requests.post(
"https://api.zinfra.vip/v1/sandbox/cards/{id}/simulate",
headers={
"x-auth-token": "Bearer $TOKEN",
"x-on-behalf-of": "$MEMBER_ID",
"x-idempotency-key": "$IDEMPOTENCY_KEY",
"content-type": "application/json",
},
json={
"scenario": "auth_ok",
"amount": "12.99",
"merchant_name": "NETFLIX.COM",
"mcc": "4899"
},
)
# Use Decimal(str(...)) for amounts, not float.
data = res.json()req, _ := http.NewRequest("POST", "https://api.zinfra.vip/v1/sandbox/cards/{id}/simulate",
strings.NewReader(`{
"scenario": "auth_ok",
"amount": "12.99",
"merchant_name": "NETFLIX.COM",
"mcc": "4899"
}`))
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/sandbox/cards/{id}/simulate"))
.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("""
{
"scenario": "auth_ok",
"amount": "12.99",
"merchant_name": "NETFLIX.COM",
"mcc": "4899"
}
"""))
.build();
// Use String / BigDecimal for amounts, not double.$ch = curl_init('https://api.zinfra.vip/v1/sandbox/cards/{id}/simulate');
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'
{
"scenario": "auth_ok",
"amount": "12.99",
"merchant_name": "NETFLIX.COM",
"mcc": "4899"
}
JSON,
]);
$res = curl_exec($ch);
// Use bcmath / strings for amounts, not floatval.
{
"card_id": "crd_2c9e4b10-77af-4d3a-8e51-b0d6c2f9a134",
"scenario": "auth_ok",
"expect": "过⑤号凭证 · 已使用额度 +金额 · 可用额度 −金额 · 不计风控",
"events": [
{
"txn_id": "SBX-acme_sbx-9F2C7A1B4D6E8035",
"auth_code": "6E8035",
"deduplicated": false
}
],
"note": "载荷经与生产逐字相同的回调管线落地(含收件箱去重),只跳过验签。"
}