Skip to content

Prefill what the shopper already told you

Goal: a shopper never answers what you already know. Three sources, from the simplest up: values in the page’s URL (a link from an email or an ad), the signed-in Shopify customer (name and email), and the person’s last completed run of the quiz (every answer, through your server).

Uses: Storefront JS API (ready, prefill, setIdentity, navigate) on the page; for the last-run variant, People (GET /v1/profiles, GET /v1/profiles/{profile_id}) from your server with a key that carries profiles:read. The methods work on every plan; the events need Plus or Enterprise.

What prefill takes

prefill({ componentKey: value }) stages values into the quiz’s answers. Keys are the question keys from the editor (the same keys question.answered and a person’s latest_results[].answers[].component_key report); a key that is not an answer component is ignored. A value is what the quiz stores, not what it shows:

QuestionValue to pass
Single choice, image choiceThe option’s stored value, "dry" (not its label "Dry")
Multi-selectA list of stored values, ["sleep", "energy"]
Text, email, phoneThe string
Number, sliderA number, 42
Date{ "date": "1990-05-04" }

The value is validated when the page is submitted, like a typed one: a stored value the question does not have fires page.rejected with reason: "invalid" on that key, and the shopper sees the same message as for a wrong answer. Prefilled answers on later pages stay staged and appear filled in when the shopper reaches the page. setIdentity({ email, phone }) fills the quiz’s first email and phone fields; it never ticks the marketing checkbox, and nothing can.

1. From the URL

A link such as /pages/quiz?q_image_choice-48su5=dry&q_number_input-re853=42 fills two questions. The prefix keeps quiz values apart from your other parameters; skip=1 says the link covers the whole first page, so the quiz moves on at once.

<script>
window.octaneai = window.octaneai || [];
window.octaneai.push(function (octaneai) {
octaneai.ready(function (api) {
var params = new URLSearchParams(window.location.search);
var values = {};
params.forEach(function (value, key) {
if (key.indexOf('q_') !== 0) return;
var componentKey = key.slice(2);
values[componentKey] = value.indexOf(',') === -1 ? value : value.split(','); // "a,b" is a multi-select
});
if (!Object.keys(values).length) return;
api.prefill(values);
if (params.get('skip') === '1') api.navigate('next');
});
});
</script>

ready runs once the quiz is mounted and has its session, so the values land on a live quiz. Put only choice and number values in a link: an email or a name in a URL is the shopper’s own words in every log along the way; for those, use the next two sources. A URL value is a string; a number question accepts "42" from the link and reports it as typed ("42"), where a number passed from code is reported as 42.

2. From the signed-in Shopify customer

On a Shopify page the theme knows the customer. Liquid puts the customer’s email and name on the page, and setIdentity and prefill do the rest:

{% if customer %}
<script>
window.octaneai = window.octaneai || [];
window.octaneai.push(function (octaneai) {
octaneai.ready(function (api) {
api.setIdentity({ email: {{ customer.email | json }} });
api.prefill({
'text_input-fname': {{ customer.first_name | json }},
'text_input-lname': {{ customer.last_name | json }}
});
});
});
</script>
{% endif %}

| json quotes and escapes the values for a script. The shopper still sees the email field, filled in, and can change it; the marketing checkbox stays as it was. If your quiz asks for the email on its last page, the identity is staged now and captured (identity.captured) when that page is submitted.

3. From the person’s last run

A returning shopper reviews their answers instead of redoing them. The answers live on the person (GET /v1/profiles/{profile_id} -> latest_results[], one entry per quiz), and the API key that reads them must stay on your server. The page calls a route of yours; the route knows who the shopper is from Shopify, not from the browser.

On Shopify the natural route is an app proxy: the storefront calls /apps/quiz/prefill, Shopify forwards it to your server with a signature and, for a signed-in customer, logged_in_customer_id. Your server verifies the signature, resolves that customer’s email with the Admin API, finds the person, and answers the allowlisted answers of the quiz.

