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

Xử lý kết quả thanh toán

Sau khi khách hàng thanh toán trực tuyến trên V-App, chúng tôi thực hiện hàm callback mà đối tác đăng ký khi gọi initPayment. Đồng thời, chúng tôi còn gửi một IPN message đến backend của phía đối tác.

Quan trọng
  • Luồng bất đồng bộ: Mọi luồng thanh toán (MiniApp chuẩn, thanh toán tập trung / Payment Session, v.v.) đều coi IPN là kênh kết quả chính — backend đối tác nên xử lý cập nhật đơn/giao dịch khi nhận IPN (có thể kết hợp tra cứu API để đối soát); không nên giả định client/SDK trả kết quả đồng bộ thay cho IPN.
  • Khi thanh toán thành công, IPN sẽ được gởi ngay lập tức với status COMPLETED
  • Khi thanh toán thất bại, IPN sẽ được gởi với trạng thái FAILED trong hai trường hợp sau:
    • Khách hàng tới màn hình thanh toán và click huỷ thanh toán: IPN sẽ được gởi ngay lập tức
    • Khách hàng tới màn hình thanh toán và tắt app hoặc không làm gì cả: IPN sẽ được gởi sau đó 4 tiếng

IPN - Instant Payment Notification

Chúng tôi sẽ sử dụng Postback Url mà đối tác cung cấp lúc đăng ký chức năng thanh toán với V-App để gửi một HTTP request với thông tin như sau:

Thông tin HTTP

KeyValue
Content-Typeapplication/json
MethodPOST
URLpostback url

HTTP Request

Thuộc tínhKiểu dữ liệuMô tả
orderIdstringID của order trong hệ thống backend (unique identifier)
transactionIdstringID giao dịch từ Payment Service (unique per transaction)
providerTransactionIdstringID giao dịch từ provider
providerInvoiceIdstringID invoice do provider cung cấp cho PayCollect (có thể null). (OnePay: provider_invoice_id)
referenceIdstringID tham chiếu từ Gateway/Bank (có thể null)
transactionTypestringLoại giao dịch: PAYMENT | REFUND | CANCEL | CHARGEBACK
amountnumberSố tiền giao dịch (không bao gồm fee), phải > 0
currencystringĐơn vị tiền tệ theo chuẩn ISO 4217 (VND | USD | EUR | etc)
statusstringTrạng thái giao dịch: PENDING | PROCESSING | HOLDING | COMPLETED | FAILED | CANCELLED
processedAtnumberUnix timestamp in milliseconds (UTC)
paymentMethodstringPhương thức thanh toán chính (primary) — nguồn quyết định việc hoàn tất giao dịch khi một giao dịch gồm nhiều nguồn tiền (allocations); chi tiết và ví dụ: mục Primary payment method và allocations. Ngoài các giá trị (CREDIT_CARD | DEBIT_CARD | BANK_TRANSFER | E_WALLET | QR_CODE…), luồng có voucher/VPoint có thể dùng thêm giá trị minh hoạ như PROMOTION | VPOINT (tuỳ contract Payment Hub).
paymentProviderstringHệ thống / cổng gắn với primary (không chỉ PSP thẻ-ví): thanh toán external qua cổng (MOMO | ONEPAY | VNPAY | VIETQR…); khi primary là voucher/promotion có thể là VSF Promotion; khi primary là VPoint có thể là Vinclub. Vẫn có thể null nếu contract không gắn provider cho primary (tuỳ triển khai).
bankNamestringTên ngân hàng phát hành thẻ/tài khoản thanh toán (có thể null)
accountHolderNamestringTên chủ thẻ/chủ tài khoản thanh toán (có thể null)
cardNostringSố thẻ đã masking (VD: 411111******1111) (có thể null; không trả full PAN)
feesarrayDanh sách phí dynamic
orderInfoobjectThông tin khách hàng/đơn hàng (nếu có)
secureHashstringHMAC-SHA256 hash để verify data integrity (64 chars hex)
errorCodestringMã lỗi (có thể null)
errorMessagestringThông báo lỗi (có thể null)
breakdownarray(Optional) Mảng chi tiết nhiều nguồn tiền (allocations) trong một giao dịch; thường có ở luồng thanh toán tập trung (Payment Session). Cấu trúc đầy đủ: type, amount, status, referenceId, referenceType, details — xem mục Breakdown trong IPN.

