Blog · · 11 min
The thank-you page is a component, not a receipt
The confirmation screen is the only place in a store where an offer cannot cost you the sale, because the sale is already paid. Answer the four questions the buyer arrived with first, in order, and put the offer under them. A second charge needs a second consent, a link to a new order needs none, and the page must never write the order itself.
By uxgen
Every other surface in a store carries a risk when you put an offer on it: the offer competes with the purchase in progress and can lose you the whole order. The confirmation screen is the one place where that risk is zero, because the money has already moved. Which makes it the most under-built page in most stores, and the only one where the phrase post-purchase is literally true.
The order is fixed. First, the four things the buyer came here to check. Then, and only then, one offer.
What are the four things, in order?
| The question | What renders | Failure if it is missing |
|---|---|---|
| Did it go through | order number, selectable, and the amount charged with the last four of the card | the buyer pays again, or writes to you |
| When does it arrive | a date range, in dates, not "3 to 5 business days" | a support ticket on day four |
| Where is it going | the shipping address, with a way to correct it now | a parcel to a stale wallet address |
| What did I buy | line items, quantities, the total paid | a chargeback that you cannot answer |
The order number should be the first thing on the screen and it should survive a copy. Rendering it inside an image, or splitting it across elements so a double-click grabs half, is a small cruelty with a support cost attached.
The address matters more than it looks, and more if the buyer paid with a wallet. That address came from an account, not from a form they just filled in, so this screen is the last cheap moment to catch a flat number that moved three years ago. A Not right? Fix the address link that opens a short form, backed by a real window before the label is printed, pays for itself in reshipments.
The delivery estimate should be dates. Arriving between Thursday 11 and Monday 15 September is checkable. 3 to 5 business days asks the buyer to run a calendar in their head, and the arithmetic they do is not the arithmetic your warehouse does.

