Chuyển tới nội dung chính

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:

  1. Số tiền luôn là chuỗi thập phân ("150000"), không bao giờ là số float.
  2. 201 + status=declined khô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.
  3. 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 quanNội dung
merchant-authorize-errors.mdToàn bộ mã lỗi và decline_reason của luồng thanh toán
merchant-classic-tap.mdLuồng chạm thẻ MIFARE Classic (chip_tap)
auth-architecture.mdMô hình identity/principal/session dùng chung
CARD-CORE-BRIEF.mdThiết kế tổng thể của hệ thống
GET /api-docs.html, GET /swaggerContract 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ĩaLưu ý khi chọn
aliasHandle đă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_codesNgành dọc: taxi, hotel, car_rental, fnb, otherMỗi type góp một schema attributes bắt buộc. Chọn được nhiều
attributesMetadata 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_urlNơi nhận webhookBỏ 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_toleranceTrần capture so với số tiền đã authorize, mặc định 1.20 (120%)Xem §6.1
enhanced_data_requiredBắt buộc dữ liệu nghiệp vụ (chuyến đi / đêm phòng / bill)Xem §7
invoice_requiredMerchant nợ hoá đơn VAT sau mỗi giao dịchXem §8. Bật là đổi định nghĩa "giao dịch hoàn tất"
owner_emailTài khoản owner (sub-account role admin) được tạo kèmBắt buộc nếu muốn có owner_password / owner_phone
owner_phoneĐích đến của OTP reset mật khẩuKhô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:

CredentialHeaderLà aiDùng cho
Parent MerchantKeyX-Merchant-Key: mk_…Máy, không có con người đứng sauServer-to-server: backend POS, hệ thống booking, job đối soát
Session sub-accountAuthorization: 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ào Authorization: Bearer sẽ bị coi là JWT hỏng → 401 token_expired.
  • Phân quyền sub-account: admin thấy toàn bộ merchant; operator chỉ 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/me là 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ồngentry_modeAi xuất trìnhCần OTP/PIN?Phù hợp
QR intent (§4)qr_intentMerchant hiện QR, khách quét bằng app XperiseKhông — khách đã xác thực trong appQuầy có màn hình, bill nhà hàng, khách sạn, app đặt xe
One-time token (§5)tokenKhách mở QR trong app, merchant quétChỉ khi token có PINPOS có máy quét, khách chủ động
Manual PAN / card QR (§5.3)manual_pan, card_qrNhâ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_tapChạm thẻ nhựa vào máy Android NFCLuô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_fields kè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 định INTENT_TTL_SECONDS (600s).
  • external_ref là 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.

statusNghĩ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
declinedKhách đã xác nhận nhưng bị từ chối — đọc decline_reason trên auth_id
canceledMerchant đã huỷ (POST /merchant/payment-intents/{id}/cancel)
expiredHế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_modecard_token là gìRàng buộc
tokenToken một lần khách mở trong app XperiseHết hạn nhanh; có max_amount gắn kèm
manual_panPAN đầy đủ, 16 chữ sốNhân viên gõ tay
card_qrptok_… in trên thẻQuét QR trên mặt thẻ
chip_tapcard_token đọc từ chip, 32 ký tự hex thườngCầ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:

