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:
| Question | Value to pass |
|---|---|
| Single choice, image choice | The option’s stored value, "dry" (not its label "Dry") |
| Multi-select | A list of stored values, ["sleep", "energy"] |
| Text, email, phone | The string |
| Number, slider | A 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 hashlibimport hmacimport osimport httpxfrom 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 textADMIN_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)import express from "express";import { createHmac, timingSafeEqual } from "node:crypto";
const OCTANE = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}` };const QUIZ_ID = "quiz_7c9e6679742540de944be07fc1f90ae7";const PREFILL_KEYS = new Set(["image_choice-48su5", "number_input-re853"]); // never an email, a phone or free textconst ADMIN_URL = `https://${process.env.SHOPIFY_SHOP}/admin/api/2025-07/graphql.json`;const ADMIN_HEADERS = { "X-Shopify-Access-Token": process.env.SHOPIFY_ADMIN_TOKEN!, "Content-Type": "application/json" };
function proxySignatureOk(query: Record<string, unknown>): boolean { const message = Object.keys(query).filter((k) => k !== "signature").sort() .map((k) => `${k}=${([] as string[]).concat(query[k] as string | string[]).join(",")}`).join(""); const digest = createHmac("sha256", process.env.SHOPIFY_APP_SECRET!).update(message).digest("hex"); const given = String(query.signature ?? ""); return given.length === digest.length && timingSafeEqual(Buffer.from(digest), Buffer.from(given));}
async function customerEmail(customerId: string): Promise<string | null> { const res = await fetch(ADMIN_URL, { method: "POST", headers: ADMIN_HEADERS, body: JSON.stringify({ query: "query ($id: ID!) { customer(id: $id) { email } }", variables: { id: `gid://shopify/Customer/${customerId}` } }) }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return (await res.json()).data.customer?.email ?? null;}
async function lastAnswers(email: string): Promise<Record<string, unknown>> { const qs = new URLSearchParams({ q: email, quiz_id: QUIZ_ID }); let res = await fetch(`https://api.octaneai.com/v1/profiles?${qs}`, { headers: OCTANE }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const rows = (await res.json()).data; if (!rows.length) return {}; res = await fetch(`https://api.octaneai.com/v1/profiles/${rows[0].id}`, { headers: OCTANE }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const result = (await res.json()).latest_results.find((r: { quiz_id: string }) => r.quiz_id === QUIZ_ID); const answers: Record<string, unknown> = {}; for (const a of result?.answers ?? []) if (PREFILL_KEYS.has(a.component_key) && a.value !== null) answers[a.component_key] = a.value; return answers;}
const app = express();app.get("/apps/quiz/prefill", async (req, res) => { if (!proxySignatureOk(req.query as Record<string, unknown>)) return res.sendStatus(401); const customerId = req.query.logged_in_customer_id as string | undefined; const email = customerId ? await customerEmail(customerId) : null; const answers = email ? await lastAnswers(email) : {}; console.log(customerId, email, Object.keys(answers).sort()); // 8261234567890 shopper@example.com [ 'image_choice-48su5', 'number_input-re853' ] res.set("Cache-Control", "no-store").json({ answers });});app.listen(8080);package main
import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "net/url" "os" "sort" "strings")
const quizID = "quiz_7c9e6679742540de944be07fc1f90ae7"
var prefillKeys = map[string]bool{"image_choice-48su5": true, "number_input-re853": true} // never an email, a phone or free text
func proxySignatureOK(q url.Values) bool { parts := []string{} for k, v := range q { if k != "signature" { parts = append(parts, k+"="+strings.Join(v, ",")) } } sort.Strings(parts) mac := hmac.New(sha256.New, []byte(os.Getenv("SHOPIFY_APP_SECRET"))) mac.Write([]byte(strings.Join(parts, ""))) return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(q.Get("signature")))}
func customerEmail(customerID string) (string, error) { body, _ := json.Marshal(map[string]any{ "query": "query ($id: ID!) { customer(id: $id) { email } }", "variables": map[string]string{"id": "gid://shopify/Customer/" + customerID}, }) req, _ := http.NewRequest("POST", "https://"+os.Getenv("SHOPIFY_SHOP")+"/admin/api/2025-07/graphql.json", bytes.NewReader(body)) req.Header.Set("X-Shopify-Access-Token", os.Getenv("SHOPIFY_ADMIN_TOKEN")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer res.Body.Close() var out struct { Data struct { Customer *struct { Email string `json:"email"` } `json:"customer"` } `json:"data"` } json.NewDecoder(res.Body).Decode(&out) if out.Data.Customer == nil { return "", nil } return out.Data.Customer.Email, nil}
func octane(path string, into any) error { req, _ := http.NewRequest("GET", "https://api.octaneai.com"+path, nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode != 200 { return fmt.Errorf("status %d", res.StatusCode) } return json.NewDecoder(res.Body).Decode(into)}
func lastAnswers(email string) (map[string]any, error) { answers := map[string]any{} var list struct { Data []struct { ID string `json:"id"` } `json:"data"` } q := url.Values{"q": {email}, "quiz_id": {quizID}} if err := octane("/v1/profiles?"+q.Encode(), &list); err != nil || len(list.Data) == 0 { return answers, err } var profile struct { LatestResults []struct { QuizID string `json:"quiz_id"` Answers []struct { ComponentKey string `json:"component_key"` Value any `json:"value"` } `json:"answers"` } `json:"latest_results"` } if err := octane("/v1/profiles/"+list.Data[0].ID, &profile); err != nil { return answers, err } for _, r := range profile.LatestResults { if r.QuizID != quizID { continue } for _, a := range r.Answers { if prefillKeys[a.ComponentKey] && a.Value != nil { answers[a.ComponentKey] = a.Value } } } return answers, nil}
func main() { http.HandleFunc("/apps/quiz/prefill", func(w http.ResponseWriter, r *http.Request) { if !proxySignatureOK(r.URL.Query()) { http.Error(w, "bad signature", 401) return } answers := map[string]any{} email := "" if id := r.URL.Query().Get("logged_in_customer_id"); id != "" { email, _ = customerEmail(id) } if email != "" { answers, _ = lastAnswers(email) } fmt.Println(email, len(answers)) // shopper@example.com 2 w.Header().Set("Cache-Control", "no-store") json.NewEncoder(w).Encode(map[string]any{"answers": answers}) }) http.ListenAndServe(":8080", nil)}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
| Where | What | Do |
|---|---|---|
GET /v1/profiles | 403 insufficient_scope | The key needs profiles:read. |
GET /v1/profiles | 404 not_found on quiz_id | The quiz id is not this store’s, or the quiz is deleted. |
GET /v1/profiles/{profile_id} | 404 not_found | The person asked to be removed between the two calls; answer { "answers": {} }. |
| App proxy | No logged_in_customer_id | A guest: answer { "answers": {} }, never a 4xx the page would have to handle. |
| Page | page.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.
qonGET /v1/profilesmatches one email or phone after normalization (case, dots in a Gmail address, E.164). Passquiz_idso the list is that quiz’s takers only; the row’sidis theprof_id the detail route takes.- The people routes are
Cache-Control: no-storeon purpose; keep your proxy’s answerno-storetoo, 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_resultsentry 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.
