Skip to content

Webhook Dispatch

1. Overview

The webhook dispatch system receives MQ-Pay events via WebhookEventHandlerHelper (implements IPaymentEventHandler), queries WebhookConfigRepository for active webhook subscriptions matching the event type, and fans out HTTP POST requests via WebhookDispatcherService with configurable exponential backoff retry. Dispatch is fire-and-forget — there is no dead-letter queue.

2. Event Flow

3. Supported Events

All 9 events are active in MQ-Pay. Refund events are defined but commented out in source.

Active Events

EventConstantPayload TypeWebhook DispatchWebSocketTrigger
mq-pay:transaction.createdTRANSACTION_CREATEDTTransactionEventPayloadSkippedYesNew transaction + attempt created
mq-pay:transaction.settledTRANSACTION_SETTLEDTTransactionEventPayloadYesYesTransaction fully settled
mq-pay:transaction.cancelledTRANSACTION_CANCELLEDTTransactionEventPayloadYesYesAll attempts cancelled on NEW transaction
mq-pay:attempt.createdATTEMPT_CREATEDTAttemptEventPayloadSkippedYesNew payment attempt created
mq-pay:attempt.sentATTEMPT_SENTTAttemptEventPayloadSkippedYesAttempt sent to provider (QR available)
mq-pay:attempt.successATTEMPT_SUCCESSTAttemptEventPayloadYesYesIPN confirms successful payment
mq-pay:attempt.failedATTEMPT_FAILEDTAttemptEventPayloadYesYesProvider call failed
mq-pay:attempt.expiredATTEMPT_EXPIREDTAttemptEventPayloadYesYesQR expired, no payment received
mq-pay:attempt.cancelledATTEMPT_CANCELLEDTAttemptEventPayloadYesYesAttempt cancelled

Creation/sent events (TRANSACTION_CREATED, ATTEMPT_CREATED, ATTEMPT_SENT) are WebSocket-only — they skip HTTP webhook dispatch to avoid noise on high-frequency lifecycle events.

Commented Out (Not Active)

EventStatus
mq-pay:refund.successCommented out
mq-pay:refund.failedCommented out

Payload Types

typescript
// Base fields shared by all event payloads
interface IBaseEventFields {
  timestamp: string;  // ISO timestamp string
  source: string;     // Source identifier
}

// Transaction events — discriminated union with `event` field
type TTransactionEventPayload =
  | ITransactionCreatedPayload    // { event, transaction, attempt }
  | ITransactionSettledPayload    // { event, transaction, attempt }
  | ITransactionCancelledPayload  // { event, transaction }

// Attempt events — discriminated union with `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 events
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.

4. WebhookEventHandlerHelper

Source: src/helpers/webhook-event-handler/helper.tsExtends: BaseHelperImplements: IPaymentEventHandler (from @nx/mq-pay)

4.1. Construction

Not DI-managed. Manually instantiated in ApplicationPaymentComponent.setupMQPay():

typescript
const eventHandler = new WebhookEventHandlerHelper({
  webhookConfigRepository,   // WebhookConfigRepository from DI
  dispatcherService,         // WebhookDispatcherService from DI
  socketEventService,        // PaymentSocketEventService from DI
});

4.2. IPaymentEventHandler Interface

MethodReturnDescription
handle(opts: { payload: TMQPayEventPayload })Promise<void>Receives event, dispatches webhooks + WS notifications
isReady()booleanAlways returns true
getName()stringReturns 'WebhookEventHandlerHelper'

4.3. handle() Implementation

The handle() method is a thin orchestrator that delegates to two private methods:

typescript
async handle(opts: { payload: TMQPayEventPayload }): Promise<void> {
  const { payload } = opts;
  this.logger.for(this.handle.name).info('Received event | type: %s', payload.event);

  await this._dispatchWebhooks({ payload });
  this._notifySocket({ payload });
}

4.4. _dispatchWebhooks() — HTTP Webhook Fan-out

typescript
private async _dispatchWebhooks(opts: { payload: TMQPayEventPayload }): Promise<void> {
  const { payload } = opts;
  const eventType = payload.event;

  // Creation/sent events are WS-only — skip webhook dispatch
  if (
    eventType === MQPayEventTypes.TRANSACTION_CREATED ||
    eventType === MQPayEventTypes.ATTEMPT_CREATED ||
    eventType === MQPayEventTypes.ATTEMPT_SENT
  ) {
    return;
  }

  // Query active webhooks that subscribe to this event type
  const webhooks = await this._webhookConfigRepository.find<TWebhookConfig>({
    filter: {
      where: {
        status: WebhookConfigStatuses.ACTIVATED,
        eventTypes: { contains: [eventType] },
      } as TWhere<TWebhookConfig>,
    },
  });

  if (webhooks.length === 0) {
    logger.warn('No webhooks subscribed to event | type: %s', eventType);
    return;
  }

  // Fan out to each webhook (fire-and-forget)
  for (const webhookConfig of webhooks) {
    this._dispatcherService.dispatch({ webhookConfig, eventType, payload });
  }
}

