Skip to content

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

Terminal window
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"}'

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/...

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):

  1. customerSet with identifier: { email } creates or updates the customer by email, so a shopper who finishes the quiz twice does not become two customers.
  2. metafieldsSet on the customer id, one metafield per answer you care about, under your own namespace (octane).
  3. customerEmailMarketingConsentUpdate only when consent === true. false means the shopper saw the checkbox and left it unticked; null means 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 os
import 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: accept records the webhook-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.finished is sent 30 seconds after completion so the result page’s products are in products_shown.
  • Answer the webhook with 2xx within 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.