Lưu ý:

  • bankName, accountHolderName, cardNo thường có khi giao dịch thẻ/tài khoản ngân hàng.
  • Với một số phương thức như ví điện tử/QR, các field này có thể null.
  • cardNo luôn là dữ liệu đã masking; Payment Service không trả full số thẻ (PAN).
  • providerInvoiceId chỉ có ý nghĩa với giao dịch PayCollect; các giao dịch khác có thể null.
  • Luồng thanh toán tập trung: Payment Service thường gửi một IPN kết thúc cho cả phiên (thành công / thất bại / huỷ); payload có thể kèm breakdown. Khi có breakdown, ý nghĩa amount top-level và cách đọc từng dòng nằm ở mục Breakdown trong IPN — khác với luồng MiniApp cổ điển không có breakdown.
  • referenceId ở root body IPN (cột HTTP Request) vẫn là tham chiếu theo contract IPN chung (ví dụ Gateway/Bank hoặc tham chiếu đơn, tuỳ triển khai). referenceId trong từng phần tử breakdown là ID downstream theo từng dòng nguồn tiền — hai tầng khác nhau, đừng gộp nhầm.

Primary payment method và allocations

Một giao dịch có thể tương ứng nhiều nguồn tiền (promotion/voucher, VPoint, tiền qua cổng…). Trong IPN:

  • breakdown (khi có — thường ở luồng thanh toán tập trung) mô tả từng allocation và số tiền từng dòng; định nghĩa chi tiết field: mục Breakdown trong IPN.
  • paymentMethod (và paymentProvider khi áp dụng) mô tả phương thức thanh toán chính (primary): nguồn quyết định việc giao dịch được coi là complete theo quy ước sau (minh hoạ):
CaseThành phần trong đơn / sessionpaymentMethod (primary) minh hoạpaymentProvider (primary) minh hoạGhi chú
1Chỉ promotion — đơn 100% miễn phí (không VPoint, không cổng)PROMOTIONVSF PromotionThanh toán bằng voucher/khuyến mãi (promotion); không có bước thu tiền qua PSP.
2Promotion + VPoint (chưa có tiền qua cổng ngoài)VPOINTVinclubPromotion chỉ là discount; VPoint là nguồn tiền thực trả nên primary là VPoint / Vinclub.
3Promotion + VPoint + thanh toán external (thẻ/ví/QR qua PSP)CREDIT_CARD (hoặc DEBIT_CARD / E_WALLET / QR_CODE… tuỳ user chọn)VNPAY | MOMO | ONEPAYExternal là bước cuối quyết định success/fail; paymentProvider là cổng thanh toán tương ứng.

Tóm lại: paymentMethod / paymentProvider trong payload IPN không liệt kê hết mọi dòng trong breakdown; chúng chỉ định danh primary. paymentProvider không giới hạn cổng thẻ/ví — có thể là VSF Promotion (voucher) hoặc Vinclub (VPoint) khi đó là primary. Muốn phân bổ đầy đủ, đọc breakdown (mục bên dưới).

Breakdown trong IPN

Field breakdown là mảng (optional) mô tả phân bổ nguồn tiền (allocations) khi một giao dịch gồm nhiều thành phần (tiền qua cổng, voucher, VPoint…). Thường xuất hiện cùng luồng thanh toán tập trung (Payment Session): Payment Service gửi một IPN kết thúc cho phiên và có thể kèm breakdown để backend đối tác đối soát từng nguồn.

