Skip to content

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/ipn

VNPAY Phone POS

POST /api/mq-pay/vnpay/phone-pos/ipn

VNPAY Smart POS

POST /api/mq-pay/vnpay/smart-pos/ipn

System (Manual)

POST /api/mq-pay/system/ipn

VNPAY IPN Data

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

typescript
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

typescript
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

  1. Payment services (BasePaymentService subclasses) and QueueProcessorService call this.emitEvent(eventType, payload)
  2. BasePaymentService.emitEvent() delegates to AppRegistry.emit({ payload }) which attaches event, source, and timestamp fields
  3. AppRegistry.emit() calls IPaymentEventHandler.handle({ payload }) on the registered handler
  4. The consuming service's handler receives the typed TMQPayEventPayload and 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.

EventConstantCategoryDescription
mq-pay:transaction.createdTRANSACTION_CREATEDTransactionNew transaction + initial attempt created
mq-pay:transaction.settledTRANSACTION_SETTLEDTransactionTransaction fully paid
mq-pay:transaction.cancelledTRANSACTION_CANCELLEDTransactionAll attempts cancelled on NEW transaction
mq-pay:attempt.createdATTEMPT_CREATEDAttemptNew payment attempt created
mq-pay:attempt.sentATTEMPT_SENTAttemptAttempt sent to provider (e.g., QR code available)
mq-pay:attempt.successATTEMPT_SUCCESSAttemptPayment confirmed via IPN
mq-pay:attempt.failedATTEMPT_FAILEDAttemptProvider call failed
mq-pay:attempt.expiredATTEMPT_EXPIREDAttemptQR expired, no payment received
mq-pay:attempt.cancelledATTEMPT_CANCELLEDAttemptAttempt cancelled

Event Payload Types

All payloads extend a base interface with timestamp (ISO string) and source (emitting service name).

typescript
// 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

SourceEvents Emitted
PaymentExecutionServiceTRANSACTION_CREATED, ATTEMPT_CREATED, ATTEMPT_SENT, ATTEMPT_FAILED
PaymentConfirmationServiceATTEMPT_SUCCESS, TRANSACTION_SETTLED
PaymentCancellationServiceATTEMPT_CANCELLED, TRANSACTION_CANCELLED
QueueProcessorServiceATTEMPT_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

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

ProviderRetry CountInterval
VNPAY QR MMS55 min
VNPAY Phone POS32 min
VNPAY Smart POS32 min

Testing Webhooks

Local Development

Use ngrok to expose local server:

bash
ngrok http 3000

Update your IPN callback URL in your VNPAY merchant portal to point to your ngrok URL.

Simulate IPN

bash
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"
  }'

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