4.5. _notifySocket() — WebSocket Broadcast

typescript
private _notifySocket(opts: { payload: TMQPayEventPayload }): void {
  const { payload } = opts;
  const eventType = payload.event;

  switch (eventType) {
    case MQPayEventTypes.TRANSACTION_CREATED:
    case MQPayEventTypes.TRANSACTION_SETTLED:
    case MQPayEventTypes.TRANSACTION_CANCELLED: {
      this._socketEventService.notifyTransactionUpdate({
        payload: payload as TTransactionEventPayload,
      });
      return;
    }
    case MQPayEventTypes.ATTEMPT_CREATED:
    case MQPayEventTypes.ATTEMPT_SENT:
    case MQPayEventTypes.ATTEMPT_SUCCESS:
    case MQPayEventTypes.ATTEMPT_FAILED:
    case MQPayEventTypes.ATTEMPT_EXPIRED:
    case MQPayEventTypes.ATTEMPT_CANCELLED: {
      this._socketEventService.notifyAttemptUpdate({
        payload: payload as TAttemptEventPayload,
      });
      return;
    }
    default: {
      this.logger.for(this._notifySocket.name)
        .warn('Unhandled event type for WS notification | type: %s', eventType);
    }
  }
}

NOTE

The webhook query uses eventTypes: { contains: [eventType] } which translates to a PostgreSQL array containment check (@> operator). This means a webhook with eventTypes: ['mq-pay:attempt.success', 'mq-pay:transaction.settled'] will match both event types.

5. WebhookDispatcherService

Source: packages/core/src/services/webhook-dispatcher.service.ts (provided by @nx/core, not local to payment) Extends: BaseServiceDependencies: None (no constructor injection — standalone service) HTTP Client: axios

5.1. dispatch() Method

typescript
dispatch<PayloadType>(opts: {
  webhookConfig: TWebhookConfig;
  eventType: string;
  payload: PayloadType;
}): void

Steps:

  1. Parse webhookConfig.metadata through WebhookConfigMetadataSchema.parse() to get timeoutMs and maxRetries with Zod defaults
  2. Build request body: { eventType, timestamp: Date.now(), payload }
  3. Build headers: Content-Type, X-Webhook-Timestamp, X-Webhook-Event-Type, plus any custom headers from webhookConfig.headers
  4. Call sendWithRetry() starting at attempt 0 (non-blocking)

5.2. Retry Logic

Strategy: Exponential backoff with jitter, non-blocking via setTimeout.

Backoff formula:

delay = 2^attempt * 1000 + random(0, 500)ms
AttemptDelay RangeCumulative
0 (initial)Immediate0ms
1 (1st retry)1,000 – 1,500ms~1.25s
2 (2nd retry)2,000 – 2,500ms~3.5s
3 (3rd retry)4,000 – 4,500ms~7.75s

Failure handling:

typescript
private handleFailure(opts: { url, maxRetries, attempt, error, ... }): void {
  const nextAttempt = attempt + 1;

  // All retries exhausted → log error and give up
  if (nextAttempt > maxRetries) {
    logger.error('Webhook failed | attempted: %d | url: %s | error: %s', nextAttempt, url, error);
    return;
  }

  // Schedule retry (non-blocking)
  const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
  setTimeout(() => {
    this.sendWithRetry({ ...opts, attempt: nextAttempt });
  }, delay);
}

Success criteria: HTTP status code 200–299 (checked via response.status >= 200 && response.status < 300).

Error message extraction:

Error ConditionMessage Format
Connection timeoutTimeout after {timeout}ms
HTTP error responseHTTP {status}: {statusText}
Network/other errorerror.message

WARNING

Webhook dispatch is fire-and-forget. The dispatch() method returns void immediately. There is no dead-letter queue, no persistent retry state, and no delivery guarantee. Failed deliveries after max retries are only logged. If the Payment service restarts, in-flight retries are lost.

5.3. Payload Format

json
{
  "eventType": "mq-pay:attempt.success",
  "timestamp": 1735689600000,
  "payload": {
    "attempt": {
      "id": "123456789",
      "type": "100_MAKE_PAYMENT",
      "status": "302_SUCCESS",
      "provider": "VNPAY_QR_MMS",
      "amount": 150000
    },
    "transaction": {
      "id": "987654321",
      "uid": "TXN-2024-001",
      "totalAmount": 150000,
      "paidAmount": 150000,
      "status": "304_SETTLED"
    },
    "timestamp": "2024-12-31T12:00:00.000Z",
    "source": "mq-pay"
  }
}

5.4. Request Headers

HeaderValueSource
Content-Typeapplication/jsonHardcoded via HTTP.HeaderValues.APPLICATION_JSON
X-Webhook-TimestampString(Date.now())Generated per dispatch call
X-Webhook-Event-TypeEvent type stringFrom opts.eventType
(custom)(varies)Merged from webhookConfig.headers (JSONB)

NOTE

Custom headers from webhookConfig.headers are spread after the standard headers, so they can override Content-Type if needed. The headers object is typed as Record<string, string>.