SurfaceField
POST /merchant/payments/authorizesub_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/payments của người được gán (operator bị 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_by rồ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_id vẫn là người cầm máy đọc, không phải người được gán.
Idempotency

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ì:

methodNhân viên phải nói gì với kháchGử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ọi POST /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_qr không phải luồng nhẹ hơn manual_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 ở authorize với entry_mode=token, nhưng ở /verify với manual_pan / card_qr / chip_tap. Mọi lỗi ví đều có tiền tố service_payment_ — đừng lẫn service_payment_insufficient_funds (ví hết tiền) với insufficient_funds (hạn mức/số dư thẻ).
  • Một authorize mới cho cùng thẻ + merchant sẽ tự void auth pending_verification cũ.

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:

  1. Refund đảo cả giao dịch trên ví Xperise, không chỉ sổ cái nội bộ.
  2. 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.
  3. 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:

typeField bắt buộcField tuỳ chọn
taxi_trippickup_location, destination, plate_number, fare, sub_feedistance_km (sẽ thành bắt buộc — xem ghi chú dưới bảng)
hotel_stayroom_no, check_in, check_out (YYYY-MM-DD), nightsguest_name
fnb_orderoutlet_name, order_no, subtotaloutlet_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_statusNghĩa
completeĐủ field bắt buộc
incompleteThiếu — vẫn capture được, nhưng chặn chốt bảng kê đối soát
not_requiredMerchant 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:

  1. Mỗi payment sinh ra với invoice.status = "pending".
  2. Pre-auth chưa có hoá đơn thì không capture được → 422 invoice_required.
  3. 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_detail luôn là hoá đơn cho doanh nghiệp: buyer_tax_id bắ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​

eventKhi nàodata chứa
payment.authorizedAuth được duyệt (chưa thu tiền)auth_id, amount, currency, external_ref, hold_expires_at
payment.capturedĐã thu tiềncapture_id, auth_id, amount, currency, merchant_id, booking_ref, external_ref, attribution
payment.voidedAuth bị huỷauth_id, reason
payment.refundedĐã hoàn tiềnrefund_id, capture_id, auth_id, amount, currency
payment.invoice_issuedMerchant đã nộp hoá đơnauth_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ọi5 giây
Thành côngHTTP 2xx
Backoff(lần thử)² × 10s, tức 10s → 40s → 90s…, trần 1 giờ
Số lần thử tối đa10, sau đó status = failed và bỏ hẳn
Nhịp quét hàng đợimỗ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
QueryDùng để
statuspending_verification, approved, declined, captured, partially_captured, voided, expired
sub_account_idLọc theo nhân viên. operator chỉ được truyền id của chính mình, khác → 403
invoice_statuspending = hàng đợi hoá đơn còn nợ
from / toKhoảng thời gian ISO-8601
cursor / limitPhâ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​

EndpointIdempotency-Key
POST /merchant/payments/authorizeBắt buộc
POST /merchant/payments/{id}/captureBắt buộc
POST /merchant/captures/{id}/refundsBắt buộc
POST /merchant/payment-intentsBắt buộc
/verify, /resend-verification, /void, /invoiceKhông nhận

Thiếu header → 422 missing_idempotency_key.

Tình huốngMãXử lý
Cùng key, cùng payloadTrả lại response gốcRetry an toàn, cứ retry
Cùng key, payload khác409 idempotency_conflictSinh key mới cho giao dịch mới
Request gốc đang chạy409 request_in_flightRetry 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ụcMặc địnhGhi chú
Rate limit chung300 request / 60 giâyTheo IP — cả cửa hàng sau NAT dùng chung hạn mức
POST /merchant/login10 / 60 giâyMục tiêu dò mật khẩu
Endpoint chip tap120 / 60 giâyMột lần tap tốn 2 request
Hold hết hạn48 giờ (HOLD_EXPIRY_HOURS)Sau đó auth expired, không capture được
TTL intent600 giây (INTENT_TTL_SECONDS)Ghi đè từng lần bằng ttl_seconds (60–1800)
TTL mã OTP300 giây (OTP_TTL_SECONDS)Chỉ mã chết, giao dịch còn sống
Deadline xác thực600 giây (VERIFICATION_DEADLINE_SECONDS)Giao dịch chết hẳn
Timeout webhook5 giây10 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_key và webhook_secret đã nằm trong secret manager, không có trong repo
  • Đã chọn đúng type_codes + attributes, capture_tolerance khớp nghiệp vụ
  • Đã quyết định enhanced_data_required và 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=declined hiển thị decline_reason cho 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-Key sinh 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-Signature trê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 theo amount củ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ới CHIP_AUTO_REVOKE_ON_REPLAY=false (bug replay counter sẽ thu hồi thẻ thật, phải personalize lại)