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

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:

  1. 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.
  2. 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_usage set, so the PaymentMethod is attached to a Customer and 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.

Two brown parcels on dark slate, the left one taped shut and labelled, the right one still open with a small extra sachet resting on the flap and a brass stamp beside it reading ADD.
The whole mechanic in one picture: the addition is cheap while the box is still open, and expensive one minute after it is taped.

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 boughtBad offerGood offerWhy
Grinder, $180A second grinderA cleaning brush, $9Related by use, trivially cheap next to $180
One bag of coffee, $19A $95 subscriptionA second bag, $17Same decision they already made, smaller
A jacket, $240A matching jacketWaterproofing spray, $14Protects the purchase, not a competing one
Skincare set, $60Another setRefill of the item in it, $22Extends 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.

MomentSurfaceRisk to the current sale
Quantity tiersproduct pagenone, it is the choice itself
Order bumpcart, above the totalssmall, it adds a decision before payment
Free-shipping barcartnone, it acts on a decision already made
Post-purchaseconfirmation, shipping emailnone, 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.

uxgen is a service of UXGen AI, LLC — 131 Continental Dr, Suite 305, Newark, DE 19713, United States.

© 2026 UXGen AI, LLC. All rights reserved.