UMMAH FLOWS · 05 Verified against code · Aug 2026

Payment links · creation, redemption & lifecycle

Payment Links

How a merchant turns a store, an amount and a capture mode into a shareable URL and QR code; how a donor's browser redeems that URL through the hosted checkout into a completely normal split payment; and why a lost link can never be recovered — only regenerated, which kills the old URL on the spot. Includes the honest guide to debugging "Link not found", because the hosted page will never tell you why. Every claim below was verified in the platform code.

Section 1

What a payment link is

A payment link is a stored request to be paid — a PaymentLink row owned by a merchant, pointing at one of its stores, carrying an amount, line items and a capture mode — addressed by an unguessable pl_ token that exists in raw form only in the moment of creation. The link itself moves no money.

When a donor opens the hosted page, the platform mints a completely normal checkout session from the link's stored parameters. Everything downstream — the frozen split plan, the browser SDK surface, the Adyen /payments call, the webhook-driven settlement — is the standard card path, indistinguishable from a session a merchant's own server created via POST /v1/checkout/sessions. The economics (who earns what on each payment) are frozen at session creation and are the subject of Pricings, Splits & Fees; this page does not re-explain them.

Three properties define the feature's character: the token is stored only as an environment-bound peppered hash (so it can be verified but never re-read); the link is reusable until paid, with the PAID flip evaluated lazily on read; and "re-copy the URL" does not exist — the only recovery is regeneration, which mints a new token and silently kills the old URL.

Payment link lifecycle

