A discount on exactly the quiz's picks
Goal: a shopper who finishes the quiz sees an automatic discount at cart and checkout on the products the quiz recommended to them, and on nothing else in the cart. No code to type, no code to leak to coupon sites; the percentage and its ceiling live in the Function, not on the page.
Uses: Storefront JS API (quiz.finished, product.shown) on the page, Shopify’s Ajax cart API to write a cart attribute, and a Shopify Discount Function that reads that attribute. The Function lives in a Shopify CLI app with the write_discounts scope. A custom app that contains a Function runs only on a Shopify Plus store (Shopify Functions: “Only stores on a Shopify Plus plan can use custom apps that contain Shopify Function APIs”); a public App Store app works on any plan.
Why a Function
A page script can add a discount code to the cart, but a code is shareable and applies to whatever the code allows. A Discount Function runs on Shopify’s side on every cart change and decides per cart line: it targets only the lines whose product is in the list the quiz wrote, and it clamps the percentage. Nothing on the page can raise the percentage or turn it into a code.
1. The page writes the picks to the cart
quiz.finished carries products[], every product card the session showed, each with product_id as a Shopify GID (gid://shopify/Product/8692896989320) and its page_key. The script keeps the ids of the result page’s products and writes them to a cart attribute; a card revealed on the result page after the finish arrives as a late product.shown and is added too.
<script> window.octaneai = window.octaneai || []; window.octaneai.push(function (octaneai) { var ATTR = 'octane_quiz_products'; // read by the Function var SESSION_ATTR = 'octane_quiz_session'; var ids = []; var sessionId = null; var resultPage = null; var pending = null;
function add(productId) { if (/^gid:\/\/shopify\/Product\/\d+$/.test(productId) && ids.indexOf(productId) === -1) ids.push(productId); }
function write() { pending = null; var value = ids.slice().sort().join(','); if (!value) return; fetch('/cart.js', { credentials: 'same-origin' }) .then(function (r) { return r.json(); }) .then(function (cart) { var attrs = cart.attributes || {}; if (attrs[ATTR] === value && attrs[SESSION_ATTR] === sessionId) return; // nothing changed var attributes = {}; attributes[ATTR] = value; attributes[SESSION_ATTR] = sessionId; return fetch('/cart/update.js', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ attributes: attributes }) }); }) .catch(function (e) { console.warn('[quiz-discount] cart write failed', e); }); }
function schedule() { clearTimeout(pending); pending = setTimeout(write, 300); // one write per burst of cards }
octaneai.on('quiz.finished', function (event) { var o = event.data.object; sessionId = o.session_id; resultPage = o.terminal_page; o.products.forEach(function (p) { if (p.page_key === resultPage) add(p.product_id); }); schedule(); }); octaneai.on('product.shown', function (event) { var p = event.data.object; if (resultPage && p.page_key === resultPage) { add(p.product_id); schedule(); } // a card revealed after the finish }); });</script>Read the cart before writing so a cart that already carries the same list is left alone, and never use a __-prefixed key: private attributes are not returned by /cart.js, so the read-before-write could not see them. The quiz embed writes its own attributes (__octane_session_id, __octane_app_id, __octane_page_key); these keys are distinct.
2. The Function
In a Shopify CLI app: shopify app generate extension --template discount --name quiz-picks-discount, JavaScript. Add write_discounts to scopes in shopify.app.toml. The generator writes extensions/quiz-picks-discount/shopify.extension.toml with two targets; keep only the cart-lines one (this recipe gives no shipping discount):
api_version = "2026-01"
[[extensions]]name = "t:name"handle = "quiz-picks-discount"type = "function"
[[extensions.targeting]] target = "cart.lines.discounts.generate.run" input_query = "src/cart_lines_discounts_generate_run.graphql" export = "cart_lines_discounts_generate_run"
[extensions.build] command = "" path = "dist/function.wasm"src/cart_lines_discounts_generate_run.graphql, the input query. cart.attribute(key:) returns the one attribute the Function needs; ask for nothing else:
query Input { cart { attribute(key: "octane_quiz_products") { value } lines { id merchandise { __typename ... on ProductVariant { product { id } } } } } discount { discountClasses metafield(namespace: "$app:quiz-picks", key: "function-configuration") { jsonValue } }}src/cart_lines_discounts_generate_run.js:
import { DiscountClass, ProductDiscountSelectionStrategy } from '../generated/api';
const MAX_PERCENT = 30; // the ceiling, compiled in: no metafield edit and no attribute can exceed itconst MAX_IDS = 50; // a quiz shows a handful of products; a longer list is not a quiz resultconst PRODUCT_GID = /^gid:\/\/shopify\/Product\/\d+$/;
function recommendedIds(attribute) { const raw = attribute && attribute.value ? attribute.value : ''; const ids = raw.split(',').map((s) => s.trim()).filter((s) => PRODUCT_GID.test(s)); return new Set(ids.slice(0, MAX_IDS));}
export function cartLinesDiscountsGenerateRun(input) { if (!input.discount.discountClasses.includes(DiscountClass.Product)) return { operations: [] }; const ids = recommendedIds(input.cart.attribute); if (ids.size === 0) return { operations: [] };
const config = (input.discount.metafield && input.discount.metafield.jsonValue) || {}; const percent = Math.min(MAX_PERCENT, Math.max(0, Number(config.percentage) || 0)); if (percent === 0) return { operations: [] };
const targets = input.cart.lines .filter((line) => line.merchandise.__typename === 'ProductVariant' && ids.has(line.merchandise.product.id)) .map((line) => ({ cartLine: { id: line.id } })); if (targets.length === 0) return { operations: [] };
return { operations: [{ productDiscountsAdd: { selectionStrategy: ProductDiscountSelectionStrategy.First, candidates: [{ message: percent + '% off your quiz picks', targets, value: { percentage: { value: percent.toFixed(1) } }, }], }, }], };}One candidate whose targets list every recommended cart line, with selectionStrategy: First, so the single candidate applies to all of them. The percentage comes from the discount’s own metafield (the merchant’s setting) and is clamped inside the Function; the attribute only ever selects which lines. A token that is not a product GID, a list longer than 50, or a discount without the PRODUCT class yields no operations.
3. Create the discount and try it
shopify app devin the app folder and pick the store. The CLI streams every Function run with its input and output.- Create the automatic discount once, with
discountAutomaticAppCreate(from the dev console’s GraphiQL orshopify app execute). From API version 2025-10 the mutation takes the Function’sfunctionHandle; earlier versions need the function id fromshopifyFunctions. The percentage goes in the metafield the input query reads:
mutation { discountAutomaticAppCreate(automaticAppDiscount: { title: "Quiz picks", functionHandle: "quiz-picks-discount", startsAt: "2026-09-15T00:00:00Z", discountClasses: [PRODUCT], combinesWith: { orderDiscounts: true, productDiscounts: false, shippingDiscounts: true }, metafields: [{ namespace: "$app:quiz-picks", key: "function-configuration", type: "json", value: "{\"percentage\": 15}" }] }) { automaticAppDiscount { discountId status } userErrors { field message } }}The discount then appears under Discounts in the admin like any other; deactivate it or change its dates there.
3. On the store, take the quiz to its result page. In the browser’s network panel: POST /cart/update.js with attributes.octane_quiz_products = the GIDs of the result page’s products. Add one recommended product and one unrelated product to the cart.
4. At /cart and at checkout the recommended line shows the struck-through price and the label 15% off your quiz picks; the unrelated line is full price; no code field is involved. The app dev terminal shows the run’s productDiscountsAdd with one target per recommended line. shopify app function replay re-runs a logged input locally while you iterate.
5. shopify app deploy releases the Function; shopify app logs --source extensions.quiz-picks-discount streams production runs.
The page script (step 1) and the cart writes are what this page’s test exercises; the Function build, the discount creation and the checkout check (steps 2 to 5) follow Shopify’s documented flow and need a Shopify CLI app on a Plus (or development) store of your own.
Things to know
- Attributes are shopper-editable. Anything on the page can call
/cart/update.js. The Function treats the attribute as “which lines”, never as proof the quiz was taken, and the ceiling is compiled in. The worst case is exactly the discount you already give quiz takers, on a product you did sell them. If you need proof, have your server confirm the picks:GET /v1/profiles?q=<email>thenGET /v1/profiles/{profile_id}(key withprofiles:read) returnslatest_results[].result_page.shown_products[].product_id; write the confirmed ids to a cart metafield through the Storefront API and read that metafield in the input query instead of the attribute. - The cart is per browser. A quiz taken on the phone does not discount a cart built on the laptop; a signed-in customer’s cart follows them only with a persistent-cart app, and only if that app syncs attributes. Unused carts expire within 30 days, and the attribute with them.
- Combination rules.
combinesWith.productDiscounts: falseunless you want stacking with other product discounts. A store can activate at most 25 discount Functions; they run concurrently with no knowledge of each other. - Only the result page’s products.
products[]lists every card the session showed, on any page; the filter onpage_key === terminal_pagekeeps the picks, not the products of an earlier page. A product the store no longer sells is still listed and simply matches no cart line. - The native per-session discount code (
discount.issued) is the other tool: a code the shopper can copy, applying by the code’s own rules. Use it when the offer is “a code for you”, this Function when the offer is “these products, for you”.