Mỗi phần tử trong breakdown có dạng:

  • type: nguồn tiền / dòng breakdown (tiền qua cổng, voucher, VPoint…), ví dụ: PAYMENT, VOUCHER, VPOINT. Khác với referenceType (phân loại tham chiếu downstream — xem bảng enum bên dưới).
  • amount: số tiền tương ứng với nguồn đó.
  • status: trạng thái từng item (mỗi phần tử trong mảng breakdown là một item) — PENDING | SUCCESS | FAILED | CANCELLED (xem bảng breakdown[].status bên dưới). Khác với status giao dịch ở root IPN (COMPLETED | FAILED | …).
  • referenceId (optional): ID từ hệ thống downstream gắn với dòng breakdown này (PSP, promotion ledger, VPoint…), khi có tham chiếu cần đối soát.
  • referenceType (optional, đi cặp referenceId): phân loại ý nghĩa của referenceId. Khuyến nghị đặt referenceId / referenceType cùng cấp type / amount thay vì trong details: schema thống nhất cho mọi type; details chỉ chứa field đặc thù từng loại.
  • details (optional): map (object JSON — key-value). Không phải mảng ở root: mỗi key là tên field đặc thù theo type, giá trị có thể là string/number/boolean hoặc mảng (ví dụ key voucherCodes → mảng string). Ví dụ PAYMENT: method, provider; VOUCHER: một mã → voucherCode / promotionId (string); nhiều mã → voucherCodes / promotionIds (giá trị là mảng string trong map); VPOINT: points.

Ví dụ payload (rút gọn các field IPN khác):

{
"orderId": "Order_001",
"transactionId": "Txn_001",
"status": "COMPLETED",
"amount": 70000,
"currency": "VND",
"breakdown": [
{
"type": "PAYMENT",
"amount": 70000,
"status": "SUCCESS",
"referenceId": "PSP_TXN_123",
"referenceType": "TRANSACTION",
"details": {
"method": "CARD",
"provider": "ONEPAY"
}
},
{
"type": "VOUCHER",
"amount": 10000,
"status": "SUCCESS",
"referenceId": "PROMO_REV_123",
"referenceType": "RESERVATION",
"details": {
"voucherCodes": ["VOUCHER_2025", "VOUCHER_EXTRA"],
"promotionIds": ["PROMO_001", "PROMO_002"]
}
},
{
"type": "VPOINT",
"amount": 20000,
"status": "SUCCESS",
"referenceId": "PROMO_REV_123",
"referenceType": "REDEMPTION",
"details": {
"points": "20000"
}
}
]
}

Ý nghĩa amount top-level khi có breakdown (minh hoạ):

  • amount (top-level): số tiền user thực trả qua kênh thanh toán online (tương ứng phần type: PAYMENT trong breakdown), không phải tổng mọi dòng khi có VPoint/voucher.

Theo từng breakdown.type (minh hoạ):

  • PAYMENT: tiền thu qua cổng thanh toán. status: kết quả xử lý dòng đó. referenceId / referenceType (nếu có): tham chiếu PSP / giao dịch ngoài. details: method, provider.
  • VOUCHER: phần giảm từ voucher/khuyến mãi. status: kết quả áp dụng/hold promotion. referenceId / referenceType: tham chiếu downstream promotion (ví dụ hold). details vẫn là map: key voucherCodes / promotionIds với giá trị là mảng string khi nhiều mã; hoặc key voucherCode / promotionId (string) khi một mã. Có thể tách nhiều dòng type: VOUCHER trong breakdown (mỗi dòng một voucher) thay vì gom list trong map — tuỳ contract Payment Hub.
  • VPOINT: phần thanh toán bằng VPoint. status: kết quả trừ/quy đổi điểm. referenceId / referenceType: tham chiếu downstream khi quy đổi/tiêu điểm. details: points (số điểm đã trừ, minh hoạ dạng string).

Field cấp dòng (minh hoạ):

