Skip to content
Ecom

Checkout Upsells on Shopify Plus: What Works, What Backfires

Post-purchase vs in-checkout upsells on Shopify Plus, realistic AOV lift data, the three upsell patterns that quietly destroy conversion, and the exact Shopify Functions plus UI extension code for a...

By WitsCode11 min read

Most Shopify Plus upsell projects are quietly losing money. Not because the upsell is bad, but because it sits in a render target that should never have held an offer. The store sees a pretty take-rate number in the app dashboard, celebrates a 6 percent AOV lift on takers, and never connects the 3 percent drop in checkout completion to the widget installed two months earlier. We have audited more than forty Shopify Plus upsell implementations at WitsCode, and the same three patterns keep killing conversion. This is the version of that guidance written by someone who has debugged the 3PL fulfilment fallout at two in the morning when a post-purchase upsell landed after the pick slip printed.

The Only Two Surfaces That Matter on Shopify Plus

Everything sold as a "Shopify checkout upsell" on Plus runs on one of two technical surfaces. The in-checkout Checkout UI Extension, which renders inside the checkout page itself and is gated to Plus merchants for most targets. And the Post-Purchase UI Extension, which renders on the short-lived page that sits between the customer pressing Pay and the thank-you page resolving. Both are Shopify-owned, React-based extension points, and both are what apps like ReConvert, AfterSell, and Monk ultimately wrap.

Cart page upsells, product page bundles, and thank-you page offers are different surfaces with different mechanics. When clients say "checkout upsell," they almost always mean one of the two above, and the choice between them determines whether the project works or quietly bleeds revenue. Get the surface wrong and no amount of creative, copy, or A/B testing will rescue it.

Shopify Functions is a third piece of the stack but it is commonly misunderstood. Functions do not render upsell UI. They run server-side in the cart evaluation pipeline and enforce rules, so the natural role for a Function here is to apply the post-purchase discount, validate that the upsell line came from the approved source via a cart attribute, or transform a single SKU into a bundle. Working Rust code below.

In-Checkout vs Post-Purchase: The Honest Trade-Off

In-checkout upsells land the offer before the customer has committed. They increase AOV on the same payment authorisation, they do not need a second charge against a stored payment method, and they work across every payment method Shopify supports. The cost is that they add decision load to a page the customer is trying to escape. Done at the wrong render target, the AOV lift on takers is destroyed by the checkout completion rate drop across everyone else.

Post-purchase upsells land after the charge on the original cart has succeeded. The customer has mentally exited the decision loop. Shopify retries the stored payment method as a merchant-initiated transaction, so most SCA challenges are skipped and the flow feels genuinely one-click. The cost is that the surface is narrower. Only one post-purchase extension can be active per store. BNPL providers, PayPal, and Shop Pay Installments do not reliably support the merchant-initiated retry, so you have to silently skip the offer for those customers because showing it and failing the charge is worse than not showing it at all.

The realistic numbers we see across WitsCode engagements, blended across the whole order flow and not just takers, are a 5 to 10 percent AOV lift on post-purchase one-click offers with take rates of 5 to 12 percent, and a 3 to 8 percent AOV lift on disciplined in-checkout accessory upsells with take rates of 2 to 6 percent. Vendor decks quoting 30 percent lifts are almost always measuring AOV among customers who took the offer, or comparing to a promo window baseline, or both. If you are promised more than 15 percent blended lift on a mature Plus store, the number is either wrong or it is about to cost you elsewhere.

The Render Target That Quietly Kills Checkout Completion

If you take one thing from this article, take this. Do not put an upsell widget immediately above the Pay Now button. The Checkout UI Extension framework exposes a target called purchase.checkout.actions.render-before, and because it is visually prominent and sits in the buyer's final line of sight, it is the first place inexperienced teams drop an upsell. It is also the worst possible place for one.

The customer at that point has reviewed cart, entered shipping, selected a method, entered payment details, and moved the cursor towards the confirm button. Their working memory is loaded with the commitment. Introducing a new product decision in that exact moment pulls the buyer out of the commitment frame and into a fresh evaluation loop. In the audits we have run, completion rates at this target drop between 2 and 7 percent. The upsell take rate might be a respectable 4 percent, but 4 percent of a cart value lifted on 96 percent of orders does not come close to covering a 3 percent drop in completed orders across the whole volume.

Safer targets for in-checkout upsell widgets are purchase.checkout.cart-line-list.render-after, which sits below the cart summary and is read as part of the cart rather than as a new decision, and purchase.checkout.shipping-option-list.render-before, which frames the offer as related to delivery, which the customer is already evaluating. Both of these treat the upsell as contextual information rather than as a final gate. Completion rates stay flat, and take rate is comparable on a correctly priced accessory.

The Three Upsell Patterns That Backfire

