Payment Links

Shareable hosted payment URLs

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:

LayerPathRole
Dashboard API routesapps/mvs-pay/app/api/payment-links/Next.js route handlers. Authenticate, scope-check, then delegate to the SDK. Reshape the { data } envelope into { paymentLink } / { paymentLinks } for the UI. Owns the email send route.
Typed SDK namespacepackages/mvs-pay-sdk/src/namespaces/payment-links.tsclient.paymentLinks.{create,list,get,update,cancel}. Maps each method to an HTTP call against the extensions service.
Extensions serviceapps/mvs-pay-extensions/src/routes/payment-links.tsThe only layer that talks to Hyperswitch and to the PaymentLinkExtension table. Mints the link, persists the sidecar, enforces org tenancy.

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.

FieldTypeNotes
idString (cuid)MVS-owned primary key. This is the :id used in every route.
organizationIdStringTenant key. FK to PaymentOrganization, onDelete: Cascade. The only tenant boundary on the shared DB client.
hyperswitchPaymentIdString @uniqueThe Hyperswitch payment intent that backs the link.
hyperswitchMerchantIdString?Hyperswitch merchant the link belongs to. Used as the list filter.
linkUrlString?The hosted pay-page URL Hyperswitch returned (payment_link.link). Null if Hyperswitch did not return one.
nicknameString?MVS-only display label.
descriptionString?Mirrored onto the Hyperswitch intent at create time, then also stored here.
amountTypeString @default("FIXED")FIXED or CUSTOMER_CHOOSES.
amountInt?Cents. Null when amountType = CUSTOMER_CHOOSES — the create path explicitly drops amount unless the type is FIXED.
currencyString @default("USD")ISO currency code.
stateString @default("ACTIVE")ACTIVE | EXPIRED | PAID | CANCELED.
expiresAtDateTime?MVS-tracked expiry.
allowTippingBoolean @default(false)MVS display flag.
tagsString[]MVS-only tags. Settable on create.
metadataJson?Free-form. Not surfaced in the view shape today.
createdAt / updatedAtDateTimeStandard timestamps. Indexed (organizationId, createdAt desc) for the list query’s order-by.

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:

  • hyperswitchMerchantId is surfaced as merchantId.
  • expiresAt and createdAt are serialized to ISO strings (null when unset).
  • metadata, organizationId, and hyperswitchPaymentId are not in the view — they never leave the extensions service.

Create / list / send flow

POST /api/payment-links (dashboard) → client.paymentLinks.createPOST /v1/payment-links (extensions). The extensions handler does two writes in sequence:

2

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.

3

Return the merged view

The new row is mapped through toView() and returned. The dashboard route wraps it as { paymentLink }.

The create body (CreatePaymentLinkInput):

1interface CreatePaymentLinkInput {
2 merchantId?: string;
3 amountType?: string; // defaults to "FIXED"
4 amount?: number; // cents; defaults to 0 on the intent
5 currency?: string; // defaults to "USD"
6 nickname?: string;
7 description?: string;
8 expiresAt?: string;
9 allowTipping?: boolean;
10 tags?: string[];
11 customerEmail?: string; // forwarded to the Hyperswitch intent
12 payment_link_config?: Record<string, unknown>; // passthrough to Hyperswitch
13}

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

OperationDashboard routeSDKExtensions endpoint
ListGET /api/payment-linkspaymentLinks.listGET /v1/payment-links
Get oneGET /api/payment-links/[id]paymentLinks.getGET /v1/payment-links/:id
UpdatePUT /api/payment-links/[id]paymentLinks.updatePATCH /v1/payment-links/:id
Cancel(via update with state)paymentLinks.cancelPOST /v1/payment-links/:id/cancel

A few specifics worth knowing:

  • List filter. The dashboard forwards an optional merchant_id query param straight through; the extensions handler adds hyperswitchMerchantId to the where-clause when present. Results are ordered createdAt desc and returned as { data: [...] }, which the dashboard reshapes to { paymentLinks }.
  • Org scoping is the security boundary. Every read/write is filtered by organizationId from the request context. A link another org owns surfaces as a plain 404, not a 403. There is no cross-org read path.
  • Update is a whitelist. PATCH strips organizationId, hyperswitchPaymentId, and id so a caller cannot mass-assign tenancy or identity columns, then applies only nickname, description, expiresAt, allowTipping, and state. The dashboard uses the PUT verb; it maps to the SDK’s update method (and the extensions service’s PATCH).
  • Cancel. Both the dedicated SDK cancel() (POST .../:id/cancel) and an update with state: "CANCELED" set the sidecar state to CANCELED. As noted above, this is a DB-only state change today.

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:

  1. Auth + scope. Requires payment_links:write.
  2. Config guard. If process.env.RESEND_API_KEY is 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.
  3. Validate input. email is required (400 if missing/blank). message is optional and becomes the template’s personalMessage.
  4. Fetch the link via client.paymentLinks.get(id) (org-scoped — a not-owned link 404s here).
  5. Require a URL. If link.linkUrl is null it returns 422 (“This payment link has no shareable URL”).
  6. Format the amount. link.amount is in cents; it is divided by 100 and formatted with Intl.NumberFormat in the link’s currency. When amount is null (e.g. CUSTOMER_CHOOSES) it renders the literal string "Amount at checkout".
  7. Send via @mvs/mail’s sendEmail with templateId: "paymentLinkRequest". The merchantName falls back through link.nickname → auth.context.orgSlug → "MVS Pay". expiresAt is formatted as a long US date. tenantBrand is defaultTenantBrand.
  8. Map the result. sendEmail returns a boolean; false502, true200 with { sent: true, email }.

tenantBrand is hard-coded to defaultTenantBrandthe 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 | dayBucket and 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. paymentLinkRequest is not in the clinical template set, so failed sends are logged and return false (no dead-letter enqueue).
  • Provider. packages/mail/src/provider/index.ts re-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, then POSTs to https://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):

PropSource in the send route
merchantName`link.nickname
amountformatted from link.amount (cents → currency), or "Amount at checkout"
descriptionlink.description ?? undefined
paymentUrllink.linkUrl (button target — “Pay Now”)
expiresAtlink.expiresAt formatted as a long US date, else omitted
personalMessagerequest body message (rendered as an info Alert)
tenantBranddefaultTenantBrand (see warning above)

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)

MethodPath (dashboard)ScopeSDK callExtensions endpoint
POST/api/payment-linkspayment_links:writepaymentLinks.createPOST /v1/payment-links
GET/api/payment-linkspayment_links:readpaymentLinks.listGET /v1/payment-links
GET/api/payment-links/[id]payment_links:readpaymentLinks.getGET /v1/payment-links/:id
PUT/api/payment-links/[id]payment_links:writepaymentLinks.updatePATCH /v1/payment-links/:id
(cancel)payment_links:writepaymentLinks.cancelPOST /v1/payment-links/:id/cancel
POST/api/payment-links/[id]/sendpayment_links:write(none — uses @mvs/mail)n/a

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

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.

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 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".

@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.

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.