Skip to content

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

  1. 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).
  2. 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.

ProviderIPN endpointOutbound base (TEST / PROD)Status
QR-MMSPOST /v1/api/payment/payments/vnpay/qr-mms/ipndoitac-tran.vnpaytest.vn / doitac-tran.vnpay.vn✅ wired
PhonePOSPOST /v1/api/payment/payments/vnpay/phone-pos/ipn- (SDK-initiated; IPN-only)✅ wired
Smart POSPOST /v1/api/payment/payments/vnpay/smart-pos/ipnspos-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:

ProviderAlgorithmSecret sourceInbound IPN verified?
QR-MMSMD5 - secret appended to the pipe-joined data (not a true HMAC)per-merchant (credential getter)✅ yes
PhonePOSHMAC-SHA256, Base64per-merchant (credential getter)✅ yes - a missing checksum is rejected
Smart POSHMAC-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-fail 06, already-paid 03, invalid-amount 07.
  • PhonePOS / Smart POS success: { "code": "200", "message": "…" } - invalid checksum 410, already-processed 412.

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 return 400 { error, code? }.
  • Only terminal events are dispatched; TRANSACTION_CREATED / ATTEMPT_CREATED / ATTEMPT_SENT are WebSocket-only.
  • Authentication: none today. Core ships an X-Webhook-Signature HMAC-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.

DomainMember = value
Transaction statusNEW 100_NEW · PARTIAL 300_PARTIAL · SETTLED 304_SETTLED · BLOCKED 403_BLOCKED · CLOSED 404_CLOSED · CANCELLED 505_CANCELLED
Attempt statusNEW 100_NEW · SENT 204_SENT · SUCCESS 302_SUCCESS · FAIL 500_FAIL · EXPIRED 501_EXPIRED
Attempt typeMAKE_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/payment is unauthenticated on the sale side - it trusts the X-Webhook-Event-Type header and can complete orders without verifying the dispatcher's X-Webhook-Signature. Hardening this (verify the HMAC + add a replay window) is a Security action item.

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.