Section 1
The event spine in one view
Ummah never asks Adyen "what happened?" — Adyen tells it. A dedicated ingress service (ummah-platform-webhook) receives every inbound webhook, verifies its HMAC signature, persists it as a row with a unique dedupe key, and enqueues a BullMQ job for the worker fleet (ummah-platform-worker) to process. The same persist-then-enqueue shape serves Sumsub KYC events. On the outbound side, the backend renders every operational email into a Notification row and hands only its id to SQS for the notification service to deliver.
Adyen webhook ingress pipeline
FIG 1 · ingress
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13px","primaryColor":"#FBE5D6","primaryTextColor":"#00273A","primaryBorderColor":"#E86C2B","lineColor":"#8195A1","actorBkg":"#FFFFFF","actorBorder":"#D2D8DE","actorTextColor":"#00273A","actorLineColor":"#D2D8DE","signalColor":"#33505F","signalTextColor":"#33505F","activationBkgColor":"#FBE5D6","activationBorderColor":"#E86C2B","noteBkgColor":"#FEF3DC","noteBorderColor":"#E8D5A8","noteTextColor":"#00273A","labelBoxBkgColor":"#F1F4F8","labelBoxBorderColor":"#D2D8DE","loopTextColor":"#33505F"},"sequence":{"mirrorActors":false,"actorMargin":46,"messageMargin":30,"boxMargin":8}}}%%
sequenceDiagram
participant AD as Adyen
participant WH as Webhook service
participant PG as Postgres
participant MQ as Redis · BullMQ
participant WK as Worker dispatcher
AD->>WH: POST /webhooks/adyen
WH->>WH: HMAC verify — HmacSignature or per-item
WH->>PG: persist AdyenWebhookEvent + dedupeKey
alt row already has processedAt
WH-->>AD: ack — redelivery dropped
else fresh event
WH->>MQ: enqueue webhook-in.adyen, jobId = sha256(dedupeKey)
WH-->>AD: ack
MQ->>WK: job {eventId, format}
WK->>PG: re-check processedAt
WK->>WK: dispatch / dispatchBalancePlatform
WK->>PG: stamp processedAt
Note over MQ,WK: throw → BullMQ exponential backoff, 3 attempts
end
AdyenWebhookEvent · SumsubWebhookEvent
The persisted envelope
Every inbound event becomes a row first: raw payload, a unique dedupeKey, and a processedAt stamp that starts empty. The row is simultaneously the inbound ledger, the dedupe register, and the staff event browser's data source.
dedupeKey → jobId
Dedupe by construction
BP-native events key on bp: + SHA-256 of the raw body; classic items key on eventCode:pspReference:success. The BullMQ jobId is the SHA-256 of the dedupe key, so two ingress heads receiving the same redelivery race harmlessly — the queue accepts the job once.
Notification
Job and audit log in one
The backend renders each email fully — type, recipient, subject, HTML and text bodies — into a Notification row before anything is queued. SQS carries only {notificationId}; the row itself is what gets retried, marked SENT or FAILED, and browsed by staff.
ummah-platform-webhook/src/webhooks/ingress.service.ts:18-144 · ummah-platform-worker/src/workers/webhook-in-adyen.processor.ts:15-86 · ummah-platform-backend/prisma/schema.prisma:1273, 752, 2168
Section 2
The vocabulary
Twelve terms carry this whole page. Everything else is composition.
| Term | What it means here |
|---|---|
| BP-native event | A Balance Platform webhook — JSON of shape {type, environment, data} covering transfers, balances, account holders, payment instruments and sweeps. Verified as one raw body. |
| Classic notification | An Adyen classic-platform envelope of notificationItems[] — payments, refunds, disputes, account events. Each item is verified independently. |
HmacSignature | The header carrying the BP-native raw-body HMAC. The ingress tries it against three configured keys and accepts on any match. |
| Canonical projection | The fixed subset of a classic item's fields that Adyen signs. Per-item verification recomputes the HMAC over this projection, not the raw JSON. |
dedupeKey | The unique key on the persisted event row: bp:+sha256(raw) for BP-native; eventCode:pspReference:success for classic items; classic:+sha256(item) when a classic item has no pspReference. |
jobId | sha256(dedupeKey). BullMQ refuses a second job with the same id, so concurrent enqueues of one event collapse to one job. |
processedAt | The idempotency stamp on the event row — checked at ingress (stamped redeliveries are dropped) and re-checked by the worker before dispatching. |
| At-least-once | The delivery contract: an event can reach its handler more than once (backoff retries, PSP redelivery), never zero times. Handlers are therefore idempotent upserts and state machines. |
PaymentEvent | A timeline row written for every classic payment event, raw payload included — the per-transaction audit trail shown in both portals. |
AdyenTransferLeg | One booked transfer leg from balancePlatform.transfer.* — the raw material from which settlement is derived, since no settlement webhook exists. |
Notification | A fully rendered email row (type, to, subject, html, text) that doubles as the delivery job and the audit record. Status via NotificationStatus. |
| Visibility timeout | SQS's retry mechanism: a message that is not deleted reappears after its timeout. The notification consumer rides this up to NOTIFY_MAX_ATTEMPTS (default 5), then marks the row FAILED. |
Section 3
Ingress & verification
All Adyen traffic — every product, every event family — lands on a single endpoint: POST /webhooks/adyen in ummah-platform-webhook. The controller distinguishes two wire formats and verifies each differently:
- BP-native (
{type, environment, data}): the raw request body is HMAC-signed by Adyen, with the signature in theHmacSignatureheader. The ingress computes the HMAC against three configured keys —hmacKey(the Configuration webhook),transferWebhookHmacKey(ADYEN_TRANSFER_WEBHOOK_HMAC) andbalanceWebhookHmacKey(ADYEN_BALANCE_WEBHOOK_HMAC) — and accepts on any match. Adyen configures Configuration, Transfer and Balance webhooks as separate subscriptions, each with its own secret; pointing all three at one endpoint and trying each key avoids running three routes. - Classic (
notificationItems[]): each item is verified individually by recomputing the HMAC over the item's canonical field projection, againsthmacKeyandpaymentHmacKey(ADYEN_PAYMENT_HMAC_KEY).
Sumsub has a twin door: its own controller in the same service (sumsub-webhook.controller.ts) verifies the Sumsub HMAC, persists a SumsubWebhookEvent row and enqueues to the worker's Sumsub processor, which runs the merchant KYC state machine — a GREEN review answer enqueues Adyen provisioning, RED fails the merchant. That downstream story lives on the onboarding page of this series; here it matters only that Sumsub events ride the identical persist–dedupe–stamp spine.
ummah-platform-webhook/src/webhooks/adyen-webhook.controller.ts:42-119 · ummah-platform-webhook/src/webhooks/ingress.service.ts:50-107 · ummah-platform-webhook/src/config/configuration.ts:202-205 · ummah-platform-webhook/src/webhooks/sumsub-webhook.controller.ts · ummah-platform-worker/src/workers/webhook-in-sumsub.processor.ts:1-16
Section 4
Dedupe & at-least-once delivery
Adyen and Sumsub both redeliver webhooks until acknowledged, and BullMQ retries failed jobs — so the same event will arrive more than once. The pipeline never promises exactly-once delivery; instead it makes duplicates cheap at three separate layers and requires every handler to be idempotent.
What happens to a duplicate
FIG 2 · dedupe
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart TD
RX["Verified event arrives"]:::adyen --> KEY["Compute dedupeKey
bp: + sha256 raw · or eventCode:psp:success"]:::ummah
KEY --> SEEN{"Row exists with
processedAt stamped?"}:::plain
SEEN -->|"yes"| DROP["Drop the redelivery"]:::plain
SEEN -->|"no"| ENQ["Persist event row +
enqueue jobId = sha256 of dedupeKey"]:::ummah
ENQ --> RECHK{"Worker re-checks
processedAt"}:::plain
RECHK -->|"stamped meanwhile"| DROP
RECHK -->|"fresh"| RUN["Dispatch to handler
idempotent upsert or state machine"]:::ummah
RUN -->|"success"| STAMP["Stamp processedAt"]:::sub
RUN -->|"throw"| RETRY["BullMQ exponential backoff ×3
then PSP redelivery re-enters"]:::warn
RETRY -.-> RECHK
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF;
classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF;
classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A;
classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A;
classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
The retry ladder is worth stating precisely. A handler that throws rides BullMQ's exponential backoff for three attempts. Beyond that, the event row exists but stays unstamped, and Adyen's or Sumsub's own redelivery schedule re-enters the pipeline at ingress — the unstamped row means the redelivery is not dropped, and a fresh job is enqueued. The system therefore converges as long as the underlying fault clears, without any manual replay machinery.
ummah-platform-webhook/src/webhooks/ingress.service.ts:18-144 · ummah-platform-worker/src/workers/webhook-in-adyen.processor.ts:56-62 · ummah-platform-backend/prisma/schema.prisma:1273 (AdyenWebhookEvent dedupeKey unique)
Section 5
The event-code inventory
The worker's dispatcher (AdyenWebhookService.dispatch for classic, dispatchBalancePlatform for BP-native) is the single routing table for everything Adyen says. Below is the complete handled set and what each code changes locally. Every classic payment event additionally writes a PaymentEvent timeline row carrying the raw payload, and fans out the corresponding merchant webhook.
5.1 · Classic payment family
| Event code | Local effect | Fan-out |
|---|---|---|
AUTHORISATION | Success → Transaction AUTHORIZED + authorizedAt; card token captured into StoredPaymentMethod from additionalData['recurring.recurringDetailReference']. Failure → FAILED + a DECLINE fee charge is booked. | payment.authorized / payment.failed |
CAPTURE | Stamps capturedMinor/capturedAt (never downgrades a transaction already SETTLED); success re-runs split reconciliation via reconcileForPsp. A failed capture on CAPTURE_PENDING lands EXPIRED or falls back to AUTHORIZED. | payment.captured |
CAPTURE_FAILED | Capture-failure branch of the same state machine. | — |
CANCELLATION · CANCEL_OR_REFUND | Transaction → CANCELLED. | payment.cancelled |
REFUND | Accumulates refundedMinor (→ REFUNDED when fully refunded); settles the Refund row COMPLETED/FAILED by adyenRefundPspReference; books the REFUND fee charge. Hand-off: Refunds & Approvals. | refund.completed |
REFUND_FAILED · REFUNDED_REVERSED | Refund failure branches of the same settlement logic. | refund.failed |
RECURRING_CONTRACT | Tokenisation event — upserts the StoredPaymentMethod against the Customer by merchant reference. | — |
5.2 · Dispute family
Every dispute code first runs handlePayment — Transaction → DISPUTED, with won/reversed outcomes restoring SETTLED — then DisputesIntakeService.ingest, whose applyDisputeEvent dedupes repeats. The full handled set:
- Opening:
CHARGEBACK,NOTIFICATION_OF_CHARGEBACK— dispute row created (anchored on the payment'spspReference), local earmark taken inBalanceSnapshot.disputeReservedMinor, chargeback fee booked,dispute.openedwebhook + email. - Evidence:
REQUEST_FOR_INFORMATION,INFORMATION_SUPPLIED— drive the RFI stage and thedispute.evidence_requiredevent. - Escalation:
SECOND_CHARGEBACK,PREARBITRATION_WON/LOST,SCHEME_ARBITRATION_WON/LOST. - Deadlines and outcomes:
CHARGEBACK_REVERSED,ISSUER_RESPONSE_TIMEFRAME_EXPIRED,DISPUTE_DEFENSE_PERIOD_ENDED— settle the disputeWON/LOSTwithdispute.won/dispute.lostevents and emails; a loss triggers the recovery waterfall and, if the platform absorbs the remainder, therecovery.written_offemail.
5.3 · Balance Platform family
| Event | Local effect |
|---|---|
balancePlatform.accountHolder.created/updated | Merchant ACTIVE/FAILED, derived from capabilities.receivePayments.allowed and verificationStatus 'invalid' — deliberately never from accountHolder.status. |
balancePlatform.balanceAccount.created/updated | Idempotent backfill of Merchant.adyenBalanceAccountId. |
balancePlatform.balanceAccount.balance.updated | BalanceSnapshot upsert — available/balance/reserved/pending minor units; stale writes dropped by asOf comparison. |
balancePlatform.paymentInstrument.created/updated | Stamps BankAccount.verifiedAt. |
balancePlatform.transfer.created/updated | Routed to TransferEventService: payout resolution (booked → SETTLED; failed/returned/cancelled/refused/error → FAILED, with payout.settled/payout.failed fan-out), split-leg upserts into AdyenTransferLeg, and mirroring of unmatched bank transfers into SweepExecution by source balance account. |
balancePlatform.balanceAccountSweep.created/updated/deleted | Logged only — the code says "persisted for now"; no local sweep rows are mirrored. See the gap analysis. |
BALANCE_PLATFORM.LEGAL_ENTITY.UPDATED · ACCOUNT_HOLDER.CREATED/UPDATED · ACCOUNT_HOLDER_VERIFICATION · BALANCE_ACCOUNT.CREATED/UPDATED · PAYMENT_INSTRUMENT.UPDATED | Classic-format account events, routed to the same handlers as their BP-native twins. |
5.4 · Settlement is derived from transfer legs
There is no settlement webhook — no SETTLED event code is consumed anywhere. Instead, balancePlatform.transfer.* events with category platformPayment are the booked split legs, upserted into AdyenTransferLeg keyed by categoryData.pspPaymentReference. reconcileSplits buckets the incoming BalanceAccount, Commission and AdditionalCommission legs (statuses booked/captured, never outgoing legs) and waits until they sum exactly to the captured or authorised amount — only then does the Transaction promote to SETTLED, with splitsReconciledAt stamped and the rolling reserve accrued. The economics of those legs — who gets what and why — are covered on Pricings, Splits & Fees; this page only cares that the legs arrive as events and are reconciled.
Settlement derivation from booked legs
FIG 3 · settlement
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13.5px","primaryColor":"#FFFFFF","primaryTextColor":"#00273A","primaryBorderColor":"#D2D8DE","lineColor":"#8195A1","secondaryColor":"#F1F4F8","tertiaryColor":"#F7F8FA","clusterBkg":"#F7F8FA","clusterBorder":"#E5E8EC","edgeLabelBackground":"#FFFFFF"},"flowchart":{"curve":"basis","nodeSpacing":36,"rankSpacing":48,"padding":10}}}%%
flowchart LR
TR["balancePlatform.transfer.*
category platformPayment"]:::adyen --> LEG["Upsert AdyenTransferLeg
key categoryData.pspPaymentReference"]:::ummah
PF["PaymentFee 0-marker legs"]:::plain -.->|"excluded"| BUCKET
LEG --> BUCKET["Bucket incoming booked legs
BalanceAccount · Commission · AdditionalCommission"]:::ummah
BUCKET --> SUM{"Legs sum equals
captured amount?"}:::plain
SUM -->|"exact"| SET["Transaction SETTLED
splitsReconciledAt + reserve accrual"]:::sub
SUM -->|"divergence"| MIS["splitsMismatch recorded
+ split.mismatch alert"]:::danger
SUM -->|"FX leg currency differs"| FX["SETTLED unverified
reserve accrual skipped"]:::warn
classDef ummah fill:#FBE5D6,stroke:#E86C2B,stroke-width:1.5px,color:#00273A;
classDef liable fill:#E86C2B,stroke:#B84E1F,stroke-width:1.5px,color:#FFFFFF;
classDef adyen fill:#00273A,stroke:#00273A,color:#FFFFFF;
classDef client fill:#E8F0FE,stroke:#1A73B8,stroke-width:1.5px,color:#00273A;
classDef sub fill:#E6F4EA,stroke:#1E8E3E,stroke-width:1.5px,color:#00273A;
classDef warn fill:#FEF3DC,stroke:#B26A00,stroke-width:1.5px,color:#00273A;
classDef danger fill:#FCE8E6,stroke:#C5221F,stroke-width:1.5px,color:#00273A;
classDef plain fill:#FFFFFF,stroke:#D2D8DE,stroke-width:1.5px,color:#00273A;
PaymentFee legs carry Adyen's real fee but are deliberately excluded from the verdict — the platform records a 0-value marker and lets Adyen book its own fee. A divergence raises splitsMismatch plus the split.mismatch admin email, and the 02:00 reconciliation sweep re-checks the same invariant daily.SETTLED without amount verification and skip reserve accrual, an acknowledged blind spot in the code (2026-08-18).ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:72-191, 210-348, 350-408 · ummah-platform-worker/src/modules/webhooks-in/transfer-event.service.ts:45-89, 229-311, 313-412 · ummah-platform-worker/src/modules/balances/balance-snapshot.service.ts:42-70 · ummah-platform-backend/prisma/schema.prisma:1477-1485
Section 6
The email pipeline
The backend is the single template authority: it renders all fifteen notification types into a Notification row — type, recipient, subject, HTML and text bodies — and posts a message containing only {notificationId} to SQS. Because the row is complete before queueing, it serves as the delivery job and the audit log in one; nothing about the email's content depends on the consumer.
ummah-platform-notification long-polls the queue (WaitTimeSeconds 20, batch size 10), loads each row by id, sends it through the SMTP mailer, and marks it SENT with sentAt. A failed send is simply not deleted from the queue: the message reappears after the visibility timeout, up to NOTIFY_MAX_ATTEMPTS (default 5), after which the row is marked FAILED. A direct-SMTP fallback path in the backend shares the exact same templates, so a queue outage degrades delivery, never content. Donor-facing invoice download emails from the invoices module ride this same pipeline.
Notification pipeline — render, queue, deliver
FIG 4 · email
%%{init:{"theme":"base","themeVariables":{"fontFamily":"Bricolage Grotesque, Inter, Segoe UI, sans-serif","fontSize":"13px","primaryColor":"#FBE5D6","primaryTextColor":"#00273A","primaryBorderColor":"#E86C2B","lineColor":"#8195A1","actorBkg":"#FFFFFF","actorBorder":"#D2D8DE","actorTextColor":"#00273A","actorLineColor":"#D2D8DE","signalColor":"#33505F","signalTextColor":"#33505F","activationBkgColor":"#FBE5D6","activationBorderColor":"#E86C2B","noteBkgColor":"#FEF3DC","noteBorderColor":"#E8D5A8","noteTextColor":"#00273A","labelBoxBkgColor":"#F1F4F8","labelBoxBorderColor":"#D2D8DE","loopTextColor":"#33505F"},"sequence":{"mirrorActors":false,"actorMargin":46,"messageMargin":30,"boxMargin":8}}}%%
sequenceDiagram
participant BE as Backend
participant PG as Notification row
participant SQ as SQS
participant NS as Notification service
participant SM as SMTP mailer
BE->>PG: render template — type, to, subject, html, text
BE->>SQ: publish {notificationId}
NS->>SQ: long-poll WaitTimeSeconds 20, batch 10
SQ-->>NS: up to 10 messages
NS->>PG: load row by notificationId
NS->>SM: send email
alt send succeeds
NS->>PG: status SENT + sentAt
NS->>SQ: delete message
else send fails
Note over SQ,NS: message reappears after visibility timeout
NOTIFY_MAX_ATTEMPTS 5 → row FAILED
end
Note over BE,SM: direct-SMTP fallback in the backend shares the same templates
| Family | Types | Fired by |
|---|---|---|
| Onboarding | onboarding.succeeded · onboarding.failed | KYC/provisioning outcome from the Sumsub processor and provisioning chain. |
| Payouts | payout.requested · payout.settled · payout.failed | Payout request intake; transfer-leg resolution of the bank transfer. |
| Refunds | refund.pending_approval · refund.completed · refund.failed | Ops-approval queueing and the REFUND/REFUND_FAILED webhooks — see Refunds & Approvals. |
| Integrity | split.mismatch | Live leg reconciliation divergence, and the daily reconciliation sweep. |
| Disputes | dispute.opened · dispute.evidence_required · dispute.won · dispute.lost | The dispute event family in Section 5.2. |
| Recovery | recovery.written_off | The recovery waterfall's ABSORBED tranche — the platform ate a chargeback loss. |
| Ops | reconciliation.alert | A DEGRADED reconciliation run, prompting a manual re-run. |
FAILED row afterwards — staff can see it, but no redrive is documented.ummah-platform-backend/src/core/notifications/notification-templates.ts:238-253 · ummah-platform-notification/src/consumer/notifications.consumer.ts:7-145 · ummah-platform-backend/prisma/schema.prisma:2168 (Notification), 2162 (NotificationStatus)
Section 7
Staff surfaces
Because every inbound event and every rendered email is a database row, observability is a read model, not a logging exercise.
api/admin/events
The event browser
Three tabs backed by one controller: inbound (the persisted AdyenWebhookEvent and SumsubWebhookEvent rows, dedupe key and processing stamp included), outbound (merchant webhook deliveries from the developer platform), and endpoints (the registered delivery targets and their health). This is the first stop when a merchant asks "did you receive the event?" — the answer is a row, or it is not.
Back-office · notifications
The notifications screen
Staff browse the Notification rows themselves — type, recipient, status, timestamps — via the core admin-notifications.controller. Since the row carries the full rendered subject and body, support can see exactly what a merchant was sent, not a reconstruction of it.
ummah-platform-backend/src/modules/events/events.controller.ts:24-160 · ummah-platform-backend/src/core/notifications/admin-notifications.controller.ts · ummah-platform-backoffice/app/(app)/ (events, notifications screens)
Section 8
Implementation notes
| Surface | Guard / semantics | Purpose |
|---|---|---|
POST /webhooks/adyen | BP-native: raw-body HMAC via HmacSignature vs hmacKey, transferWebhookHmacKey, balanceWebhookHmacKey. Classic: per-item canonical-projection HMAC vs hmacKey + paymentHmacKey. | Single ingress for every Adyen event, both wire formats. |
Sumsub ingress (sumsub-webhook.controller.ts) | Sumsub HMAC verification. | KYC applicant-review events → SumsubWebhookEvent. |
BullMQ webhook-in.adyen | jobId = sha256(dedupeKey); 3 attempts, exponential backoff; worker re-checks processedAt. | Asynchronous dispatch of Adyen events to handlers. |
Sumsub worker queue (webhook-in-sumsub.processor.ts) | Same persist–dedupe–stamp pattern. | Runs the merchant KYC state machine; GREEN enqueues provisioning. |
| SQS notifications queue | Message {notificationId}; WaitTimeSeconds 20; batch 10; visibility-timeout retries capped at NOTIFY_MAX_ATTEMPTS (default 5). | Email delivery hand-off to ummah-platform-notification. |
GET api/admin/events | Staff JWT + RBAC permissions. | Inbound / outbound / endpoints event browser. |
Admin notifications (admin-notifications.controller.ts) | Staff JWT + RBAC permissions. | Browse rendered Notification rows and statuses. |
Data model
AdyenWebhookEvent(schema:1273) — uniquededupeKey,rawPayload,processedAt;SumsubWebhookEvent(schema:752) is its Sumsub twin.AdyenTransferLeg(schema:1477-1485) — uniqueadyenTransferId, category,platformPaymentType(BalanceAccount|Commission|AdditionalCommission|PaymentFee),pspPaymentReference, amount, balance account, direction, status.Notification(schema:2168) withNotificationStatus(schema:2162) — the rendered email, its recipient and delivery state;PaymentEventrows form the per-transaction timeline.
Section 9
Gaps & recommendations
The spine is honest about its own limits — two of the four gaps below are flagged in code comments. Priorities: p0 correctness/money-risk, p1 commercial/ops friction, p2 polish.
Sweep lifecycle events are logged, not mirrored
balancePlatform.balanceAccountSweep.created/updated/deleted are received, verified and logged — the handler comment says "persisted for now" — but no local sweep rows are updated. Sweep executions are instead reconstructed from unmatched bank transfer events by source-balance-account guesswork. Fix: mirror sweep lifecycle events into local rows (noted as pending PR-7/8) so the sweeps a merchant sees are event-sourced, not inferred.
Redelivery safety rests entirely on handler idempotency — untested
The pipeline is at-least-once by design: backoff retries and PSP redelivery both re-run handlers, and the only protection is that every handler is an idempotent upsert or state machine. That invariant is convention, not contract — nothing replays events in CI to prove it. Fix: add a replay harness that dispatches every handled event code twice (and interleaved) against a seeded database and asserts identical end state.
FX settlements are promoted unverified
Because settlement is derived from leg sums and no Adyen FX rate is available, a settlement whose leg currency differs from the payment currency is promoted to SETTLED without amount verification and skips rolling-reserve accrual — a blind spot acknowledged in code (2026-08-18). Fix: flag FX-settled transactions for the daily reconciliation sweep rather than promoting them silently, and accrue reserve from the converted leg amount.
FAILED notifications have no redrive
After the five-attempt cap a Notification parks at FAILED. Staff can see the row on the notifications screen, and the direct-SMTP fallback covers queue outages — but no resend action or automatic redrive is documented for rows that exhausted their attempts. Fix: a back-office "resend" that re-publishes {notificationId}; the render-before-queue design makes this a one-liner.