Skip to content

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:

  1. Terminal → SmartPOS → PGW — Android terminals send payment requests through svc-smarttab, which calls mpac-pgw via HMAC-authenticated SDK
  2. PGW → Provider — mpac-pgw creates QR codes or initiates payments with providers (PayPay, OnePay, ALIPAY+)
  3. Provider → PGW — Providers notify mpac-pgw of payment results via webhooks (or polling for PayPay). Each state change writes an outbox event atomically
  4. PGW → SNS → SQS — The outbox worker publishes events to SNS, which fans out to SQS queues
  5. 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 TypeTrigger
payment_intent.createdPaymentIntent created (requires_payment_method)
payment_intent.requires_actionConfirm called — QR generated or external payment started
payment_intent.processingPayment is being processed
payment_intent.succeededPayment completed
payment_intent.failedPayment failed
payment_intent.cancelledPayment cancelled
payment_intent.cancellingCancellation/refund in progress (OnePay refund flow)
provider_webhook.receivedProvider 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).

sql
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')
COMMIT

Outbox Table

sql
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

BehaviorDetail
Poll interval5 seconds
Batch size100 events per cycle
Concurrency safetyFOR UPDATE SKIP LOCKED — multiple Fargate instances won't process the same event
RetryUp to 5 attempts; after 5 failures → status = 'failed' permanently
CleanupDaily 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

json
{
  "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

HeaderPurpose
X-MPS-SignatureHMAC-SHA256 signature (per-ApiKey webhook_secret)
X-MPS-Event-IDEvent ID for partner-side deduplication
X-MPS-TimestampUnix timestamp for replay protection

Retry Policy

AttemptBackoffAction on failure
1stImmediateRetry via SQS visibility timeout
2nd~5sRetry
3rd~25sRetry
After 3rdMoved 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

EnvironmentPublisherSubscriber
Local dev (make dev)In-memory channelIn-memory channel
Unit testsMockMock
Staging / ProdSNSSQS

All implementations share the same vendor-agnostic interfaces — the backing technology can be swapped via config:

yaml
messaging:
  driver: "memory"   # memory | sns

File 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_url

Observability

Metrics

MetricType
pgw.outbox.events_published_totalCounter (by event_type, status)
pgw.outbox.pending_countGauge
pgw.outbox.failed_countGauge
pgw.outbox.publish_latency_secondsHistogram (by event_type)
pgw.webhook.delivery_totalCounter (by api_key_id, status)
pgw.webhook.delivery_latency_secondsHistogram (by api_key_id)

Alerts

ConditionMeaning
pgw.outbox.pending_count > 500 for 5 minOutbox worker stuck
pgw.outbox.failed_count > 0Events failed permanently — needs investigation
pgw.webhook.delivery_total{status="failed"} rate spikePartner endpoint down
DLQ message count > 0 (CloudWatch)Webhook delivery exhausted retries

MPAC — MP-Solution Advanced Cloud Service