Blog · · 10 min
Post-purchase upsell without an app
On a store you coded yourself, a post-purchase offer is one of two things: a new order the buyer confirms, or an addition to the order just paid. The second needs a saved payment method and a fresh authorisation, and that is the whole difficulty. Everything else is layout.
By uxgen
A post-purchase upsell is an offer shown after the money has moved. That single fact is why it is worth building: it cannot break the purchase you already have. If the buyer ignores it, you keep the original order. There is no risk of distraction, no added field, no extra decision before payment.
On a custom store the offer takes one of two shapes, and choosing between them is the entire engineering decision:
- A new order. The offer links to a pre-filled cart. The buyer confirms and pays again, normally. Easy, honest, and the conversion cost is one more checkout.
- An addition to the order just paid. One tap, no re-entry, same shipment. This requires a payment method you are allowed to reuse and a second authorisation. It is the version worth money, and it is the version people get wrong.
What does "one click" actually require?
Nothing about it is one click on your side. The buyer taps once; you need four things to already be true.
- A payment method stored for reuse. With Stripe that means the original payment was made with
setup_future_usageset, so thePaymentMethodis attached to aCustomerand can be charged later. If you did not set it at the time of the first payment, you cannot charge again without asking for the card again. - A recorded mandate. The buyer must have agreed, at the moment of the first payment, that the card can be charged again for purchases they initiate. That agreement is a sentence next to the pay button, not a checkbox buried in terms.
- A second authorisation that can fail. A merchant-initiated charge can be declined, and in Europe it can require Strong Customer Authentication. Your flow needs a path where the offer is accepted and the charge does not go through.
- A fulfilment record that can grow. If the addition ships with the original order, your order object needs to accept a new line after it was created, and your warehouse needs to see it before the box is closed.
Miss the fourth and you get the failure that costs the most: a customer charged for an item that shipped separately three days later, at your cost.

