Request a card replacement (no direct balance transfer between old and new cards)
x-on-behalf-of required
Requires x-idempotency-key
Creates an order only; no money movement or upstream call. Our executor advances the stages: freeze the original card / return its balance / close it / issue the new card.
⚠ Balances are never transferred directly between the old and new cards: the upstream has no card-to-card transfer API. The only correct path is original card → return to available balance → user tops up the new card. An automatic transfer would leave a window between two upstream actions in which the funds are on neither card. Any error during that window would require manual reconciliation.
⚠ Fees depend on responsibility, not processing difficulty. Security-related cases (pan_leaked / stolen / pin_locked / damaged / user_request / expiring): freeze the original card → return its balance → close as replaced; replacement and shipping fees apply. Non-security cases (lost_in_transit / not_received / production_error / name_error): the original card never reached the user and its inventory row is invalidated; neither fee applies.
⚠ Non-security cases require the original card to never have been activated. Reporting Not received for an active card is rejected, because that path skips the balance-return step.
⚠ Each card may have only one replacement in progress, enforced by a database constraint. A concurrent second submission is rejected, not silently treated as successful.
⚠ The original card ends in replaced, not closed. They are deliberately separate: closed means the user closed it voluntarily; overwriting it would erase evidence of why it ended.
Prerequisites
- The original card is not
closed/replaced/expired. - For non-security cases, the original card's status is not
active. - No other replacement order is in progress for this card.
Path Parameters
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Required | Original card ID |
Request Headers
| Field | Type | Required | Description |
|---|---|---|---|
x-on-behalf-of |
string | Required | The member on whose behalf to call. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
reason |
"pan_leaked" | "stolen" | "pin_locked" | "damaged" | "user_request" | "expiring" | "lost_in_transit" | "not_received" | "production_error" | "name_error" | Required | Replacement reason. ⚠ This endpoint currently does not validate the enum against an allowlist.
A value outside the list violates a database constraint and returns 500 api_error.
Use exactly one of these ten values. |
note |
string | Optional | Notes, recorded in our audit trail and truncated to 200 characters. |
Response
status is always requested; our executor handles subsequent stages.{
"id": "crp_8e4a2f16-b073-4c95-a2d8-3f6e1c07b94a",
"status": "requested"
}state_invalid: the original card's status does not allow replacement. invalid_request: body is not valid JSON.
idempotency_key_required / idempotency_key_invalid.
⚠ A non-security case with an activated original card, an existing replacement in progress, or a reason outside the enum
currently returns 500 api_error. The first two have unregistered internal codes;
the third lacks an allowlist validation step.not_found: the card does not exist or does not belong to this member.curl -X POST 'https://api.zinfra.vip/v1/cards/{id}/replacements' \
-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 '{
"reason": "pan_leaked",
"note": "用户报告卡号在钓鱼站被输入"
}'const res = await fetch("https://api.zinfra.vip/v1/cards/{id}/replacements", {
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({
"reason": "pan_leaked",
"note": "用户报告卡号在钓鱼站被输入"
}),
});
// Keep monetary amounts as strings, never numbers.
const data = await res.json();import requests
res = requests.post(
"https://api.zinfra.vip/v1/cards/{id}/replacements",
headers={
"x-auth-token": "Bearer $TOKEN",
"x-on-behalf-of": "$MEMBER_ID",
"x-idempotency-key": "$IDEMPOTENCY_KEY",
"content-type": "application/json",
},
json={
"reason": "pan_leaked",
"note": "用户报告卡号在钓鱼站被输入"
},
)
# Use Decimal(str(...)) for amounts, not float.
data = res.json()req, _ := http.NewRequest("POST", "https://api.zinfra.vip/v1/cards/{id}/replacements",
strings.NewReader(`{
"reason": "pan_leaked",
"note": "用户报告卡号在钓鱼站被输入"
}`))
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}/replacements"))
.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("""
{
"reason": "pan_leaked",
"note": "用户报告卡号在钓鱼站被输入"
}
"""))
.build();
// Use String / BigDecimal for amounts, not double.$ch = curl_init('https://api.zinfra.vip/v1/cards/{id}/replacements');
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'
{
"reason": "pan_leaked",
"note": "用户报告卡号在钓鱼站被输入"
}
JSON,
]);
$res = curl_exec($ch);
// Use bcmath / strings for amounts, not floatval.
{
"id": "crp_8e4a2f16-b073-4c95-a2d8-3f6e1c07b94a",
"status": "requested"
}