Section 1
What it is
Checkout & Payments is the platform's acceptance stack: five repos cooperating so that a merchant's server, a shopper's browser and Adyen each see exactly what they are allowed to see.
The design principle is a single sentence: every money decision is made on the server before the browser is involved. The merchant's server, holding an sk_ secret key, creates a checkout session; that call resolves and freezes the split plan for all eight payment methods into CheckoutSession.splitsSnapshot and returns a single-use cs_ token plus the merchant's pk_ publishable key. The browser — whether the embeddable web SDK or a hosted page — presents that token pair to the /sdk/checkout surface, where the backend injects the amount and the split plan itself. The browser chooses a payment method; it can never choose a price.
After authorisation the story becomes asynchronous: Adyen's webhooks land at the dedicated ummah-platform-webhook ingress (HMAC-verified, persisted, deduplicated), ride the BullMQ queue webhook-in.adyen, and the worker walks the Transaction through its lifecycle — settlement is inferred from Adyen's booked split legs, not from any settlement webhook.
The acceptance stack in one view
FIG 1 · hero
%%{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
MS["Merchant server
sk_ key"] -->|"POST /v1/checkout/sessions"| SES["CheckoutSession
splitsSnapshot · 8 FeeMethods"]
SES -->|"single-use cs_ + pk_"| BR["Shopper browser
Adyen Web SDK"]
BR -->|"POST /sdk/checkout/payments"| PAY["PaymentsService
amount + splits injected"]
PAY -->|"POST /payments"| AD["Adyen
authorise + auto-split"]
AD -.->|"webhooks · queue webhook-in.adyen"| WK["Worker
lifecycle + reconciliation"]
WK -->|"booked legs sum complete"| FIN["Transaction SETTLED"]
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;
class MS sub
class SES,PAY,WK ummah
class BR plain
class AD adyen
class FIN sub
CheckoutSession
The frozen intent
One row per payment attempt. Carries the single-use cs_ token, the purpose (payment or PM_UPDATE tokenisation), the customer link, and splitsSnapshot — a per-method split plan computed before any card is seen. Payment links, subscription links and MIT charges all mint one.
Transaction
The lifecycle record
Upserted with status INITIATED before the Adyen call, then driven entirely by webhooks. Carries feeMethod, the splitsApplied mirror, capturedMinor/refundedMinor, and a merchantReference of the form <merchantSlug>-<ref> so webhooks can resolve it.
StoredPaymentMethod
The saved card
Tokenised during checkout when the session has a customer and savePaymentMethod; the token lands via Adyen's RECURRING_CONTRACT webhook (and from additionalData on a successful authorisation). It powers POST /v1/charges MIT payments and subscription billing.
ummah-platform-backend/src/modules/checkout/checkout.controller.ts:24-38 · checkout.service.ts:91-99 · sdk.controller.ts:17-62 · prisma/schema.prisma:1052,1149,1637
Section 2
The vocabulary
Twelve terms carry this whole page. Economics terms (rates, legs, bearers) live on Pricings, Splits & Fees.
| Term | What it is | Where it lives |
|---|---|---|
sk_ key | Server-to-server secret key; Authorization: Bearer on the /v1 surface via ApiKeyGuard. Merchant status gates the whole surface: SUSPENDED blocks everything, INACTIVE allows reads only. | api-key.guard.ts:39-84 |
pk_ key | Publishable key identifying the merchant to the browser. Useless alone — it must be paired with a live session token, and it is rejected outright on /v1. | publishable-session.guard.ts |
cs_ token | Single-use checkout-session token returned by session create; sent as X-Ummah-Session beside X-Ummah-Publishable-Key. | sdk.controller.ts:17-62 |
splitsSnapshot | Frozen per-method split plans plus totalChargeMinor, computed at session create for all 8 fee methods — the browser never influences it. | checkout.service.ts:525-561, schema.prisma:1080 |
FeeMethod | The 8 pricing buckets: VISA, MASTERCARD, AMEX, DEBIT, CREDIT, APPLE_PAY, GOOGLE_PAY, OTHER. | pricing.constants.ts:36-45 |
brandToFeeMethod | Maps the SDK's BIN-lookup hint (paymentMethod.brand/type + fundingSource) to a FeeMethod: amex→AMEX, visa→VISA, mc/mastercard→MASTERCARD, wallets, debit/credit, else OTHER. | checkout.constants.ts:27-41 |
splitsApplied | The locally recorded split mirror written on Transaction before the Adyen call, re-written at split-aware capture, reconciled later against Adyen's booked legs. | payments.service.ts:199-232 |
captureMode | MANUAL turns the authorisation into a hold (additionalData.manualCapture 'true'); the default captures immediately. Set per session or by an "authorise only" payment link. | payments.service.ts:266-271 |
PublishableSessionGuard | Authenticates the pk_ + cs_ pair on every /sdk/checkout route; the origin-enforcement interceptor additionally checks the browser Origin against the merchant's registered domains. | publishable-session.guard.ts, origin-enforcement.interceptor.ts |
| MIT / ContAuth | Merchant-initiated transaction on a stored card — POST /v1/charges — capped by Merchant.mitAmountCapMinor (0 = uncapped). | checkout.controller.ts:40-57 |
PM_UPDATE | Session purpose for zero-value card tokenisation: amount 0, no splits, no Transaction row. Used by card-update and subscription links. | checkout.service.ts:200-268 |
RECURRING_CONTRACT | The Adyen classic webhook that lands a tokenised card as a StoredPaymentMethod against the Customer. | adyen-webhook.service.ts:161 |
Section 3
The payment flow
One session, two authenticated surfaces, one asynchronous truth. This is the canonical card payment from POST /v1/checkout/sessions to SETTLED.
3a · Creating the session (server side)
POST /v1/checkout/sessions sits behind ApiKeyGuard and @Idempotent() (optional Idempotency-Key header, replayed for 24 hours, 422 if the same key arrives with a different body). CheckoutService.createSession then:
- Gates the merchant —
gate.assertOperational— and resolves the payee: a Client passingonBehalfOfMerchantIdcreates the session for one of its sub-merchants. - Refuses unsplittable stores —
assertStoreIsSplitrequiressplitProfileId,adyenStoreIdandadyenBalanceAccountId, erroring with codestore_not_split. An unconfigured store would silently book 100% of the funds to Ummah's liable balance account. - Provisions balance accounts lazily —
ensureBalanceAccountfor the payee store, the Client store, and any tip beneficiary. - Freezes pricing —
buildSnapshotloops the 8FeeMethods, callingFeeResolverService.resolvePaymentFeethenAdyenSplitService.buildResolvedSplitsper method, and stores the result assplitsSnapshot. - Returns the single-use
cs_token and thepk_publishable key for the browser.
3b · Paying in the browser
The browser surface is four routes on /sdk/checkout, all guarded by PublishableSessionGuard and the domain-allowlist origin interceptor. GET client-key hands the Adyen Web client key and environment; POST paymentMethods proxies Adyen's method list for the session's store; the Adyen Web component mounts the card form and its BIN lookup reports the brand as the shopper types. On POST payments, PaymentsService.payments derives feeMethod = brandToFeeMethod(...) from that pre-authorisation hint, picks snapshot.byMethod[feeMethod] ?? byMethod['OTHER'], upserts the Transaction (INITIATED, splitsApplied recorded) and only then calls Adyen — amount from snapshot.totalChargeMinor, store set to adyenStoreId, idempotency key pay-<sessionId>. 3DS2 runs natively in the component where possible; challenge and redirect flows finish through POST payments/details, which feeds the same applyPaymentResult.
The embeddable SDK (ummah-platform-web-sdk) wraps all of this as a React <UmmahCheckout> and a framework-agnostic mountUmmahCheckout(el, opts) — card plus Apple Pay and Google Pay, brand detection for per-method pricing, native 3DS2 in-component with a full-page redirect helper, and an environment switch of 'test'|'live' choosing the API host. The bundled Adyen Web library is invisible to integrators.
Full payment sequence — session to settlement
FIG 2 · sequence
%%{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 M as Merchant server
participant B as Shopper browser
participant U as Ummah backend
participant A as Adyen
participant W as Worker
M->>U: POST /v1/checkout/sessions
Note over U: assertStoreIsSplit · buildSnapshot
8 FeeMethod plans frozen into splitsSnapshot
U-->>M: single-use cs_ token + pk_ key
M-->>B: page mounts SDK with pk_ + cs_
B->>U: POST /sdk/checkout/paymentMethods
U->>A: /paymentMethods for the session store
A-->>U: available methods
U-->>B: card form mounts · BIN lookup hints brand
B->>U: POST /sdk/checkout/payments with brand hint
Note over U: feeMethod = brandToFeeMethod(hint)
Transaction INITIATED · splitsApplied recorded
U->>A: /payments — amount injected from snapshot
splits sent only for profile-less stores
alt 3DS2 challenge or redirect
A-->>U: action
U-->>B: SDK handles action
B->>U: POST /sdk/checkout/payments/details
U->>A: /payments/details
end
A-->>U: resultCode Authorised
U-->>B: success · Transaction AUTHORIZED
A--)W: AUTHORISATION + balancePlatform.transfer webhooks
Note over W: HMAC-verified at ingress, deduped,
queued on webhook-in.adyen
W->>W: reconcileSplits — booked legs vs mirror → SETTLED
ummah-platform-backend/src/modules/checkout/checkout.controller.ts:24-41 · checkout.service.ts:91-99,489-561 · sdk.controller.ts:17-62 · payments.service.ts:129-134,199-263,303-319 · common/interceptors/idempotency.interceptor.ts:20-60 · ummah-platform-web-sdk/src/index.ts · src/mount.ts
Section 4
Where splits attach
This page tracks where the split plan touches the payment flow; the economics — rates, legs, bearers, worked numbers — belong to Pricings, Splits & Fees.
Splits attach at four moments:
- Session create — the freeze.
buildSnapshotresolves a full split plan perFeeMethodand freezes all eight intosplitsSnapshot, so a rate change after session create cannot alter an in-flight payment. - Pay time — the selection.
PaymentsService.paymentspicks the plan for the BIN-lookup brand hint. For a store carrying aSplitProfilethe request sends nosplits[]— Adyen auto-splits from the store's own split configuration — and the platform records a locally recomputed mirror (buildProfileSplits, banker's rounding, chosen bysplitGroupFor(feeMethod):VISA_MC/AMEX/REMAINING). Only profile-less stores send the frozen explicit legs. - Capture time — the recompute. On a manual-capture hold,
CapturesServicere-records (and for explicit-splits payments re-sends) splits for the captured amount — Section 6. - Settlement — the audit. The worker's
TransferEventService.reconcileSplitscompares Adyen's bookedplatformPaymenttransfer legs againstsplitsApplied; a complete matching set promotes the transaction toSETTLED, a divergence stampssplitsMismatchand alarms the back office.
splitsApplied is a pre-authorisation prediction. The actual scheme returned in additionalData after authorisation is stored but the mirror is never recomputed from it — a wrong or missing brand hint records the wrong rate group while Adyen books the real one, and only the reconciliation alarm catches it.ummah-platform-backend/src/modules/checkout/payments.service.ts:184-232,255-263,404-461,672-676 · core/adyen/adyen-split.service.ts:259-350,498-619 · ummah-platform-worker/src/modules/webhooks-in/transfer-event.service.ts:246-470
Section 5
Transaction lifecycle
Ten statuses, all webhook-driven after the initial upsert. There is no settlement webhook: SETTLED is inferred when Adyen's booked split legs sum exactly to the captured or authorised amount.
Transaction state machine
FIG 3 · states
%%{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
I["INITIATED"] -->|"AUTHORISATION ok"| AU["AUTHORIZED"]
I -->|"AUTHORISATION failed"| FA["FAILED"]
AU -->|"CAPTURE webhook"| CA["CAPTURED"]
AU -->|"manual capture submitted"| CP["CAPTURE_PENDING"]
CP -->|"CAPTURE ok"| CA
CP -->|"CAPTURE failed"| EX["EXPIRED"]
AU -->|"CANCELLATION"| CN["CANCELLED"]
CA -->|"legs sum complete"| SE["SETTLED"]
AU -->|"legs complete first"| SE
SE -->|"CHARGEBACK"| DI["DISPUTED"]
DI -->|"won or reversed"| SE
SE -->|"refundedMinor reaches total"| RF["REFUNDED"]
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;
class I,CN,RF plain
class AU,CP,EX warn
class CA,SE sub
class FA,DI danger
| Status | Set by | Notes |
|---|---|---|
INITIATED | PaymentsService.payments upsert, before the Adyen call | Session moves to IN_PROGRESS; splitsApplied already recorded. |
AUTHORIZED | applyPaymentResult on resultCode Authorised; confirmed by the AUTHORISATION webhook | Stores actual scheme/fundingSource from additionalData; token capture for saved cards. |
FAILED | Failed AUTHORISATION webhook | Also triggers the DECLINE fee charge (decline-<txnId>-<psp>). |
CAPTURE_PENDING | CapturesService.capture before submitting to Adyen | Submit failure restores AUTHORIZED. |
CAPTURED | CAPTURE webhook success | Sets capturedMinor/capturedAt; never downgrades a row already SETTLED. |
EXPIRED | Failed capture on a CAPTURE_PENDING hold | Or back to AUTHORIZED, depending on the failure. |
CANCELLED | CANCELLATION webhook | Books hold releases. |
SETTLED | Worker reconcileSplits — complete booked-leg set | Also accrues rolling reserve. FX-converted settlements promote with amounts explicitly unverified. |
DISPUTED | CHARGEBACK family webhooks | Won / reversed outcomes restore SETTLED. |
REFUNDED | REFUND webhooks once refundedMinor covers the total | The refund path itself is on Refunds & Approvals. |
ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:143-201,210-348 · transfer-event.service.ts:246-470,313-366 · ummah-platform-backend/src/modules/checkout/payments.service.ts:404-461
Section 6
Holds & capture
A session (or an "authorise only" payment link) with captureMode MANUAL pins additionalData.manualCapture 'true' on the Adyen call: the authorisation becomes a hold, displayed with authorisedUntil of roughly seven days — a display value, not an enforced expiry.
The split-aware path is POST /api/portal/payments/:id/capture or its admin twin, both landing in CapturesService.capture. It validates hard: loadHold rejects anything whose captureMode is not MANUAL, the row must be AUTHORIZED, the amount must not exceed the authorisation, and there is one capture per authorisation. Then splitsForCapture branches:
- Profile store — re-record
buildProfileSplits(capturedAmount)using the transaction's scheme group, and send nothing: Adyen's store split configuration recomputes the split itself at capture. - Explicit-splits payment —
scaleSplitsProportionallyproduces pro-rata legs which are recorded and sent assplits[]onPOST /payments/{psp}/captures, because a capture whose amount differs from the authorisation, sent without splits, books everything to the liable account.
Worked shape: a £100.00 hold captured at £60.00 re-records the mirror for £60.00 (with splitsReconciledAt nulled and splitsMismatch cleared), transitions to CAPTURE_PENDING under idempotency key capture-<txnId>, and lets the CAPTURE webhook decide the truth. A submit failure restores AUTHORIZED. Releases go through CapturesService.release, confirmed by the CANCELLATION webhook.
Capture-hold flow — the split-aware path and the bypass
FIG 4 · capture
%%{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
H["AUTHORIZED hold
additionalData.manualCapture true"] --> SAFE["Portal / admin capture
CapturesService.capture"]
H --> BY["sk_ POST /v1/payments/:id/capture"]
SAFE --> Q{"store has a
SplitProfile?"}
Q -->|"yes"| MIR["re-record buildProfileSplits
send no splits"]
Q -->|"no"| SCL["scaleSplitsProportionally
record and send splits"]
MIR --> CP2["CAPTURE_PENDING
idempotency capture-txnId"]
SCL --> CP2
CP2 --> AD2["Adyen POST captures"]
BY --> RAW["raw adyen.checkout.capture
no guard · no splits · no mirror"]
AD2 -.->|"CAPTURE webhook"| OK2["CAPTURED → SETTLED"]
RAW -.->|"CAPTURE webhook"| OK2
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;
class H warn
class SAFE ummah
class Q,MIR,SCL plain
class CP2 warn
class AD2 adyen
class BY,RAW danger
class OK2 sub
6a · The sk_ bypass
POST /v1/payments/:id/capture (and its sibling POST /v1/payments/:id/cancel) on the merchant API surface still route to PaymentsService.capture, which submits adyen.checkout.capture(pspReference, ...) directly with reference cap-<txn.id> — no MANUAL/AUTHORIZED guard, no splits sent, no splitsApplied re-record, no CAPTURE_PENDING transition, and no Adyen idempotency key. A partial capture through this route on an explicit-splits (profile-less) payment makes Adyen book the entire captured amount to the liable account, while the recorded mirror stays at the authorisation amount.
/v1 capture and cancel are routed through CapturesService, every API-integrated merchant with manual capture is one partial capture away from mis-booked funds and a guaranteed splitsMismatch alarm.ummah-platform-backend/src/modules/captures/captures.service.ts:60-146,216-297 · portal-captures.controller.ts:20-33 · admin-captures.controller.ts:21-35 · checkout.controller.ts:90-106 · payments.service.ts:608-650
Section 7
Saved cards & merchant-initiated charges
Cards are tokenised inside the normal checkout; charging them later is a first-class server-side flow with its own cap — and its own recorded-rate gap.
Tokenisation during checkout
A session carrying customerId and savePaymentMethod adds shopperReference and storePaymentMethod to the Adyen call. The token lands twice over: a successful AUTHORISATION carries additionalData['recurring.recurringDetailReference'], and the RECURRING_CONTRACT webhook upserts the StoredPaymentMethod against the Customer. Merchants browse chargeable cards through the checkout service's saved-card lookup and the /v1/customers surface.
MIT charges — POST /v1/charges
Behind sk_ auth and @Idempotent(), CheckoutService.mitCharge reuses the entire session machinery — a real CheckoutSession with a frozen splitsSnapshot — then pays as ContAuth with paymentMethod {type:'scheme', storedPaymentMethodId}. Merchant.mitAmountCapMinor caps the amount (0 = uncapped). Subscription billing drives the same path: the worker's subscription-tick queue calls the backend's signed internal endpoint (X-Internal-Signature, idempotent per billing period), which lands in mitCharge.
The gap: that payment method object has no brand, so brandToFeeMethod('scheme') resolves to OTHER and the mirror records REMAINING-group rates regardless of the stored card's real scheme — while Adyen books the real scheme's rule. On every per-scheme-priced profile store, MIT charges rely on the splitsMismatch alarm rather than a correct mirror.
StoredPaymentMethod already knows the card's brand — pass it into brandToFeeMethod, or recompute the mirror post-authorisation.7a · PM_UPDATE zero-value sessions
Card refresh without a charge uses a session of purpose PM_UPDATE: the snapshot is stored empty (byMethod: {}) and PaymentsService.pmUpdatePayments sends amount 0 with storePaymentMethod and recurringProcessingModel 'Subscription'. A store is still required — store-less payments 403 on platform accounts — but there are deliberately no splits and no Transaction row: nothing to account for, nothing to reconcile. Card-update links (/u/[token]) and subscription links both ride this.
ummah-platform-backend/src/modules/checkout/checkout.controller.ts:40-57 · checkout.service.ts:200-268,278-365 · payments.service.ts:272-291,331-385 · ummah-platform-worker/src/workers/subscription-tick.processor.ts:86-130 · ummah-platform-worker/src/modules/webhooks-in/adyen-webhook.service.ts:161
Section 8
Hosted checkout pages
The Next.js app ummah-platform-checkout serves three token-addressed experiences, all funnelling into the same session machinery as the SDK.
/l/[token]
Payment link
Resolves GET checkout/links/:token for link details and merchant branding, then POST checkout/links/:token/session mints a normal session via CheckoutService.createSession with the link's amount, store, line items and capture mode, tagged metadata {paymentLinkId}. "Authorise only" links set captureMode MANUAL; the link flips to PAID lazily once a session with that metadata holds an authorised transaction. /l/[token]/return closes redirect flows.
/sub/[token]
Subscription link
SubscribeFlow walks GET checkout/subscribe/:token → POST :token/validate-coupon → POST :token/session (initial payment plus card tokenisation) → POST :token/complete, which creates the subscription. Recurring billing then runs through the MIT path in Section 7.
/u/[token]
Card update
UpdateCard drives GET checkout/pm-update/:token → POST :token/session (a PM_UPDATE zero-value authorisation) → POST :token/complete. Minted from the API by the pm-update controller; used for expiring-card upkeep on subscriptions.
All three tokens are stored only as environment-bound peppered hashes — the raw token is returned once at creation and is irrecoverable, so "re-copy the URL" is really rotation: POST :id/regenerate mints a fresh token and the old URL stops working. Link redemption requires status ACTIVE. The shared HostedCheckout component wraps the same checkout-controller core the web SDK uses, so splits, 3DS2 and lifecycle behave identically to Section 3.
ummah-platform-checkout/app/l/[token]/LinkCheckout.tsx · app/sub/[token]/SubscribeFlow.tsx · app/u/[token]/UpdateCard.tsx · ummah-platform-backend/src/modules/payment-links/payment-links.service.ts:38-133,229-289 · public-payment-links.controller.ts:21-28 · pm-update-public.controller.ts:21-39 · subscription-links/public-subscribe.controller.ts:24-51
Section 9
Implementation notes
Every endpoint in the acceptance stack, by surface, with its guard. The /v1 surface additionally sits behind ApiExposureGuard — anything absent from the exposure catalog 404s.
| Endpoint | Guard / auth | Purpose |
|---|---|---|
POST /v1/checkout/sessions | ApiKeyGuard (sk_) + @Idempotent | Create session, freeze splitsSnapshot, return cs_ + pk_. |
POST /v1/charges | ApiKeyGuard (sk_) + @Idempotent | MIT charge on a stored card (ContAuth, mitAmountCapMinor). |
GET /v1/payments · GET /v1/payments/:id | ApiKeyGuard (sk_) | List and inspect transactions. |
POST /v1/payments/:id/capture | ApiKeyGuard (sk_) | Raw Adyen capture — bypasses CapturesService (see 6a). |
POST /v1/payments/:id/cancel | ApiKeyGuard (sk_) | Raw cancel — bypasses CapturesService.release. |
GET /sdk/checkout/client-key | PublishableSessionGuard + origin allowlist | Adyen Web client key and environment. |
POST /sdk/checkout/paymentMethods | PublishableSessionGuard + origin allowlist | Payment methods for the session's store. |
POST /sdk/checkout/payments | PublishableSessionGuard + origin allowlist | Pay — amount and splits injected server-side; Transaction upserted INITIATED. |
POST /sdk/checkout/payments/details | PublishableSessionGuard + origin allowlist | 3DS2 / redirect completion via applyPaymentResult. |
GET checkout/links/:token · POST .../session | public, token-addressed | Payment-link resolution and session mint. |
GET checkout/subscribe/:token · .../validate-coupon · .../session · .../complete | public, token-addressed | Hosted subscribe flow. |
GET checkout/pm-update/:token · .../session · .../complete | public, token-addressed | Zero-value card refresh (PM_UPDATE). |
POST /api/portal/payments/:id/capture | portal JWT + RBAC | Split-aware capture via CapturesService (release action alongside). |
POST /api/admin/payments/:id/capture | staff JWT + RBAC | Back-office twin of the portal capture. |
POST /internal/subscriptions/:id/tick | X-Internal-Signature HMAC | Worker-triggered subscription MIT charge, idempotent per period. |
Data model notes
CheckoutSession— status, purpose,splitsSnapshot(Json),customerId,savePaymentMethod; single-usecs_token.Transaction— status,feeMethod,splitsApplied/splitsReconciledAt/splitsMismatch,capturedMinor,refundedMinor,pspReference,merchantReference(<merchantSlug>-<ref>, capped at 80 chars).PaymentEventrows keep the raw webhook timeline.IdempotencyKey— store-and-replay for@Idempotentendpoints: scoped by method, path, actor and body hash; 24 h TTL; responses persisted only on success so failures stay retryable; fails open on infrastructure errors.- Adyen idempotency —
pay-<sessionId>on/payments,capture-<txnId>on split-aware captures; the sk_ capture bypass sends none. AdyenWebhookEvent— the ingress ledger: HMAC-verified, uniquededupeKey(eventCode:pspReference:successfor classic items), BullMQjobId= hash of the dedupe key,processedAtre-checked by the worker.
ummah-platform-backend/src/modules/api-keys/api-key.guard.ts:39-84 · api-catalog/api-exposure.guard.ts · common/interceptors/idempotency.interceptor.ts:20-60 · prisma/schema.prisma:833,1052,1178-1191,1273 · ummah-platform-webhook/src/webhooks/ingress.service.ts:18-144 · payments.service.ts:719-725
Section 10
Gaps & recommendations
Everything below is verified against current code, ordered by how much money it can misplace.
Route /v1 capture and cancel through CapturesService
POST /v1/payments/:id/capture and /cancel still call raw Adyen operations — no guards, no splits, no mirror re-record, no idempotency key. A partial capture on an explicit-splits payment books the full amount to the liable account. Fix: delegate both to CapturesService.capture/release, exactly as the portal and admin controllers already do.
Correct feeMethod/splitsApplied from post-auth truth
applyPaymentResult stores the actual scheme and funding source from additionalData but never recomputes the mirror; 3DS completions don't touch it either, and brand-less MIT charges always record REMAINING-group rates. Fix: recompute the mirror in applyPaymentResult (and seed MIT charges from the stored card's known brand), leaving splitsMismatch as a backstop rather than the primary mechanism.
Add a splitsApplied backfill job
Rows that missed both the /payments write and the capture re-record can never gain a mirror; refunds on them submit without splits, and the daily splits-awaiting-stale check only inspects rows that already have one. Fix: a worker job that reconstructs the mirror from booked AdyenTransferLeg rows.
Unify the two recorded-split dialects
'sub-net'/'client-fee'/'ummah-fee' legs coexist with 'seller-split'/'ummah-fixed'/'ummah-variable'; refund funding and capture scaling match both by reference string. A third producer that forgets a name silently degrades refunds to Adyen's default proportional funding. Fix: a versioned leg schema, consumed by role rather than by reference name.
Tips lose their routing on profile stores
buildResolvedSplits supports a tip leg, but profile stores send no explicit splits and the store split configuration has no tip concept — the tip books inside the seller remainder, and the beneficiary routing recorded in the snapshot is lost. Fix on the pricing side; economics on Pricings, Splits & Fees.
Make authorisedUntil honest
The hold's displayed expiry (~7 days) is decorative — nothing expires or alerts on it, and scheme auth validity varies. Fix: either drive EXPIRED from it with a sweep, or label it an estimate in both portals.