FieldBắt buộcÝ nghĩa
typeNguồn tiền của dòng: PAYMENT, VOUCHER, VPOINT, …
amountSố tiền tương ứng dòng đó.
statusTrạng thái từng item trong breakdown; giá trị PENDING | SUCCESS | FAILED | CANCELLED — xem bảng breakdown[].status.
referenceIdKhông (khi có tham chiếu downstream)ID từ hệ thống downstream cho dòng này.
referenceTypeKhông (đi cặp referenceId)Phân loại referenceId; giá trị xem bảng dưới.

Giá trị status từng item trong breakdown (minh hoạ):

Giá trịÝ nghĩa
PENDINGItem đang xử lý / chờ kết quả.
SUCCESSItem đã hoàn tất thành công.
FAILEDItem thất bại (ví dụ trừ VPoint lỗi, cổng từ chối phần tương ứng).
CANCELLEDItem đã bị huỷ (user/system).

details theo từng breakdown.type (minh hoạ): — luôn là map (object); các dòng dưới là key trong map (giá trị có thể scalar hoặc mảng).

Field (key trong map)breakdown.typeÝ nghĩa
methodPAYMENTPhương thức thanh toán (ví dụ CARD).
providerPAYMENTMã nhà cung cấp thanh toán (ví dụ ONEPAY).
voucherCodeVOUCHER(Một voucher) Mã voucher đã áp dụng.
promotionIdVOUCHER(Một promotion) Định danh promotion.
voucherCodesVOUCHER(Nhiều voucher) Mảng mã voucher đã áp dụng.
promotionIdsVOUCHER(Nhiều promotion) Mảng định danh promotion.
pointsVPOINTSố điểm đã trừ.

Giá trị referenceType (minh hoạ):

Giá trịÝ nghĩa
TRANSACTIONTham chiếu thanh toán bên ngoài (external payment), ví dụ giao dịch trên PSP/cổng.
RESERVATIONTham chiếu giữ tài nguyên (hold resource) — đặt chỗ / giữ hạn mức trước khi chốt.
REDEMPTIONTham chiếu tiêu thụ tài nguyên (consume resource), ví dụ quy đổi / trừ benefit sau xác nhận.

Lưu ý về breakdown:

  • breakdown[].status dùng bộ giá trị PENDING / SUCCESS / FAILED / CANCELLEDkhông trùng với status giao dịch ở root IPN (COMPLETED | FAILED | CANCELLED | …). Root mô tả kết quả tổng thể; mỗi item trong breakdown mô tả kết quả từng nguồn.
  • Vẫn cần breakdown[].type (PAYMENT / VOUCHER / VPOINT…) dù đã có referenceId / referenceType: typenguồn tiền trong đơn; referenceType chỉ mô tả loại tham chiếu downstream của referenceId — hai trục khác nhau.
  • breakdown[].type dùng PAYMENT, không dùng TRANSACTION ở đây. TRANSACTION / RESERVATION / REDEMPTION là giá trị của referenceType, không thay thế breakdown[].type.
  • Bản triển khai cũ có thể gửi referenceId / referenceType lồng trong details; contract khuyến nghị đặt hai field ở cấp phần tử breakdown như ví dụ.
  • breakdown có thể có nhiều phần tử cùng type (ví dụ nhiều VOUCHER).
  • Tổng amount các phần tử breakdown bằng tổng giá trị đơn (tổng các dòng PAYMENT + VOUCHER + VPOINT + …). amount top-level là phần user trả qua kênh online; hai giá trị khác nhau khi có VPoint/voucher.

orderInfo Structure

Thuộc tínhKiểu dữ liệuMô tả
customerNamestringTên khách hàng
customerEmailstringEmail khách hàng
customerPhonestringSố điện thoại khách hàng
orderCreatedAtnumberUnix timestamp (milliseconds) khi order được tạo
notesstringGhi chú bổ sung

Fees Array Structure

Thuộc tínhKiểu dữ liệuMô tả
typestringLoại phí: GATEWAY_FEE | PROCESSING_FEE | CURRENCY_CONVERSION | WEEKEND_FEE | PROMOTION_DISCOUNT | etc
namestringTên phí
amountnumberSố tiền phí
currencystringĐơn vị tiền tệ của phí
ratestringTỷ lệ phí (có thể null)
calculationstringCách tính phí: PERCENTAGE | FIXED | TIERED | DYNAMIC
chargedBystringĐơn vị thu phí: GATEWAY | INTERNAL | BANK | GOVERNMENT

