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;
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.
| Term | What it is | Where it lives |
|---|---|---|
pl_ token | The raw link credential embedded in the hosted URL /l/<token>. Plaintext exists only in the create/regenerate response. | Returned by generatePaymentLinkToken; never persisted |
| Pepper | An 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) |
tokenHash | The only stored form of the token. Resolution re-hashes the incoming token and looks the hash up. | PaymentLink row |
PaymentLinkStatus | Lifecycle 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 = MANUAL | An 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 |
splitsSnapshot | The 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.paymentLinkId | The stamp on every redemption session; it is how the lazy PAID flip finds "a session of mine with an authorised transaction". | CheckoutSession metadata |
paymentLinkExpiryDays | Platform setting governing link expiry, valid range 1–365 days. | PlatformSetting (back-office api/admin/settings) |
cs_ token | The 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
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-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
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
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.
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)
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.)
/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".
| Verb | Endpoint | What happens | Use 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. |
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
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;
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 | Method · guard | Purpose |
|---|---|---|
api/portal/payment-links | POST · portal JWT, RolesGuard MERCHANT scope | Create a link; the response carries the raw pl_ token — the only time it exists. |
api/portal/payment-links/:id/regenerate | POST · portal JWT | Rotate: new token + hash, fresh URL returned once, old URL dead. |
api/portal/payment-links/:id | DELETE · portal JWT | Deactivate; redemption refused thereafter. |
v1/payment-links | sk_ · ApiKeyGuard + ApiExposureGuard | API-first link creation and management for integrated merchants. |
checkout/links/:token | GET · public, unauthenticated | Resolve link details + merchant branding via peppered-hash lookup. |
checkout/links/:token/session | POST · public, unauthenticated | Redeem: mint a NORMAL checkout session (link's store / amount / lineItems / captureMode, metadata.paymentLinkId). |
/l/[token], /l/[token]/return | Hosted pages · ummah-platform-checkout | Donor-facing checkout and return; session mint proxied via app/api/links/[token]/session. |
api/portal/payments/:id/capture | POST · portal JWT | Split-aware capture of an authorise-only hold (CapturesService); admin twin at api/admin/payments/:id/capture. |
/v1/payments/:id/capture | POST · 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 frozensplitsSnapshotandmetadata.paymentLinkId; the lazyPAIDflip queries sessions by that metadata for an authorisedTransaction.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.
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.
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.
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).
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.
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.