Webhook Dispatch
1. Overview
The webhook dispatch system receives MQ-Pay events via
WebhookEventHandlerHelper(implementsIPaymentEventHandler), queriesWebhookConfigRepositoryfor active webhook subscriptions matching the event type, and fans out HTTP POST requests viaWebhookDispatcherServicewith 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
| Event | Constant | Payload Type | Webhook Dispatch | WebSocket | Trigger |
|---|---|---|---|---|---|
mq-pay:transaction.created | TRANSACTION_CREATED | TTransactionEventPayload | Skipped | Yes | New transaction + attempt created |
mq-pay:transaction.settled | TRANSACTION_SETTLED | TTransactionEventPayload | Yes | Yes | Transaction fully settled |
mq-pay:transaction.cancelled | TRANSACTION_CANCELLED | TTransactionEventPayload | Yes | Yes | All attempts cancelled on NEW transaction |
mq-pay:attempt.created | ATTEMPT_CREATED | TAttemptEventPayload | Skipped | Yes | New payment attempt created |
mq-pay:attempt.sent | ATTEMPT_SENT | TAttemptEventPayload | Skipped | Yes | Attempt sent to provider (QR available) |
mq-pay:attempt.success | ATTEMPT_SUCCESS | TAttemptEventPayload | Yes | Yes | IPN confirms successful payment |
mq-pay:attempt.failed | ATTEMPT_FAILED | TAttemptEventPayload | Yes | Yes | Provider call failed |
mq-pay:attempt.expired | ATTEMPT_EXPIRED | TAttemptEventPayload | Yes | Yes | QR expired, no payment received |
mq-pay:attempt.cancelled | ATTEMPT_CANCELLED | TAttemptEventPayload | Yes | Yes | Attempt 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)
| Event | Status |
|---|---|
mq-pay:refund.success | Commented out |
mq-pay:refund.failed | Commented out |
Payload Types
// 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():
const eventHandler = new WebhookEventHandlerHelper({
webhookConfigRepository, // WebhookConfigRepository from DI
dispatcherService, // WebhookDispatcherService from DI
socketEventService, // PaymentSocketEventService from DI
});4.2. IPaymentEventHandler Interface
| Method | Return | Description |
|---|---|---|
handle(opts: { payload: TMQPayEventPayload }) | Promise<void> | Receives event, dispatches webhooks + WS notifications |
isReady() | boolean | Always returns true |
getName() | string | Returns 'WebhookEventHandlerHelper' |
4.3. handle() Implementation
The handle() method is a thin orchestrator that delegates to two private methods:
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
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
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
dispatch<PayloadType>(opts: {
webhookConfig: TWebhookConfig;
eventType: string;
payload: PayloadType;
}): voidSteps:
- Parse
webhookConfig.metadatathroughWebhookConfigMetadataSchema.parse()to gettimeoutMsandmaxRetrieswith Zod defaults - Build request body:
{ eventType, timestamp: Date.now(), payload } - Build headers:
Content-Type,X-Webhook-Timestamp,X-Webhook-Event-Type, plus any custom headers fromwebhookConfig.headers - 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| Attempt | Delay Range | Cumulative |
|---|---|---|
| 0 (initial) | Immediate | 0ms |
| 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:
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 Condition | Message Format |
|---|---|
| Connection timeout | Timeout after {timeout}ms |
| HTTP error response | HTTP {status}: {statusText} |
| Network/other error | error.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
{
"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
| Header | Value | Source |
|---|---|---|
Content-Type | application/json | Hardcoded via HTTP.HeaderValues.APPLICATION_JSON |
X-Webhook-Timestamp | String(Date.now()) | Generated per dispatch call |
X-Webhook-Event-Type | Event type string | From 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
| Column | DB Column | Drizzle Type | Default | Constraints | Notes |
|---|---|---|---|---|---|
id | id | text | Snowflake | PK | Generated by generateIdColumnDefs |
name | name | text | — | NOT NULL | Display name for the webhook |
url | url | text | — | NOT NULL | Target HTTP endpoint URL |
eventTypes | event_types | text[] (array) | — | NOT NULL | Array of MQ-Pay event type strings |
status | status | text | ACTIVATED | — | TWebhookConfigStatus enum |
headers | headers | jsonb | {} | — | Record<string, string> — custom HTTP headers |
metadata | metadata | jsonb | {timeoutMs: 30000, maxRetries: 3} | — | Dispatch configuration |
createdAt | created_at | timestamp | Auto | — | From generateTzColumnDefs |
modifiedAt | modified_at | timestamp | Auto | — | From generateTzColumnDefs |
6.2. Indexes
| Index Name | Column | Type |
|---|---|---|
IDX_WebhookConfig_status | status | B-tree |
6.3. Metadata Schema (Zod)
Defined in WebhookConfigMetadataSchema:
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),
});| Field | Type | Default | Min | Max | Used By |
|---|---|---|---|---|---|
timeoutMs | integer | 30,000 | 1,000 | 300,000 | axios.post() timeout parameter |
maxRetries | integer | 3 | 0 | 10 | handleFailure() 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):
| Status | Ignis Constant | Effect |
|---|---|---|
ACTIVATED | Statuses.ACTIVATED | Receives events (included in webhook query) |
DEACTIVATED | Statuses.DEACTIVATED | Temporarily disabled (excluded from query) |
ARCHIVED | Statuses.ARCHIVED | Permanently 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
| Method | Signature | Description |
|---|---|---|
notifyTransactionUpdate | ({ payload: TTransactionEventPayload }) → void | Extract merchantId, compute rooms, send transaction data (fire-and-forget) |
notifyAttemptUpdate | ({ payload: TAttemptEventPayload }) → void | Extract merchantId, compute rooms, send attempt data (fire-and-forget) |
sendToRoom | ({ room, topic, data }) → Promise<void> | Send to a specific WebSocket room (inherited) |
isReady | () → boolean | Check if emitter is available (inherited) |
7.2. Room Routing
Transaction events → PaymentWebSocketRooms.getTransactionRooms():
| Room | Pattern |
|---|---|
| Merchant | wr:observation/merchants/{merchantId} |
| Merchant transactions | wr:observation/merchants/{merchantId}/transactions |
| Specific transaction | wr:observation/transactions/{transactionId} |
Attempt events → PaymentWebSocketRooms.getPaymentAttemptRooms():
| Room | Pattern |
|---|---|
| Merchant | wr:observation/merchants/{merchantId} |
| Merchant attempts | wr:observation/merchants/{merchantId}/payment-attempts |
| Specific attempt | wr:observation/payment-attempts/{attemptId} |
| Transaction attempts | wr:observation/transactions/{transactionId}/payment-attempts (optional) |
7.3. Topics
| Constant | Value | Used For |
|---|---|---|
PaymentWebSocketTopics.TRANSACTION | ws:observation.payment.transaction | All transaction lifecycle events |
PaymentWebSocketTopics.ATTEMPT | ws:observation.payment.payment-attempt | All 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.
export class PaymentSocketEventService extends BaseSocketEventService {
constructor(
@inject({ key: CoreBindings.APPLICATION_INSTANCE })
application: BaseApplication,
) {
super(application, {
scope: PaymentSocketEventService.name,
emitterBindingKey: PaymentWebSocketBindingKeys.WEBSOCKET_EMITTER,
});
}
}8. Related Documentation
| Document | Description |
|---|---|
| Overview & Setup | Architecture, domain model, components, services, environment variables |
| Configuration & Credentials | AES-256-GCM encryption, credential lookup via Drizzle, seeded data |
| Deployment | Traefik routing, Docker config, webhook path rewrite |
| Core Package | WebhookConfig schema, Configuration schema, CryptoUtility |