Both of Notifio's checkout routes end with the same line, give or take one parameter:
success_url: `${appUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
success_url: `${appUrl}/success?upgrade=1&session_id={CHECKOUT_SESSION_ID}`,
Stripe substitutes a real session id into that placeholder and sends the customer there. The page reads one query parameter:
const isUpgrade = (await searchParams).upgrade === "1";
That is it. grep -rn "session_id\|sessionId" src/app --include="*.tsx" returns nothing. The id is put into the URL, delivered to the browser, and never touched by any code we wrote.
That is deliberate, and it is the most important decision on the page.
Reading it would mean fulfilling from a redirect
The obvious thing to do with a session id on a success page is retrieve the session and tell the customer what they bought. For a lot of products that is fine. For this one it is a trap, because the thing the customer needs is not in the session.
A Notifio purchase produces an activation token. The token is generated by the webhook, written to one row, and emailed. The redirect and the webhook are two independent deliveries from Stripe, and nothing orders them. So a success page that fetched the session and then looked up the token would be reading a row that may not exist yet, on a page that renders exactly once, at the least predictable moment in the whole flow.
The failure is not that the page errors. It is that the page sometimes works. You test it, the webhook happens to land first, you see the token, you ship. Then a customer on a slower path sees an empty box where the important thing goes, and the only difference between the two is milliseconds you do not control.
So the page promises the email instead of the token:
Your activation token is on its way. It should arrive within a minute, so check your email.
One sentence, no data fetch, no race. The webhook is the only thing that issues a token, and the only thing that is allowed to be the source of truth about whether a purchase completed. That handler is where all the retry logic lives, which I wrote up in One payment can run our webhook four times, and the token has to come out the same every time.
So why is the parameter there at all
Because it is read by a person, not by code.
When somebody emails to say the token never arrived, the useful question is "can you paste the URL from your browser history". That URL contains the session id, which is the unique column on the row, which is the fastest path from a support email to the exact record and the exact Stripe payment. No account lookup, no asking them to remember which card.
It costs nothing to leave in: it is not a secret, it is not indexable (more on that below), and it identifies a session that cannot be acted on without our API keys. The honest version of this is that session_id in a success_url is Stripe's documented pattern and we kept it, then found a use for it that is not the documented one.
One URL, two products, and three steps each
The upgrade is a second one-time purchase that unlocks auto-reply on a licence somebody already has. It goes through the same page, and almost nothing on that page can be shared:
// The auto-reply upgrade is bought from inside the app by someone who is
// already activated, so the licence-purchase copy below would be wrong for
// them — there is no new token coming.
const isUpgrade = (await searchParams).upgrade === "1";
Two arrays of three strings:
const NEXT_STEPS = [
"Open the activation email from Notifio",
"Download Notifio if you haven't already",
"Enter your email and the token in the app to activate",
];
const UPGRADE_STEPS = [
"Go back to Notifio and open the Auto-reply tab",
"Press Refresh, and it should now say Unlocked",
"Set up each site once, then switch auto-reply on",
];
The heading, the paragraph, the steps and the ending all branch. What makes it one page rather than two is that the difference is entirely copy, and the copy is a pair of constants at the top of the file rather than scattered through the JSX.
The endings are where the branch earns itself. The licence path finishes with a button:
<Link href="/download" className="... h-11 w-full ...">
Go to download
</Link>
The upgrade path finishes with a sentence:
<p className="text-[13px] text-[#525252]">
You can close this tab.
</p>
Because that person did not come from a website. They were in the desktop app, clicked upgrade, the app opened their real browser, and the app is still sitting there behind the window waiting for them. A "Go to download" button would send an existing customer to re-download software they are currently running. "You can close this tab" is the correct call to action, and the fact that it looks like the absence of one is the point.
The same split shows up one level further in. Exactly one of the three licence steps is a link:
{!isUpgrade && i === 1 ? (
<span>
<Link href="/download" ...>Download Notifio</Link> if you haven't already
</span>
) : (
step
)}
Step one happens in an email client. Step three happens in the app. Step two is the only one that happens on the web, so it is the only one that gets a destination. Three steps, one of them actionable from here, and no attempt to make the other two look clickable.
The upgrade variant also spends a sentence on something nobody asked:
That is a one-time payment, and auto-reply stays on your licence for good. Nothing else to pay.
Which is there because the thing a person wonders immediately after paying for an upgrade to a one-time product is whether they have just started a subscription. Our pricing page says it, and the moment after payment is when it is actually load-bearing.
The robots rule names the page and does not match it
export const metadata: Metadata = {
title: "Payment Successful",
robots: { index: false, follow: false },
};
And in robots.ts:
disallow: ["/api/", "/success/"],
Which produces:
User-Agent: *
Allow: /
Disallow: /api/
Disallow: /success/
robots.txt matching is a literal prefix, and /success does not start with /success/. The disallow covers children of a route that has none. The page itself is crawlable.
That turns out to be the outcome you want, for a reason that is easy to get backwards. A Disallow prevents crawling, and a page that is never crawled is a page whose noindex is never read, which is how URLs end up in search results as a bare link with no snippet. The meta tag is the thing that actually keeps the page out of the index, and it only works on a page a crawler is allowed to fetch. So the trailing slash that makes the rule miss is what lets the rule it was supposed to enforce work.
I would rather that were on purpose. The same trailing-slash semantics bit a dashboard route elsewhere, which is robots.txt matching is a literal prefix, and one trailing slash would have left our dashboard crawlable, and the general argument for preferring the meta tag over the robots rule is robots.txt cannot protect a URL somebody forwards, and that is the whole reason for the meta tag. The change I will make here is to drop /success/ from the disallow list entirely, because it is doing nothing and it reads as though it is doing the job the meta tag is doing.
One side effect of it being a real page with real metadata: it is checked by the same test that checks every other page's title length, and Payment Successful | Notifio is 28 characters. A noindex page still gets measured, which is the right default for a test that walks the directory rather than a list somebody maintains.
What transfers
- Decide whether your success page is a receipt or a signpost. If the thing the customer needs is produced asynchronously, the page cannot be a receipt, and trying makes it flaky in a way that passes every test you will write.
- A parameter with no code reading it is not necessarily dead. But say so, or somebody will either delete it or assume it is load-bearing.
- One page per URL, two sets of copy, and put the copy in constants. The branch is readable because every string it chooses between is in the same twelve lines at the top.
- The last element on a confirmation page should match where the user actually is. Half the people who see this page are not in a browser session at all, and the right button for them is no button.
-
noindexneeds the page to be crawlable. If you also disallow it, you get the worst of both.
You can look at the whole flow from the outside: notifio.app/pricing is the checkout, notifio.app/download is step two, and notifio.app/help is what step three asks you to do.
Top comments (0)