6. WebhookConfig Schema (Detailed)

PostgreSQL schema: paymentTable name: WebhookConfigRepository: DefaultCRUDRepository (no soft delete — DELETE is permanent)

6.1. Columns

ColumnDB ColumnDrizzle TypeDefaultConstraintsNotes
ididtextSnowflakePKGenerated by generateIdColumnDefs
namenametextNOT NULLDisplay name for the webhook
urlurltextNOT NULLTarget HTTP endpoint URL
eventTypesevent_typestext[] (array)NOT NULLArray of MQ-Pay event type strings
statusstatustextACTIVATEDTWebhookConfigStatus enum
headersheadersjsonb{}Record<string, string> — custom HTTP headers
metadatametadatajsonb{timeoutMs: 30000, maxRetries: 3}Dispatch configuration
createdAtcreated_attimestampAutoFrom generateTzColumnDefs
modifiedAtmodified_attimestampAutoFrom generateTzColumnDefs

6.2. Indexes

Index NameColumnType
IDX_WebhookConfig_statusstatusB-tree

6.3. Metadata Schema (Zod)

Defined in WebhookConfigMetadataSchema:

typescript
const WebhookConfigMetadataSchema = z.object({
  timeoutMs: z.number().int().min(1000).max(300_000).default(30_000),
  maxRetries: z.number().int().min(0).max(10).default(3),
});
FieldTypeDefaultMinMaxUsed By
timeoutMsinteger30,0001,000300,000axios.post() timeout parameter
maxRetriesinteger3010handleFailure() retry count check

The Zod schema is called in WebhookDispatcherService.dispatch() via WebhookConfigMetadataSchema.parse(webhookConfig.metadata ?? {}) — this means missing or partial metadata will get defaults applied.

6.4. Status Values

Defined in WebhookConfigStatuses (from @nx/core):

StatusIgnis ConstantEffect
ACTIVATEDStatuses.ACTIVATEDReceives events (included in webhook query)
DEACTIVATEDStatuses.DEACTIVATEDTemporarily disabled (excluded from query)
ARCHIVEDStatuses.ARCHIVEDPermanently disabled (excluded from query)

NOTE

Only ACTIVATED webhooks are queried by WebhookEventHandlerHelper. Changing a webhook status to DEACTIVATED or ARCHIVED immediately stops event delivery without deleting the configuration.

7. PaymentSocketEventService

Alongside webhook dispatch, payment events are broadcast via WebSocket for real-time consumers (e.g., POS terminals).

Source: src/services/payment-socket-event.service.tsExtends: BaseSocketEventService (from @nx/core) Binding key for emitter: @nx/payment/websocket-emitter

7.1. Methods

MethodSignatureDescription
notifyTransactionUpdate({ payload: TTransactionEventPayload }) → voidExtract merchantId, compute rooms, send transaction data (fire-and-forget)
notifyAttemptUpdate({ payload: TAttemptEventPayload }) → voidExtract merchantId, compute rooms, send attempt data (fire-and-forget)
sendToRoom({ room, topic, data }) → Promise<void>Send to a specific WebSocket room (inherited)
isReady() → booleanCheck if emitter is available (inherited)

7.2. Room Routing

Transaction eventsPaymentWebSocketRooms.getTransactionRooms():

RoomPattern
Merchantwr:observation/merchants/{merchantId}
Merchant transactionswr:observation/merchants/{merchantId}/transactions
Specific transactionwr:observation/transactions/{transactionId}

Attempt eventsPaymentWebSocketRooms.getPaymentAttemptRooms():

RoomPattern
Merchantwr:observation/merchants/{merchantId}
Merchant attemptswr:observation/merchants/{merchantId}/payment-attempts
Specific attemptwr:observation/payment-attempts/{attemptId}
Transaction attemptswr:observation/transactions/{transactionId}/payment-attempts (optional)

7.3. Topics

ConstantValueUsed For
PaymentWebSocketTopics.TRANSACTIONws:observation.payment.transactionAll transaction lifecycle events
PaymentWebSocketTopics.ATTEMPTws:observation.payment.payment-attemptAll attempt lifecycle events

7.4. Emitter Resolution

BaseSocketEventService resolves the WebSocketEmitter lazily from the DI container using the emitterBindingKey passed in the constructor. The emitter is bound by ApplicationWebSocketComponent during component initialization.

typescript
export class PaymentSocketEventService extends BaseSocketEventService {
  constructor(
    @inject({ key: CoreBindings.APPLICATION_INSTANCE })
    application: BaseApplication,
  ) {
    super(application, {
      scope: PaymentSocketEventService.name,
      emitterBindingKey: PaymentWebSocketBindingKeys.WEBSOCKET_EMITTER,
    });
  }
}
DocumentDescription
Overview & SetupArchitecture, domain model, components, services, environment variables
Configuration & CredentialsAES-256-GCM encryption, credential lookup via Drizzle, seeded data
DeploymentTraefik routing, Docker config, webhook path rewrite
Core PackageWebhookConfig schema, Configuration schema, CryptoUtility

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