Z Zise Developers 简体中文

Request a card replacement (no direct balance transfer between old and new cards)

POST /v1/cards/{id}/replacements scope: cards:write
On behalf of a member · 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

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

Request Body

FieldTypeRequiredDescription
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

201Accepted. status is always requested; our executor handles subsequent stages.
{
  "id": "crp_8e4a2f16-b073-4c95-a2d8-3f6e1c07b94a",
  "status": "requested"
}
400state_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.
404not_found: the card does not exist or does not belong to this member.
Request
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.
201
{
  "id": "crp_8e4a2f16-b073-4c95-a2d8-3f6e1c07b94a",
  "status": "requested"
}