Vi dụ về IPN message request

curl --location --request POST 'https://partner.example.com/payment/ipn' \
--header 'Content-Type: application/json' \
--data-raw '{
"orderId": "Order_001",
"transactionId": "Txn_001",
"providerTransactionId": "provider_txn_001",
"providerInvoiceId": "ONEPAY_INVOICE_001",
"referenceId": "Ref_001",
"transactionType": "PAYMENT",
"amount": 30000,
"currency": "VND",
"status": "COMPLETED",
"processedAt": 1705320600000,
"paymentMethod": "CREDIT_CARD",
"paymentProvider": "VNPAY",
"bankName": "Vietcombank",
"accountHolderName": "NGUYEN VAN A",
"cardNo": "411111******1111",
"fees": [
{
"type": "GATEWAY_FEE",
"name": "VNPay Processing Fee",
"amount": 900,
"currency": "VND",
"rate": "3%",
"calculation": "PERCENTAGE",
"chargedBy": "GATEWAY"
}
],
"orderInfo": {
"customerName": "Nguyen Van A",
"customerEmail": "[email protected]",
"customerPhone": "+84901234567",
"orderCreatedAt": 1760505607000,
"notes": "notes"
},
"secureHash": "a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890",
"errorCode": null,
"errorMessage": null
}'

Trạng thái giao dịch

Payment Service sẽ trả về các trạng thái giao dịch sau trong IPN message:

Trạng tháiGiá trịMô tảHành động đề xuất
PENDINGPENDINGĐang chờ xử lýChờ cập nhật trạng thái tiếp theo. Không coi là thành công hay thất bại
PROCESSINGPROCESSINGĐang xử lý thanh toánGiao dịch đang được xử lý bởi payment gateway. Chờ kết quả cuối cùng
HOLDINGHOLDINGĐang giữ tiền, chờ xác nhậnTiền đã được giữ tạm thời, chờ xác nhận từ merchant hoặc hệ thống
COMPLETEDCOMPLETEDThanh toán thành côngCập nhật đơn hàng thành công, giao hàng cho khách
FAILEDFAILEDThanh toán thất bạiThông báo cho khách hàng, hủy đơn hàng hoặc yêu cầu thanh toán lại
CANCELLEDCANCELLEDĐã hủy giao dịchĐơn hàng đã bị hủy bởi khách hàng hoặc hệ thống
Lưu ý khi xử lý trạng thái
  • Chỉ coi giao dịch là thành công khi nhận status COMPLETED
  • Với status PENDING, PROCESSING, HOLDING: Chờ IPN tiếp theo, không kết luận kết quả
  • Với status FAILED hoặc CANCELLED: Coi như giao dịch không thành công
  • Luôn kiểm tra secureHash trước khi xử lý bất kỳ status nào
  • Sử dụng transactionIdprocessedAt để tránh xử lý trùng lặp

Gửi lại IPN message

Chúng tôi sẽ gửi lại IPN message khi:

  • Kết quả trả về có HTTP status codes từ 500 đến 599
  • Hơn 15 giây không trả về kết quả
  • Xảy lỗi về kết nối mạng

Thời gian gửi lại sẽ tăng theo cấp số nhân: 2s → 4s → 8s → 16s → ...

Số lần retry tối đa được cấu hình khi onboard merchant với Payment Service.

Kiểm tra toàn vẹn dữ liệu

Dữ liệu có thể bị thay đổi trên đường truyền giữa hai hệ thống V-Appđối tác khi giao tiếp qua đường truyền mạng, dẫn đến thông tin giao dịch có thể bị sai lệch, đặc biệt là số tiền thanh toán và kết quả giao dịch.

