Tích hợp card-core làm phương thức thanh toán cho merchant mới
Tài liệu dành cho một merchant vừa được kết nạp muốn nhận thanh toán bằng thẻ Xperise (card-core) tại quầy / trên app của mình.
card-core là hệ closed-loop: issuer + network + acquirer nằm trong cùng một hệ thống. Không có tổ chức thẻ trung gian, không có PCI mandate — nhưng cũng có nghĩa thẻ này chỉ chi tiêu được ở các merchant đã onboard vào card-core, và tiền được đối soát theo bảng kê (statement) hàng tháng chứ không về tài khoản merchant theo từng giao dịch.
Trước khi đọc tiếp, ba điều quyết định phần lớn thiết kế tích hợp:
- Số tiền luôn là chuỗi thập phân (
"150000"), không bao giờ là số float. 201 + status=declinedkhông phải lỗi kỹ thuật. Đó là một kết quả thanh toán hợp lệ. Toàn bộ bảng mã ở merchant-authorize-errors.md.- Hầu hết các luồng có bước xác thực chủ thẻ (OTP/PIN) tách khỏi
authorize. Đừng thiết kế UI giả định "quẹt xong là xong".
| Tài liệu liên quan | Nội dung |
|---|---|
| merchant-authorize-errors.md | Toàn bộ mã lỗi và decline_reason của luồng thanh toán |
| merchant-classic-tap.md | Luồng chạm thẻ MIFARE Classic (chip_tap) |
| auth-architecture.md | Mô hình identity/principal/session dùng chung |
| CARD-CORE-BRIEF.md | Thiết kế tổng thể của hệ thống |
GET /api-docs.html, GET /swagger | Contract OpenAPI sinh thẳng từ code |
1. Onboarding — tạo merchant và nhận credential
Merchant không tự đăng ký. Một system admin (hoặc PlatformKey nội bộ) gọi:
POST /merchants
Authorization: Bearer <system-admin-token>
Content-Type: application/json
{
"name": "Nhà hàng Sông Xanh",
"email": "ketoan@songxanh.vn",
"phone": "0901234567",
"address": "12 Nguyễn Huệ, Q1, TP.HCM",
"tax_id": "0312345678",
"alias": "song-xanh",
"type_codes": ["fnb"],
"attributes": { "brand_name": "Sông Xanh", "outlet_count": 3, "service_style": "table" },
"webhook_url": "https://pos.songxanh.vn/webhooks/card-core",
"capture_tolerance": "1.20",
"enhanced_data_required": true,
"invoice_required": false,
"owner_email": "chu@songxanh.vn",
"owner_phone": "0901234567",
"owner_password": "<md5 hex của mật khẩu>"
}
1.1 Các trường cần quyết định ngay từ đầu
| Trường | Ý nghĩa | Lưu ý khi chọn |
|---|---|---|
alias | Handle đăng nhập cho nhân viên (POST /merchant/login) | Tự sinh từ name nếu bỏ trống. Đặt tay thì trùng → 409 alias_taken |
type_codes | Ngành dọc: taxi, hotel, car_rental, fnb, other | Mỗi type góp một schema attributes bắt buộc. Chọn được nhiều |
attributes | Metadata theo ngành (vd fleet_size, star_rating) | Thiếu field bắt buộc của bất kỳ type nào đã chọn → 422 |
webhook_url | Nơi nhận webhook | Bỏ trống = không có webhook nào được xếp hàng. Không bật lại được qua API này |
capture_tolerance | Trần capture so với số tiền đã authorize, mặc định 1.20 (120%) | Xem §6.1 |
enhanced_data_required | Bắt buộc dữ liệu nghiệp vụ (chuyến đi / đêm phòng / bill) | Xem §7 |
invoice_required | Merchant nợ hoá đơn VAT sau mỗi giao dịch | Xem §8. Bật là đổi định nghĩa "giao dịch hoàn tất" |
owner_email | Tài khoản owner (sub-account role admin) được tạo kèm | Bắt buộc nếu muốn có owner_password / owner_phone |
owner_phone | Đích đến của OTP reset mật khẩu | Không có số này thì owner không tự reset mật khẩu được |
owner_password (và mọi trường password khác trong hệ) là MD5 hex client-side,
không phải plaintext.
1.2 Response — hai bí mật chỉ hiện đúng một lần
{
"merchant_id": "7f1c…",
"alias": "song-xanh",
"api_key": "mk_…",
"access_token": "mk_…",
"token_type": "Bearer",
"webhook_secret": "whs_…",
"capture_tolerance": "1.20",
"status": "active"
}
api_key và webhook_secret không đọc lại được ở bất kỳ endpoint nào khác —
GET /merchants/{id} cố tình không trả chúng. Mất thì phải tạo lại merchant.
Cất vào secret manager ngay, đừng để trong repo hay file cấu hình build.
access_token chỉ là alias của api_key cho tương thích ngược — cùng một chuỗi.
2. Hai loại credential, dùng lúc nào
Mọi endpoint /merchant/* nhận một trong hai:
| Credential | Header | Là ai | Dùng cho |
|---|---|---|---|
| Parent MerchantKey | X-Merchant-Key: mk_… | Máy, không có con người đứng sau | Server-to-server: backend POS, hệ thống booking, job đối soát |
| Session sub-account | Authorization: Bearer <access_token> | Một nhân viên cụ thể | App cầm tay, máy POS có người đăng nhập |
Quy tắc chọn:
- Giao dịch tạo bằng parent key có
sub_account = null— không truy được ai đã thu tiền. Với merchant có nhân viên (tài xế, lễ tân, thu ngân) thì đây là lựa chọn sai. - API key là credential máy nên chỉ được chấp nhận qua
X-Merchant-Key. Nhét nó vàoAuthorization: Bearersẽ bị coi là JWT hỏng →401 token_expired. - Phân quyền sub-account:
adminthấy toàn bộ merchant;operatorchỉ thấy giao dịch của chính mình, hỏi của người khác là403 forbidden.
2.1 Đăng nhập nhân viên
POST /merchant/login # public, 10 request / 60s
{ "alias": "song-xanh", "username": "thu-ngan-01", "password": "<md5 hex>" }
Trả access_token + refresh_token (trong body, không dùng cookie — client
native tự lưu), kèm khối sub_account và merchant.
401 token_expired→ gọi refresh rồi retry.401 session_invalid→ session đã bị thu hồi, phải đăng nhập lại.GET /merchant/melà nơi POS đọc cấu hình runtime:type_codes,invoice_required,enhanced_data_required, vai trò của người đang đăng nhập.
2.2 Tạo nhân viên
POST /merchant/sub-accounts # cần quyền quản lý (admin / parent key)
{ "username": "tai-xe-01", "password": "<md5>", "display_name": "Nguyễn Văn A",
"role": "operator", "sub_type": "driver", "phone": "090…",
"attributes": { "default_plate": "51F-123.45" } }
Nếu người đó đã có tài khoản Xperise (cùng email) thì không tạo mới được —
một email là một người. Dùng luồng hai bước có OTP:
POST /merchant/sub-accounts/link/request → POST /merchant/sub-accounts/link/confirm.
3. Chọn luồng thanh toán
Bốn cách xuất trình thẻ, cộng luồng QR do merchant hiện. Merchant mới hầu như luôn nên bắt đầu bằng QR intent (§4).
| Luồng | entry_mode | Ai xuất trình | Cần OTP/PIN? | Phù hợp |
|---|---|---|---|---|
| QR intent (§4) | qr_intent | Merchant hiện QR, khách quét bằng app Xperise | Không — khách đã xác thực trong app | Quầy có màn hình, bill nhà hàng, khách sạn, app đặt xe |
| One-time token (§5) | token | Khách mở QR trong app, merchant quét | Chỉ khi token có PIN | POS có máy quét, khách chủ động |
| Manual PAN / card QR (§5.3) | manual_pan, card_qr | Nhân viên gõ số thẻ, hoặc quét QR in trên thẻ | Luôn có | Fallback khi không quét được app |
| Chạm thẻ | chip_tap | Chạm thẻ nhựa vào máy Android NFC | Luôn có | Xem merchant-classic-tap.md |
s2s là entry mode chỉ dành cho platform — merchant gọi sẽ nhận
422 unsupported_entry_mode.
Vì sao QR intent nhẹ nhất: số tiền nằm ở server, QR chỉ mang mã tham chiếu, và việc xác thực chủ thẻ đã xảy ra trong app Xperise trước khi khách bấm xác nhận. Merchant không phải thu OTP, không phải dựng UI nhập mã.
4. Luồng khuyến nghị — QR do merchant hiện
POS card-core app Xperise
│ POST /merchant/payment-intents ─────────────────►│
│◄── {intent_id, qr_payload: "XPCC:pi_…", …} ──────┤
│ hiện QR lên màn hình │
│ │◄── GET /payment-intents/{id} ──┤ (quét)
│ │◄── POST …/confirm {card_id} ───┤
│ GET /merchant/payment-intents/{id} (poll) ──────►│
│◄── {status: "captured", auth_id} ────────────────┤
4.1 Tạo intent
POST /merchant/payment-intents
X-Merchant-Key: mk_… (hoặc Authorization: Bearer <session>)
Idempotency-Key: 7f9c2e1a-… (bắt buộc)
{
"amount": "450000",
"currency": "VND",
"external_ref": "BILL-2026-0912",
"product_type": "fnb_order",
"ttl_seconds": 600,
"enhanced_data": {
"outlet_name": "Sông Xanh Nguyễn Huệ",
"outlet_code": "SX-NH",
"order_no": "A-1182",
"subtotal": 409090,
"vat_amount": 40910
}
}
product_type:taxi_trip|hotel_stay|fnb_order|other.enhanced_datađược validate ngay tại đây (khác các luồng khác) — thiếu field bắt buộc là422 missing_required_fieldskèm danh sách đường dẫn thiếu. Đây là lúc tạo đơn, trước khi tiền chạy, và app merchant vốn đã có dữ liệu này.ttl_seconds: 60–1800, mặc địnhINTENT_TTL_SECONDS(600s).external_reflà mã đơn phía merchant — dùng để đối soát ngược.sub_account_id(tuỳ chọn): tạo đơn hộ một nhân viên khác — xem §5.0. Luật giống hệt luồng authorize, và gán ở đây đi theo đơn: khi khách quét QR và xác nhận, authorization sinh ra thuộc về nhân viên được gán.
Response: render qr_payload nguyên văn thành QR (dạng XPCC:pi_…).
Trường này chỉ khác null khi status = requires_confirmation.
4.2 Theo dõi kết quả
GET /merchant/payment-intents/{intent_id} trong lúc QR còn trên màn hình.
status | Nghĩa |
|---|---|
requires_confirmation | Đang chờ khách quét/xác nhận |
captured | Đã thu tiền xong — auth_id trỏ tới giao dịch |
declined | Khách đã xác nhận nhưng bị từ chối — đọc decline_reason trên auth_id |
canceled | Merchant đã huỷ (POST /merchant/payment-intents/{id}/cancel) |
expired | Hết ttl_seconds mà không ai xác nhận |
Intent xác nhận thành công là capture luôn, không có bước capture riêng:
không tồn tại pre-auth trong luồng này. Muốn giữ tiền trước rồi chốt sau thì
phải dùng luồng token với auto_capture=false (§5).
Huỷ QR khi khách đổi ý: POST /merchant/payment-intents/{intent_id}/cancel.
Chỉ huỷ được intent requires_confirmation hoặc expired; intent đã captured
thì đường lùi là refund (§6.3).
5. Luồng authorize trực tiếp
Dùng khi merchant tự cầm thẻ / token của khách.
POST /merchant/payments/authorize
X-Merchant-Key: mk_…
Idempotency-Key: <uuid v4 mới cho mỗi giao dịch>
{
"card_token": "…",
"entry_mode": "token",
"amount": "450000",
"currency": "VND",
"external_ref": "BILL-2026-0912",
"auto_capture": true,
"mcc": "5812",
"enhanced_data": { "type": "fnb_order", "outlet_name": "…", "order_no": "A-1182", "subtotal": 409090 }
}
card_token mang giá trị khác nhau tuỳ entry_mode:
entry_mode | card_token là gì | Ràng buộc |
|---|---|---|
token | Token một lần khách mở trong app Xperise | Hết hạn nhanh; có max_amount gắn kèm |
manual_pan | PAN đầy đủ, 16 chữ số | Nhân viên gõ tay |
card_qr | ptok_… in trên thẻ | Quét QR trên mặt thẻ |
chip_tap | card_token đọc từ chip, 32 ký tự hex thường | Cần thêm cryptogram, nonce, counter |
5.0 Tạo giao dịch hộ một nhân viên khác — sub_account_id
Mặc định giao dịch thuộc về người gửi request: token của ai thì created_by_sub_account_id
là người đó, parent key thì không thuộc về ai (sub_account = null). Hầu hết tích hợp không
cần đụng tới phần này.
Trường hợp cần: admin (hoặc back office cầm parent key) bấm tạo giao dịch thay cho tài xế /
thu ngân đã thực sự chạy cuốc xe, bán hàng. Truyền sub_account_id trong body:
{
"card_token": "…",
"amount": "450000",
"currency": "VND",
"external_ref": "BILL-2026-0912",
"sub_account_id": "8c1f…" // giao dịch được ghi cho nhân viên này
}
| Ai gọi | Được gán cho người khác? |
|---|---|
Parent key X-Merchant-Key | ✅ bất kỳ sub-account nào của merchant |
Sub-account admin | ✅ |
Sub-account operator | ❌ 403 forbidden_sub_account_allocation — chỉ được gán cho chính mình |
sub_account_id phải là sub-account đang active của chính merchant đó, nếu không là
422 sub_account_not_found. Sai merchant, đã xoá, đã disable, hay không tồn tại đều trả về
cùng một lỗi — cố ý, để trường này không dùng dò được id nào có thật ở merchant khác.
Cùng một field, cùng một luật, ở cả hai chỗ tạo giao dịch:
| Surface | Field |
|---|---|
POST /merchant/payments/authorize | sub_account_id |
POST /merchant/payment-intents (QR) | sub_account_id — đi theo đơn sang authorization lúc khách confirm |
Hệ quả sau khi gán:
- Giao dịch hiện trong
GET /merchant/paymentscủa người được gán (operatorbị khoá scope vào chính mình, nên đây là cách duy nhất để đơn rơi vào danh sách của họ). - Ảnh chụp nhân viên đóng băng vào
transaction_details(tên,sub_type, biển số / mã quầy) là của người được gán — nên báo cáo đối soát hiện đúng tài xế. - Ai bấm tạo vẫn được lưu lại:
metadata.allocated_by={ sub_account_id, credential }trên authorization. Với đơn QR, thông tin này lưu ởpayment_intents.allocated_byrồi chép sang metadata của authorization lúc confirm — nên hai luồng cho ra cùng một dấu vết. Attribution chuyển, quyền tác giả thì không. Merchant không giả được key này vì hệ thống ghi đè sau cùng. - Với
entry_mode=chip_tap,chip_auth_logs.reader_idvẫn là người cầm máy đọc, không phải người được gán.
sub_account_id nằm trong payload nên đổi nó mà giữ nguyên Idempotency-Key sẽ nhận
409 idempotency_conflict. Gán nhầm thì dùng key mới.
5.1 Ba dạng response của authorize
// 1. Duyệt ngay — chỉ xảy ra với entry_mode=token, token không PIN
{ "auth_id": "…", "status": "approved", "hold_expires_at": "2026-09-16T…" }
// 2. Duyệt + thu luôn (auto_capture=true)
{ "auth_id": "…", "status": "captured", "capture_id": "…" }
// 3. Chờ xác thực chủ thẻ — mọi luồng còn lại
{ "auth_id": "…", "status": "pending_verification",
"verification": { "method": "sms_otp", "phone_masked": "****1234",
"expires_at": "…", "verification_deadline": "…",
"attempts_remaining": 5, "resends_remaining": 2,
"can_resend_at": "…" } }
// 4. Từ chối — VẪN LÀ HTTP 201
{ "auth_id": "…", "status": "declined", "decline_reason": "insufficient_funds" }
Không có tiền nào bị giữ ở trạng thái pending_verification.
5.2 Xác thực chủ thẻ
POST /merchant/payments/{auth_id}/verify # HTTP 200, không cần Idempotency-Key
{ "code": "483920" }
verification.method quyết định POS hiển thị gì:
method | Nhân viên phải nói gì với khách | Gửi lại được? |
|---|---|---|
sms_otp | "Đọc giúp mã vừa nhắn tới số ****1234" | Có |
static_code | "Đọc giúp mã PIN in trên thẻ" | Không |
token_pin | "Đọc giúp mã 6 số app hiện lúc nãy" | Không |
Hai đồng hồ, và đây là chỗ dễ làm sai nhất:
expires_at(~5 phút) — chỉ mã hiện tại chết. Nhập mã cũ →422 otp_expired. Đừng tạo giao dịch mới, hãy gọiPOST /merchant/payments/{auth_id}/resend-verification.verification_deadline(~10 phút) — cả giao dịch chết (declined/verification_expired), lúc đó mới authorize lại.
Thẻ được link nhiều người (sms_otp): mã có thể gửi tới số của chủ thẻ hoặc
của bất kỳ người dùng nào được link thẻ. Khi có hơn một số, authorize chưa gửi
SMS — verification.recipient_required: true, verification.recipients là
danh sách { recipient_id, phone_masked }. Cho khách chọn số của mình rồi gọi
POST /merchant/payments/{auth_id}/resend-verification với { "recipient_id": "…" }
— lần gửi đầu này không tính là resend. Chi tiết:
merchant-authorize-errors.md §5.4.
Sai mã 3 lần → decline card_locked_too_many_attempts, khoá thẻ 15 phút.
POS mất response của /authorize? GET /merchant/payments/{auth_id} trả lại
nguyên khối verification đang sống — nối tiếp giao dịch cũ thay vì mở giao dịch mới.
5.3 Vài lưu ý theo entry mode
card_qrkhông phải luồng nhẹ hơnmanual_pan: QR in trên thẻ ai chụp cũng được, nên vẫn phải qua OTP như nhau.- Trừ ví Xperise xảy ra ở
authorizevớientry_mode=token, nhưng ở/verifyvớimanual_pan/card_qr/chip_tap. Mọi lỗi ví đều có tiền tốservice_payment_— đừng lẫnservice_payment_insufficient_funds(ví hết tiền) vớiinsufficient_funds(hạn mức/số dư thẻ). - Một
authorizemới cho cùng thẻ + merchant sẽ tự void authpending_verificationcũ.
6. Chốt tiền, huỷ, hoàn tiền
6.1 Capture — chỉ khi auto_capture=false
POST /merchant/payments/{auth_id}/capture
Idempotency-Key: <uuid mới>
{ "amount": "480000", "final": true, "external_ref": "BILL-2026-0912" }
Trần capture là amount × capture_tolerance (mặc định 1.20 = 120% số tiền đã
authorize), và tính trên tổng mọi capture của auth đó chứ không phải từng lần.
Cho phép vượt là có chủ đích: nghiệp vụ khách sạn / taxi chỉ biết số tiền cuối khi
trả phòng hoặc kết thúc chuyến. Vượt trần → 422 capture_amount_exceeds_tolerance;
xử lý đúng là capture tối đa rồi authorize phần chênh thành giao dịch mới.
Hold hết hạn sau HOLD_EXPIRY_HOURS (mặc định 48h) — capture sau đó là quá muộn.
final: false cho phép capture từng phần, auth chuyển partially_captured.
6.2 Void
POST /merchant/payments/{auth_id}/void # HTTP 200
Chỉ huỷ được auth chưa capture. Đã capture thì dùng refund.
6.3 Refund
POST /merchant/captures/{capture_id}/refunds
Idempotency-Key: <uuid mới>
{ "amount": "100000", "reason": "khách trả lại món" }
Ba điều phải biết:
- Refund đảo cả giao dịch trên ví Xperise, không chỉ sổ cái nội bộ.
- Ví chỉ nhận một refund cho mỗi payment. Hoàn một phần rồi thì không hoàn
tiếp được nữa →
422 wallet_payment_already_reversed. Cân nhắc kỹ trước khi hoàn từng phần. - Ví từ chối thì toàn bộ refund fail, không ghi gì —
422(không hoàn được như yêu cầu),409 service_refund_in_flight(retry nguyên văn),502(ví không với tới được).
Refund không đổi status của authorization — nó vẫn là captured. Tín hiệu
duy nhất là mảng refunds trong captures[]. Số tiền thực nợ của một capture =
amount trừ tổng các refund của nó.
7. enhanced_data — dữ liệu nghiệp vụ
Khi merchant bật enhanced_data_required, mỗi giao dịch nên mang theo dữ liệu
chuyến/đơn tương ứng. Trường type là discriminator, các field còn lại phẳng:
type | Field bắt buộc | Field tuỳ chọn |
|---|---|---|
taxi_trip | pickup_location, destination, plate_number, fare, sub_fee | distance_km (sẽ thành bắt buộc — xem ghi chú dưới bảng) |
hotel_stay | room_no, check_in, check_out (YYYY-MM-DD), nights | guest_name |
fnb_order | outlet_name, order_no, subtotal | outlet_code, item_count, guest_count, service_charge, vat_amount |
other | — | — |
distance_km (số km chuyến đi, JSON number) hiện tuỳ chọn ở backend: giao
diện nhập chuyến bắt buộc trước, backend sẽ bắt buộc sau khi các app điều xe đã
lên hết. Gửi ngay từ bây giờ — thiếu nó chưa bị 422/incomplete, nhưng sẽ bị
khi vòng rollout kết thúc. Nếu có gửi thì vẫn bị validate (number, >= 0).
Mỗi giao dịch trả về data_status:
data_status | Nghĩa |
|---|---|
complete | Đủ field bắt buộc |
incomplete | Thiếu — vẫn capture được, nhưng chặn chốt bảng kê đối soát |
not_required | Merchant không bật enhanced_data_required |
Khác biệt cần nhớ: ở POST /merchant/payment-intents, thiếu field là 422,
chặn ngay. Ở POST /merchant/payments/authorize, thiếu field chỉ làm
data_status=incomplete — tiền vẫn chạy, nợ dữ liệu dồn sang khâu đối soát.
Nếu merchant sống bằng luồng authorize trực tiếp thì phải tự có cơ chế bổ sung.
outlet_code (F&B) đáng gửi nếu chuỗi có nhiều chi nhánh: outlet_name là chữ
in trên hoá đơn, bị sửa/đổi tên liên tục nên không join được.
8. Hoá đơn VAT — chỉ khi invoice_required = true
Bật cờ này khi merchant không xuất hoá đơn tại quầy mà phát hành trên nhà cung cấp e-invoice sau đó. Khi đó định nghĩa "giao dịch hoàn tất" thay đổi:
- Mỗi payment sinh ra với
invoice.status = "pending". - Pre-auth chưa có hoá đơn thì không capture được →
422 invoice_required. - Merchant gọi:
POST /merchant/payments/{auth_id}/invoice # HTTP 200
{ "invoice_no": "INV-2026-0008812",
"invoice_url": "https://einvoice.example.vn/lookup/INV-2026-0008812",
"invoice_detail": { "buyer_name": "Công ty ABC", "buyer_tax_id": "0312345678-001" } }
invoice_no và invoice_url bắt buộc đi cùng nhau: số không có chứng từ thì
không kê khai được, chứng từ không có số thì không đối soát được.
- Post lại đúng cặp cũ → trả về payment không đổi (retry an toàn).
- Post cặp khác lên payment đã
issued→422 invoice_already_issued. Sai hoá đơn thì sửa ở nhà cung cấp e-invoice rồi refund, không ghi đè. invoice_detailluôn là hoá đơn cho doanh nghiệp:buyer_tax_idbắt buộc và phải là mã số thuế VN (10 số, tuỳ chọn hậu tố chi nhánh-NNN). Không có shape cho hoá đơn cá nhân.
Hàng đợi công việc: GET /merchant/payments?invoice_status=pending.
9. Webhook
Cấu hình webhook_url lúc tạo merchant. Nếu để trống thì không có delivery
nào được xếp hàng, kể cả sau này.
9.1 Sự kiện
event | Khi nào | data chứa |
|---|---|---|
payment.authorized | Auth được duyệt (chưa thu tiền) | auth_id, amount, currency, external_ref, hold_expires_at |
payment.captured | Đã thu tiền | capture_id, auth_id, amount, currency, merchant_id, booking_ref, external_ref, attribution |
payment.voided | Auth bị huỷ | auth_id, reason |
payment.refunded | Đã hoàn tiền | refund_id, capture_id, auth_id, amount, currency |
payment.invoice_issued | Merchant đã nộp hoá đơn | auth_id, invoice_no, invoice_url, amount, currency, external_ref |
Body luôn có dạng:
{ "event": "payment.captured", "data": { … }, "sent_at": "2026-09-14T08:31:02.115Z" }
9.2 Xác thực chữ ký
Header X-Xpcc-Signature = HMAC-SHA256 hex của raw body, key là webhook_secret.
const crypto = require('crypto');
function verify(rawBody, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature ?? '', 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Hai điều bắt buộc:
- Ký trên raw body, không phải JSON đã parse rồi stringify lại — thứ tự key và khoảng trắng đổi là chữ ký sai.
- So sánh bằng
timingSafeEqual, không dùng===.
Chữ ký không bao gồm timestamp, nên nó không tự chống replay. Hãy dùng
sent_at để loại bỏ payload quá cũ, và khử trùng lặp theo auth_id / capture_id
/ refund_id — đây là hệ at-least-once, cùng một sự kiện có thể tới nhiều lần.
9.3 Giao và retry
| Tham số | Giá trị |
|---|---|
| Timeout mỗi lần gọi | 5 giây |
| Thành công | HTTP 2xx |
| Backoff | (lần thử)² × 10s, tức 10s → 40s → 90s…, trần 1 giờ |
| Số lần thử tối đa | 10, sau đó status = failed và bỏ hẳn |
| Nhịp quét hàng đợi | mỗi 5 giây |
Endpoint webhook phải trả 2xx nhanh rồi xử lý bất đồng bộ. Xử lý nghiệp vụ nặng trong request sẽ chạm timeout 5s và làm card-core retry một sự kiện thực ra đã được nhận.
Webhook không phải nguồn sự thật duy nhất. Sau 10 lần thất bại, sự kiện bị bỏ
vĩnh viễn. Đối soát định kỳ bằng GET /merchant/payments (§10) là bắt buộc, không
phải tuỳ chọn.
10. Đối soát
GET /merchant/payments?status=captured&from=2026-09-01T00:00:00Z&to=2026-09-30T23:59:59Z&limit=100
| Query | Dùng để |
|---|---|
status | pending_verification, approved, declined, captured, partially_captured, voided, expired |
sub_account_id | Lọc theo nhân viên. operator chỉ được truyền id của chính mình, khác → 403 |
invoice_status | pending = hàng đợi hoá đơn còn nợ |
from / to | Khoảng thời gian ISO-8601 |
cursor / limit | Phân trang; truyền lại next_cursor nguyên văn, null là hết trang |
Mỗi dòng mang captures[] (mỗi capture kèm refunds[]), sub_account,
enhanced_data, data_status, invoice. Một auth có thể có nhiều capture nên
đối soát theo captures[], đừng theo amount của authorization — đó là số
tiền đã authorize, không phải số đã thu.
statement_id khác null nghĩa là capture đó đã vào một bảng kê đối soát.
11. Idempotency
| Endpoint | Idempotency-Key |
|---|---|
POST /merchant/payments/authorize | Bắt buộc |
POST /merchant/payments/{id}/capture | Bắt buộc |
POST /merchant/captures/{id}/refunds | Bắt buộc |
POST /merchant/payment-intents | Bắt buộc |
/verify, /resend-verification, /void, /invoice | Không nhận |
Thiếu header → 422 missing_idempotency_key.
| Tình huống | Mã | Xử lý |
|---|---|---|
| Cùng key, cùng payload | Trả lại response gốc | Retry an toàn, cứ retry |
| Cùng key, payload khác | 409 idempotency_conflict | Sinh key mới cho giao dịch mới |
| Request gốc đang chạy | 409 request_in_flight | Retry nguyên văn sau vài trăm ms |
Sinh key ở thời điểm bắt đầu giao dịch tại quầy, lưu cùng đơn hàng, và dùng lại đúng key đó cho mọi lần retry của cùng một lần thu tiền. Sinh key mới mỗi lần retry là cách chắc chắn nhất để thu tiền khách hai lần.
12. Giới hạn và cấu hình vận hành
| Hạng mục | Mặc định | Ghi chú |
|---|---|---|
| Rate limit chung | 300 request / 60 giây | Theo IP — cả cửa hàng sau NAT dùng chung hạn mức |
POST /merchant/login | 10 / 60 giây | Mục tiêu dò mật khẩu |
| Endpoint chip tap | 120 / 60 giây | Một lần tap tốn 2 request |
| Hold hết hạn | 48 giờ (HOLD_EXPIRY_HOURS) | Sau đó auth expired, không capture được |
| TTL intent | 600 giây (INTENT_TTL_SECONDS) | Ghi đè từng lần bằng ttl_seconds (60–1800) |
| TTL mã OTP | 300 giây (OTP_TTL_SECONDS) | Chỉ mã chết, giao dịch còn sống |
| Deadline xác thực | 600 giây (VERIFICATION_DEADLINE_SECONDS) | Giao dịch chết hẳn |
| Timeout webhook | 5 giây | 10 lần thử rồi bỏ |
Vượt rate limit → 429 rate_limited, backoff rồi retry.
GET /health không cần xác thực — dùng cho uptime check, đừng dùng endpoint
nghiệp vụ để ping.
13. Checklist go-live
Credential & cấu hình
-
api_keyvàwebhook_secretđã nằm trong secret manager, không có trong repo - Đã chọn đúng
type_codes+attributes,capture_tolerancekhớp nghiệp vụ - Đã quyết định
enhanced_data_requiredvàinvoice_required(đổi sau tốn công) - Owner có
owner_phoneđể tự reset mật khẩu được
Luồng thanh toán
- POS xử lý được cả bốn dạng response của authorize, gồm
pending_verification -
201 + status=declinedhiển thịdecline_reasoncho nhân viên, không báo "lỗi hệ thống" - Mã OTP hết hạn → gọi
/resend-verification, không tạo giao dịch mới - Mất response giữa chừng →
GET /merchant/payments/{auth_id}để nối tiếp -
Idempotency-Keysinh một lần cho mỗi giao dịch, tái dùng khi retry - Số tiền xử lý bằng decimal/chuỗi, không dùng float ở bất kỳ đâu
Webhook
- Xác thực
X-Xpcc-Signaturetrên raw body bằng so sánh timing-safe - Trả 2xx dưới 5 giây, xử lý nghiệp vụ bất đồng bộ
- Khử trùng lặp theo id sự kiện (at-least-once)
- Có job đối soát định kỳ qua
GET /merchant/payments— không phụ thuộc mỗi webhook
Nhân sự & đối soát
- Giao dịch có người thu tiền thì dùng session sub-account, không dùng parent key
- Đối soát theo
captures[]vàrefunds[], không theoamountcủa authorization - Nếu
invoice_required: có quy trình xử lý hàng đợi?invoice_status=pending
Trước khi chạy thật
- Đã test decline thật (hết hạn mức, hết số dư, thẻ khoá), không chỉ happy path
- Đã test refund và hiểu ví chỉ cho hoàn một lần cho mỗi payment
- Nếu dùng
chip_tap: staging chạy vớiCHIP_AUTO_REVOKE_ON_REPLAY=false(bug replay counter sẽ thu hồi thẻ thật, phải personalize lại)