The actions-render-before placement is pattern one. Pattern two is the price-jumping upsell. The product in cart is a £38 serum. The upsell is a £94 device. The customer opened the transaction with a mental anchor of around £40. Introducing a £94 option does not read as "complete your order," it reads as "you might be spending too much." The consequence is not only a low take rate, which is expected, it is also a small measurable uptick in cart abandonment on the original SKU and a higher refund rate in the first 72 hours after purchase because the customer is now in a cart-reconsideration frame rather than a purchase frame. The rule we enforce on WitsCode builds is simple: upsell price under 40 percent of subtotal, ideally under 25 percent. Above that threshold the offer becomes a cross-sell, not an upsell, and it belongs on the product page or in an email flow, not at checkout.

Pattern three is the algorithmic cross-sell with no curation. Pulling "frequently bought together" from a naive recommender without product-type fencing produces the serum-plus-socks offer that signals the store does not know its own catalogue. Take rates below 1 percent are the symptom, brand perception damage is the real cost, and customers do mention it in post-purchase surveys when asked. For stores under roughly 500 SKUs, manually curating the upsell product per source SKU or per collection beats every algorithm we have tested. For larger catalogues the curation shifts to product-type rules with explicit exclusions rather than pure collaborative filtering. The engineering work to support this is minimal. The discipline to do it is where projects fail.

The Post-Purchase Flow, End to End

Here is the working anatomy of a post-purchase upsell that does not surprise anyone. The customer completes checkout and Shopify processes the payment. Between payment success and the thank-you page, Shopify checks whether a post-purchase extension is registered and whether its ShouldRender lifecycle returns true. If so, the extension renders. The customer accepts, and the extension calls calculateChangeset to get a token representing the add, then applyChangeset with that token, which tells Shopify to charge the stored payment method for the delta and update the order. The thank-you page then renders with the updated line items.

The extension code, for API version 2025-01 using the post-purchase UI extensions React package, looks like this.

import { useEffect, useState } from 'react';
import {
  extend,
  render,
  BlockStack,
  Button,
  CalloutBanner,
  Heading,
  Image,
  Text,
  useExtensionApi,
} from '@shopify/post-purchase-ui-extensions-react';

render('Checkout::PostPurchase::Render', () => <App />);

extend('Checkout::PostPurchase::ShouldRender', async ({ inputData, storage }) => {
  const eligible = inputData.initialPurchase.lineItems.some(
    (li) => li.product.tags.includes('upsell-eligible')
  );
  if (!eligible) return { render: false };

  const offer = await pickOffer(inputData.initialPurchase);
  if (!offer) return { render: false };

  await storage.update(offer);
  return { render: true };
});

function App() {
  const { storage, calculateChangeset, applyChangeset, done } = useExtensionApi();
  const offer = storage.initialData;
  const [changeset, setChangeset] = useState(null);
  const [busy, setBusy] = useState(false);

  useEffect(() => {
    calculateChangeset({
      changes: [{
        type: 'add_variant',
        variantId: offer.variantId,
        quantity: 1,
        attributes: [{ key: '_source', value: 'post_purchase_upsell' }],
      }],
    }).then(setChangeset);
  }, []);

  const accept = async () => {
    setBusy(true);
    try {
      await applyChangeset(changeset.token);
    } finally {
      done();
    }
  };

  return (
    <BlockStack>
      <CalloutBanner>Add this before we ship your order</CalloutBanner>
      <Image source={offer.imageUrl} />
      <Heading>{offer.title}</Heading>
      <Text>{`One-time offer at £${offer.displayPrice.toFixed(2)}`}</Text>
      <Button onPress={accept} loading={busy}>Add to my order</Button>
      <Button subdued onPress={() => done()}>No thanks</Button>
    </BlockStack>
  );
}

Three details in that code matter more than they look. The _source cart attribute on the added line is the hook a downstream Discount Function uses to apply the post-purchase discount without making the same SKU discounted everywhere else on the site. The displayPrice must be tax-inclusive in UK and EU stores, because Shopify will render the tax-inclusive number on the thank-you page and a mismatch between offer screen and charge screen is a Trustpilot complaint waiting to happen. And the pickOffer call in ShouldRender must return null for orders where the payment method does not support MIT retries. The cleanest way to detect that is to inspect inputData.initialPurchase.lineItems for signals from the order's payment gateway metadata, or to simply skip the offer if the order attribute set by your checkout extension flagged a non-MIT method.

The Shopify Functions Piece

The Function is where the discount gets applied. It does not render anything, it does not offer anything, and it does not know the word "upsell." It reads the cart attributes on each line, sees the _source tag, and applies a 15 percent discount to that line only. Rust target, compiled to WASM, deployed with the Shopify CLI.

use shopify_function::prelude::*;
use shopify_function::Result;
use rust_decimal_macros::dec;

generate_schema!("schema.graphql");

