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 session | Portal deep link | |
|---|---|---|
| Lifetime | ~35 minutes | Until the token is rotated (never, by default) |
| What it is | One Stripe-hosted payment page | The client's portal, opened at one invoice |
| Pays more than one invoice | No | generate_pay_all_link does, for everything outstanding |
| Safe to store on a row | No | Yes |
| Safe to print or text | No | Yes |
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
- Invoice send: gates and guards
- Can it handle residential and commercial clients?
- Chase old money with a statement and one payment link — in the in-app help centre, once you are signed in