Create a Shopify customer when a quiz finishes
Goal: the moment a shopper finishes a quiz and leaves an email, a Shopify customer exists (or is updated) with their name, their answers as metafields, and marketing consent only when they ticked the box.
Uses: Webhooks (quiz.finished with contact details) on your server; or the Storefront JS API from the page when you would rather not run a webhook receiver. Scopes: webhooks:manage and profiles:read on the key that creates the endpoint (contact details are personal data and need both). On the Shopify side, a custom app with write_customers and access to protected customer data.
1. Subscribe with contact details
curl -X POST https://api.octaneai.com/v1/webhooks \ -H "Authorization: Bearer $OCTANE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://hooks.example.com/octane", "topics": ["quiz.finished"], "include_pii": true, "description": "Shopify customer sync"}'import osimport httpx
r = httpx.post( "https://api.octaneai.com/v1/webhooks", headers={"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}, json={"url": "https://hooks.example.com/octane", "topics": ["quiz.finished"], "include_pii": True, "description": "Shopify customer sync"}, timeout=30)r.raise_for_status()created = r.json()print(created["endpoint"]["id"], created["endpoint"]["status"]) # whe_... pendingprint(created["secret"]) # whsec_...: put it in OCTANEAI_WEBHOOK_SECRET on the receiverconst res = await fetch("https://api.octaneai.com/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.OCTANE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ url: "https://hooks.example.com/octane", topics: ["quiz.finished"], include_pii: true, description: "Shopify customer sync" }),});if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);const created = await res.json();console.log(created.endpoint.id, created.endpoint.status); // whe_... pendingconsole.log(created.secret); // whsec_...: put it in OCTANEAI_WEBHOOK_SECRET on the receiverpackage main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os")
func main() { body := []byte(`{"url": "https://hooks.example.com/octane", "topics": ["quiz.finished"], "include_pii": true, "description": "Shopify customer sync"}`) req, _ := http.NewRequest("POST", "https://api.octaneai.com/v1/webhooks", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() if res.StatusCode >= 400 { log.Fatalf("status %d", res.StatusCode) } var created struct { Endpoint struct { ID string `json:"id"` Status string `json:"status"` } `json:"endpoint"` Secret string `json:"secret"` } json.NewDecoder(res.Body).Decode(&created) fmt.Println(created.Endpoint.ID, created.Endpoint.Status) // whe_... pending fmt.Println(created.Secret) // whsec_...: put it in OCTANEAI_WEBHOOK_SECRET on the receiver}With include_pii: true the session object carries identities[] (each with kind, value, consent) and the answers to email and phone fields. Without it, every message carries ids and the other answers only, and this recipe has nothing to work with.
2. Receive quiz.finished
The object is described in Webhooks. The parts this recipe reads:
{ "id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e", "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "answers": { "text_input-fname": { "page_key": "you", "kind": "text", "option_ids": [], "value": "Ada", "values": ["Ada"], "label": "First name" }, "image_choice-48su5": { "page_key": "skin", "kind": "image", "option_ids": ["dry"], "value": "dry", "values": ["Dry"], "label": "Skin type?" } }, "identities": [ { "kind": "email", "value": "ada@example.com", "first_seen_at": "2026-09-07T09:10:00Z", "last_seen_at": "2026-09-07T09:10:00Z", "consent": true } ], "terminal_page": { "page_key": "result", "title": "Your match", "position": 3, "page_count": 3, "shown_products": [] }}This is the job that Verify a webhook signature’s accept enqueues; it receives the parsed event. upsert_customer, set_metafields and subscribe_to_email_marketing are your functions wrapping the three Shopify mutations in step 3.
def upsert_customer(email: str, first_name, last_name) -> str: """Your function here: customerSet by email; returns the customer GID.""" raise NotImplementedError
def set_metafields(customer_id: str, fields: dict) -> None: """Your function here: metafieldsSet under the octane namespace.""" raise NotImplementedError
def subscribe_to_email_marketing(customer_id: str) -> None: """Your function here: customerEmailMarketingConsentUpdate.""" raise NotImplementedError
def handle(event): if event["type"] != "quiz.finished": return s = event["data"]["object"] email = next((i for i in s.get("identities") or [] if i["kind"] == "email"), None) if not email: return
def answer(key): a = s["answers"].get(key) return ", ".join(a["values"]) if a else None
customer_id = upsert_customer(email["value"], answer("text_input-fname"), answer("text_input-lname")) set_metafields(customer_id, {"skin_type": answer("image_choice-48su5"), "quiz_session": s["id"]}) if email["consent"] is True: subscribe_to_email_marketing(customer_id) print(s["id"], email["value"], customer_id) # sess_9b2d... ada@example.com gid://shopify/Customer/...// Your functions here, wrapping the three Shopify mutations of step 3.async function upsertCustomer(email: string, firstName: string | null, lastName: string | null): Promise<string> { throw new Error("not implemented"); }async function setMetafields(customerId: string, fields: Record<string, string | null>): Promise<void> { throw new Error("not implemented"); }async function subscribeToEmailMarketing(customerId: string): Promise<void> { throw new Error("not implemented"); }
async function handle(event: { type: string; data: { object: any } }) { if (event.type !== "quiz.finished") return; const s = event.data.object; const email = (s.identities || []).find((i: { kind: string }) => i.kind === "email"); if (!email) return;
const answer = (key: string): string | null => (s.answers[key] ? s.answers[key].values.join(", ") : null);
const customerId = await upsertCustomer(email.value, answer("text_input-fname"), answer("text_input-lname")); await setMetafields(customerId, { skin_type: answer("image_choice-48su5"), quiz_session: s.id }); if (email.consent === true) await subscribeToEmailMarketing(customerId); console.log(s.id, email.value, customerId); // sess_9b2d... ada@example.com gid://shopify/Customer/...}package main
import ( "encoding/json" "fmt" "log" "os" "strings")
// Your functions here, wrapping the three Shopify mutations of step 3.func upsertCustomer(email, firstName, lastName string) string { panic("not implemented") }func setMetafields(customerID string, fields map[string]string) { panic("not implemented") }func subscribeToEmailMarketing(customerID string) { panic("not implemented") }
type finished struct { ID string `json:"id"` Answers map[string]struct { Values []string `json:"values"` } `json:"answers"` Identities []struct { Kind string `json:"kind"` Value string `json:"value"` Consent *bool `json:"consent"` } `json:"identities"`}
func handle(topic string, data json.RawMessage) { if topic != "quiz.finished" { return } var wrapper struct { Object finished `json:"object"` } json.Unmarshal(data, &wrapper) s := wrapper.Object answer := func(key string) string { return strings.Join(s.Answers[key].Values, ", ") } for _, id := range s.Identities { if id.Kind != "email" { continue } customerID := upsertCustomer(id.Value, answer("text_input-fname"), answer("text_input-lname")) setMetafields(customerID, map[string]string{"skin_type": answer("image_choice-48su5"), "quiz_session": s.ID}) if id.Consent != nil && *id.Consent { subscribeToEmailMarketing(customerID) } fmt.Println(s.ID, id.Value, customerID) // sess_9b2d... ada@example.com gid://shopify/Customer/... }}func main() { var event struct { Type string `json:"type"` Data json.RawMessage `json:"data"` } if err := json.NewDecoder(os.Stdin).Decode(&event); err != nil { log.Fatal(err) } handle(event.Type, event.Data)}Read answers by component_key (the keys are in the editor and on any person’s latest_results[].answers[]), and use values (the labels) for anything a human will read in Shopify; value is the stored option id for a choice and the raw value for anything else.
3. The Shopify side
Three Admin API mutations, in this order (see Shopify’s docs for each):
customerSetwithidentifier: { email }creates or updates the customer by email, so a shopper who finishes the quiz twice does not become two customers.metafieldsSeton the customer id, one metafield per answer you care about, under your own namespace (octane).customerEmailMarketingConsentUpdateonly whenconsent === true.falsemeans the shopper saw the checkbox and left it unticked;nullmeans the field had no checkbox. Neither is permission.
The three functions, complete, in Python (pip install httpx; SHOPIFY_SHOP is your-store.myshopify.com, SHOPIFY_ADMIN_TOKEN the custom app’s Admin API token). A TypeScript or Go receiver sends the same three documents with its own HTTP client; the queries and variables are the contract.
import osimport httpx
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 admin(query: str, variables: dict) -> dict: r = httpx.post(ADMIN_URL, headers=ADMIN_HEADERS, json={"query": query, "variables": variables}, timeout=30) r.raise_for_status() body = r.json() if body.get("errors"): raise RuntimeError(body["errors"]) # a missing scope, a bad query (payload,) = body["data"].values() if payload["userErrors"]: raise RuntimeError(payload["userErrors"]) # an invalid email, a bad metafield key return payload
def upsert_customer(email: str, first_name, last_name) -> str: payload = admin( """mutation ($identifier: CustomerSetIdentifiers!, $input: CustomerSetInput!) { customerSet(identifier: $identifier, input: $input) { customer { id } userErrors { field message } } }""", {"identifier": {"email": email}, "input": {"email": email, "firstName": first_name, "lastName": last_name}}, ) return payload["customer"]["id"] # gid://shopify/Customer/...
def set_metafields(customer_id: str, fields: dict) -> None: metafields = [ {"ownerId": customer_id, "namespace": "octane", "key": key, "type": "single_line_text_field", "value": value} for key, value in fields.items() if value ] if metafields: admin( """mutation ($metafields: [MetafieldsSetInput!]!) { metafieldsSet(metafields: $metafields) { metafields { key } userErrors { field message } } }""", {"metafields": metafields}, )
def subscribe_to_email_marketing(customer_id: str) -> None: admin( """mutation ($input: CustomerEmailMarketingConsentUpdateInput!) { customerEmailMarketingConsentUpdate(input: $input) { customer { id } userErrors { field message } } }""", {"input": {"customerId": customer_id, "emailMarketingConsent": {"marketingState": "SUBSCRIBED", "marketingOptInLevel": "SINGLE_OPT_IN"}}}, )CustomerSetIdentifiers takes email, phone, id or customId; customerSet creates the customer when no one has that email and updates them otherwise. A metafield value is a string whatever its type; single_line_text_field fits a label. marketingOptInLevel is what your form did: SINGLE_OPT_IN for a plain checkbox, CONFIRMED_OPT_IN only when you sent a confirmation email.
Things to know
- Deliveries are at least once:
acceptrecords thewebhook-id, and the three writes are safe to repeat (set by email, set by key, set consent). - A customer metafield is visible to every staff account and every app with
read_customers. Copy coarse cohorts (skin_type: Dry), never a free-text answer or anything health-adjacent. quiz.finishedis sent 30 seconds after completion so the result page’s products are inproducts_shown.- Answer the webhook with
2xxwithin 10 seconds and do the Shopify calls afterwards; a retry after a timeout would run your handler twice.
The browser-side variant
Prefer the webhook path: it carries a signature. A browser call can be forged, and anything on the page can read the event off the document mirror, including the email the shopper typed, so treat what arrives here as a hint and, when it matters, look the person up on your server with GET /v1/profiles?q=<email> and read their latest_results from GET /v1/profiles/{profile_id}.
If you still want to post from the page, the storefront event carries what this recipe needs in a different shape: answers[] is a list (component_key, value, option_ids, values), identities[] carries kind, value and consent, and products[] lists what was shown with a page_key. (The two payloads overlap; each carries fields the other does not.)
<script> window.octaneai = window.octaneai || []; window.octaneai.push(function (api) { api.on('quiz.finished', function (event) { var o = event.data.object; var email = (o.identities || []).find(function (i) { return i.kind === 'email'; }); if (!email) return; fetch('https://hooks.example.com/octane/finished', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: o.session_id, email: email.value, consent: email.consent === true, answers: o.answers.map(function (a) { return [a.component_key, a.values]; }), products: o.products.filter(function (p) { return p.page_key === o.terminal_page; }).map(function (p) { return p.product_id; }) }) }); }); });</script>Your endpoint then does the same three Shopify calls.
