Activate a physical card (irreversible; call directly after your own user verification)
x-on-behalf-of required
Requires x-idempotency-key
The second step after binding. It remains irreversible, but the Open API no longer sends end users to our hosted screen for email-code verification.
This endpoint serves merchant_hosted merchants whose end users are already in your App / H5. Inserting hosted_url would split the flow into you collecting the PIN → our page collecting a verification code → you resending the request. External-link opening and email delivery would reduce completion rates and noticeably worsen the experience.
The responsibility boundary is therefore:
- Verify the end user yourself, using a logged-in session, additional confirmation, SMS / biometrics, etc.
- Then call this endpoint directly.
- We handle card ownership and status checks, failed-attempt locking, audit records, and actual upstream activation.
⚠ 3 failures lock the card for 24 hours, counted per card. Network errors and upstream 5xx do not count: counting transient network errors as incorrect credentials would lock users out for a full day after three glitches. Once locked, there is no self-service unlock, which would give attackers a way to resume their attempts. Manual unlocking through our administration system is required. A locked response includes locked_until.
Activating an already active card returns 200 without incrementing failures, as repeated user taps are common.
Prerequisites
- The card is physical and its status is
unactivated. - The card's inventory row has left inventory (
shipped/binding/bound). - Not currently within a 24-hour lock window.
Path Parameters
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Required | 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 |
|---|---|---|---|
expiry |
string | Required | Printed expiry date, MM/YY. |
pin |
string | Required | The PIN chosen by the user or supplied in the envelope. Never stored in our database or logs; do not retain it on your side either. |
Response
{
"id": "crd_9f2c1b7a-3d51-4a2e-9c08-6b1f0d4e77aa",
"status": "active"
}state_invalid: the card is not awaiting activation, or it is locked. A locked response also includes
locked_until, an ISO timestamp. Use it to show when to retry rather than letting users keep tapping.
product_not_available: issuer unavailable.
idempotency_key_required / idempotency_key_invalid.
⚠ Mismatched card details and a card not yet shipped currently return 500 api_error
(internal codes exist but are not registered in the public catalog). The former counts toward the 3 failures.not_found: the card does not exist or does not belong to this member.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/cards/{id}/activate' \
-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 '{
"expiry": "08/29",
"pin": "1234"
}'const res = await fetch("https://api.zinfra.vip/v1/cards/{id}/activate", {
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({
"expiry": "08/29",
"pin": "1234"
}),
});
// Keep monetary amounts as strings, never numbers.
const data = await res.json();import requests
res = requests.post(
"https://api.zinfra.vip/v1/cards/{id}/activate",
headers={
"x-auth-token": "Bearer $TOKEN",
"x-on-behalf-of": "$MEMBER_ID",
"x-idempotency-key": "$IDEMPOTENCY_KEY",
"content-type": "application/json",
},
json={
"expiry": "08/29",
"pin": "1234"
},
)
# Use Decimal(str(...)) for amounts, not float.
data = res.json()req, _ := http.NewRequest("POST", "https://api.zinfra.vip/v1/cards/{id}/activate",
strings.NewReader(`{
"expiry": "08/29",
"pin": "1234"
}`))
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}/activate"))
.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("""
{
"expiry": "08/29",
"pin": "1234"
}
"""))
.build();
// Use String / BigDecimal for amounts, not double.$ch = curl_init('https://api.zinfra.vip/v1/cards/{id}/activate');
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'
{
"expiry": "08/29",
"pin": "1234"
}
JSON,
]);
$res = curl_exec($ch);
// Use bcmath / strings for amounts, not floatval.
{
"id": "crd_9f2c1b7a-3d51-4a2e-9c08-6b1f0d4e77aa",
"status": "active"
}