ExplanationView as Markdown ↗

A payment link you can store

In plain terms

Two things get called a payment link. One is a checkout page that expires about half an hour after you make it — fine for paying on the spot. The other is the client's own portal page: it never expires and makes a fresh checkout page each time it opens. Anything you save or hand over must be that one.

Two URLs are both called a pay-now link, and only one survives being stored: a Stripe checkout session expires about 35 minutes after it is minted, while the client portal deep link never expires and mints a fresh session on each open. Store the portal link; use a checkout session only for a payment being made in front of you.

At a glance

Who it's for:
Engineers building on Biloh
What a person approves:
Biloh never moves money: its tools record payments that already happened, and a pay-now link opens the business's own Stripe checkout for its client.

Two different URLs in this platform are both called a "pay now" link, and they fail in opposite directions. Choosing the wrong one does not break a test — it hands somebody a dead link a week later.

The two links

generate_pay_now_link creates a Stripe Checkout Session on the tenant's connected account and returns Stripe's hosted URL. It also returns expires_at, and that field is the whole point: the session expires about 35 minutes after it is created. That is deliberate, not a limitation to work around — a long-lived checkout session is an open window in which the same invoice can be paid twice.

The client portal deep link is the other one. It is the client's persistent portal URL with the invoice named on it, in the shape /portal/client/{token}/finance?pay={invoice_id} — get_client_portal_link returns the token half. It does not expire. Each time the client opens it, the portal mints a fresh checkout session behind the page. The token is reused unchanged unless the tenant has set portal.link_ttl_days, which defaults to 0; with a TTL set, an aged token is rotated and older copies of the URL stop working, which is the behaviour a tenant opts into deliberately.

Checkout sessionPortal deep link
Lifetime~35 minutesUntil the token is rotated (never, by default)
What it isOne Stripe-hosted payment pageThe client's portal, opened at one invoice
Pays more than one invoiceNogenerate_pay_all_link does, for everything outstanding
Safe to store on a rowNoYes
Safe to print or textNoYes

The rule that follows

If a link is going to outlive the moment it was made, it must be the portal link. A link stored in a column, printed on a document, pasted into a message thread, or left on a screen an operator comes back to tomorrow is all the same case. A checkout session put anywhere durable is a dead link within the hour, and the person who finds it dead is a customer trying to pay.

A checkout session is the right answer for exactly one shape: a payment happening in front of you, now, where a bounded window is a feature.

Build the URL in one place

The link emailed to a client and the link an operator copies off a screen have to be the same string. If two pieces of code assemble that URL separately, they will eventually disagree — different host resolution, a different query parameter — and "what link did the client actually get" stops having one answer.

So the host cascade and the URL shape belong in one function that every surface calls. The cascade itself is worth naming because it is easy to get wrong: a tenant's canonical domain, then their marketing domain, then a slug-derived host, then the platform host. A link built with the platform host for a tenant who has their own domain works, but it looks like it came from someone else's business.

What to do when the link cannot be made

Minting a link touches a connected Stripe account and a portal token, so it can fail while the invoice itself is perfectly fine.

When that happens, the invoice stays sent. Issuing a document and producing a convenience link are two different jobs, and the second one failing is not grounds for undoing the first. The client has a valid invoice, payable by bank transfer like any other, and the link is retried. Rolling the send back instead would turn a missing button into an unbilled visit — much the worse outcome, and much harder to notice.

This is the same lifecycle-versus-delivery split that governs invoice sends generally: an invoice is issued when it transitions to sent, and whether a given channel reached the client is recorded separately rather than being allowed to rewrite the issuance.

Related

Under the hood

Source files this page is checked against:

  • lib/payments/stripe/checkout.ts
  • lib/finance/pay-now-portal-link.ts

Raw markdown: /docs-md/explanation/a-payment-link-you-can-store

Last updated 2026-11-08