import hashlib
import hmac
import os
import httpx
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
OCTANE = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}
QUIZ_ID = "quiz_7c9e6679742540de944be07fc1f90ae7"
PREFILL_KEYS = {"image_choice-48su5", "number_input-re853"} # never an email, a phone or free text
ADMIN_URL = f"https://{os.environ['SHOPIFY_SHOP']}/admin/api/2025-07/graphql.json"
ADMIN_HEADERS = {"X-Shopify-Access-Token": os.environ["SHOPIFY_ADMIN_TOKEN"]}
def proxy_signature_ok(args) -> bool:
message = "".join(sorted(f"{k}={','.join(args.getlist(k))}" for k in args if k != "signature"))
digest = hmac.new(os.environ["SHOPIFY_APP_SECRET"].encode(), message.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(digest, args.get("signature", ""))
def customer_email(customer_id: str) -> str | None:
r = httpx.post(ADMIN_URL, headers=ADMIN_HEADERS, timeout=30,
json={"query": "query ($id: ID!) { customer(id: $id) { email } }",
"variables": {"id": f"gid://shopify/Customer/{customer_id}"}})
r.raise_for_status()
customer = r.json()["data"]["customer"]
return customer and customer["email"]
def last_answers(email: str) -> dict:
r = httpx.get("https://api.octaneai.com/v1/profiles", params={"q": email, "quiz_id": QUIZ_ID}, headers=OCTANE, timeout=30)
r.raise_for_status()
rows = r.json()["data"]
if not rows:
return {}
r = httpx.get(f"https://api.octaneai.com/v1/profiles/{rows[0]['id']}", headers=OCTANE, timeout=30)
r.raise_for_status()
result = next((x for x in r.json()["latest_results"] if x["quiz_id"] == QUIZ_ID), None)
if result is None:
return {}
return {a["component_key"]: a["value"] for a in result["answers"]
if a["component_key"] in PREFILL_KEYS and a["value"] is not None}
@app.get("/apps/quiz/prefill")
def prefill():
if not proxy_signature_ok(request.args):
abort(401)
customer_id = request.args.get("logged_in_customer_id")
email = customer_email(customer_id) if customer_id else None
answers = last_answers(email) if email else {}
print(customer_id, email, sorted(answers)) # 8261234567890 shopper@example.com ['image_choice-48su5', 'number_input-re853']
return jsonify({"answers": answers}), 200, {"Cache-Control": "no-store"}
if __name__ == "__main__":
app.run(port=8080)

The page then fetches the route and prefills what comes back:

<script>
window.octaneai = window.octaneai || [];
window.octaneai.push(function (octaneai) {
octaneai.ready(function (api) {
fetch('/apps/quiz/prefill', { credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : { answers: {} }; })
.then(function (body) {
if (Object.keys(body.answers).length) api.prefill(body.answers);
});
});
});
</script>

value on a person’s answer is the stored value, exactly what prefill takes, so the answers pass through untouched. latest_results holds the person’s latest completed run per quiz; an email or phone answer that was erased carries value: null and removed: true, which the allowlist leaves out anyway.

Errors to handle

WhereWhatDo
GET /v1/profiles403 insufficient_scopeThe key needs profiles:read.
GET /v1/profiles404 not_found on quiz_idThe quiz id is not this store’s, or the quiz is deleted.
GET /v1/profiles/{profile_id}404 not_foundThe person asked to be removed between the two calls; answer { "answers": {} }.
App proxyNo logged_in_customer_idA guest: answer { "answers": {} }, never a 4xx the page would have to handle.
Pagepage.rejected with reason: "invalid"A prefilled value the question does not accept (an option that was renamed, a number out of range). Log the key and let the shopper answer.

Things to know

  • Prefill only what you would show on the screen: the values are on the page for the shopper to read and to change, and anything on the page can read them. Free-text answers from a previous run belong to that run.
  • q on GET /v1/profiles matches one email or phone after normalization (case, dots in a Gmail address, E.164). Pass quiz_id so the list is that quiz’s takers only; the row’s id is the prof_ id the detail route takes.
  • The people routes are Cache-Control: no-store on purpose; keep your proxy’s answer no-store too, so a shared cache never hands one shopper another’s answers.
  • There is no restart method on the API: the prefilled answers become a new session, not a resumed one. The person’s next latest_results entry for the quiz will be this run.
  • Marketing consent cannot be prefilled. A shopper who consented in Shopify still ticks the quiz’s checkbox, or you keep the consent you already hold and ignore the quiz’s.