Giải pháp

  • Để đảm bảo thông tin giao dịch chính xác và đầy đủ, đối tác cần KIỂM TRA secureHash mỗi lần nhận IPN message từ V-App. SecureHash được tính bằng HMAC-SHA-256 với format: HMAC-SHA-256(secretKey, rawDataString(orderId + '|' + transactionId + '|' + transactionType + '|' + amount + '|' + status + '|' + processedAt)).
  • Sử dụng request /api/payments/v1/transactions để kiểm tra trạng thái giao dịch trước khi cập nhật.
  • Luôn sử dụng IPN message để xử lý kết quả giao dịch. Khắc phục trường hợp khi thanh toán app V-App bị đóng bất ngờ.
  • Vì phụ thuộc vào đường truyền mạng và hệ thống, có khả năng IPN message bị gửi chậm hoặc một message bị gửi nhiều lần. Do đó, đối tác cần dựa vào trường transactionIdprocessedAt nhằm tránh 1 message được xử lý nhiều lần hay xử lý sai thứ tự.

Sample Code để tạo/verify SecureHash (HMAC-SHA-256)

// Example HMAC-SHA-256 using Web Crypto API (browser / Node.js with global crypto)
async function getExpectedHash(data, hashKey) {
const encoder = new TextEncoder()
const keyData = encoder.encode(hashKey)
const message = encoder.encode(data)

// Import secret key for HMAC-SHA-256
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)

// Compute HMAC-SHA-256 signature
const signature = await crypto.subtle.sign('HMAC', cryptoKey, message)
const hashArray = Array.from(new Uint8Array(signature))
const expectedHash = hashArray
.map(b => b.toString(16).padStart(2, '0'))
.join('')
console.log('data =', data)
console.log('expectedHash =', expectedHash)
return expectedHash
}

// Example test
getExpectedHash(
'25VFX8ME3PQ1|b1ecc589-d029-4214-bd73-80992e0fcd73|PAYMENT|1699000000|COMPLETED|1760669994639',
'sk_dev_js9u6kyzssf1me4znf0vax1fz21i',
).then(h => console.log('Result =', h))

IPN Response

HTTP Status Code

Luôn trả về HTTP Status 200 cho mọi trường hợp xử lý IPN, kể cả thành công hay thất bại.

Response Format

{
"status": "SUCCESS",
"message": "Transaction processed successfully",
"orderId": "Order_001",
"transactionId": "Txn_001",
"processedAt": 1705320600500
}
FieldTypeRequiredDescription
statusstringTrạng thái xử lý IPN. Domain values:
- SUCCESS: Xử lý thành công
- FAILED: Xử lý thất bại
- INVALID_HASH: SecureHash không hợp lệ
- DUPLICATE: IPN đã được xử lý trước đó
- ORDER_NOT_FOUND: Không tìm thấy đơn hàng
messagestringMessage mô tả kết quả xử lý (dùng để debug)
orderIdstringID của đơn hàng được xử lý
transactionIdstringID của giao dịch được xử lý
processedAtnumberTimestamp khi backend xử lý xong IPN (Unix timestamp in milliseconds)

Example Responses

Thành công:

{
"status": "SUCCESS",
"message": "Transaction processed successfully",
"orderId": "Order_001",
"transactionId": "Txn_001",
"processedAt": 1705320600500
}

SecureHash không hợp lệ:

{
"status": "INVALID_HASH",
"message": "Invalid secure hash",
"orderId": "Order_001",
"transactionId": "Txn_001",
"processedAt": 1705320600500
}

Trùng lặp IPN:

{
"status": "DUPLICATE",
"message": "IPN already processed",
"orderId": "Order_001",
"transactionId": "Txn_001",
"processedAt": 1705320600500
}
Chú ý xử lý Response
  • LUÔN trả về HTTP Status 200 cho mọi trường hợp
  • Sử dụng field status để báo kết quả xử lý thực tế
  • Đảm bảo processedAt là timestamp hợp lệ (milliseconds)
  • Điền đầy đủ orderIdtransactionId để tracking
  • Đặt message rõ ràng để dễ debug khi có vấn đề