High-risk member actions need a step-up authentication ticket—who issues it and how to use it.
Step-Up Authentication
Some actions are irreversible. An API Key alone is insufficient; the end user themselves must prove their identity again at that moment. We do this on our hosted page, so you never handle authentication factors.
Strict Boundary: We Do Not Accept Your Self-Attestation
Fields such as "step_up_passed": true in a body or x-verified: 1 in a header are not accepted and never will be.
This is not a matter of distrust. The gate protects adding withdrawal addresses, reporting cards lost, and changing PINs— irreversible actions. If callers could declare the gate passed, it would no longer be a gate, just a field. Any SSRF or injection point on your side capable of constructing a body would then directly enable withdrawals to arbitrary addresses.
Viewing a card number / CVV / expiry no longer uses this gate to restrict merchants. Once ownership matches both the merchant and member, plaintext is returned. We only guarantee that our own databases and logs do not store these three values.
Your responsibilities in this flow are therefore just two: give the URL to the end user, and resend with the ticket afterward.
Actions That Require It
| Action | Endpoint | action |
|---|---|---|
| Add a withdrawal address | POST /v1/withdraw-addresses | withdraw_address_add |
| Report lost / replace card | POST /v1/cards/{id}/lost | card_lost:<卡 id> |
| Set card PIN | POST /v1/cards/{id}/pin | card_pin:<卡 id> |
| QR payment | POST /v1/qrpay/payments | qrpay_pay:<码值摘要> |
| Execute exchange (when the pair requires verification) | POST /v1/exchange/orders | exchange:<FROM>:<TO> |
| Wealth subscription / redemption (when the product requires verification) | POST /v1/earn/subscriptions · POST /v1/earn/redemptions | earn_subscribe:<产品 id> · earn_redeem:<产品 id> |
⚠ Deleting a withdrawal address (DELETE /v1/withdraw-addresses/{id}) does not require step-up— deletion improves safety and should not be obstructed. Likewise, shipping address creation, editing, and deletion do not require it: the real risk is changing the default address → applying for a physical card → sending it to an attacker; the gate belongs at order submission.
Three Rules for When It Is Required
- Always—the first six rows above. These are irreversible or directly spend funds.
- Configuration-dependent—the exchange / transfer / wealth rows. An admin switch determines the requirement, and you can read it:
GET /v1/earn/products/{id}exposesrequire_step_up. Reading ahead and including the step in your flow is smoother than adding it after a 400. - Every QR payment.
require_step_upfromGET /v1/qrpay/configis alwaystrue; it is exposed so you can design the flow in advance.
⚠ Before 2026-08-14, exchange always rejected without a
hosted_url—pairs configured to require verification were therefore permanently unexecutable through the Open API.
Exchange now uses the same two-stage hosted flow as other actions. If you added a branch
telling users to contact support for this code, you can remove it.
Interaction
First call (without a ticket) → 400:
{
"type": "invalid_request_error",
"code": "step_up_required",
"message": "Strong authentication is required. See hosted_url / challenge_id.",
"request_id": "…",
"challenge_id": "chl_9f2c…",
"hosted_url": "https://…/hosted/step-up/chl_9f2c…",
"expires_at": 1754872500
}
Give hosted_url to the end user through an in-app browser, SMS, or push notification. The page is on our domain. We send a 6-digit code to their registered email, and they pass by entering it correctly. expires_at is Unix seconds; validity is 5 minutes.
⚠ We do not call you back after completion. The page issues no session and returns nothing to you— it only marks the ticket passed. Ask the user to select “I have completed verification,” or resend when the page closes.
Second call: the same idempotency key and body, with one extra header:
POST /v1/withdraw-addresses
x-idempotency-key: <与第一次完全相同>
x-step-up: chl_9f2c…
The body must also be byte-for-byte identical to the first request. Recalculate the signature because the timestamp and nonce change.
The Only Idempotency Exception Where Resending the Same Key Executes Again
The usual rule is: the same idempotency key returns the same result, including errors. If the first request returned 400, sending the same key again replays that exact 400.
Step-up authentication is the only exception, explicitly handled on the server.
The reason: same-key matching hashes only the body, not headers, while x-step-up is a header— the hash is identical with or without it. Without this exception, the documented resend would hit the replay branch and return the old step_up_required unchanged; the business handler would never execute. The flow could never complete at the code level.
Therefore:
- Resend with the original key. A new key on withdrawal/card issuance endpoints == another real operation;
- The exception applies only to
step_up_required. All other errors (insufficient balance, invalid parameters) replay normally. In those cases, correct the issue and use a new key.
Ticket Binding and Validity
A ticket is bound to three things. Any mismatch is treated as no ticket (you receive a fresh step_up_required, not an error explaining the mismatch):
| Binding | What happens without it |
|---|---|
| Member | A ticket completed by A could move B's money |
| Action | A card activation ticket could reveal card credentials |
| Passed flag | An issued ticket would be immediately usable, reducing the whole chain to self-attestation |
Plus two rules:
- Single use: deleted after validation. The same ticket cannot authorize a second request;
- 5 minutes: measured from issuance, not when the end user opens it. If they spend more than 5 minutes on the hosted page, the ticket expires and you receive a new
step_up_required. This short validity is intentional.
Viewing Card Credentials Does Not Use a Step-Up Ticket
POST /v1/cards/{id}/secure-session validates merchant, member, and card ownership, then calls the upstream in real time, returning plaintext card number / CVV / expiry. It issues no hosted_url and does not enter the idempotency cache.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Resending still returns step_up_required, with a new challenge_id | The user did not finish the page; the ticket exceeded 5 minutes; or x-step-up contains the full hosted_url instead of challenge_id |
Resending returns 409 idempotency_key_reused | The body differs from the original, even by one space, or this key was used on another endpoint |
| A new idempotency key succeeded, but reconciliation shows an extra operation | You resent an already completed operation with a new key. This case must retain the original key |
⚠ Another exception to normal behavior: after a successful resend, the key does not cache the success result. Sending the same request with it again returns 409 idempotency_in_progress, rather than replaying success, and stays that way until the 24-hour window ends.
In this flow, the idempotency key is used only for the pair “initial call → resend with ticket.” Stop using it after the resend gives a definitive response. If no response arrives because the connection breaks, query the result through a GET endpoint instead of blindly sending again.