UMMAH FLOWS · 12 Verified against code · Aug 2026

Event architecture · ingress & email flows

Events, Ingress & Notifications

Everything the platform learns from the outside world arrives through one narrow door, and every operational email leaves through another. This page walks the inbound event spine — HMAC verification against multiple keys, dedupe, at-least-once dispatch, and the full Adyen event-code inventory with what each code changes locally — then the SQS email pipeline that turns fifteen template types into delivered mail. One endpoint in, one queue out, and an idempotency stamp in between. Every claim below was verified in the platform code.

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
Verification and persistence happen synchronously in the webhook service; all business logic runs asynchronously in the worker. Adyen is acknowledged as soon as the row and job exist, so a slow handler never causes Adyen to time out and re-fire.

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.

TermWhat it means here
BP-native eventA Balance Platform webhook — JSON of shape {type, environment, data} covering transfers, balances, account holders, payment instruments and sweeps. Verified as one raw body.
Classic notificationAn Adyen classic-platform envelope of notificationItems[] — payments, refunds, disputes, account events. Each item is verified independently.
HmacSignatureThe header carrying the BP-native raw-body HMAC. The ingress tries it against three configured keys and accepts on any match.
Canonical projectionThe fixed subset of a classic item's fields that Adyen signs. Per-item verification recomputes the HMAC over this projection, not the raw JSON.
dedupeKeyThe 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.
jobIdsha256(dedupeKey). BullMQ refuses a second job with the same id, so concurrent enqueues of one event collapse to one job.
processedAtThe idempotency stamp on the event row — checked at ingress (stamped redeliveries are dropped) and re-checked by the worker before dispatching.
At-least-onceThe 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.
PaymentEventA timeline row written for every classic payment event, raw payload included — the per-transaction audit trail shown in both portals.
AdyenTransferLegOne booked transfer leg from balancePlatform.transfer.* — the raw material from which settlement is derived, since no settlement webhook exists.
NotificationA fully rendered email row (type, to, subject, html, text) that doubles as the delivery job and the audit record. Status via NotificationStatus.
Visibility timeoutSQS'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

Adyen Sumsub Webhook service Postgres Redis · BullMQ

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 the HmacSignature header. The ingress computes the HMAC against three configured keys — hmacKey (the Configuration webhook), transferWebhookHmacKey (ADYEN_TRANSFER_WEBHOOK_HMAC) and balanceWebhookHmacKey (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, against hmacKey and paymentHmacKey (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.

Verdict · soundOne door per provider, verification at the edge, and nothing but persistence and enqueueing in the synchronous path. The three-key acceptance is a deliberate consolidation, not a weakness — each key is still required to match exactly, and an unverifiable payload never reaches the queue.

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;
External provider Ummah pipeline step Terminal success Retry path Decision / no-op
Three duplicate filters in sequence: the ingress drops redeliveries of stamped rows, the jobId collapses concurrent enqueues, and the worker re-checks the stamp just before dispatch. None of them is sufficient alone — a retry after a partial failure can still re-run a handler, which is why the fourth filter is the handlers themselves.

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.

Verdict · disciplined but unprovenAt-least-once with idempotent handlers is the right architecture, and the handlers audited so far are genuine upserts and guarded state machines. But the safety property lives in every handler, not in the pipe — one non-idempotent addition (an increment, an unconditional email) would silently double on redelivery. Nothing currently tests that invariant; see the gap analysis.

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

Payment lifecycle event codes and their local effects
Event codeLocal effectFan-out
AUTHORISATIONSuccess → Transaction AUTHORIZED + authorizedAt; card token captured into StoredPaymentMethod from additionalData['recurring.recurringDetailReference']. Failure → FAILED + a DECLINE fee charge is booked.payment.authorized / payment.failed
CAPTUREStamps 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_FAILEDCapture-failure branch of the same state machine.—
CANCELLATION · CANCEL_OR_REFUNDTransaction → CANCELLED.payment.cancelled
REFUNDAccumulates 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_REVERSEDRefund failure branches of the same settlement logic.refund.failed
RECURRING_CONTRACTTokenisation 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's pspReference), local earmark taken in BalanceSnapshot.disputeReservedMinor, chargeback fee booked, dispute.opened webhook + email.
  • Evidence: REQUEST_FOR_INFORMATION, INFORMATION_SUPPLIED — drive the RFI stage and the dispute.evidence_required event.
  • 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 dispute WON/LOST with dispute.won/dispute.lost events and emails; a loss triggers the recovery waterfall and, if the platform absorbs the remainder, the recovery.written_off email.

5.3 · Balance Platform family

BP-native events (and their classic twins) and their local effects
EventLocal effect
balancePlatform.accountHolder.created/updatedMerchant ACTIVE/FAILED, derived from capabilities.receivePayments.allowed and verificationStatus 'invalid' — deliberately never from accountHolder.status.
balancePlatform.balanceAccount.created/updatedIdempotent backfill of Merchant.adyenBalanceAccountId.
balancePlatform.balanceAccount.balance.updatedBalanceSnapshot upsert — available/balance/reserved/pending minor units; stale writes dropped by asOf comparison.
balancePlatform.paymentInstrument.created/updatedStamps BankAccount.verifiedAt.
balancePlatform.transfer.created/updatedRouted 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/deletedLogged 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.UPDATEDClassic-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;
Adyen event Ummah reconciliation Verified settlement Unverified promotion Mismatch alarm
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.
Verdict · inference, not evidenceDeriving settlement from leg sums is exact for same-currency payments and self-alarming on divergence — a strong design. But FX-converted settlements are promoted to 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

Backend renderer SQS Notification service SMTP Recipients

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
The SQS message is intentionally tiny — a pointer, not a payload. Retries therefore always re-read the canonical row, and a template fix deployed between attempts takes effect on the next retry without touching the queue.
The fifteen notification types and what fires them
FamilyTypesFired by
Onboardingonboarding.succeeded · onboarding.failedKYC/provisioning outcome from the Sumsub processor and provisioning chain.
Payoutspayout.requested · payout.settled · payout.failedPayout request intake; transfer-leg resolution of the bank transfer.
Refundsrefund.pending_approval · refund.completed · refund.failedOps-approval queueing and the REFUND/REFUND_FAILED webhooks — see Refunds & Approvals.
Integritysplit.mismatchLive leg reconciliation divergence, and the daily reconciliation sweep.
Disputesdispute.opened · dispute.evidence_required · dispute.won · dispute.lostThe dispute event family in Section 5.2.
Recoveryrecovery.written_offThe recovery waterfall's ABSORBED tranche — the platform ate a chargeback loss.
Opsreconciliation.alertA DEGRADED reconciliation run, prompting a manual re-run.
Verdict · soundRender-before-queue is the right call: one template source, retries that cannot drift from the audit record, and a fallback path that reuses everything. The five-attempt cap keeps a poisoned message from spinning forever; the open question is what happens to a 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

Endpoints, queues and their guards
SurfaceGuard / semanticsPurpose
POST /webhooks/adyenBP-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.adyenjobId = 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 queueMessage {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/eventsStaff 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) — unique dedupeKey, rawPayload, processedAt; SumsubWebhookEvent (schema:752) is its Sumsub twin.
  • AdyenTransferLeg (schema:1477-1485) — unique adyenTransferId, category, platformPaymentType (BalanceAccount|Commission|AdditionalCommission|PaymentFee), pspPaymentReference, amount, balance account, direction, status.
  • Notification (schema:2168) with NotificationStatus (schema:2162) — the rendered email, its recipient and delivery state; PaymentEvent rows 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.

P1

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.

P1

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.

P1

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.

P2

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.