VNPAY IPN Contract
Canonical & code-verified
The single source of truth for the BANA ↔ VNPAY payment contract, verified against third-parties/mq-pay and packages/{payment,sale}. Where the integrations/mq-pay pages or any other page disagree, this page wins.
mq-pay is not a standalone service - it is a component (MQPayComponent) inside the payment service. Only QR-MMS and PhonePOS are wired in production; Smart POS exists in code but is NOT provisioned (see Known issues).
Flow - two hops
- VNPAY → payment service. VNPAY POSTs an IPN to a provider endpoint; mq-pay verifies the checksum, enqueues a confirmation job, and returns a
{ code, message }ack (HTTP 200). - payment service → sale. On a terminal event, the webhook dispatcher POSTs
{ eventType, timestamp, payload }to sale's/webhooks/payment.
Hop 1 - VNPAY → payment (IPN endpoints)
Absolute path = APP_ENV_SERVER_BASE_PATH (conventionally /v1/api/payment) + the relative path below. This is the URL you register in the VNPAY merchant portal.
| Provider | IPN endpoint | Outbound base (TEST / PROD) | Status |
|---|---|---|---|
| QR-MMS | POST /v1/api/payment/payments/vnpay/qr-mms/ipn | doitac-tran.vnpaytest.vn / doitac-tran.vnpay.vn | ✅ wired |
| PhonePOS | POST /v1/api/payment/payments/vnpay/phone-pos/ipn | - (SDK-initiated; IPN-only) | ✅ wired |
| Smart POS | POST /v1/api/payment/payments/vnpay/smart-pos/ipn | spos-api.vnpaytest.vn / spos-api.vnpay.vn | ⚠️ not provisioned |
Signature (checksum) per provider
The checksum travels as a body field checksum (not a header), and the algorithm differs per provider:
| Provider | Algorithm | Secret source | Inbound IPN verified? |
|---|---|---|---|
| QR-MMS | MD5 - secret appended to the pipe-joined data (not a true HMAC) | per-merchant (credential getter) | ✅ yes |
| PhonePOS | HMAC-SHA256, Base64 | per-merchant (credential getter) | ✅ yes - a missing checksum is rejected |
| Smart POS | HMAC-SHA256, hex (uppercase) | static config (secretKey.ipn) | ⚠️ weak - accepts a missing checksum (test stub) |
QR-MMS IPN signed string (verify):
code | msgType | txnId | qrTrace | bankCode | mobile | accountNo | amount | payDate | merchantCode | secretKey→ MD5 hex, compared case-insensitively. PhonePOS IPN signed string:secret + (merchantMethodCode | orderCode | amount | "" | responseCode)→ HMAC-SHA256 Base64.
Ack returned to VNPAY
Not the classic { RspCode, Message }. mq-pay always returns HTTP 200 with { code, message, data? }:
- QR-MMS success:
{ "code": "00", "message": "Success", "data": { "txnId": "…" } }- auth-fail06, already-paid03, invalid-amount07. - PhonePOS / Smart POS success:
{ "code": "200", "message": "…" }- invalid checksum410, already-processed412.
Hop 2 - payment → sale (internal webhook)
- Endpoint:
POST /webhooks/payment(sale service). - Body:
{ eventType: string, timestamp: number, payload: { transaction?, attempt?, source?, timestamp } }.transaction={ id, uid, status, total, paid, sourceType?, sourceId?, metadata? }attempt={ id, uid, status, amount, paymentProvider, reason?, metadata? }
- Response: HTTP
200{ success: true, message? }; schema-validation failures return400 { error, code? }. - Only terminal events are dispatched;
TRANSACTION_CREATED/ATTEMPT_CREATED/ATTEMPT_SENTare WebSocket-only. - Authentication: none today. Core ships an
X-Webhook-SignatureHMAC-SHA256 helper and the dispatcher can attach it, but sale does not verify it - see Known issues.
Status codes - real values
From MQPay*Statuses (which alias the IGNIS Statuses). The maximum band is 5xx - there is no 600.
| Domain | Member = value |
|---|---|
| Transaction status | NEW 100_NEW · PARTIAL 300_PARTIAL · SETTLED 304_SETTLED · BLOCKED 403_BLOCKED · CLOSED 404_CLOSED · CANCELLED 505_CANCELLED |
| Attempt status | NEW 100_NEW · SENT 204_SENT · SUCCESS 302_SUCCESS · FAIL 500_FAIL · EXPIRED 501_EXPIRED |
| Attempt type | MAKE_PAYMENT 100_MAKE_PAYMENT · CANCEL_PAYMENT 200_CANCEL_PAYMENT · REFUND_PAYMENT 300_REFUND_PAYMENT |
Common doc errors (now corrected)
300_SUCCESS is wrong - 300_ is PARTIAL; success is 302_SUCCESS. SETTLED 600 is wrong - SETTLED is 304_SETTLED. Copy values from the table above.
Known issues
- Smart POS connector is dead in the payment service (never passed to
IMQPayOptions); its IPN verification returns valid on a missing checksum and the signed field order is unconfirmed. Do not rely on it. /webhooks/paymentis unauthenticated on the sale side - it trusts theX-Webhook-Event-Typeheader and can complete orders without verifying the dispatcher'sX-Webhook-Signature. Hardening this (verify the HMAC + add a replay window) is a Security action item.