#[shopify_function]
fn cart_lines_discounts_generate_run(
    input: input::ResponseData,
) -> Result<output::CartLinesDiscountsGenerateRunResult> {
    let mut operations = Vec::new();

    for line in input.cart.lines.iter() {
        let is_upsell = line
            .attribute
            .as_ref()
            .and_then(|a| a.value.as_deref())
            .map(|v| v == "post_purchase_upsell")
            .unwrap_or(false);

        if !is_upsell { continue; }

        operations.push(output::CartOperation::ProductDiscountsAdd(
            output::ProductDiscountsAddOperation {
                selection_strategy: output::ProductDiscountSelectionStrategy::First,
                candidates: vec![output::ProductDiscountCandidate {
                    targets: vec![output::ProductDiscountCandidateTarget::CartLine(
                        output::CartLineTarget {
                            id: line.id.clone(),
                            quantity: None,
                        },
                    )],
                    value: output::ProductDiscountCandidateValue::Percentage(
                        output::Percentage { value: dec!(15.0) },
                    ),
                    message: Some("Post-purchase offer".to_string()),
                    associated_discount_code: None,
                }],
            },
        ));
    }

    Ok(output::CartLinesDiscountsGenerateRunResult { operations })
}

That Function plus the extension above plus a small pick-offer service is the entire upsell stack. No ReConvert, no AfterSell, no monthly fee, no third-party SDK in the checkout bundle. On a Plus store doing meaningful volume the avoided app fees alone justify the build inside the first quarter.

The Fulfilment and Refund Gotchas Nobody Talks About

The part of this flow that regularly burns teams sits downstream of the payment retry. When the customer accepts the offer, the upsell line is appended to the existing order. Shopify fires orders/updated. If your 3PL integration is listening to orders/paid or orders/create and already printed a pick slip, the upsell line will not be on it. The upsell simply ships missing, or not at all. The operations team explains this as "the app is broken." The app is not broken. Your webhook plumbing assumed orders never change after creation, and post-purchase upsells break that assumption.

The fix is to subscribe to orders/updated specifically for orders with a line carrying the _source: post_purchase_upsell attribute and reissue the pick instruction to the 3PL, ideally with a "replaces previous slip" flag if the provider supports it. On Shopify's side, the order.note_attributes or the line-level properties carry the attribute through to downstream consumers, so the 3PL integration can filter on it cheaply.

Tax is the second trap. If the upsell SKU sits in a different tax class from the original order, the total charged against the customer's stored payment method will differ from the number shown on the offer screen unless the offer screen is rendered with a tax-inclusive price. In the UK and most of the EU this is a legal requirement, not a preference. Display the inclusive price, compute it at ShouldRender time using inputData.initialPurchase.taxLines as a reference, and make sure the pickOffer function returns an displayPrice that already includes VAT.

Shipping is the third. Address validation, carrier rates, and package dimensions were all computed against the original cart. If the upsell pushes the shipment into a higher weight bracket or a dimensional surcharge, the merchant absorbs the delta. Shopify will not, and cannot, re-prompt the customer for an updated shipping cost on a post-purchase upsell. The defensive posture is to never offer physical SKUs in post-purchase that would push weight across a carrier break, and to curate the eligible offer list accordingly.

Refunds are the fourth. Cancellation within the return window gives you a single order carrying two distinct purchase intents. Partial refunds via refund_line_items work correctly, but if the 3PL has already separated shipments, COGS reconciliation will show the upsell landing in a different period from the original. Tag the upsell line at the order level as well as the line level so finance can filter exports cleanly.

Where WitsCode Engineers Actually Earn the Fee

Shopify Plus stores hire WitsCode for this work rather than stacking apps because the job is 30 percent checkout extension code and 70 percent the pieces nobody sells a plugin for. Picking offers that respect payment method constraints. Handling tax-inclusive display in UK and EU storefronts. Plumbing orders/updated into the 3PL so pick slips do not ghost the upsell. Writing the Function that discounts the line without breaking every other discount rule the store runs. Choosing the render target that does not strangle completion. None of that ships in a box.

We build these as a single deployable across Checkout UI Extension, Post-Purchase UI Extension, Shopify Function, and the small offer-picking service that sits behind them. It replaces three or four apps, removes the monthly fees, removes the third-party SDK load from the checkout bundle, and gives the merchant a unit of engineering they own rather than rent. If your Plus store is currently running an upsell app that is lifting AOV on takers but you cannot confidently tell whether total revenue is up, that is the exact problem we are usually called in to fix.

If you want a second pair of eyes on an existing upsell implementation, or a clean-room build that does not rely on the app stack, the WitsCode team takes a small number of Plus engagements each quarter. The first conversation is free and usually ends with a short note identifying the specific render target, price anchor, or webhook gap that is costing you money. Chaos to code to clarity, on the surface that controls revenue.

Get weekly field notes.

Practical writing on shipping products, straight to your inbox. No spam.

Need help with this?

Shopify Development

We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.

Talk to us

Want to discuss ecom for your business?

Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.