Payment Links
A payment link is a shareable URL that opens a hosted pay page where a
customer can complete a one-off payment without any integration on the
merchant’s side. MVS Pay does not build its own hosted checkout for this — the
URL is minted by Hyperswitch’s native payment-link feature, and MVS keeps a
thin sidecar row (PaymentLinkExtension) for the dashboard-only fields that
Hyperswitch doesn’t model (nickname, amount type, lifecycle state, expiry,
tipping, tags).
This split is a direct application of ADR-007 · Native-Hyperswitch bias: let Hyperswitch own anything it already does well (here, the hosted pay page and the payment intent), and have MVS own only the gaps.
This page documents how the feature is wired and where the truth lives in code. For the exact request/response contract of each endpoint, see the API Reference tab — it is generated from the OpenAPI fragments and is authoritative for payloads.
How it fits together
There are three layers in front of every payment-link operation, and they all appear in the source listed at the bottom of this section:
The extensions service is mounted at /v1/payment-links (see
apps/mvs-pay-extensions/src/app.ts, where api.route("/payment-links", paymentLinkRoutes) is registered and an idempotency() middleware is attached
to the same prefix). It is not a Next.js API route — orchestration lives in
the standalone EKS service per
ADR-006. See the
Extensions Service and SDK
surface pages for the broader picture.
The PaymentLinkExtension model
The sidecar is a Prisma model in
packages/mvs-pay/prisma/schema.prisma (mapped to table
payment_link_extension). Per the schema’s own comment, the extension sidecars
are keyed by an opaque Hyperswitch ID string — there is no foreign key into
Hyperswitch’s database, which is separate. The “join” back to Hyperswitch
happens at the application layer through the SDK.
state, amountType, expiresAt, and tags are MVS bookkeeping, not
Hyperswitch enforcement. Marking a link EXPIRED or CANCELED in the sidecar
updates the dashboard’s view but does not currently call Hyperswitch to
deactivate the underlying intent or pay page (see
Known gaps). Treat lifecycle state as advisory
until that linkage is closed.
The view shape vs. the row
Routes never return the raw Prisma row. toView() in payment-links.ts maps a
PaymentLinkExtension to a merged PaymentLinkView — Hyperswitch holds
amount/currency/description on the intent, MVS holds everything else — and the
SDK commits to that view (PaymentLinkRow in
packages/mvs-pay-sdk/src/types.ts) rather than the database row. Notable
renames the new owner should know:
hyperswitchMerchantIdis surfaced asmerchantId.expiresAtandcreatedAtare serialized to ISO strings (nullwhen unset).metadata,organizationId, andhyperswitchPaymentIdare not in the view — they never leave the extensions service.
Create / list / send flow
Creating a link
POST /api/payment-links (dashboard) → client.paymentLinks.create →
POST /v1/payment-links (extensions). The extensions handler does two writes in
sequence:
Mint the Hyperswitch payment + hosted link
It calls hyperswitch.payments.create with payment_link: true,
capture_method: "automatic", the amount/currency/description, the optional
payment_link_config, and metadata stamping the org id (mvs_org_id) plus
mvs_payment_link: "true". Hyperswitch returns a payment_id, optional
merchant_id, and payment_link.link (the URL).
amount defaults to 0 and currency defaults to "USD" if the caller omits
them.
Persist the MVS sidecar
It writes a PaymentLinkExtension row keyed by the returned payment_id, copies
payment_link.link into linkUrl, sets state: "ACTIVE", and stores the
MVS-only fields. amount is persisted only when amountType === "FIXED";
otherwise it is null. hyperswitchMerchantId falls back to the body’s
merchantId, then the Hyperswitch-returned merchant_id, then null.
The create body (CreatePaymentLinkInput):
Pass an idempotency-key header on create. It is forwarded all the way to
hyperswitch.payments.create, and the /payment-links prefix is wrapped by the
extensions service’s idempotency() middleware. The same header flows through
the dashboard route and the SDK’s RequestMetadata.idempotencyKey.
Listing and reading
A few specifics worth knowing:
- List filter. The dashboard forwards an optional
merchant_idquery param straight through; the extensions handler addshyperswitchMerchantIdto the where-clause when present. Results are orderedcreatedAt descand returned as{ data: [...] }, which the dashboard reshapes to{ paymentLinks }. - Org scoping is the security boundary. Every read/write is filtered by
organizationIdfrom the request context. A link another org owns surfaces as a plain404, not a403. There is no cross-org read path. - Update is a whitelist.
PATCHstripsorganizationId,hyperswitchPaymentId, andidso a caller cannot mass-assign tenancy or identity columns, then applies onlynickname,description,expiresAt,allowTipping, andstate. The dashboard uses thePUTverb; it maps to the SDK’supdatemethod (and the extensions service’sPATCH). - Cancel. Both the dedicated SDK
cancel()(POST .../:id/cancel) and an update withstate: "CANCELED"set the sidecar state toCANCELED. As noted above, this is a DB-only state change today.
Sending a link by email
This is the one piece of payment-link behaviour that lives entirely in the
Next.js app, not the extensions service or the SDK namespace. The route is
POST /api/payment-links/[id]/send
(apps/mvs-pay/app/api/payment-links/[id]/send/route.ts).
What the route does, in order:
- Auth + scope. Requires
payment_links:write. - Config guard. If
process.env.RESEND_API_KEYis unset it returns a 503 with an honest message —"Email is not configured (RESEND_API_KEY is unset); use Copy Link / Open Link to share this payment link". It does not fake a delivery. - Validate input.
emailis required (400if missing/blank).messageis optional and becomes the template’spersonalMessage. - Fetch the link via
client.paymentLinks.get(id)(org-scoped — a not-owned link 404s here). - Require a URL. If
link.linkUrlis null it returns 422 (“This payment link has no shareable URL”). - Format the amount.
link.amountis in cents; it is divided by 100 and formatted withIntl.NumberFormatin the link’s currency. Whenamountis null (e.g.CUSTOMER_CHOOSES) it renders the literal string"Amount at checkout". - Send via
@mvs/mail’ssendEmailwithtemplateId: "paymentLinkRequest". ThemerchantNamefalls back throughlink.nickname → auth.context.orgSlug → "MVS Pay".expiresAtis formatted as a long US date.tenantBrandisdefaultTenantBrand. - Map the result.
sendEmailreturns a boolean;false→ 502,true→ 200 with{ sent: true, email }.
tenantBrand is hard-coded to defaultTenantBrand — the send route does not
yet resolve per-organization branding (logo, primary color, support email),
even though the template (PaymentLinkRequest.tsx) and the underlying layout
fully support it. Per-tenant brand wiring for this email is an open gap.
The @mvs/mail delivery path
sendEmail (packages/mail/src/util/send.ts) is shared platform mail
infrastructure, not payment-link-specific. For a payment-link send the relevant
behaviours are:
- Idempotency. It builds a SHA-256 idempotency key from
to | templateId | subject | dayBucketand short-circuits (return true, logged as a skip) if the same email was already sent that calendar day. This means a second send of the same link to the same recipient on the same day is silently de-duplicated and still reports success. - Retries. Up to two retries with exponential backoff, but only for
network-shaped errors (
ECONNREFUSED,ENOTFOUND,EAI_AGAIN, DNS, network). Other failures fail fast. - Clinical DLQ does not apply here.
paymentLinkRequestis not in the clinical template set, so failed sends are logged and returnfalse(no dead-letter enqueue). - Provider.
packages/mail/src/provider/index.tsre-exports only./resend, so the active provider is Resend.send()resolves config with precedence cached DB settings → env (RESEND_API_KEY,config.mails.from,RESEND_REPLY_TO_EMAIL) → defaults, thenPOSTs tohttps://api.resend.com/emails. Mailgun/Postmark/Paubox/Plunk/Nodemailer/ console providers exist in the directory but are not wired through the index.
There are two Resend config layers and they are not the same gate. The
send route guards on process.env.RESEND_API_KEY directly (its 503 path),
while the mail package’s Resend provider resolves the key through
getResendConfig(). In practice both read the same env var, but the route’s
guard runs first and is what produces the user-facing “not configured” 503.
The email template
packages/mail/src/templates/billing/PaymentLinkRequest.tsx is a
react-email template rendered through PatientEmailLayout. The route passes
this context (PaymentLinkRequestProps):
The rendered email shows a centered “Payment Request from {merchantName}”, a prominent amount card, the optional personal message, a “Pay Now” button, and a footer noting the link is secure and (if set) when it expires.
Endpoint reference (summary)
Scopes are defined in apps/mvs-pay/lib/api-auth.ts
(payment_links:read = “View payment links”,
payment_links:write = “Create payment links”). See
Authentication & Tenancy for how scopes are
resolved from API keys vs. session.
There is no dedicated dashboard route for cancel today — the
POST /v1/payment-links/:id/cancel extensions endpoint and the SDK’s cancel()
exist, but the Next.js app reaches cancel through the generic PUT update with
state: "CANCELED". Both paths converge on the same DB state change.
Known gaps and honest caveats
Lifecycle state is MVS-only and not pushed to Hyperswitch
Setting state to EXPIRED/CANCELED (via update or cancel) only mutates the
PaymentLinkExtension row. The extensions service does not call Hyperswitch
to deactivate the underlying payment intent or hosted pay page. A “canceled”
link’s URL may still be payable on the Hyperswitch side. Closing this requires a
Hyperswitch call in the cancel/expire path. There is also no scheduled job that
flips ACTIVE → EXPIRED when expiresAt passes, nor a webhook that flips
ACTIVE → PAID on payment success — state is set to ACTIVE at creation and
only changes through explicit dashboard edits.
Email branding is not per-tenant yet
The send route hard-codes tenantBrand: defaultTenantBrand. The template and
layout support full tenant branding (organizationName, primaryColor,
supportEmail), but the route does not resolve the caller’s organization brand.
Until wired, every payment-link email uses MVS default branding regardless of
org.
merchantName fallback can be unintuitive
merchantName falls back to link.nickname. The nickname is an internal
display label, not necessarily a customer-facing merchant name, so the email
header may show an internal label. If nickname is unset it falls back to the org
slug, then "MVS Pay".
Same-day email de-duplication is silent
@mvs/mail’s idempotency key buckets by calendar day. Re-sending the same link
to the same recipient on the same day returns { sent: true } without actually
dispatching a second email. This is correct-by-design for most flows but can
surprise an operator expecting a re-send.
Mail provider is Resend-only as wired
Although the provider/ directory contains Mailgun, Postmark, Paubox, Plunk,
Nodemailer, and console implementations, provider/index.ts re-exports only
./resend. Switching providers is a code change to that index, not a config
toggle.
Related pages
Why the link is minted by Hyperswitch and MVS only owns the sidecar.
The standalone EKS service that holds the routes and the DB writes.
The typed paymentLinks namespace consumers call.
The underlying Hyperswitch payment intent a link is built on.
Where the extension-sidecar pattern is documented in full.
Authoritative payload contracts for every endpoint above.