Event Messaging & Webhook Delivery
mpac-pgw uses a Transactional Outbox pattern with AWS SNS/SQS to guarantee event delivery when PaymentIntent states change. Domain events are captured in the same database transaction as the state change, then published asynchronously to SNS for fan-out to SQS consumers.
System Interaction
The diagram below shows how payment events flow across the entire MPAC platform — from terminal to payment provider and back to client partners via webhook notifications.
┌──────────────────────────────────────────────────────────────────────────────┐
│ SmartPOS Terminals │
│ ┌─────────────┐ ┌─────────┐ │
│ │ Business App │ │ QR App │ │
│ └──────┬───────┘ └────┬────┘ │
│ └───────┬───────┘ │
└─────────────────┼────────────────────────────────────────────────────────────┘
│ payment request
▼
┌─────────────────────────────┐ ┌──────────────────────────────────────┐
│ mpac-smartpos │ │ Payment Providers │
│ ┌─────────────────────────┐ │ │ ┌────────┐ ┌───────┐ ┌───────────┐ │
│ │ svc-smarttab │ │ │ │ PayPay │ │OnePay │ │ ALIPAY+ │ │
│ └────────────┬────────────┘ │ │ └───┬────┘ └───┬───┘ └─────┬─────┘ │
└───────────────┼──────────────┘ └──────┼──────────┼───────────┼───────┘
│ pgw-sdk (HMAC) │ webhook │ webhook │
▼ │ /poll │ │
┌──────────────────────────────────────────────┼──────────┼───────────┼───────┐
│ mpac-pgw │ │ │ │
│ ┌──────────────────────┐ create QR / pay │ │ │ │
│ │ Payment API │ ──────────────────►│ │ │ │
│ │ │ ◄──────────────────┘──────────┘───────────┘ │
│ └──────────┬───────────┘ │
│ │ state change │
│ ▼ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ DB Transaction │ │
│ │ UPDATE payment_intent status │ │
│ │ INSERT outbox_event ──► Outbox Worker (polls 5s) │ │
│ └──────────────────────────────────┬──────────────────┘ │
└─────────────────────────────────────┼────────────────────────────────────────┘
│ publish
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ AWS SNS/SQS │
│ ┌───────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐ │
│ │ SNS: pgw-events │───►│ SQS: webhook-delivery│ │ SQS: internal-events│ │
│ │ (fan-out) │───►│ (terminal states) │ │ (all events) │ │
│ └───────────────────┘ └──────────┬──────────┘ └──────────────────────┘ │
└──────────────────────────────────────┼───────────────────────────────────────┘
│ Webhook Dispatcher
│ HTTP POST (HMAC-SHA256 signed)
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ Client Partners │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ mpac-smartpos │ │ KQR Client │ │ (future partners)│ │
│ │ (svc-smarttab) │ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘Flow summary:
- Terminal → SmartPOS → PGW — Android terminals send payment requests through
svc-smarttab, which calls mpac-pgw via HMAC-authenticated SDK - PGW → Provider — mpac-pgw creates QR codes or initiates payments with providers (PayPay, OnePay, ALIPAY+)
- Provider → PGW — Providers notify mpac-pgw of payment results via webhooks (or polling for PayPay). Each state change writes an outbox event atomically
- PGW → SNS → SQS — The outbox worker publishes events to SNS, which fans out to SQS queues
- Webhook Dispatcher → Partners — Terminal-state events (
succeeded,failed,cancelled) are delivered via signed HTTP POST to all registered client partners
Internal Architecture Overview
Event Flow by Source
Payment state changes originate from different sources but all flow through the same transactional outbox:
Domain Events
Every PaymentIntent state transition emits a domain event. Events are immutable value objects identified by ULID.
| Event Type | Trigger |
|---|---|
payment_intent.created | PaymentIntent created (requires_payment_method) |
payment_intent.requires_action | Confirm called — QR generated or external payment started |
payment_intent.processing | Payment is being processed |
payment_intent.succeeded | Payment completed |
payment_intent.failed | Payment failed |
payment_intent.cancelled | Payment cancelled |
payment_intent.cancelling | Cancellation/refund in progress (OnePay refund flow) |
provider_webhook.received | Provider webhook received (internal-only, never sent to partners) |
Cash / Manual Payments
When processing_mode = manual, the PaymentIntent is created directly in succeeded status. Only payment_intent.created is emitted — no separate succeeded event. The created event's payload carries the terminal status.
Transactional Outbox
How It Works
The outbox guarantees no events are lost — the event is written in the same database transaction as the state change (no dual-write problem).
BEGIN TRANSACTION
UPDATE payment_intents SET status = 'succeeded' ...
INSERT INTO outbox_events (id, event_type, aggregate_id, payload, status)
VALUES ('01J...', 'payment_intent.succeeded', '01H...', '{...}', 'pending')
COMMITOutbox Table
CREATE TABLE outbox_events (
id TEXT PRIMARY KEY, -- ULID
event_type TEXT NOT NULL,
aggregate_type TEXT NOT NULL DEFAULT 'payment_intent',
aggregate_id TEXT NOT NULL,
payload JSONB NOT NULL, -- full event data + metadata
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'published', 'failed')),
retry_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ
);Outbox Worker
| Behavior | Detail |
|---|---|
| Poll interval | 5 seconds |
| Batch size | 100 events per cycle |
| Concurrency safety | FOR UPDATE SKIP LOCKED — multiple Fargate instances won't process the same event |
| Retry | Up to 5 attempts; after 5 failures → status = 'failed' permanently |
| Cleanup | Daily job deletes published events older than 7 days. failed events are kept for investigation |
Future: Near Real-Time
A PostgreSQL NOTIFY/LISTEN trigger on outbox_events INSERT can wake the worker immediately instead of waiting for the next poll cycle — no schema change required.
Webhook Delivery
Delivery Flow
When a PaymentIntent reaches a terminal state (succeeded, failed, cancelled), the webhook dispatcher delivers the event to all active API keys with a configured webhook_url for the organization.
Webhook Payload
{
"id": "evt_01J...",
"type": "payment_intent.succeeded",
"created_at": "2026-03-23T10:00:00Z",
"data": {
"id": "pi_01H...",
"status": "succeeded",
"amount": 1500,
"currency": "JPY",
"store_id": "store_01...",
"merchant_ref_id": "ORDER-123",
"captured_at": "2026-03-23T10:00:00Z"
}
}Security Headers
| Header | Purpose |
|---|---|
X-MPS-Signature | HMAC-SHA256 signature (per-ApiKey webhook_secret) |
X-MPS-Event-ID | Event ID for partner-side deduplication |
X-MPS-Timestamp | Unix timestamp for replay protection |
Retry Policy
| Attempt | Backoff | Action on failure |
|---|---|---|
| 1st | Immediate | Retry via SQS visibility timeout |
| 2nd | ~5s | Retry |
| 3rd | ~25s | Retry |
| After 3rd | — | Moved to DLQ |
Partners can use GET /v1/payment_intents (HMAC auth) as a polling fallback for missed events.
Provider Webhook Processing
Non-PayPay providers use an async processing flow for incoming webhooks:
PayPay continues to use the existing polling manager unchanged. When polling detects a status change, UpdateStatus() writes the outbox event in the same transaction.
Infrastructure
Messaging Implementations
| Environment | Publisher | Subscriber |
|---|---|---|
Local dev (make dev) | In-memory channel | In-memory channel |
| Unit tests | Mock | Mock |
| Staging / Prod | SNS | SQS |
All implementations share the same vendor-agnostic interfaces — the backing technology can be swapped via config:
messaging:
driver: "memory" # memory | snsFile Structure
pgw/internal/
├── domain/event/
│ ├── event.go # DomainEvent, EventType constants
│ ├── publisher.go # EventPublisher interface
│ ├── subscriber.go # EventSubscriber, EventHandler interfaces
│ └── builder.go # Build DomainEvent from PaymentIntent
│
├── infrastructure/messaging/
│ ├── memory/ # In-memory (local dev)
│ ├── sns/ # AWS SNS/SQS (staging/prod)
│ └── outbox/ # Outbox worker + cleanup
│
└── infrastructure/webhook/
└── dispatcher.go # Reads events → POSTs to partner webhook_urlObservability
Metrics
| Metric | Type |
|---|---|
pgw.outbox.events_published_total | Counter (by event_type, status) |
pgw.outbox.pending_count | Gauge |
pgw.outbox.failed_count | Gauge |
pgw.outbox.publish_latency_seconds | Histogram (by event_type) |
pgw.webhook.delivery_total | Counter (by api_key_id, status) |
pgw.webhook.delivery_latency_seconds | Histogram (by api_key_id) |
Alerts
| Condition | Meaning |
|---|---|
pgw.outbox.pending_count > 500 for 5 min | Outbox worker stuck |
pgw.outbox.failed_count > 0 | Events failed permanently — needs investigation |
pgw.webhook.delivery_total{status="failed"} rate spike | Partner endpoint down |
| DLQ message count > 0 (CloudWatch) | Webhook delivery exhausted retries |