Why is this the only safe place for an offer?
Because there is no sale in progress to lose.
An upsell on a product page competes with the add-to-cart. A bump in the cart competes with the checkout button. Both are worth doing and both are constrained by that competition, which is why the rules for them are so specific: below the button, never above it, never a step to clear, never pre-ticked. That whole argument is in where to place an ecommerce upsell and an order bump that survives European law.
On the confirmation screen the constraint disappears and a different one takes its place. The buyer came here for information. If the offer is above the order number, you have taken a screen they needed and used it to sell, which reads exactly as it is. Put the confirmation block first, complete, and the offer under it. The buyer who wanted the tracking number never has to look at the offer, and the buyer who is still in a buying mood scrolls into it.
One offer. A grid of six related products is a category page wearing a confirmation page's clothes.
The two kinds of post-purchase offer are not the same thing
This is the distinction that decides your implementation, and it is legal before it is technical.
| One-click, charges again | Link to a new order | |
|---|---|---|
| What happens on click | a second payment on the stored method | the buyer lands in a prefilled checkout |
| Needs express consent for the extra payment | yes, and it must be unmistakable | it is a new order, so the ordinary flow gives it |
| Needs a stored payment method | yes, arranged at the first payment | no |
| Can be blocked by a bank challenge | yes, and you must be able to show it | no more than any other checkout |
| Ships in the same parcel | usually, if you hold the order | only if your warehouse merges it |
| Work to build | a payment path, a failure path, a second confirmation | a link and a prefilled cart |
The second row is the one people skip. In the EU, Article 22 of Directive 2011/83/EU requires express consent for any payment beyond the main obligation, and consent inferred from a default the consumer has to reject does not count. A one-click post-purchase upsell is a payment beyond the main obligation by definition. So the control is a button the buyer presses, labelled with the amount, never a pre-ticked box and never a countdown that charges on expiry. And because it forms a new distance contract, it comes with its own confirmation and its own withdrawal period, not a footnote on the first one.
Nothing about that makes the one-click version wrong. It makes it a build with three paths instead of one. If you are shipping this week, the link to a prefilled new order is the version that is honest, legal in every market you sell to, and finished this afternoon.
What does the one-click version actually require?
Two things at the first payment, before the buyer ever reaches this page. A customer object, and permission to reuse the method.
// At the FIRST payment. Without these two lines there is no second charge,
// and you cannot add them retroactively to an order already paid.
const session = await stripe.checkout.sessions.create({
mode: 'payment',
customer: customerId,
line_items: lineItems,
payment_intent_data: { setup_future_usage: 'off_session' },
success_url: `${origin}/order/complete?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/cart`,
})
Then, on the confirmation page, the second charge. The buyer is sitting in front of the screen, so treat the payment as on-session: that way, if the bank asks for a challenge, you can show it instead of failing silently.
// Server. The price comes from your catalogue, never from the request body.
export async function POST(req: Request) {
const { orderId, offerId } = await req.json()
const order = await orders.byId(orderId) // and check it belongs to this session
const offer = OFFERS[offerId] // amount lives here
if (!order || !offer) return new Response('no', { status: 400 })
const intent = await stripe.paymentIntents.create(
{
amount: offer.amountCents,
currency: order.currency,
customer: order.stripeCustomerId,
payment_method: order.stripePaymentMethodId,
confirm: true,
off_session: false, // they are here; allow a challenge
metadata: { parentOrderId: order.id, offerId },
},
{ idempotencyKey: `upsell:${order.id}:${offerId}` }, // one press, one charge
)
return Response.json({ status: intent.status, clientSecret: intent.client_secret })
}
// Client. The two outcomes that are not success both have to be handled,
// and the button must be unpressable while the first press is in flight.
async function accept(offerId: string) {
setBusy(true)
const r = await fetch('/api/upsell', {
method: 'POST',
body: JSON.stringify({ orderId, offerId }),
}).then((r) => r.json())
if (r.status === 'requires_action') {
const { error } = await stripe.handleNextAction({ clientSecret: r.clientSecret })
if (error) return fail('Your bank declined the extra item. Your original order is unchanged.')
} else if (r.status !== 'succeeded') {
return fail('We could not add that. Your original order is unchanged.')
}
setAdded(true)
}
That last sentence in both failure messages is the whole job. A buyer who sees an error on a page titled Thank you assumes the order failed. Say which order is safe, in the same breath as the failure.
How do you re-deliver the page on a reload without replaying anything?
This is the part that turns into duplicate orders in production, and the rule is short: the confirmation page reads, it never writes.
The order becomes real in your webhook handler, not here. The buyer can close the tab during the redirect, lose signal, or land on this URL from their history two days later, and the order has to exist in all three cases. That argument, with the handler, is in Stripe Checkout with Claude Code.
Which leaves one real problem: the redirect can beat the webhook by a second or two. So the loader resolves the order in two steps.
// Server component / route loader. Nothing here creates or mutates an order.
export async function loadConfirmation(sessionId: string, viewerToken?: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
})
if (session.payment_status !== 'paid') return { state: 'unpaid' as const }
// A session id in a URL is shareable. Full details need a token we issued,
// or a short window after payment; otherwise render the minimum.
const full = viewerToken ? await tokens.matches(viewerToken, session.id) : false
const order = await orders.byCheckoutSession(session.id)
if (order) return { state: 'ready' as const, order, full }
// The webhook has not landed yet. Show the confirmation we can prove from
// the session alone, and let the client poll for the order row.
return {
state: 'pending' as const,
full,
provisional: {
amountTotal: session.amount_total,
email: session.customer_details?.email,
items: session.line_items?.data ?? [],
},
}
}
Three properties of that loader are worth naming, because they are the ones an agent leaves out.
payment_statusis checked, not assumed. With delayed payment methods a session can be complete and unpaid, and the confirmation for those says received, waiting for the payment to clear, not paid.- The page degrades instead of failing. In the
pendingbranch the buyer sees an amount and a list, taken from the session itself, which is enough to stop them paying twice. The tracking block appears when the order row lands. - The URL is treated as public. A
session_idgets pasted into support chats and screenshots. Gate the address and the full line items behind a token you issued, or a short window, and render the rest.
And the offer block only mounts in the ready state. Selling into a page that has not yet confirmed the first order is the one version of this that is worse than no offer at all.
Disclosure. We build uxgen, an MCP server that gives a coding agent commerce components, this one included, as HTML that stays in the merchant's repository. The kit is public and MIT if you would rather read the parts than install anything.
FAQ
What should a thank-you page contain?
The order number in copyable text, the amount charged and the method, a delivery estimate written as calendar dates, the shipping address with a way to correct it, and the line items. Those four answers come first and complete, because they are what the buyer opened the page for. Anything you want to sell goes underneath them.
Is a post-purchase upsell allowed in Europe?
Yes, if the extra payment is expressly consented to. Article 22 of Directive 2011/83/EU requires express consent for any payment beyond the main contractual obligation and refuses consent inferred from a default the consumer must reject, so the offer is a button carrying the amount, never a pre-ticked box or an expiry that charges. It also forms a new contract, with its own confirmation and withdrawal period.
Can I charge a second time without asking for the card again?
Only if the first payment was made with a customer attached and the payment method stored for future use, which has to be arranged when that first payment is created and cannot be added afterwards. Treat the second charge as on-session, since the buyer is on the page, so a bank challenge can be displayed rather than failing silently.
How do I stop the thank-you page creating a duplicate order on reload?
Never write from that page. The order is created by your webhook handler, and the confirmation page only reads it back from the checkout session id in the URL. When the redirect arrives before the webhook, render a provisional confirmation from the session itself and let the client poll for the order row rather than writing one.