Where does the offer go?
On the confirmation screen, below the confirmation. Not above it, and not instead of it.
The buyer arriving on that page is looking for three facts: the order number, the amount, and when it arrives. Until they have found all three, every other element on the page is an obstacle. Give them the receipt first, then the offer. The thank-you page as a component covers that ordering in detail.
There is a second placement that works and is much rarer: the shipping notification email. The buyer opens it, the box has not left yet, and the offer is genuinely still addable. It is the only email where an upsell is not an interruption.
What should the offer be?
One item. Not a grid, not a carousel, not three tiers.
The rule that holds across the whole family of upsell surfaces: the offer must be cheaper than what was just bought, and it must be related by use rather than by category. Someone who just bought a coffee grinder is not in the market for a second grinder; they are in the market for the thing that makes the grinder work on Tuesday morning.
| Just bought | Bad offer | Good offer | Why |
|---|---|---|---|
| Grinder, $180 | A second grinder | A cleaning brush, $9 | Related by use, trivially cheap next to $180 |
| One bag of coffee, $19 | A $95 subscription | A second bag, $17 | Same decision they already made, smaller |
| A jacket, $240 | A matching jacket | Waterproofing spray, $14 | Protects the purchase, not a competing one |
| Skincare set, $60 | Another set | Refill of the item in it, $22 | Extends the thing they chose |
The price ratio is the part that carries the mechanic. A $9 offer next to a $180 purchase is not evaluated on its merits; it is evaluated against a number the buyer just accepted. That is not a trick, it is the actual reason the offer is reasonable.
The code: charging a saved method
The first payment has to opt into reuse. On a Checkout Session:
// Stripe: at the FIRST payment, or the second one is impossible.
const session = await stripe.checkout.sessions.create({
mode: "payment",
customer: customerId, // a Customer, not a guest email
line_items: lines,
payment_intent_data: {
setup_future_usage: "off_session",
},
success_url: `${origin}/order/{CHECKOUT_SESSION_ID}`,
});
Then the addition, later, off-session:
export async function chargeAddition(
orderId: string,
customerId: string,
paymentMethodId: string,
amountCents: number,
) {
try {
const intent = await stripe.paymentIntents.create(
{
amount: amountCents,
currency: "eur",
customer: customerId,
payment_method: paymentMethodId,
off_session: true,
confirm: true,
metadata: { kind: "post_purchase", parent_order: orderId },
},
// Same offer accepted twice (double tap, retry) must not charge twice.
{ idempotencyKey: `addition:${orderId}` },
);
return { ok: true as const, intent };
} catch (err) {
// A declined off-session charge lands here, and it is a NORMAL path.
// requires_action means the bank wants the buyer present: send them
// to a page that confirms the intent, do not retry silently.
const pi = (err as { payment_intent?: { id: string; status: string } }).payment_intent;
return { ok: false as const, needsAuth: pi?.status === "requires_action", pi };
}
}
Two lines in there do the real work. The idempotencyKey keyed on the order, not on a random value, is what stops a double tap from becoming a double charge — the buyer's thumb on a mobile confirmation page is the most reliable double-submit generator on the web. And the catch is not error handling, it is a branch of the flow: an off-session charge that needs authentication is expected, not exceptional.
What has to happen on your side of the order
The addition only pays for itself if it ships in the same box. That means a window, and the window is short.
type Order = {
id: string;
lines: Line[];
/** set the moment the pick list is printed — after this, no additions */
pickedAt: string | null;
};
const ADDITION_WINDOW_MINUTES = 30;
export function canStillAdd(order: Order, now = Date.now()): boolean {
if (order.pickedAt) return false; // the box is being packed
const created = Date.parse(order.createdAt);
return now - created < ADDITION_WINDOW_MINUTES * 60_000;
}
Show the offer only while canStillAdd is true. Outside the window, the honest version is the first shape: a pre-filled cart for a new order, with its own shipping. Do not show a one-tap addition you cannot honour — a customer charged for a "we will add it to your box" item that arrives separately has been told something untrue by your software.
When is an app the better answer?
If you are on Shopify, an app that runs the native post-purchase page has access to a surface you cannot build yourself, and that is a real argument. The trade is the pricing model: several of these charge per order rather than per month, which means you pay on orders the widget had nothing to do with. That trade is worked through in upsell apps for Shopify, or build it.
On a store you coded yourself, there is no native surface to rent, so the question does not arise. You have a confirmation route, a customer, and a payment processor. That is the whole dependency list.
The legal line, said once
An addition charged to a saved card is a purchase the buyer initiates by tapping. It is not a subscription and it is not a negative option, and it must not become one. Three rules keep it on the right side:
- The price, in full, next to the button that charges it. Not "add for free", not a price revealed after.
- No pre-selection. A pre-ticked addition on a page where the card is already stored is close to a charge without consent.
- A refund path stated on the same screen, because the buyer had no cart step to reconsider in.
The European rules on unfair commercial practices apply to this surface exactly as they apply to the cart. The specific case of the pre-payment version is covered in the order bump that is legal in Europe; the constraints are the same and the timing is different. As always with anything regulated, check how it lands in the country you actually ship to.
Where this sits in the set
The post-purchase offer is the last of four moments where the basket can grow, and it is the only one that carries no risk to the sale in progress.
| Moment | Surface | Risk to the current sale |
|---|---|---|
| Quantity tiers | product page | none, it is the choice itself |
| Order bump | cart, above the totals | small, it adds a decision before payment |
| Free-shipping bar | cart | none, it acts on a decision already made |
| Post-purchase | confirmation, shipping email | none, the money has moved |
Which is the argument for building it early rather than last. It is the only one of the four where a mistake in judgement costs you an ignored offer rather than an abandoned cart.
We build these as components rather than as prompts, because the parts that matter here — the idempotency key, the fulfilment window, the failure branch — are decisions that have to survive being regenerated. uxgen is a MCP server that hands your coding agent that kind of component as HTML you own, with no per-order fee attached. The open pieces are in the commerce kit, MIT.
FAQ
What is a post-purchase upsell?
It is an offer shown after the buyer has paid, either on the order confirmation screen or in the shipping notification. Because the original order is already complete, the offer cannot cause the purchase to fail, which is what separates it from an upsell placed in the cart or on the product page.
Can I add an item to an order without asking for the card again?
Only if the first payment stored the payment method for reuse and the buyer agreed to that reuse at the time. With Stripe that means setup_future_usage on the original payment and a mandate sentence next to the pay button. Without both, the honest option is a new order with a normal checkout.
What should a post-purchase offer contain?
One item, cheaper than what was just bought, related to it by use rather than by category. A single accessory at a small fraction of the original price is evaluated against a number the buyer just accepted, which is why one item outperforms a grid of three.
How long can I keep the offer open?
Until the pick list is printed. The value of a one-tap addition comes from shipping in the same box, so the window closes when the box does. After that, show a pre-filled cart for a separate order instead, with its own shipping stated, rather than promising an addition you can no longer make.