UMMAH FLOWS · 11 Verified against code · Aug 2026

Developer platform · keys, docs, webhooks & SDK

Developer Platform

How a merchant's engineers integrate Ummah: the two-tier key model that keeps server secrets and browser credentials apart, the exposure catalog that makes the documentation and the enforcement the same file, replay-safe writes, signed outbound webhooks, and the origin allow-list that guards the embeddable SDK. Every guard name, header, and failure code on this page is the real one — verified in the platform code.

Section 1

The developer surface, in one view

The developer platform is everything a merchant's engineering team touches: API keys, generated API docs and a Postman collection, outbound webhooks, an origin allow-list for the browser SDK, and the SDK setup walkthrough — all managed from the portal's developers/ area and enforced by a small set of named guards in the backend.

The backend exposes four API surfaces, each with its own authentication contract. Two are session-based (the merchant portal and the back-office, both behind JWT and RolesGuard with MERCHANT / STAFF scopes). The other two are the developer platform proper: the public /v1 server-to-server API, authenticated by sk_ secret keys through ApiKeyGuard and allow-listed by ApiExposureGuard; and the browser surface — sdk/checkout plus the public checkout/* token endpoints — authenticated by a pk_ publishable key paired with a single-use cs_ session token through PublishableSessionGuard.

Four API surfaces, four authentication contracts

FIG 1 · surfaces
%%{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
  srv["Merchant server
Authorization: Bearer sk_"] brw["Donor browser
web SDK or hosted checkout"] pu["Portal user
JWT, MERCHANT scope"] st["Ummah staff
JWT, STAFF scope"] v1["/v1 public API
ApiKeyGuard + ApiExposureGuard"] sdkx["sdk/checkout + checkout/* tokens
PublishableSessionGuard + origin check"] por["api/portal/*
RolesGuard"] adm["api/admin/*
RolesGuard"] be["NestJS backend
~40 modules"] srv --> v1 brw --> sdkx pu --> por st --> adm v1 --> be sdkx --> be por --> be adm --> be 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 srv,brw,pu client class st,be ummah class v1,sdkx,por,adm plain
Merchant-side caller Ummah-owned Guarded surface
The two right-hand session surfaces are covered by the portal and back-office pages of this series; this page is about the bottom two contracts — and the guards are not interchangeable: ApiKeyGuard explicitly rejects a pk_ key presented to /v1.

sk_ secret key

The server credential

Sent as Authorization: Bearer sk_… to /v1. ApiKeyGuard resolves it to a merchant (and optionally one store) and pins req.merchantId, req.storeId and the key's scopes + allowChildMerchantIds onto the request. Never ships to a browser.

pk_ + cs_ pair

The browser credentials

A publishable pk_ key identifies the merchant; a single-use cs_ session token authorises exactly one checkout. Sent as X-Ummah-Publishable-Key and X-Ummah-Session headers, verified by PublishableSessionGuard.

api-catalog.ts

The exposure catalog

One file is the single source of truth for the public API: ApiExposureGuard enforces it, the portal API-docs page renders from it, and the Postman collection is generated from it. Documentation cannot drift from enforcement.

Section 2

The vocabulary

Ten identifiers carry the whole developer platform. Each one below is a real name from the code, not a paraphrase.

TermWhere it livesWhat it means
sk_ secret keyApiKey model, ApiKeyType enumServer-to-server credential for the /v1 surface, presented as a Bearer token. Secret revealed once at creation.
pk_ publishable keyApiKey modelBrowser-safe merchant identifier for the SDK surface; useless without a live session token.
cs_ session tokenMinted with each CheckoutSessionSingle-use browser token returned by POST /v1/checkout/sessions; pairs with the pk_ key for the life of one checkout.
Idempotency-KeyIdempotencyKey tableOptional replay-protection header honoured on endpoints marked @Idempotent(); stored responses replay for 24 h.
X-Ummah-Signaturewebhooks-out moduleHMAC over the outbound webhook envelope so merchants can authenticate deliveries.
X-Ummah-Publishable-Key / X-Ummah-SessionPublishableSessionGuardThe header pair that authenticates every browser SDK call.
Catalog entryapi-catalog.tsAn allow-list row (group, title, sample, enablement) — the only way a /v1 route becomes publicly reachable and documented.
api.disabled_endpointsPlatformSetting key/valueRuntime kill-switch: staff disable individual public endpoints without a deploy; live within 30 s.
WebhookHealthEnum on WebhookEndpointRUNNING or FAILING — the endpoint-level health derived from delivery outcomes.
MerchantDomaindomains moduleAn allow-listed browser origin; the SDK surface refuses requests from anywhere else.

Section 3

API keys & authentication

Merchant developer Backend · ApiKeyGuard Portal · developers/api-keys Ummah staff

Every /v1 request enters through ApiKeyGuard. The guard does three jobs in one pass: authenticate the key, enforce the merchant's lifecycle status, and pin the request's identity so downstream code never has to ask "whose data is this?".

The sk_ decision path

The guard resolves the Bearer token to an ApiKey row and its merchant. A pk_ key presented here is rejected outright — publishable keys belong to the browser surface only. Then the merchant's lifecycle gates apply: a SUSPENDED merchant loses the whole /v1 surface (403 with code merchant_suspended), while an INACTIVE merchant becomes read-only — GET requests pass, writes fail with 403 merchant_inactive. A live request gets req.merchantId, req.storeId (when the key is store-bound) and req.apiKey (carrying scopes and allowChildMerchantIds) pinned before any handler runs. For a marketplace Client, allowChildMerchantIds is what lets a single key act on its sub-merchants' resources.

What happens to a /v1 request

FIG 2 · key auth
%%{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
  rq["Request to /v1
Authorization: Bearer sk_..."] kg{"ApiKeyGuard
key resolves + usable?"} bad["401 - unknown, revoked,
or pk_ presented"] ms{"Merchant status?"} sus["403 merchant_suspended
whole /v1 surface blocked"] inw["403 merchant_inactive
writes refused"] pin["Pin req.merchantId, storeId,
scopes, allowChildMerchantIds"] eg{"ApiExposureGuard
catalog entry ENABLED?"} nf["404 - route is not part
of the public API"] ok["Handler runs"] rq --> kg kg -->|"no"| bad kg -->|"yes"| ms ms -->|"SUSPENDED"| sus ms -->|"INACTIVE + write"| inw ms -->|"INACTIVE + GET"| pin ms -->|"ACTIVE"| pin pin --> eg eg -->|"missing or disabled"| nf eg -->|"ENABLED"| ok 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 rq client class kg,eg,pin ummah class ms plain class bad,sus,inw,nf danger class ok sub
Merchant request Ummah guard / pinning Admitted Refused
Note the deliberate asymmetry in failure codes: auth failures are 401/403 and say why, but an un-catalogued route is a plain 404 — from outside, an unexposed endpoint simply does not exist.

Reveal, rotate, revoke

Keys are managed in three places, all writing to the same ApiKey model (ApiKeyType / ApiKeyStatus enums):

  • Portal — the developers/api-keys screen drives api/portal/api-keys: create sk_ and pk_ keys with an optional store binding and, for Clients, allowChildMerchantIds; rotate; revoke. The secret is shown once, at creation — after that only a prefix survives, so a lost secret means a rotation, not a lookup.
  • Back-office — staff manage any merchant's keys at api/admin/merchants/:merchantId/api-keys.
  • The API itself — GET v1/merchant is a self-inspection endpoint: a server can ask "who am I?" and see the merchant, store binding and scopes its key carries. Useful as the first smoke-test call of any integration.
Solid
A clean two-tier design: secrets live server-side and are irrecoverable after reveal, browser credentials are worthless without a one-shot session, and merchant suspension is enforced at the front door rather than per-endpoint. The request-pinning pattern means tenancy is decided once, in one guard, not re-derived in forty modules.

ummah-platform-backend/src/modules/api-keys/api-key.guard.ts:39–84 · ummah-platform-backend/src/modules/api-keys/portal-api-keys.controller.ts · ummah-platform-backend/src/modules/checkout/publishable-session.guard.ts · ummah-platform-backend/prisma/schema.prisma:879 (ApiKey), 106 (ApiKeyType)

Section 4

The exposure catalog

Backend · api-catalog module Merchant developer Ummah staff

Most platforms document their API in one place and enforce it in another, and the two drift. Ummah collapses them: api-catalog.ts is a single TypeScript file that is simultaneously the allow-list, the documentation source, and the Postman generator.

Four consumers read the one file:

  • ApiExposureGuard returns 404 for any /v1 request whose route has no ENABLED catalog entry — a strict allow-list, so an endpoint that exists in code but not in the catalog is invisible to merchants.
  • The portal API-docs page (developers/api-docs) renders groups, titles and shape-accurate request/response samples straight from the catalog. Merchants never see raw Swagger.
  • The Postman collection is generated from the same entries by postman.ts and downloadable from the docs page.
  • The kill-switch: staff can disable individual endpoints at runtime via the PlatformSetting key api.disabled_endpoints (managed at api/admin/settings). No deploy; the change is live within 30 s.

Why 404 and not 403 for an un-catalogued route?

Deliberate information hygiene. A 403 confirms the route exists; a 404 makes internal and staff-only routes indistinguishable from typos. Combined with the kill-switch, staff can make a misbehaving endpoint vanish from the public surface in under a minute while the docs page — driven by the same catalog — stays honest about what is callable.

Solid
Single-source-of-truth catalogs are the difference between "the docs say" and "the platform does". The one discipline this design demands: adding a /v1 endpoint without a catalog entry ships dead code — the guard will 404 it — so the catalog entry is part of the definition of done.

ummah-platform-backend/src/modules/api-catalog/api-catalog.ts:1–30 · ummah-platform-backend/src/modules/api-catalog/api-exposure.guard.ts · ummah-platform-backend/src/modules/api-catalog/postman.ts · ummah-platform-merchant-portal/app/(app)/developers/api-docs/page.tsx · ummah-platform-backend/prisma/schema.prisma:1139 (PlatformSetting)

Section 5

Idempotency: retry without fear

Merchant server Backend · IdempotencyInterceptor

Money-creating endpoints — POST /v1/checkout/sessions, POST /v1/charges — are marked @Idempotent(). A merchant server that sends an Idempotency-Key header can retry a timed-out request and get the original response back instead of a second charge.

The IdempotencyInterceptor implements store-and-replay with four properties worth knowing before you build a retry loop:

  • Scoping — a stored record is keyed by method, path, actor and a hash of the body, backed by the IdempotencyKey table. Different merchants (or different keys) can never collide on the same idempotency key.
  • Conflict detection — reusing a key with a different body is a 422, not a silent replay. This catches the classic bug of a shared constant key.
  • Success-only persistence — responses are stored only when the handler succeeds, so a failed attempt stays retryable with the same key.
  • Fails open — if the idempotency store itself errors, the request processes normally rather than failing. Availability wins; the replay guarantee is best-effort under infrastructure failure.

Store-and-replay on an @Idempotent endpoint

FIG 3 · idempotency
%%{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
  rq["POST /v1/charges
Idempotency-Key: k1"] h{"Header present?"} lk{"Record for method + path
+ actor within 24 h TTL?"} bm{"Body hash matches
the stored record?"} rp["Replay stored response
no second charge"] cf["422 - same key,
different body"] run["Run the handler"] sv["Persist response
under k1 for 24 h"] ns["Nothing stored -
same key retries cleanly"] rq --> h h -->|"absent - no protection"| run h -->|"present"| lk lk -->|"miss"| run lk -->|"store error - fails open"| run lk -->|"hit"| bm bm -->|"identical"| rp bm -->|"different"| cf run -->|"success"| sv run -->|"failure"| ns 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 rq client class h,lk,bm ummah class rp,sv sub class cf danger class ns warn class run plain
Merchant request Interceptor decision Safe outcome Rejected Retryable
The 24 h window is IDEMPOTENCY_TTL_MS configuration. The "fails open" edge is the one to remember: during an idempotency-store outage a retried request can process twice, so merchant-side retry loops should still treat a duplicate as possible, not impossible.
Caution
The protection is opt-in (no header, no replay guard) and fails open under infrastructure error. Both are defensible choices — but they mean the guarantee is "at-most-once under normal operation", not absolute. Integrations moving money should always send the header, and reconciliation (not hope) remains the backstop.

ummah-platform-backend/src/common/interceptors/idempotency.interceptor.ts:20–60 · ummah-platform-backend/src/common/decorators/idempotent.decorator.ts · ummah-platform-backend/src/modules/checkout/checkout.controller.ts:24–41 · ummah-platform-backend/prisma/schema.prisma:833 (IdempotencyKey)

Section 6

Outbound webhooks

Merchant developer Backend · webhooks-out Worker · webhook-out queue Merchant endpoint

Outbound webhooks are how a merchant's systems learn that something happened without polling. Endpoints are managed at api/portal/webhook-endpoints (the portal's developers/webhooks screen): CRUD, a POST :id/test ping, delivery inspection at GET :id/deliveries, and a GET events-catalogue listing every event type.

The event catalogue is generated from webhook-events.ts — the same single-source pattern as the API catalog, so the documented event list cannot drift from what the platform actually emits. The full set: payment.authorized / captured / cancelled / failed / disputed; refund.pending_ops / completed / failed (the refund lifecycle these mirror is on Refunds & Approvals); payout.sent / settled / failed; dispute.opened / evidence_required / submitted / won / lost; and ping for the test button.

Delivery is asynchronous: the backend enqueues, the worker's webhook-out queue drains. Every delivery is signed with X-Ummah-Signature — an HMAC over the envelope — so a receiving endpoint can verify the payload really came from Ummah before acting on it. Two routing rules matter for marketplaces: a sub-merchant's events deliver to the parent Client's endpoints (the Client integrates once for its whole tree), and endpoint health is tracked per endpoint — a successful delivery flips WebhookHealth to RUNNING, exhausted retries flip it to FAILING.

Delivery, signature and health

FIG 4 · webhook-out
%%{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 dev
  participant B as Backend
  participant Q as webhook-out queue
  participant W as Worker
  participant E as Merchant endpoint
  Note over B: payment.captured fires
sub-merchant event routes to parent Client endpoints B-->>Q: enqueue delivery envelope Q-->>W: job drained W->>W: sign envelope with X-Ummah-Signature HMAC W->>E: POST signed envelope alt endpoint answers 2xx E-->>W: 200 Note over W: WebhookHealth flips to RUNNING else retries exhausted Note over W: WebhookHealth flips to FAILING
portal shows the endpoint unhealthy end M->>B: POST webhook-endpoints/:id/test B-->>Q: ping event, same pipeline as real events
Delivery is at-least-once: a slow endpoint that times out and later succeeds may see the same event twice, so receivers should key on the event id, verify the signature, and treat handlers as idempotent — the same discipline Ummah applies to Adyen's inbound webhooks.
Caution
The pipeline itself is sound — signed envelopes, queue-backed delivery, per-endpoint health. The soft spot is observability: since WebhookDelivery rows were removed (P7B PR-05), health is endpoint-level only, yet the portal still exposes GET :id/deliveries. A merchant debugging "which event did I miss?" has no per-delivery record to consult. See Gaps.

ummah-platform-backend/src/modules/webhooks-out/webhook-events.ts:1–162 · ummah-platform-backend/src/modules/webhooks-out/webhooks-out.controller.ts:23–97 · ummah-platform-backend/src/modules/webhooks-out/webhooks-out.service.ts:289–300 · ummah-platform-worker/src/workers/webhook-out.processor.ts:1–21 · ummah-platform-backend/prisma/schema.prisma:1542 (WebhookEndpoint), 1570 (WebhookHealth)

Section 7

Domains & the browser SDK

Merchant developer Donor browser Backend · domains + checkout modules ummah-platform-web-sdk

The embeddable SDK puts a card form on the merchant's own page — which means the browser surface must decide which pages are allowed to talk to it. That is the domain allow-list; everything else is the two-step integration the portal's developers/sdk-setup screen walks through.

Origin allow-listing

Merchants register allowed origins on the portal's developers/domains screen, backed by the domains module and the MerchantDomain model, capped at MAX_DOMAINS_PER_MERCHANT. On every SDK-surface request the origin-enforcement interceptor checks the browser's Origin header against that allow-list, with a 30 s cache (CACHE_TTL_MS) so the check costs almost nothing per request. A stolen pk_ key is therefore doubly contained: it cannot be used from an unregistered origin, and it does nothing without a freshly minted single-use cs_ token anyway.

The two-step integration

The whole integration contract fits in two steps, and the split of responsibilities is the point — the secret key never leaves the merchant's server, and the browser never chooses the amount:

  • Step 1 — mint a session, server-side. The merchant's server calls POST /v1/checkout/sessions with its sk_ key (and an Idempotency-Key — see Section 5). The backend resolves and freezes the payment's split plan into CheckoutSession.splitsSnapshot — the economics of that snapshot are the subject of Pricings, Splits & Fees — and returns the pk_ key plus a single-use cs_ token.
  • Step 2 — mount the component, browser-side. The page renders the React <UmmahCheckout> component or calls the framework-agnostic mountUmmahCheckout(el, opts) with the pk_/cs_ pair. The SDK then drives the sdk/checkout endpoints itself: GET client-key, POST paymentMethods, POST payments, POST payments/details for 3DS completion — every call authenticated by X-Ummah-Publishable-Key + X-Ummah-Session and origin-checked. On POST payments the server injects the amount and splits from the frozen session, so nothing the browser sends can alter what is charged or how it divides.

The SDK bundles Adyen Web invisibly (card form, Apple Pay / Google Pay, card-brand detection for per-method pricing, native 3DS2 plus a full-page redirect helper), and its environment: 'test' | 'live' option selects which API host it talks to — TEST and LIVE are separate deployments, a fact with consequences covered in Gaps.

Solid
Server-minted sessions with server-injected amounts are the strongest browser-payment shape available: the client is a dumb renderer of a decision already frozen. The origin allow-list closes the remaining hole (embedding on a hostile page), and the 30 s cache keeps it cheap.

ummah-platform-backend/src/modules/domains/domains.service.ts:1–25 · ummah-platform-backend/src/modules/domains/origin-enforcement.interceptor.ts · ummah-platform-backend/src/modules/checkout/sdk.controller.ts:17–62 · ummah-platform-web-sdk/src/index.ts · ummah-platform-web-sdk/src/mount.ts · ummah-platform-backend/prisma/schema.prisma:1580 (MerchantDomain) · ummah-platform-merchant-portal/app/(app)/developers/domains/

Section 8

Implementation notes

The endpoints and models that make up the developer platform, by surface. Guard names are the enforcement reality, not convention.

Developer-platform endpoints
EndpointAuth / guardPurpose
api/portal/api-keysPortal JWT · RolesGuardCreate (one-time secret reveal), rotate, revoke sk_/pk_ keys; optional store binding; allowChildMerchantIds for Clients.
api/admin/merchants/:merchantId/api-keysStaff JWT · RolesGuardBack-office key management per merchant.
GET v1/merchantsk_ · ApiKeyGuardKey self-inspection — the "who am I?" smoke test.
POST /v1/checkout/sessionssk_ · ApiKeyGuard + @Idempotent()Mint a checkout session; freeze splits; returns pk_ + cs_.
POST /v1/chargessk_ · ApiKeyGuard + @Idempotent()Merchant-initiated charge on a saved card.
sdk/checkout — client-key, paymentMethods, payments, payments/detailspk_ + cs_ · PublishableSessionGuard + origin interceptorThe browser SDK surface; amount and splits injected server-side.
api/portal/webhook-endpoints (CRUD)Portal JWTManage outbound webhook endpoints.
POST api/portal/webhook-endpoints/:id/testPortal JWTSend a ping through the real delivery pipeline.
GET api/portal/webhook-endpoints/:id/deliveriesPortal JWTDelivery inspection — see Gaps for what remains behind it.
GET api/portal/webhook-endpoints/events-cataloguePortal JWTEvent-type catalogue generated from webhook-events.ts.
developers/domains (portal screen, domains module)Portal JWTRegister allowed browser origins, capped at MAX_DOMAINS_PER_MERCHANT.
api/admin/settingsStaff JWTPlatformSetting key/value store, including the api.disabled_endpoints kill-switch.
api/admin/eventsStaff JWTStaff-only browser over inbound (Adyen/Sumsub) and outbound (merchant webhook) events.

Data model notes

  • ApiKey (schema:879) with ApiKeyType (schema:106) and ApiKeyStatus — key records store scopes, optional store binding, and allowChildMerchantIds; the secret itself is never retrievable after creation.
  • IdempotencyKey (schema:833) — the store-and-replay backing table; rows age out on the 24 h TTL.
  • WebhookEndpoint (schema:1542) with WebhookHealth (schema:1570) — endpoint config plus its RUNNING/FAILING health; there is no per-delivery table any more.
  • MerchantDomain (schema:1580) — one row per allow-listed origin.
  • PlatformSetting (schema:1139) — platform key/value config read live by the exposure guard.

Section 9

Gaps & recommendations

What the code verifies as missing or awkward today, with a suggested fix for each. Priorities: p0 = correctness or money at risk, p1 = commercial or operational friction, p2 = polish and roadmap.

P1

The deliveries endpoint outlived its data

WebhookDelivery rows were removed in P7B PR-05, so per-delivery history no longer exists — yet the portal still exposes GET :id/deliveries and a delivery-inspection UI. Verify what that endpoint now returns, then either retire it (and present health honestly as endpoint-level RUNNING/FAILING) or reinstate a bounded, TTL-limited delivery log so merchants can answer "which event did I miss?".

P1

TEST and LIVE are separate deployments

There is no key-scoped test mode inside one environment: the SDK's environment: 'test' | 'live' option switches API hosts, and credentials belong to one deployment. Integrators can point live keys at the wrong host and get opaque auth failures. Document the environment pairing prominently on the developers/sdk-setup screen and in the API docs, and have error responses name the environment mismatch explicitly.

P2

Merchants have no event history view

Staff get the full inbound/outbound event browser at api/admin/events; a merchant's only event visibility is its own webhook deliveries — which, per the first gap, are now health-only. A read-only events feed in the portal's developers area would close the loop and cut "did you send it?" support tickets.

P2

Fail-open idempotency is invisible when it happens

The interceptor deliberately fails open on infrastructure errors — the right availability call, but each occurrence is a window where a merchant retry can double-process. Emit a metric and alert when the fail-open path is taken, so an idempotency-store outage is a known incident rather than a silent weakening of the replay guarantee.