FIG 1 · state flow
%%{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
  create["Merchant creates link
portal or v1 surface"]:::ummah raw["Raw pl_ token — shown once
URL and QR shared"]:::ummah active["ACTIVE
reusable until paid"]:::plain paid["PAID
flip is lazy, on read"]:::sub regen["Regenerated
new token minted"]:::warn gone["Deactivated or expired
redemption refused"]:::danger create --> raw --> active active -->|"authorised transaction
carries its paymentLinkId"| paid active -->|"POST :id/regenerate"| regen regen -->|"fresh URL — old one dies"| active active -->|"DELETE :id, or
paymentLinkExpiryDays elapse"| gone 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;
Merchant action Live state Terminal, money in Token rotation No longer redeemable
The PAID transition is not driven by the authorisation webhook: it happens the next time the link is read and an authorised transaction stamped with its paymentLinkId is found. Until that read, the link stays technically redeemable.

PaymentLink

The stored request

One row in the backend's payment-links module: the payee store, amount, line items, captureMode, a PaymentLinkStatus, and the tokenHash — never the raw token. Managed from the portal payment-links screen and the /v1 surface.

Redemption session

A normal checkout, minted late

Each open of the hosted page calls POST checkout/links/:token/session, which runs PaymentLinksService.redeem and mints a standard CheckoutSession through CheckoutService.createSession — split plan frozen, cs_ token issued, metadata.paymentLinkId stamped.

pl_ token

A one-shot credential

Generated with an environment-specific pepper; only the hash persists. The raw token is returned exactly once, at create or regenerate. Irrecoverable by design — and unresolvable in any other environment, because the pepper differs.

ummah-platform-backend/src/modules/payment-links/payment-links.service.ts:38-133 · payment-links.service.ts:229-252 · payment-links.service.ts:268-289 · ummah-platform-checkout/app/l/[token]/page.tsx

Section 2

The vocabulary

Nine terms carry this whole feature. All names are verbatim from the code.

TermWhat it isWhere it lives
pl_ tokenThe raw link credential embedded in the hosted URL /l/<token>. Plaintext exists only in the create/regenerate response.Returned by generatePaymentLinkToken; never persisted
PepperAn environment-specific secret mixed into the token hash. It is what makes tokens environment-bound: a TEST token hashed with the LIVE pepper matches nothing.Input to generatePaymentLinkToken(pepper)
tokenHashThe only stored form of the token. Resolution re-hashes the incoming token and looks the hash up.PaymentLink row
PaymentLinkStatusLifecycle enum. ACTIVE is required to redeem; PAID is set lazily when an authorised transaction exists; deactivated and expired links refuse redemption.prisma/schema.prisma
captureMode = MANUALAn authorise-only link: the donor's card is authorised but not captured — a pledge hold the merchant captures or releases later.Copied from the link onto the minted session
splitsSnapshotThe per-method split plan frozen onto every CheckoutSession at creation. Link payments get one like any other payment — see Pricings, Splits & Fees.CheckoutSession Json column
metadata.paymentLinkIdThe stamp on every redemption session; it is how the lazy PAID flip finds "a session of mine with an authorised transaction".CheckoutSession metadata
paymentLinkExpiryDaysPlatform setting governing link expiry, valid range 1–365 days.PlatformSetting (back-office api/admin/settings)
cs_ tokenThe single-use checkout-session token the browser presents (with the pk_ key) to the sdk/checkout surface.Issued by createSession, consumed by PublishableSessionGuard

Section 3

Create & share

Merchant portal Public API (sk_) Backend payment-links module

Two front doors, one service. The portal's payment-links screen calls POST api/portal/payment-links; API-first merchants use the v1/payment-links surface behind an sk_ key (gated, like all of /v1, by ApiKeyGuard and the ApiExposureGuard allowlist).

Creation is where the token's whole security story happens. generatePaymentLinkToken(pepper) returns a {token, hash} pair; the service persists only the hash and returns the raw pl_ token — embedded in the hosted URL — exactly once. The portal presents the URL for copying and as a QR code for print and screen sharing. There is no "show me the link again": because the raw token is irrecoverable, the portal's re-copy affordance is regenerate, which mints a fresh token and hash. The moment the new hash lands, the old URL — on posters, in WhatsApp messages, in QR codes already printed — resolves to nothing.

Links do not live forever: the platform-level paymentLinkExpiryDays setting (1–365 days, held in PlatformSetting and editable from the back-office settings screen) bounds how long a link remains redeemable.

Token custody: peppered hash, one-shot raw, rotation on re-copy

FIG 2 · token security
%%{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
  mint["generatePaymentLinkToken
with the environment pepper"]:::ummah raw2["raw pl_ token
returned exactly once"]:::ummah store["PaymentLink.tokenHash
raw token never stored"]:::plain res["GET checkout/links/:token
re-hash incoming token, look up"]:::plain rot["POST :id/regenerate
new pair replaces the hash"]:::warn dead["old URL, and any token from
another environment — no match"]:::danger mint -->|"returns"| raw2 mint -->|"persists"| store raw2 -->|"donor opens URL"| res res -->|"hash lookup"| store rot -->|"overwrites"| store rot -->|"previous token"| dead 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;
Ummah-owned action Neutral step Rotation Dead credential
The same design covers the sibling hosted-token families (subscribe and card-update links). Because the pepper differs per environment, the hash lookup is also an environment check: nothing special-cases "this token exists in TEST" — it simply matches no row.
Verdict · OKToken custody is genuinely one-shot and the database leaks nothing useful if dumped. The operational cost is real, though: every "can you resend me the link?" is a rotation event that invalidates printed material. Teams sharing links on physical media should treat regenerate as a breaking change — see Section 6.

ummah-platform-backend/src/modules/payment-links/payment-links.service.ts:38-133 · ummah-platform-backend/src/modules/payment-links/portal-payment-links.controller.ts:26-83 · ummah-platform-backend/src/modules/api-catalog/api-exposure.guard.ts

Section 4

Donor redemption

Donor browser ummah-platform-checkout (hosted app) Backend Adyen Checkout

The donor opens /l/[token] on the hosted Next.js checkout app. Two public, unauthenticated backend endpoints do all the work: one resolves the link, one mints the session. From there it is the standard checkout path.

First, the page calls GET checkout/links/:token for the link details plus the merchant's branding (the hosted page is already branded per merchant, even while the portal branding screen remains a placeholder). Then it calls POST checkout/links/:token/session — proxied through the app's own app/api/links/[token]/session route — which runs PaymentLinksService.redeem: the token hash is resolved, the link must be ACTIVE, and a normal session is minted via CheckoutService.createSession with the link's store, amount, line items and capture mode, plus metadata {paymentLinkId}.

Because it is the real createSession, the real gates run: gate.assertOperational, and assertStoreIsSplit — a store missing any of splitProfileId, adyenStoreId or adyenBalanceAccountId refuses payment with error code store_not_split rather than silently booking everything to the platform's liable account. The per-method split plan is frozen into splitsSnapshot exactly as described in Pricings, Splits & Fees. The shared HostedCheckout component then drives the browser surface: POST sdk/checkout/paymentMethods and POST sdk/checkout/payments under PublishableSessionGuard (headers X-Ummah-Publishable-Key + X-Ummah-Session), with amount and splits injected server-side from the frozen session. Success, or 3DS completion via POST sdk/checkout/payments/details, lands the donor on /l/[token]/return.

Redeeming a link: resolve, mint, pay, settle

FIG 3 · 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 D as Donor browser
  participant C as Checkout app
  participant B as Backend
  participant A as Adyen
  D->>C: open /l/[token]
  C->>B: GET checkout/links/:token
  B-->>C: link details + branding
  C->>B: POST checkout/links/:token/session
  Note over B: redeem — link must be ACTIVE
createSession freezes splitsSnapshot
metadata.paymentLinkId, link captureMode B-->>C: cs_ session token + pk_ key D->>B: POST sdk/checkout/payments Note over B: amount + splits injected
server-side from the session B->>A: POST /payments A-->>B: resultCode or 3DS action B-->>D: result, then /l/[token]/return A-->>B: AUTHORISATION webhook Note over B: Transaction AUTHORIZED —
next link read flips PAID
The session mint travels through the checkout app's own app/api/links/[token]/session proxy, so the donor's browser never needs the backend host for that step — one reason front-end misconfiguration and dead tokens look identical from outside (Section 7). The webhook is asynchronous: the donor sees the return page before the platform's Transaction is confirmed.

Reusable until paid

A link is not single-use by construction: every open mints a fresh session, and the link keeps resolving while ACTIVE. What ends its life as a payable object is the lazy PAID flip — on read, if any checkout session carrying its paymentLinkId has an authorised transaction, the status becomes PAID and redemption stops. Lazy means there is a window between authorisation and the next read in which a second donor with the page already open can also pay; nothing in the redemption path blocks the second session. Money that arrives through a link leaves through the ordinary path — see Refunds & Approvals.

Verdict · WatchThe lazy flip is fine for donation-style links where a second gift is a feature, not a fault. For invoice-style "pay exactly once" links, it is a real double-payment window: the flip should also be driven eagerly from the AUTHORISATION webhook via metadata.paymentLinkId (recommendation R4).

ummah-platform-backend/src/modules/payment-links/public-payment-links.controller.ts:21-28 · payment-links.service.ts:229-252, 268-289 · ummah-platform-backend/src/modules/checkout/checkout.service.ts:91-99, 489-505, 525-561 · ummah-platform-checkout/app/l/[token]/LinkCheckout.tsx · ummah-platform-checkout/components/HostedCheckout.tsx

Section 5

Authorise-only links (pledge holds)

Merchant portal / Back-office CapturesService Adyen

A link created with captureMode MANUAL takes a pledge rather than a payment: the donor's card is authorised, the funds are held, and nothing is captured until the merchant decides.

Mechanically, the link's capture mode flows onto the minted session, and the /payments call carries additionalData.manualCapture 'true'. The authorisation leaves the transaction AUTHORIZED with a display-only authorisedUntil of roughly seven days. From there the merchant captures through POST api/portal/payments/:id/capture (staff twin api/admin/payments/:id/capture), which is the split-aware path: CapturesService.capture validates AUTHORIZED + MANUAL, allows a single capture up to the authorised amount, recomputes the recorded splits for the captured amount, moves the row through CAPTURE_PENDING, and lets the CAPTURE webhook be the truth. Releasing the pledge instead books a cancellation, confirmed by the CANCELLATION webhook.

The trap: the sk-key surface still exposes POST /v1/payments/:id/capture, and it bypasses CapturesService entirely — a raw Adyen capture with no status or capture-mode guard, no splits sent, no re-record of the split mirror, and no Adyen idempotency key. A partial capture through that endpoint on an explicit-splits (profile-less) payment books the whole captured amount to Ummah's liable account. An API-integrated merchant capturing its link pledges is one innocent-looking call away from that. (/v1/payments/:id/cancel has the same relationship to the proper release path.)

Verdict · Money riskAuthorise-only links are sound through the portal and back-office. The /v1 capture bypass is a live mis-booking path for exactly this feature's API users — route it through CapturesService (recommendation R1).

ummah-platform-backend/src/modules/captures/captures.service.ts:60-146, 216-297 · ummah-platform-backend/src/modules/captures/portal-captures.controller.ts:20-33 · ummah-platform-backend/src/modules/captures/admin-captures.controller.ts:21-35 · ummah-platform-backend/src/modules/checkout/checkout.controller.ts:90-106 · ummah-platform-backend/src/modules/checkout/payments.service.ts:608-650

Section 6

Regenerate, deactivate, expire

Three ways a URL stops working, with very different intents. Choosing the wrong verb is the most common self-inflicted "Link not found".

The three ending verbs
VerbEndpointWhat happensUse it when
Regenerate POST api/portal/payment-links/:id/regenerate A new {token, hash} pair replaces the stored hash. The link row, amount and settings survive; the old URL dies instantly and a fresh URL is returned once. The URL may have leaked, or the merchant lost it. Never as a casual "resend" if the old URL is on printed material.
Deactivate DELETE api/portal/payment-links/:id The link leaves ACTIVE; redemption is refused (redeem requires ACTIVE). The row and its history remain for reporting. The campaign is over, or the ask was withdrawn. Deliberate and permanent-feeling; there is no reactivate verb on the portal surface.
Expire Platform-wide, via paymentLinkExpiryDays Past the configured window (1–365 days, a PlatformSetting), the link stops resolving. No per-link override surface exists. Automatic hygiene. Merchants planning long-running QR campaigns must know the platform value.
Verdict · WatchAll three verbs collapse into the same donor experience — "Link not found" — because the hosted page reports every failure identically. The merchant-side distinction is clear; the donor-side one does not exist. That asymmetry is the subject of the next section.

ummah-platform-backend/src/modules/payment-links/portal-payment-links.controller.ts:64-83 · payment-links.service.ts:229-252 · ummah-platform-backend/src/modules/settings/admin-settings.controller.ts:28-35 · prisma/schema.prisma:1139 (PlatformSetting)

Section 7

"Link not found", debugged

Hosted page swallows all errors Tokens are environment-bound

The hosted page has exactly one failure state. Whatever goes wrong — a regenerated token, a deactivated or expired link, a token pasted into the wrong environment, a misconfigured front-end build, an unreachable backend — the donor sees the same "Link not found". The page tells you nothing; the API tells you everything.

The environment binding deserves emphasis because it produces the most confusing incidents: a link minted against the TEST environment cannot resolve in LIVE, and vice versa. The pepper differs per environment, so the hash lookup simply finds no row — indistinguishable from a token that never existed. A demo link pasted into production material, or a LIVE QR code scanned against a staging build, dies silently.

The triage that works

Skip the page; interrogate the resolution endpoint directly against the environment the link should live in — for example curl https://api.dev.ummah.com/checkout/links/<token> for the dev environment. The status code is the only honest signal in the system:

  • 200 — the link is fine; the problem is in front of it. Suspect the hosted app's build configuration: the checkout app must have its API host baked in at build time, and a known incident (fixed in commit df969cc) was exactly this — environment variables not baked into the Next.js production build, so every link on that deployment rendered "Link not found" while the API answered perfectly.
  • Non-200 — the token really does not resolve here. In order of likelihood: the link was regenerated (the URL in hand predates the rotation), it was deactivated or has expired, or the token belongs to another environment. Check the portal's payment-links screen for the link's current status and creation environment before assuming a bug.

Triage: let the API speak, not the page

FIG 4 · debugging
%%{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
  seen["Donor reports Link not found
page gives no reason, ever"]:::danger curl["curl the environment API directly
GET checkout/links/:token"]:::ummah ok["200 — link resolves"]:::sub bad["404 or error — token unknown here"]:::warn front["Front-end problem — API host not
baked into the hosted build"]:::warn cause["Token problem — regenerated,
deactivated, expired, or wrong environment"]:::danger seen --> curl curl -->|"200"| ok curl -->|"non-200"| bad ok --> front bad --> cause 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;
Failure surface Your move Backend healthy Narrowed cause
Never regenerate as a first debugging move: if the real fault is front-end configuration, regenerating kills a perfectly good URL that donors may already hold, converting one incident into two.

ummah-platform-backend/src/modules/payment-links/public-payment-links.controller.ts:21-28 · ummah-platform-checkout/app/l/[token]/page.tsx · ummah-platform-checkout/app/api/links/[token]/session

Section 8

Implementation notes

Every endpoint the feature touches, with its guard. Names verbatim from the controllers.

Endpoint map
EndpointMethod · guardPurpose
api/portal/payment-linksPOST · portal JWT, RolesGuard MERCHANT scopeCreate a link; the response carries the raw pl_ token — the only time it exists.
api/portal/payment-links/:id/regeneratePOST · portal JWTRotate: new token + hash, fresh URL returned once, old URL dead.
api/portal/payment-links/:idDELETE · portal JWTDeactivate; redemption refused thereafter.
v1/payment-linkssk_ · ApiKeyGuard + ApiExposureGuardAPI-first link creation and management for integrated merchants.
checkout/links/:tokenGET · public, unauthenticatedResolve link details + merchant branding via peppered-hash lookup.
checkout/links/:token/sessionPOST · public, unauthenticatedRedeem: mint a NORMAL checkout session (link's store / amount / lineItems / captureMode, metadata.paymentLinkId).
/l/[token], /l/[token]/returnHosted pages · ummah-platform-checkoutDonor-facing checkout and return; session mint proxied via app/api/links/[token]/session.
api/portal/payments/:id/capturePOST · portal JWTSplit-aware capture of an authorise-only hold (CapturesService); admin twin at api/admin/payments/:id/capture.
/v1/payments/:id/capturePOST · sk_Capture bypass — no guard, no splits, no mirror re-record. Documented as a gap; avoid for link holds.

Data model notes

  • PaymentLink — payee store, amount, line items, captureMode, PaymentLinkStatus, tokenHash. The raw token has no column, by design.
  • CheckoutSession — one per redemption attempt, carrying the frozen splitsSnapshot and metadata.paymentLinkId; the lazy PAID flip queries sessions by that metadata for an authorised Transaction.
  • PlatformSetting — paymentLinkExpiryDays (1–365) governs expiry platform-wide; no per-link expiry field exists on the current surface.
  • Siblings — subscribe links (/sub/[token]) and card-update links (/u/[token]) reuse the same peppered-token pattern; this page covers only payment links.

ummah-platform-backend/src/modules/payment-links/payment-links.service.ts:38-133, 229-289 · portal-payment-links.controller.ts:26-83 · public-payment-links.controller.ts:21-28 · ummah-platform-backend/prisma/schema.prisma:1052 (CheckoutSession), 1139 (PlatformSetting)

Section 9

Gaps & recommendations

Ranked by consequence: money risk first, then operational friction, then polish.

P0

R1 — The /v1 capture bypass reaches link pledges

Authorise-only links are captured by API merchants through POST /v1/payments/:id/capture, which skips CapturesService: no MANUAL/AUTHORIZED guard, no splits sent or re-recorded, no idempotency key — a partial capture on an explicit-splits payment books entirely to the liable account. Fix: route the sk-key capture (and /v1/payments/:id/cancel) through CapturesService.capture/release.

P1

R2 — Single-string error opacity on the hosted page

Every failure renders the same "Link not found": regenerated, deactivated, expired, wrong environment, front-end misconfiguration and backend outage are indistinguishable to donors and to first-line support. Fix: keep the token opaque, but split the page into at least two honest states — "this link is closed or expired" (backend answered) versus "something went wrong on our side" (backend did not) — and log the resolution status code client-side.

P1

R3 — Environment-bound tokens have no operational guardrails

Env-binding is correct security, but nothing warns a human: a TEST link in LIVE material dies silently, and a pepper rotation would mass-invalidate every outstanding link with no migration path. Fix: badge non-LIVE hosted pages visibly, surface the link's environment in the portal list, and document the pepper as a never-rotate secret (or design a dual-pepper rotation window before it is ever needed).

P2

R4 — Lazy PAID flip leaves a double-payment window

The flip happens on read, not on authorisation, so a second donor with the page open can pay an already-paid link. Fix: also flip eagerly from the AUTHORISATION webhook using metadata.paymentLinkId, and have in-flight hosted sessions re-check status before submitting payment.

P2

R5 — Build-configuration failures masquerade as dead links

Because the hosted app bakes its API host at build time, a bad build makes every link on the deployment "not found" (the df969cc incident). Fix: add a deploy-time probe that resolves a known token against GET checkout/links/:token and fails the release when the front-end cannot reach its own backend.