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
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.
| Term | Where it lives | What it means |
|---|---|---|
sk_ secret key | ApiKey model, ApiKeyType enum | Server-to-server credential for the /v1 surface, presented as a Bearer token. Secret revealed once at creation. |
pk_ publishable key | ApiKey model | Browser-safe merchant identifier for the SDK surface; useless without a live session token. |
cs_ session token | Minted with each CheckoutSession | Single-use browser token returned by POST /v1/checkout/sessions; pairs with the pk_ key for the life of one checkout. |
Idempotency-Key | IdempotencyKey table | Optional replay-protection header honoured on endpoints marked @Idempotent(); stored responses replay for 24 h. |
X-Ummah-Signature | webhooks-out module | HMAC over the outbound webhook envelope so merchants can authenticate deliveries. |
X-Ummah-Publishable-Key / X-Ummah-Session | PublishableSessionGuard | The header pair that authenticates every browser SDK call. |
| Catalog entry | api-catalog.ts | An allow-list row (group, title, sample, enablement) — the only way a /v1 route becomes publicly reachable and documented. |
api.disabled_endpoints | PlatformSetting key/value | Runtime kill-switch: staff disable individual public endpoints without a deploy; live within 30 s. |
WebhookHealth | Enum on WebhookEndpoint | RUNNING or FAILING — the endpoint-level health derived from delivery outcomes. |
MerchantDomain | domains module | An allow-listed browser origin; the SDK surface refuses requests from anywhere else. |
Section 3
API keys & authentication
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
Reveal, rotate, revoke
Keys are managed in three places, all writing to the same ApiKey model (ApiKeyType / ApiKeyStatus enums):
- Portal — the
developers/api-keysscreen drivesapi/portal/api-keys: createsk_andpk_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/merchantis 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.
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
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:
ApiExposureGuardreturns 404 for any/v1request whose route has noENABLEDcatalog 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.tsand downloadable from the docs page. - The kill-switch: staff can disable individual endpoints at runtime via the
PlatformSettingkeyapi.disabled_endpoints(managed atapi/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.
/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
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
IdempotencyKeytable. 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
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.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
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
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
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/sessionswith itssk_key (and anIdempotency-Key— see Section 5). The backend resolves and freezes the payment's split plan intoCheckoutSession.splitsSnapshot— the economics of that snapshot are the subject of Pricings, Splits & Fees — and returns thepk_key plus a single-usecs_token. - Step 2 — mount the component, browser-side. The page renders the React
<UmmahCheckout>component or calls the framework-agnosticmountUmmahCheckout(el, opts)with thepk_/cs_pair. The SDK then drives thesdk/checkoutendpoints itself:GET client-key,POST paymentMethods,POST payments,POST payments/detailsfor 3DS completion — every call authenticated byX-Ummah-Publishable-Key+X-Ummah-Sessionand origin-checked. OnPOST paymentsthe 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.
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.
| Endpoint | Auth / guard | Purpose |
|---|---|---|
api/portal/api-keys | Portal JWT · RolesGuard | Create (one-time secret reveal), rotate, revoke sk_/pk_ keys; optional store binding; allowChildMerchantIds for Clients. |
api/admin/merchants/:merchantId/api-keys | Staff JWT · RolesGuard | Back-office key management per merchant. |
GET v1/merchant | sk_ · ApiKeyGuard | Key self-inspection — the "who am I?" smoke test. |
POST /v1/checkout/sessions | sk_ · ApiKeyGuard + @Idempotent() | Mint a checkout session; freeze splits; returns pk_ + cs_. |
POST /v1/charges | sk_ · ApiKeyGuard + @Idempotent() | Merchant-initiated charge on a saved card. |
sdk/checkout — client-key, paymentMethods, payments, payments/details | pk_ + cs_ · PublishableSessionGuard + origin interceptor | The browser SDK surface; amount and splits injected server-side. |
api/portal/webhook-endpoints (CRUD) | Portal JWT | Manage outbound webhook endpoints. |
POST api/portal/webhook-endpoints/:id/test | Portal JWT | Send a ping through the real delivery pipeline. |
GET api/portal/webhook-endpoints/:id/deliveries | Portal JWT | Delivery inspection — see Gaps for what remains behind it. |
GET api/portal/webhook-endpoints/events-catalogue | Portal JWT | Event-type catalogue generated from webhook-events.ts. |
developers/domains (portal screen, domains module) | Portal JWT | Register allowed browser origins, capped at MAX_DOMAINS_PER_MERCHANT. |
api/admin/settings | Staff JWT | PlatformSetting key/value store, including the api.disabled_endpoints kill-switch. |
api/admin/events | Staff JWT | Staff-only browser over inbound (Adyen/Sumsub) and outbound (merchant webhook) events. |
Data model notes
ApiKey(schema:879) withApiKeyType(schema:106) andApiKeyStatus— key records store scopes, optional store binding, andallowChildMerchantIds; 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) withWebhookHealth(schema:1570) — endpoint config plus itsRUNNING/FAILINGhealth; 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.
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?".
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.
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.
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.