Webhooks & IPN
MQ-Pay uses Instant Payment Notifications (IPN) to receive real-time payment status updates from payment providers.
Overview
When a customer completes a payment, the payment provider sends an IPN callback to your server. MQ-Pay processes these notifications automatically.
IPN Endpoints
VNPAY QR MMS
POST /api/mq-pay/vnpay/qr-mms/ipnVNPAY Phone POS
POST /api/mq-pay/vnpay/phone-pos/ipnVNPAY Smart POS
POST /api/mq-pay/vnpay/smart-pos/ipnSystem (Manual)
POST /api/mq-pay/system/ipnVNPAY IPN Data
interface VNPayIPNData {
vnp_TxnRef: string; // Your attempt code
vnp_TransactionNo: string; // VNPAY reference
vnp_Amount: number; // Amount (x100)
vnp_ResponseCode: string; // '00' = success
vnp_BankCode: string; // Bank code
vnp_PayDate: string; // YYYYMMDDHHMMSS
vnp_SecureHash: string; // Signature
}Signature Verification
MQ-Pay automatically verifies IPN signatures:
function verifySignature(data: VNPayIPNData, secretKey: string): boolean {
const { vnp_SecureHash, ...params } = data;
const sortedParams = Object.keys(params)
.sort()
.map(key => `${key}=${params[key]}`)
.join('&');
const hash = crypto
.createHmac('sha512', secretKey)
.update(sortedParams)
.digest('hex')
.toUpperCase();
return hash === vnp_SecureHash;
}Custom IPN Handler
vnpayQrMMS: {
enable: true,
// ...
onPaymentNotification: async (ipnData, context) => {
const { attempt, transaction } = context;
if (attempt.status === '300_SUCCESS') {
// Update order status
await saleOrderService.markAsPaid(transaction.sourceId);
// Send notification
await notificationService.send({
type: 'PAYMENT_RECEIVED',
orderId: transaction.sourceId,
amount: attempt.amount,
});
}
},
}Event Notifications
MQ-Pay emits payment lifecycle events via the IPaymentEventHandler callback interface. The consuming service implements this interface and passes it to IMQPayOptions.eventHandler during component setup.
How Events Are Emitted
- Payment services (
BasePaymentServicesubclasses) andQueueProcessorServicecallthis.emitEvent(eventType, payload) BasePaymentService.emitEvent()delegates toAppRegistry.emit({ payload })which attachesevent,source, andtimestampfieldsAppRegistry.emit()callsIPaymentEventHandler.handle({ payload })on the registered handler- The consuming service's handler receives the typed
TMQPayEventPayloadand can dispatch it however it needs (HTTP webhooks, WebSocket broadcast, etc.)
Event Types
All 9 event types are active. Refund events are defined but commented out in source.
| Event | Constant | Category | Description |
|---|---|---|---|
mq-pay:transaction.created | TRANSACTION_CREATED | Transaction | New transaction + initial attempt created |
mq-pay:transaction.settled | TRANSACTION_SETTLED | Transaction | Transaction fully paid |
mq-pay:transaction.cancelled | TRANSACTION_CANCELLED | Transaction | All attempts cancelled on NEW transaction |
mq-pay:attempt.created | ATTEMPT_CREATED | Attempt | New payment attempt created |
mq-pay:attempt.sent | ATTEMPT_SENT | Attempt | Attempt sent to provider (e.g., QR code available) |
mq-pay:attempt.success | ATTEMPT_SUCCESS | Attempt | Payment confirmed via IPN |
mq-pay:attempt.failed | ATTEMPT_FAILED | Attempt | Provider call failed |
mq-pay:attempt.expired | ATTEMPT_EXPIRED | Attempt | QR expired, no payment received |
mq-pay:attempt.cancelled | ATTEMPT_CANCELLED | Attempt | Attempt cancelled |
Event Payload Types
All payloads extend a base interface with timestamp (ISO string) and source (emitting service name).
// Transaction event payloads (discriminated union on `event` field)
type TTransactionEventPayload =
| ITransactionCreatedPayload // { event, transaction, attempt }
| ITransactionSettledPayload // { event, transaction, attempt }
| ITransactionCancelledPayload // { event, transaction }
// Attempt event payloads (discriminated union on `event` field)
type TAttemptEventPayload =
| IAttemptCreatedPayload // { event, transaction, attempt }
| IAttemptSentPayload // { event, transaction, attempt }
| IAttemptSuccessPayload // { event, transaction, attempt }
| IAttemptFailedPayload // { event, attempt } (no transaction)
| IAttemptExpiredPayload // { event, attempt } (no transaction)
| IAttemptCancelledPayload // { event, attempt } (no transaction)
// Union of all event payloads
type TMQPayEventPayload = TTransactionEventPayload | TAttemptEventPayload;NOTE
IAttemptFailedPayload, IAttemptExpiredPayload, and IAttemptCancelledPayload do not include a transaction field. Only creation/sent/success payloads include both transaction and attempt.
Emission Sources
| Source | Events Emitted |
|---|---|
PaymentExecutionService | TRANSACTION_CREATED, ATTEMPT_CREATED, ATTEMPT_SENT, ATTEMPT_FAILED |
PaymentConfirmationService | ATTEMPT_SUCCESS, TRANSACTION_SETTLED |
PaymentCancellationService | ATTEMPT_CANCELLED, TRANSACTION_CANCELLED |
QueueProcessorService | ATTEMPT_EXPIRED (scheduler), ATTEMPT_SUCCESS/ATTEMPT_FAILED (IPN confirmation) |
Integration with Consuming Services
MQ-Pay only emits events — it does not handle WebSocket broadcast, HTTP webhook dispatch, or any downstream routing. The consuming service (e.g., @nx/payment) implements IPaymentEventHandler to:
- Dispatch HTTP webhooks to subscribed endpoints
- Broadcast events over WebSocket rooms for real-time consumers (e.g., POS terminals)
- Trigger downstream business logic (e.g., update sale orders, record finance transactions)
See Payment — Webhook Dispatch for how the Payment service handles these events.
IPaymentEventHandler Example
import { IPaymentEventHandler, TMQPayEventPayload } from '@nx/mq-pay';
class MyEventHandler implements IPaymentEventHandler {
async handle(opts: { payload: TMQPayEventPayload }): Promise<void> {
const { payload } = opts;
console.log(`Event: ${payload.event}`);
// Dispatch to your webhook/WS/business logic
}
isReady(): boolean { return true; }
getName(): string { return 'MyEventHandler'; }
}
// Pass to MQ-Pay options
this.bind<IMQPayOptions>({ key: MQPayBindingKeys.MQ_PAY_CLIENT_OPTIONS })
.toValue({
eventHandler: new MyEventHandler(),
// ...provider configs
});Retry Handling
If your server is unavailable, providers will retry:
| Provider | Retry Count | Interval |
|---|---|---|
| VNPAY QR MMS | 5 | 5 min |
| VNPAY Phone POS | 3 | 2 min |
| VNPAY Smart POS | 3 | 2 min |
Testing Webhooks
Local Development
Use ngrok to expose local server:
ngrok http 3000Update your IPN callback URL in your VNPAY merchant portal to point to your ngrok URL.
Simulate IPN
curl -X POST http://localhost:3000/api/mq-pay/vnpay/qr-mms/ipn \
-H "Content-Type: application/json" \
-d '{
"vnp_TxnRef": "ATT001",
"vnp_Amount": 10000000,
"vnp_ResponseCode": "00"
}'