Mã lỗi luồng thanh toán merchant
Tài liệu này liệt kê toàn bộ mã lỗi có thể trả về ở ba endpoint của một lần thanh toán tại quầy:
| Thứ tự gọi | Endpoint | Vai trò |
|---|---|---|
| 1 | POST /merchant/chip/tap/prepare | Xin khoá Crypto1 để đọc thẻ Classic và tăng counter (chỉ dùng cho chip_tap) |
| 2 | POST /merchant/chip/tap/cryptogram | Ký tap sau khi đã tăng counter (chỉ dùng cho chip_tap) |
| 3 | POST /merchant/payments/authorize | Tạo giao dịch |
Card-core tách bạch 2 tầng lỗi:
| Tầng | HTTP | Hình dạng response | Ý nghĩa |
|---|---|---|---|
| Lỗi request / hệ thống | 4xx, 5xx | { "code": "...", "message": "...", "details"? } | Request sai, không có quyền, hoặc hệ thống hỏng — chưa có giao dịch nào được tạo |
| Từ chối nghiệp vụ (decline) | 201 | { "status": "declined", "decline_reason": "...", ... } | Request hợp lệ, giao dịch đã được ghi nhận nhưng bị từ chối |
POS/app không được coi
201 + status=declinedlà lỗi kỹ thuật. Đó là kết quả hợp lệ của một lần thanh toán và phải hiển thịdecline_reasoncho nhân viên.Hai endpoint chip ở mục 1–2 không có tầng decline: chúng chỉ trả 200 hoặc một mã lỗi HTTP.
1. POST /merchant/chip/tap/prepare
Trả 200 kèm key material (token_key_a, counter_key_b, issuer_block,
expected_mac, layout). Key chỉ giữ trong RAM cho một lần tap — không cache,
không ghi xuống đĩa.
1.1 Xác thực & tần suất
| HTTP | code | Nguyên nhân | Xử lý phía client |
|---|---|---|---|
| 401 | unauthorized | Không gửi credential; X-Merchant-Key không tồn tại hoặc merchant không active | Kiểm tra credential |
| 401 | token_expired | Bearer JWT merchant hết hạn / sai chữ ký | Refresh token rồi retry |
| 401 | session_invalid | Session sau access token đã bị thu hồi | Đăng nhập lại |
| 403 | forbidden | Token hợp lệ nhưng không được phép trên route này | Không retry |
| 429 | rate_limited | Vượt 120 request / 60 giây (tính theo IP) | Backoff rồi retry |
Giới hạn 120/60s là cho cả cửa hàng, không phải cho từng máy: throttler khoá theo IP, mà một shop NAT chung một IP. Một tap tốn 2 request (
prepare+cryptogram).
1.2 Validate request
| HTTP | code | Nguyên nhân |
|---|---|---|
| 422 | validation_failed | chip_uid không phải chuỗi hex, thiếu chip_uid, hoặc gửi thừa field lạ |
1.3 Trạng thái credential
| HTTP | code | Nguyên nhân | Xử lý phía client |
|---|---|---|---|
| 404 | credential_unknown | chip_uid không thuộc issuer này | Hiển thị "không phải thẻ của hệ thống"; không retry |
| 409 | credential_revoked | Credential đã bị thu hồi (mất/cắp/nghi ngờ clone) | Hướng dẫn chủ thẻ liên hệ issuer |
| 409 | credential_not_active | perso_state chưa phải PERSONALIZED hoặc LOCKED (thường là PENDING — personalization chưa hoàn tất) | Thẻ cần perso lại |
| 409 | not_classic | Thẻ là DESFire — khoá DESFire không rời HSM, không có key export | Dùng luồng DESFire, không dùng broker này |
1.4 Lỗi hệ thống
| HTTP | code | Nguyên nhân |
|---|---|---|
| 500 | internal_error | Không dẫn xuất được khoá sector (dữ liệu credential hỏng: UID ngắn hơn 4 byte, card_token sai độ dài, thiếu khoá cho sector cấu hình trong layout) |
2. POST /merchant/chip/tap/cryptogram
Gọi sau khi đã tăng counter trên thẻ và đọc lại giá trị mới. Trả 200 kèm
nonce, cryptogram, key_version, card_token, counter — đưa thẳng vào
authorize với entry_mode=chip_tap.
Endpoint này dùng cùng bộ mã lỗi với prepare (mục 1.1 → 1.4), cộng thêm:
2.1 Validate request
| HTTP | code | Nguyên nhân |
|---|---|---|
| 422 | validation_failed | chip_uid không phải hex; counter không phải số nguyên, < 1, hoặc > 4294967295 (uint32) |
counterphải là giá trị sau khi tăng, không phải giá trị trước.chip_countersđược seed = 0 lúc personalization nên tap đầu tiên luôn ≥ 1.
2.2 Counter
| HTTP | code | Nguyên nhân | Xử lý phía client |
|---|---|---|---|
| 409 | counter_replay | counter không lớn hơn last_counter của lần tap đã verify gần nhất | Tăng counter trên thẻ và đọc lại trước khi ký — không retry với cùng giá trị |
Đây là lá chắn cố ý. Cùng điều kiện đó nếu lọt tới
authorizesẽ thành declinechip_counter_replay, và vớiCHIP_AUTO_REVOKE_ON_REPLAY=true(mặc định) sẽ thu hồi credential — thẻ thật ngừng hoạt động, phải perso lại. Chặn tại đây biến một bug của app thành 409 vô hại thay vì làm hỏng thẻ.
3. POST /merchant/payments/authorize — lỗi HTTP (4xx / 5xx)
3.1 Xác thực & phân quyền
| HTTP | code | Nguyên nhân | Xử lý phía client |
|---|---|---|---|
| 401 | unauthorized | Không gửi credential; X-Merchant-Key không tồn tại hoặc merchant không ở trạng thái active; X-API-Key sai | Kiểm tra lại credential, không retry tự động |
| 401 | token_expired | Bearer JWT của merchant hết hạn hoặc không verify được chữ ký | Gọi refresh token rồi retry |
| 401 | session_invalid | Session đứng sau access token đã bị thu hồi | Đăng nhập lại |
| 403 | forbidden | Token hợp lệ nhưng không được phép dùng cho route này | Không retry |
| 403 | forbidden_sub_account_allocation | Sub-account operator truyền sub_account_id của người khác — chỉ admin và parent key mới tạo hộ được. Áp dụng cho cả POST /merchant/payment-intents | Bỏ sub_account_id, hoặc dùng credential admin |
| 429 | rate_limited | Vượt giới hạn tần suất | Backoff rồi retry |
3.2 Validate request
| HTTP | code | Nguyên nhân |
|---|---|---|
| 422 | validation_failed | Body không hợp lệ: amount sai định dạng số tiền, currency không khớp ^[A-Z]{3}$, mcc không phải 4 chữ số, entry_mode ngoài enum, cryptogram/nonce không phải hex, counter âm, key_version ngoài 0–255, thừa field lạ… |
| 422 | missing_idempotency_key | Thiếu header Idempotency-Key (bắt buộc) |
| 422 | merchant_mismatch | body.merchant_id khác merchant của API key đang dùng |
| 422 | sub_account_not_found | body.sub_account_id không phải sub-account đang active của merchant này (sai merchant / đã xoá / đã disable / không tồn tại đều chung một lỗi). Áp dụng cho cả POST /merchant/payment-intents |
3.3 Idempotency
| HTTP | code | Nguyên nhân | Xử lý phía client |
|---|---|---|---|
| 409 | idempotency_conflict | Dùng lại Idempotency-Key cũ với payload khác | Sinh key mới cho giao dịch mới |
| 409 | request_in_flight | Request gốc với key này vẫn đang xử lý | Retry nguyên văn sau vài trăm ms |
3.4 Sai điều kiện theo entry_mode
| HTTP | code | Nguyên nhân |
|---|---|---|
| 422 | validation_failed | entry_mode=manual_pan nhưng card_token không phải PAN đủ 16 chữ số |
| 422 | validation_failed | entry_mode=card_qr nhưng card_token không đúng dạng ptok_… |
| 422 | validation_failed | entry_mode=chip_tap nhưng card_token không phải 32 ký tự hex thường |
| 422 | validation_failed | entry_mode=chip_tap nhưng thiếu cryptogram, counter hoặc nonce |
| 422 | unsupported_entry_mode | entry_mode không được phép trên surface merchant (ví dụ s2s — chỉ dành cho platform) |
Merchant chỉ được dùng 4 mode: token, manual_pan, card_qr, chip_tap.
3.5 Không tìm thấy credential
| HTTP | code | message | Bối cảnh |
|---|---|---|---|
| 404 | not_found | card token not found | entry_mode=token, one-time token không tồn tại |
| 404 | not_found | card not found | manual_pan / card_qr, PAN hoặc pan_token không khớp thẻ nào |
| 404 | not_found | chip credential not found | chip_tap, chip token không tồn tại |
Token/PAN/chip không tồn tại → 404 (không có thẻ nào để gán decline). Token/chip tồn tại nhưng không hợp lệ → 201 decline (xem mục 4).
Lưu ý khác biệt so với hai endpoint chip ở trên: ở
prepare/cryptogram, UID lạ trả404 credential_unknown; ởauthorize, chip token lạ trả404 not_found.
3.6 Mở challenge xác thực chủ thẻ
Chỉ xảy ra với manual_pan / card_qr / chip_tap, sau khi đã qua hết các gate nghiệp vụ:
| HTTP | code | Nguyên nhân |
|---|---|---|
| 422 | no_registered_phone | Thẻ không có số điện thoại nào — cả chủ thẻ lẫn người dùng được link thẻ — → không phát được SMS OTP |
| 422 | otp_recipient_invalid | otp_recipient_id không phải một trong các số của thẻ (xem 5.4) — chưa mở giao dịch nào |
| 422 | static_code_unavailable | Thẻ cấu hình verification_preference=static_code nhưng chưa có mã tĩnh |
| 502 | otp_delivery_failed | Notification Center trả lỗi hoặc timeout — toàn bộ auth bị rollback, chưa có giao dịch treo |
3.7 Lỗi hệ thống
| HTTP | code | Nguyên nhân |
|---|---|---|
| 500 | internal_error | Lỗi không lường trước; response không lộ chi tiết |
4. POST /merchant/payments/authorize — decline_reason (HTTP 201)
Liệt kê theo đúng thứ tự gate mà authorize chạy qua — gate nào chặn trước thì
đó là decline_reason được trả về.
4.1 One-time token — chỉ entry_mode=token
decline_reason | Nguyên nhân | Hướng xử lý |
|---|---|---|
token_invalid_or_expired | Token đã dùng, đã hết hạn, hoặc số tiền vượt max_amount gắn trên token | Yêu cầu khách mở lại QR trong app |
card_link_inactive | Token do nhân viên được chia sẻ thẻ tạo, nhưng quyền dùng thẻ của họ đã ngoài thời gian công ty cho phép (hoặc đã bị gỡ) | Khách dùng thẻ khác; quyền dùng thẻ do admin công ty cấp lại |
card_locked_too_many_attempts | Thẻ đang bị khoá 15 phút do nhập sai mã xác thực 5 lần — chỉ áp dụng cho token có PIN | Chờ hết 15 phút, hoặc dùng cách thanh toán khác |
velocity_limit_exceeded | Quá 5 lần mở challenge cho cùng một thẻ trong 5 phút — chỉ áp dụng cho token có PIN | Chờ, đừng cho khách mint token liên tục |
⚠️ Token có PIN không
approvedngay tại authorize. Token được mint vớittl_seconds > 180mang theo một PIN 6 số (app hiển thị cho chủ thẻ đúng một lần). Với token đó authorize trảpending_verification+verification.method = "token_pin", và sale chỉ chốt sau khi PIN quaPOST /merchant/payments/{auth_id}/verify— xem mục 5.
ttl_secondsmặc định là 179 — nằm ngay dưới ngưỡng. App không truyềnttl_secondsthì vẫn nhận token không PIN, approve ngay tại authorize, đúng như trước. Vào luồng PIN là opt-in: phải chủ động xin TTL > 180.Mặc định trước đây là 300; hạ xuống 179 chính là để POS đang chạy không bị đổi hành vi. Đổi lại, token không chỉ định TTL giờ sống 179s thay vì 300s. POS nào muốn chắc chắn thì vẫn nên xử lý được cả hai dạng response của
entry_mode=token.
4.2 Chip tap — chỉ entry_mode=chip_tap
decline_reason | Nguyên nhân | Hướng xử lý |
|---|---|---|
chip_cryptogram_invalid | CMAC không verify — sai key, sai thứ tự message, sai cách encode counter | Lỗi tích hợp, không retry tap |
chip_nonce_replayed | Nonce này đã được dùng cho một cryptogram hợp lệ trước đó | Xin nonce mới cho mỗi lần tap |
chip_counter_replay ⚠️ | Counter không tăng — tín hiệu clone | Xem cảnh báo bên dưới |
chip_credential_revoked | Credential đã bị thu hồi (mất/cắp/nghi ngờ lộ) | Hướng dẫn chủ thẻ liên hệ issuer |
chip_credential_not_active | Credential còn ở trạng thái PENDING — chưa confirm personalization | Cần perso lại thẻ |
⚠️
chip_counter_replaythu hồi credential ngay trong cùng transaction khiCHIP_AUTO_REVOKE_ON_REPLAY=true(mặc định). Thẻ sẽ ngừng hoạt động và phải personalize lại. Một bug ở app làm replay counter sẽ hỏng thẻ thật — chạy staging vớiCHIP_AUTO_REVOKE_ON_REPLAY=falsekhi đang tích hợp.Nếu app luôn đi qua
/merchant/chip/tap/cryptogramthì counter sai đã bị chặn sớm bằng409 counter_replay(mục 2.2) và không bao giờ tới được decline này.
4.3 Trạng thái thẻ — mọi entry mode
decline_reason | Nguyên nhân |
|---|---|
card_inactive | Thẻ chưa kích hoạt |
card_blocked | Thẻ bị khoá |
card_expired | Thẻ hết hạn (theo status hoặc theo expires_at) |
card_outside_validity_window | Thẻ đang ngoài các khoảng thời gian admin công ty cho phép dùng (validity của thẻ) — ai trả cũng bị từ chối |
channel_not_allowed | Kênh chấp nhận tương ứng đang tắt trên thẻ (chỉ áp dụng khi xuất trình thẻ vật lý: manual_pan, card_qr, chip_tap) |
card_locked_too_many_attempts | Thẻ đang bị khoá 15 phút do nhập sai mã xác thực 5 lần |
4.4 Velocity — manual_pan, card_qr, chip_tap
decline_reason | Nguyên nhân |
|---|---|
velocity_limit_exceeded | Quá 5 lần mở challenge cho cùng một thẻ trong 5 phút (chống spam SMS và brute-force PIN) |
4.5 Hạn mức thẻ — mọi entry mode
decline_reason | Nguyên nhân |
|---|---|
mcc_not_allowed | MCC nằm ngoài allowed_mccs của thẻ |
merchant_not_allowed | Merchant nằm ngoài allowed_merchant_ids của thẻ |
per_transaction_limit_exceeded | Vượt hạn mức mỗi giao dịch |
daily_limit_exceeded | Vượt hạn mức ngày (đã tính cả hold đang giữ) |
monthly_limit_exceeded | Vượt hạn mức tháng (đã tính cả hold đang giữ) |
4.5b Ngân sách tháng của công ty — mọi entry mode
decline_reason | Nguyên nhân |
|---|---|
corporate_monthly_limit_exceeded | Vượt corporate_settings.monthly_limit của công ty (đã tính cả hold đang giữ) |
Khác monthly_limit_exceeded ở chỗ đây là trần của toàn công ty, không phải của một thẻ — nên
nó decline mọi thẻ trong công ty cùng lúc. Chỉ phát sinh khi công ty đặt
monthly_limit_action = 'block'; mặc định là 'warn', chỉ báo chứ không chặn.
Cửa này trễ một giao dịch theo thiết kế: không chặn cho tới khi đã có một capture vượt vạch, nên giao dịch làm tràn ngân sách vẫn qua và giao dịch kế tiếp mới bị chặn. Refund kéo chi tiêu về dưới trần, hoặc admin nâng trần, đều tự mở khoá — không cần thao tác gì thêm.
4.6 Số dư — mọi entry mode
decline_reason | Nguyên nhân |
|---|---|
insufficient_funds | Số dư khả dụng của ví thẻ (đã trừ hold active) không đủ |
Đây là decline do card-core tự sinh. Ví Xperise hết tiền là mã khác —
service_payment_insufficient_funds (mục 4.9).
4.7 Policy nội bộ (ZEN decision table) — mọi entry mode
decline_reason | Nguyên nhân |
|---|---|
policy_violation | Decision table từ chối mà không nêu lý do, hoặc engine lỗi/không có JDM và số tiền vượt degraded_threshold (fail-closed) |
| (chuỗi tuỳ biến) | Decision table trả về reason riêng của doanh nghiệp — ví dụ vượt ngân sách chuyến công tác, cần approval |
4.8 Employee policy hook (xperise-backend) — mọi entry mode
decline_reason | Nguyên nhân |
|---|---|
employee_policy_violation | Hook trả decision=declined mà không kèm reason |
| (chuỗi tuỳ biến) | reason do hook trả về |
employee_policy_unavailable | Hook trả non-2xx, timeout (4s) hoặc lỗi mạng — fail-closed, không bao giờ bỏ qua âm thầm |
Khi EMPLOYEE_POLICY_URL không cấu hình, gate này được bỏ qua hoàn toàn.
4.9 Ví Xperise (service-payment) — chỉ entry_mode=token
Với manual_pan / card_qr / chip_tap, việc trừ ví diễn ra ở
POST /merchant/payments/{auth_id}/verify — không ở authorize.
decline_reason | result_code | Nguyên nhân |
|---|---|---|
service_payment_auth_failed | 13 | Ví từ chối credential/chữ ký của card-core |
service_payment_invalid_request | 20 | Ví từ chối định dạng request |
service_payment_duplicate_order | 41 | (partner_name, order_id) đã tồn tại — external_ref bị trùng |
service_payment_user_invalid | 42 | User không hợp lệ phía ví |
service_payment_in_flight | 43 | Một giao dịch cùng key đang xử lý — retry nguyên văn |
service_payment_failed | 99 | Ví báo lỗi chung |
service_payment_insufficient_funds | 1001 | Ví không đủ số dư |
service_payment_declined | (khác) | result_code không nằm trong danh sách trên |
service_payment_unavailable | — | HTTP non-2xx, timeout (10s) hoặc lỗi mạng — fail-closed |
Mọi mã ở bảng này đều mang tiền tố service_payment_ — đó là cách duy nhất để
POS phân biệt "ví Xperise từ chối" với decline do chính card-core sinh ra. Đặc
biệt lưu ý service_payment_insufficient_funds (ví hết tiền, mục này) khác hẳn
insufficient_funds (hạn mức/số dư thẻ, mục 4.6 và 4.10): hai tình huống cần
hướng dẫn người dùng khác nhau.
message do ví trả về không nằm trong response — nó chỉ được ghi log kèm một
lệnh curl tái hiện đúng request đã gửi.
Khi SERVICE_PAYMENT_URL / CLIENT_CODE / SECRET_KEY chưa cấu hình, gate này được bỏ qua.
4.10 Sale mode (auto_capture=true) — chỉ entry_mode=token
decline_reason | Nguyên nhân |
|---|---|
insufficient_funds | Số dư ví thẻ không còn đủ tại thời điểm post ledger |
5. Trường hợp pending_verification
manual_pan, card_qr, chip_tap — và token khi token đó có PIN — khi qua hết
mọi gate không trả approved mà trả:
{
"status": "pending_verification",
"verification": {
"method": "sms_otp",
"expires_at": "2026-07-31T09:12:00.000Z",
"attempts_remaining": 5,
"phone_masked": "****1234",
"verification_deadline": "2026-07-31T09:19:00.000Z",
"resends_remaining": 2,
"can_resend_at": "2026-07-31T09:09:30.000Z",
"recipient_required": false,
"recipients": null
}
}
method là một trong ba:
method | Mã cần nhập | phone_masked | Ghi chú |
|---|---|---|---|
sms_otp | Mã gửi qua SMS | ****1234 | Gửi lại được |
static_code | PIN in trên thẻ | null | Không có gì gửi lại |
token_pin | PIN của one-time token, app hiện đúng một lần lúc mint | null | Không có gì gửi lại; hết hạn cùng token |
token_pin ưu tiên hơn verification_preference của thẻ: PIN đó là mã được
phát ra cùng chính token này, nên là mã duy nhất chủ thẻ có thể có trong tay.
Không có tiền nào bị giữ ở bước này — spam authorize không khoá được số dư, và
một authorize mới cho cùng thẻ + merchant sẽ tự động void auth
pending_verification cũ.
5.1 Hai cái đồng hồ — expires_at vs verification_deadline
Đây là chỗ dễ hiểu sai nhất, và là lý do POS không phải tạo giao dịch mới khi mã hết hạn:
| Trường | Mặc định | Hết hạn thì sao |
|---|---|---|
expires_at | 5 phút (OTP_TTL_SECONDS) | Chỉ mã hiện tại chết. Auth vẫn pending_verification → xin mã mới |
verification_deadline | 10 phút (VERIFICATION_DEADLINE_SECONDS) | Cả giao dịch chết → declined / verification_expired, phải authorize lại |
static_code và token_pin là ngoại lệ: cả hai PIN đều đã được phát ra một lần
và không có kênh nào để phát lại — resends_remaining luôn 0,
can_resend_at: null, và verification_deadline bằng luôn expires_at.
Với token_pin thì cái đồng hồ đó là thời hạn còn lại của token, không phải
OTP_TTL_SECONDS: PIN không thể sống lâu hơn token nó bảo vệ, nên một token
ttl_seconds=600 được xuất trình ở giây thứ 500 chỉ còn 100 giây để nhập PIN.
5.2 POST /merchant/payments/{auth_id}/verify
| Loại | Mã | Nguyên nhân |
|---|---|---|
| HTTP 422 | verification_not_pending | Auth không có challenge nào đang chờ |
| HTTP 422 | otp_recipient_required | Chưa có mã nào được gửi — thẻ được link nhiều người, POS phải chọn số trước (5.4). Không trừ lượt nhập. details.recipients là danh sách số |
| HTTP 422 | otp_expired | Mã đã quá expires_at nhưng giao dịch còn sống → gọi /resend-verification, đừng tạo giao dịch mới. details trả kèm verification_deadline, resends_remaining, can_resend_at |
| Decline 200 | verification_expired | Đã quá verification_deadline — hết đường cứu, authorize lại |
| Decline 200 | card_locked_too_many_attempts | Sai mã lần thứ 3 → khoá manual_pan/card_qr/chip_tap và mọi token có PIN trên thẻ 15 phút. Nếu challenge là token_pin thì token cũng bị đánh dấu đã dùng luôn |
| Decline 200 | (toàn bộ mục 4.9) | Trừ ví Xperise thực hiện tại bước này |
| Decline 200 | insufficient_funds | Số dư không đủ khi tạo hold hoặc post ledger |
/verify trả HTTP 200 (không phải 201 như /authorize), kể cả khi
status=declined.
Gọi /verify trên một auth đã captured / approved / declined / voided /
expired không phải lỗi — API trả về trạng thái hiện tại của auth.
5.3 POST /merchant/payments/{auth_id}/resend-verification
Gửi lại SMS OTP cho giao dịch còn đang chờ. Trả HTTP 200 + auth kèm block
verification mới. Dùng khi khách không nhận được SMS, hoặc mã đã quá
expires_at mà chưa quá verification_deadline.
Nó re-arm lại đúng challenge cũ (mã mới, expires_at mới, attempts_remaining
về 3) chứ không mở challenge mới — nên không tốn slot velocity
(velocity_limit_exceeded đếm số challenge/thẻ/5 phút). Mã cũ chết ngay khi
endpoint này trả về.
Không nhận Idempotency-Key: "gửi mã nữa" là một side effect thật, replay lại
response cũ sẽ im lặng không gửi SMS. Cooldown là thứ giữ cho việc bấm 2 lần vô hại.
| Loại | Mã | Nguyên nhân |
|---|---|---|
| HTTP 429 | resend_too_soon | Còn trong cooldown (mặc định 30s). details.retry_after_seconds nói còn phải đợi bao lâu |
| HTTP 422 | resend_limit_reached | Hết quota resend của giao dịch (mặc định 2 → tổng 3 SMS). details.max_resends |
| HTTP 422 | resend_not_applicable | method=static_code (PIN in trên thẻ) hoặc method=token_pin (PIN phát cùng token) — không có gì để gửi lại. Với token_pin, muốn có PIN mới thì phải mint token mới trong app |
| HTTP 422 | verification_not_pending | Auth đã settle hoặc challenge đã dùng hết |
| HTTP 422 | otp_recipient_required | Challenge chưa gửi mã (recipient_required: true) mà body không có recipient_id. details.recipients là danh sách số để chọn |
| HTTP 422 | otp_recipient_invalid | recipient_id không phải một trong các số của thẻ |
| Decline 200 | verification_expired | Đã quá verification_deadline — endpoint decline luôn auth và trả về, POS biết ngay phải authorize lại |
Body (tuỳ chọn): { "recipient_id": "…" } — gửi mã tới số đó trong
verification.recipients (5.4). Không có body thì gửi lại đúng số cũ.
Trần SMS thực tế mỗi thẻ: 3 challenge/5 phút × (1 + 2 resend) = 9 SMS/thẻ/5 phút.
5.4 Thẻ được link nhiều người — chọn số nhận OTP
Một thẻ sms_otp có thể nhận mã ở nhiều số: số của chủ thẻ, rồi số của từng
người dùng được link thẻ (theo thứ tự link), chỉ những ai có số điện thoại trong hồ sơ.
Khi có hơn một số, block verification liệt kê chúng (chỉ số đã che, không kèm tên):
"verification": {
"method": "sms_otp",
"phone_masked": null,
"recipient_required": true,
"recipients": [
{ "recipient_id": "1024", "phone_masked": "****1234" },
{ "recipient_id": "2048", "phone_masked": "****5678" }
],
"resends_remaining": 2,
"can_resend_at": "2026-07-31T09:07:00.000Z",
…
}
- Authorize không kèm
otp_recipient_id(POS chưa biết thẻ có những số nào): giao dịch vẫn mởpending_verificationnhưng chưa gửi SMS nào —recipient_required: true,phone_masked: null. POS cho khách chọn số của mình trongrecipients, rồi gọiPOST /payments/{auth_id}/resend-verificationvới{ "recipient_id": "…" }. Lần gửi đầu này không tính là resend: không chờ cooldown (can_resend_at= lúc authorize), không trừresends_remaining. - Authorize kèm
otp_recipient_id: gửi luôn tới số đó (POS đã córecipientscủa thẻ từ một giao dịch trước). - Đã gửi rồi mà khách chọn nhầm số:
resend-verificationvớirecipient_idkhác — tính là một lần resend (cooldown + quota như thường). - Thẻ chỉ có một số:
recipients: null, hành vi như cũ — gửi luôn tới số đó. /verifykhi chưa gửi mã →422 otp_recipient_required, không mất lượt nhập.GET /merchant/payments/{auth_id}trả lại cảrecipients, nên POS mất response authorize vẫn chọn tiếp được.
6. Bảng tra nhanh cho POS
| Tình huống | Nhận biết | Hành động |
|---|---|---|
prepare trả 404 credential_unknown | — | "Không phải thẻ của hệ thống", dừng |
prepare trả 409 credential_revoked / credential_not_active / not_classic | — | Không retry; hướng dẫn theo mục 1.3 |
cryptogram trả 409 counter_replay | — | Tăng counter trên thẻ, đọc lại, ký lại — không retry cùng giá trị |
Bị 429 rate_limited ở luồng chip | Nhiều máy chung IP | Backoff; lưu ý mỗi tap tốn 2 request |
| Mất kết nối giữa chừng ở authorize | Không có response | Retry cùng Idempotency-Key; nếu nhận 409 request_in_flight thì đợi rồi retry tiếp |
Bị 409 idempotency_conflict | Payload đã đổi | Sinh key mới, coi như giao dịch mới |
Retry cùng Idempotency-Key cho giao dịch đang pending_verification | Nhận lại nguyên response 201 gốc (cùng auth_id, cùng verification.expires_at / verification_deadline dạng ISO datetime) — replay là snapshot lúc tạo, không phản ánh trạng thái hiện tại | Dùng auth_id để tiếp tục /verify. Nếu verification_deadline đã qua thì /verify trả 201 declined / verification_expired → authorize lại với key mới |
Bị 201 declined | status=declined | Hiển thị decline_reason, không retry tự động |
Bị 201 pending_verification | Có block verification | Nhập mã → gọi /verify |
pending_verification với recipient_required: true | Thẻ được link nhiều người, chưa gửi SMS | Cho khách chọn số trong verification.recipients → /resend-verification với recipient_id (5.4) |
| Khách bảo số nhận mã không phải số của mình | recipients khác null | /resend-verification với recipient_id khác — tính là một lần resend |
| Khách không nhận được SMS | Còn pending_verification, resends_remaining > 0 | POST /payments/{auth_id}/resend-verification sau can_resend_at — không tạo giao dịch mới |
/verify trả 422 otp_expired | Quá expires_at, chưa quá verification_deadline | Gọi /resend-verification rồi nhập mã mới. Giao dịch vẫn sống |
Bị 429 resend_too_soon | — | Đợi details.retry_after_seconds giây rồi gọi lại |
Bị 422 resend_limit_reached | resends_remaining = 0 | Hết quota mã cho giao dịch này → authorize lại (tốn 1 slot velocity) |
Bị 422 resend_not_applicable | method=static_code | Đọc PIN in trên thẻ, không có SMS nào để gửi lại |
Bị 422 resend_not_applicable | method=token_pin | Hỏi khách PIN mà app đã hiện lúc quét QR; nếu khách không còn thì mint token mới |
Decline verification_expired | Quá verification_deadline (10 phút) | Giao dịch chết, authorize lại từ đầu |
Decline velocity_limit_exceeded | 3 giao dịch OTP cho cùng thẻ trong 5 phút | Đợi hết cửa sổ 5 phút, hoặc chuyển sang entry_mode=token (QR app). Lưu ý resend không tính vào đây |
| Mất response của authorize | — | GET /merchant/payments/{auth_id} trả lại cả block verification để tiếp tục, không cần tạo giao dịch mới |
Bị 502 otp_delivery_failed | — | Không có auth treo, có thể authorize lại từ đầu |