People
A person is someone who took a quiz on your store and, usually, left an email or a phone number. The people routes give you who they are, what they answered, what the quiz computed for them, the sessions they ran and the orders those sessions led to, and let you search for people by what they answered. The field catalogue is in the reference.
Every route needs profiles:read, the permission that exposes personal data. Nothing here is cached: responses carry Cache-Control: no-store and no ETag. Reads cost 1 unit, a search 6 (see Rate limits).
Look someone up
The list pages by cursor, newest last-seen first, and takes q (an email or phone number), quiz_id (only people who took that quiz) and from and to (inclusive UTC days on the person’s last visit). q is matched after normalization: case, provider aliases such as dots in a Gmail address, E.164 phones.
curl "https://api.octaneai.com/v1/profiles?q=shopper@example.com" \ -H "Authorization: Bearer $OCTANE_API_KEY"{ "data": [ { "id": "prof_50e1b7e458f14dfca7a2a5b971af2821", "email": "shopper@example.com", "phone": "+14155552671", "shopify_customer_id": "gid://shopify/Customer/7208734519384", "quizzes": [{ "id": "quiz_7c9e6679742540de944be07fc1f90ae7", "name": "Find your routine" }], "first_seen_at": "2026-09-01T12:00:00Z", "last_seen_at": "2026-09-01T12:00:00Z", "sessions_count": 1, "completed_count": 1, "returning": false, "orders_count": 1, "spent": { "amount": "79.97", "currency": "USD" }, "last_order_at": "2026-09-01T12:40:00Z" } ], "has_more": false, "next_cursor": null, "total": 1, "returning_total": 0, "total_capped": false}A person who asked to be removed is left out of lists and searches, and their prof_ id answers 404. A foreign or deleted quiz_id answers 404 too.
A person in full
curl https://api.octaneai.com/v1/profiles/prof_50e1b7e458f14dfca7a2a5b971af2821 \ -H "Authorization: Bearer $OCTANE_API_KEY"The answer, cut to one result, one session and one order:
{ "id": "prof_50e1b7e458f14dfca7a2a5b971af2821", "identities": [ { "kind": "email", "value": "shopper@example.com", "first_seen_at": "2026-09-01T12:00:00Z", "last_seen_at": "2026-09-01T12:00:00Z", "consent": true }, { "kind": "phone", "value": "+14155552671", "first_seen_at": "2026-09-01T12:00:00Z", "last_seen_at": "2026-09-01T12:00:00Z", "consent": false }, { "kind": "shopify_customer_id", "value": "gid://shopify/Customer/7208734519384", "first_seen_at": "2026-09-01T12:40:00Z", "last_seen_at": "2026-09-01T12:40:00Z", "consent": false } ], "latest_results": [ { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "quiz_name": "Find your routine", "version_number": 2, "session_id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e", "completed_at": "2026-09-01T12:04:00Z", "results_url": "https://shop.example.com/pages/quiz#pqs=sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e", "answers": [ { "page_key": "skin", "component_key": "image_choice-48su5", "question": "Skin type?", "kind": "image", "values": ["Dry"], "value": "dry", "image_urls": ["https://cdn.shopify.com/s/files/1/0001/skin-dry.jpg"], "removed": false }, { "page_key": "skin", "component_key": "number_input-re853", "question": "Your age", "kind": "number", "values": ["42"], "value": 42, "image_urls": [], "removed": false } ], "points": [{ "dimension_id": "dry", "name": "Dry", "total": 14 }, { "dimension_id": "oily", "name": "Oily", "total": 6 }], "winner": { "dimension_id": "dry", "name": "Dry", "total": 14 }, "formulas": [ { "page_key": "result", "component_key": "formula-tidtk", "label": "Hydration score", "kind": "formula", "value": 72, "value_text": null }, { "page_key": "result", "component_key": "value_table-shade", "label": "Shade", "kind": "table", "value": null, "value_text": "Rose 10" } ], "result_page": { "page_key": "result", "title": "Your match", "shown_products": [{ "product_id": "gid://shopify/Product/8692896989320", "variant_id": "gid://shopify/ProductVariant/40926435967056", "handle": "hydrating-serum", "title": "Hydrating Serum", "price": 39.0, "currency": "USD" }] } } ], "sessions": [ { "session_id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e", "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "version_number": 2, "status": "completed", "last_page_key": "result", "device_class": "mobile", "channel": "email", "country": "US", "source_session_id": null, "source_kind": null, "results_url": "https://shop.example.com/pages/quiz#pqs=sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e", "opens": 2, "last_opened_at": "2026-09-02T08:15:00Z", "last_open_channel": "organic_social", "order": { "order_id": "5820135686408", "order_name": "#1042", "amount": { "amount": "79.97", "currency": "USD" }, "minutes_after": 36, "matched_tier": "line_item_property" } } ], "orders": [ { "order_id": "5820135686408", "order_name": "#1042", "created_at": "2026-09-01T12:40:00Z", "amount": { "amount": "79.97", "currency": "USD" }, "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "session_id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e", "matched_tier": "line_item_property" } ], "share_orders": [ { "order_id": "5820135686531", "order_name": "#1057", "created_at": "2026-09-02T08:40:00Z", "amount": { "amount": "39.00", "currency": "USD" }, "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "session_id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e", "matched_tier": "cart_attribute", "via": "shared_page", "buyer_person_id": null } ]}Identities and consent
identities[] holds one entry per stored contact; a person who used two emails has two entries.
kind | value | How it gets there |
|---|---|---|
email | The email, trimmed and lowercase | The shopper typed it in a quiz |
phone | The phone in E.164 | The shopper typed it in a quiz |
shopify_customer_id | The Shopify customer as a GID, gid://shopify/Customer/<id> | The shopper took a quiz while logged in to the store, or placed an order that was linked to one of their quiz sessions |
A new kind may be added, so skip one you do not know. first_seen_at is when that contact was first captured; last_seen_at is the latest moment one of the person’s sessions typed that kind of contact, and for a shopify_customer_id, which nobody types, the moment it was attached. consent is the marketing-consent checkbox beside the field. Consent is true only when the shopper ticked the box on their newest run that showed it; anything else, including a skipped page or a quiz without the box, is false. A shopify_customer_id has no checkbox, so its consent is always false. The webhook payloads use the same two values. Consent is what the shopper ticked at capture time; your messaging tool’s own subscription status decides who you may contact.
The same GID is shopify_customer_id on every row of the list and the search (null when the person has none) and a column of the profiles export. A logged-in quiz session attaches it the way a typed email does: it can make a new person (profile.created) or add to a known one (profile.updated), and it never fires identity.captured. An id that arrives from an order fires no profile event of its own; the person’s next profile event carries it.
Latest results
latest_results[] holds, per quiz, the person’s latest completed run. Every answer names its question (page_key, component_key, question) and carries the value twice: value as the quiz stored it, values as a person reads it.
kind | value | values |
|---|---|---|
choice | The stored option value; a list for a multi-select (["sleep", "energy"]) | The option labels in option order (["Sleep", "Energy"]) |
image | As choice; image_urls holds one entry per label, the option’s image or null | The option labels |
number | A JSON number (42, 1.5); null when none | Its display string (["42"]) |
date | The {"date", "time"} object as stored | The date (["1990-05-04"]) |
text, email, phone | The string | [the string] |
other | The value as stored, for a question of any other type | Its display strings (true reads ["yes"]) |
removed: true marks an email or phone answer whose value was erased: its value is null and values empty. Answers to questions the published version no longer has are reported normally. Points totals are JSON numbers; product and variant ids are Shopify GIDs.
formulas[] holds what the quiz computed for the run, one entry per formula or value table on the path the shopper took. kind says which: a formula always gives a number in value; a table (a value table, which maps the answers to a cell) gives a number in value or a text in value_text, the other null. Read value with a null check: a text table has none.
Sessions and orders
sessions[] lists the person’s newest 200 runs (status is completed, in_progress or left; last_page_key is where they stopped) and the order a session led to. orders[] lists the orders linked to those sessions with the quiz_id and session_id that earned each, null when the order was matched to the person rather than to a session. sess_ ids are the same ids the webhooks and the storefront JS API report.
matched_tier says how an order was tied to its quiz session, on the session’s order, on orders[] and on share_orders[]:
matched_tier | How the order was tied |
|---|---|
line_item_property | A cart line the quiz added carried the session into checkout |
cart_attribute | The cart carried the session as a cart attribute |
pixel_claim | The Octane AI web pixel tied the checkout to the shopper’s quiz visit |
email | The order’s email belongs to a person who took the quiz inside the store’s attribution window |
The first two mean the quiz itself carried the order into checkout; the other two are a later visit linked to the quiz.
Every session also says where it came from: source_session_id is the sess_ id of the session it was started from and source_kind how, restart (the quiz’s Restart button or the storefront API’s restart()) or share (a press on a shared results page); both are null for a session the shopper opened themselves. The session the shopper restarted from or shared is left as it stands, so a retake is two sessions, the second naming the first.
Shareable results link
A finished session has a link to its results: the page the quiz ran on plus the session’s public id, https://shop.example.com/pages/quiz#pqs=sess_.... It is results_url on every session and on each latest_results[] entry, and the latest_results_url and results_url columns of the exports; it is null until the session completes, and stays null when the page the quiz ran on was not recorded.
Whoever opens the link sees the sharer’s result page as the quiz publishes it now, with the products shown in that session and the answers minus any email or phone the quiz collects. Opening is a read of the sharer’s result, not a visit: it creates no session and counts in no analytics session figure. The storefront JS API reports it with one event of its own, shared_result.viewed, and never with quiz.finished. It is counted on the shared session instead: opens is how many times its link was opened, last_opened_at and last_open_channel the newest open and the channel it came from (0 and null for a session nobody opened). Any press on the shared page starts the reader’s own session from the first page, with source_session_id naming the shared session and source_kind: "share".
share_orders[] lists the orders bought through this person’s shared results, with the same fields as orders[] plus via and buyer_person_id: via is shared_page when the buyer bought straight from the shared results page (session_id is then the sharer’s own session) or retake when they bought in a session started from the shared result (session_id is that session’s); buyer_person_id is the buyer’s prof_ id when one is known. These orders are never part of orders[], orders_count or spent; they are the buyer’s, and count for the buyer.
Search by what people answered
A search is a JSON body posted to /v1/profiles/search; it answers the same rows as the list. groups is an OR of AND groups: a person matches when every slot of at least one group holds (at most 4 groups of 8 slots). A slot is a term, or {"any": [term, ...]} with 2 to 8 terms of which one must hold, which is how you write “A and (B or C)” inside one group. Beside the terms, person facts (has_order, has_email, has_phone, has_marketing_consent, is_returning) and the list’s own quiz_id, from and to narrow the result; match decides which runs count: any_session (the default) or latest_completed_per_quiz, where a retake that changed the answer moves the person out. A body with no term and no person fact answers 422: the plain list is GET /v1/profiles.
Every term names a quiz, a kind and an op; the reference lists what each kind names and the ops it takes. This search finds everyone who (answered Dry and is 30 to 50) or (scored more than 10 on the Dry dimension):
curl -X POST "https://api.octaneai.com/v1/profiles/search?limit=100" \ -H "Authorization: Bearer $OCTANE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "match": "any_session", "groups": [ { "all": [ { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "kind": "choice", "op": "in", "page_key": "skin", "component_key": "image_choice-48su5", "option_labels": ["Dry"] }, { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "kind": "number", "op": "between", "page_key": "skin", "component_key": "number_input-re853", "min": 30, "max": 50 } ] }, { "all": [ { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "kind": "points", "op": "gt", "dimension_id": "dry", "value": 10 } ] } ] }'{ "data": [{ "id": "prof_50e1b7e458f14dfca7a2a5b971af2821", "email": "shopper@example.com", "quizzes": [{ "id": "quiz_7c9e6679742540de944be07fc1f90ae7", "name": "Find your routine" }], "last_seen_at": "2026-09-01T12:00:00Z", "completed_count": 1, "orders_count": 1 }], "has_more": false, "next_cursor": null, "total": 1, "returning_total": 0, "total_capped": false}Things the reference cannot tell you:
not_inmeans the person answered the question and picked none of these options; someone who never answered does not match.not_answered(text, email and phone questions) means the person took the quiz and left the question empty; someone who never took the quiz never matches.betweenincludes both bounds. A JSONtrueorfalsewhere a number is expected is refused, never read as 1 or 0.matchesis a case-insensitive regular expression of at most 64 characters that must contain a run of three plain letters or digits (blue|greenworks,^.*$does not), without lookarounds, inline flags or backreferences.- A
sessionterm matches how a run ended (completedorabandoned), and withpage_key, the page it was left on.
Labels or ids
A choice term takes options by option_labels (the text the shopper saw, matched trim- and case-insensitively across the quiz’s published versions, so a renamed option still finds its old answers) or by option_ids (exact); send both and they are combined. The editor assigns page_key, component_key, dimension_id and option ids; read them from the quiz’s structure, from the answers view, or from any person’s latest_results[].
A term that names an unknown dimension, page or option answers 422 validation_error with the legal values in errors[]; an unknown question answers the same 422 with “the quiz has no such question”; a quiz that is not yours, or was deleted, answers 404.
{ "type": "https://api.octaneai.com/errors/validation_error", "title": "validation_error", "detail": "Unknown answer filter term", "status": 422, "request_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "errors": [{ "field": "groups.0.all.0.option_labels.0", "message": "one of: Combo, Dry" }]}Paging and totals
The cursor is positional only: send the same body with every page, with cursor and limit (at most 100) as query parameters and no cursor on the first page. total is exact up to 5,000; a wider match reports total: 5000 with total_capped: true (read it as “5,000 or more”) and returning_total: null. Counting a sparse filter on a very large store can take about a second.
When a search is too wide
Search is built for pages of results, not for pulling a whole cohort. Each statement behind a search has 10 seconds, and a filter that matches a large share of your people can run past that. The search then answers 422 validation_error whose export field carries the url and the ready-to-send body of the export request that builds the same people as a file; post it as it is. An export takes no search slot and has no time limit, so anything you plan to process in bulk starts as an export. A narrower search (a quiz_id, a date range, a rarer term) is the other way forward.
A store runs at most 15 live searches at a time; a search that finds no free slot within a second answers 429 rate_limited with Retry-After: 1. Exports do not count against those slots.
Quiz results on Shopify customers
A quiz can write each shopper’s result onto their Shopify customer, where customer segments, Shopify Email, Shopify Flow and your theme’s Liquid read it with no API key. It is available on the Plus and Enterprise plans and switched on per quiz under the quiz’s Integrations > Shopify Customers; a quiz with it off writes nothing.
Which customer. The person’s Shopify customer id when one is known (the shopify_customer_id identity above), else the customer whose email is the email the shopper typed in the quiz. A shopper with neither is skipped; a customer is never created.
What is written. The person’s latest completed run of that quiz, the same run latest_results[] shows, as customer metafields in the namespace octaneai. Every key starts with the quiz’s prefix: q and the first eight characters of the hex in its quiz_ id, so quiz_7c9e6679742540de944be07fc1f90ae7 writes q7c9e6679_.... A question’s key is the prefix and its component_key in lowercase (q7c9e6679_image_choice-48su5), cut to 64 characters. In the Shopify admin each field is named Octane AI - <quiz name> - <question or outcome>. The quiz’s Storefront panel lists this quiz’s real keys.
| Field | Key | Shopify type | Value |
|---|---|---|---|
| A choice or image choice question | <prefix>_<component_key> | list.single_line_text_field | The labels of the options picked (["Dry"]) |
| A number question | <prefix>_<component_key> | number_decimal | The number |
| A date question | <prefix>_<component_key> | date | The date (1990-05-04) |
| Any other question but email and phone | <prefix>_<component_key> | single_line_text_field | The answer as text |
| A formula | <prefix>_<component_key> | number_decimal | Its value |
| The result | <prefix>_winner | single_line_text_field | The winning points dimension’s name |
| The points total | <prefix>_points_total | number_decimal | The points summed over every dimension |
| The result page | <prefix>_result_page | single_line_text_field | The title of the result page reached, else its page key |
| When it finished | <prefix>_completed_at | date_time | The run’s finish time in UTC |
| The results link | <prefix>_result_url | url | The run’s shareable results link |
| The discount codes | <prefix>_discount_codes | list.single_line_text_field | The codes the run granted: the per-shopper codes it minted, then the fixed codes of the coupons it showed, each once |
| The recommended products | <prefix>_products | list.product_reference | The products every product block of the run showed, in the order the shopper saw them, each once, at most 128; a product no longer in the store is left out |
Email and phone answers are never written. A field with nothing to hold is left out: no winner on a quiz without points, no link when the page the quiz ran on was not recorded or is not on one of the store’s own domains, no discount codes when the run granted none (an automatic discount has no code and writes nothing), no products when the run showed none. In Liquid, customer.metafields.octaneai.<prefix>_products.value is a list of product objects you can loop over; customer segments cannot filter on a product list (Shopify segments read text, number, boolean and date fields only), so segment on the result tag or the result field instead. A question added in a later publish gets its field on the next finish.
The tags. The customer also carries tags from four sources, each switched on or off in the panel. Every tag starts with the quiz’s tag prefix, which is the quiz name as a slug, the quiz’s key prefix and : (find-your-routine-q7c9e6679:, so two quizzes of one name never share tags) unless you set your own; a prefix you set always ends with :, added when you leave it off. For “Find your routine” with the prefix set to find-your-routine:, a shopper whose result is Dry, who reached the result page “Your dry routine” and answered Dry to the skin type question gets:
| Source | Tag | Example | On by default |
|---|---|---|---|
| Took the quiz | <prefix>completed | find-your-routine:completed | Yes |
| The winning outcome, on a quiz with points | <prefix><outcome slug> | find-your-routine:dry | Yes |
| The result page reached | <prefix>page:<page title slug> | find-your-routine:page:your-dry-routine | Yes |
| Answers to the questions you pick, choice and image questions only | <prefix><question slug>:<answer slug>, one tag per selected value | find-your-routine:skin-type:dry | No question picked |
On a retake each source replaces its own previous tags. Tags that do not start with the prefix are never touched. Shopify Email reads a customer’s tags but not their metafields, so an email that differs per result branches on the tags.
Shopify Flow. With Send quiz events to Shopify Flow on (the default), two Octane AI triggers start your workflows, each with the customer and these fields:
| Trigger | Fires when | Fields |
|---|---|---|
| Quiz completed | A matched customer finishes the quiz, once per session | “Quiz name”, “Quiz ID” (the quiz_ id), “Result”, “Score” (the points total, 0 without points), “Results URL”, “Session ID” (the sess_ id) |
| Quiz abandoned | A matched customer’s session that started has been idle for 30 minutes without finishing, as for the quiz.abandoned webhook | “Quiz name”, “Quiz ID”, “Last page” (its title, else its page key), “Session ID”, “Started at” (UTC, ISO 8601) |
A trigger can arrive twice; use “Session ID” to skip a repeat. Answers are not in the trigger: the metafields are written first, so a workflow reads them from the customer.
When it runs.
- Switching a quiz on writes new finishes only. The panel shows how many people took the quiz, and Sync them to Shopify writes them in the background, with its progress and, when it is done, how many were written, how many had no Shopify customer and how many could not be synced. That sync fires no Flow trigger, and you can run it again.
- Each finish writes again. A retake overwrites the fields with the newest run, replaces each source’s tags and fires “Quiz completed” again; the old results link keeps opening the old result.
- A shopper who finished before their customer was known (an order linked later, an email typed after the result) is written when the link arrives.
- Switching the quiz off, or a plan without the feature, stops the writing. Nothing is deleted: the metafields, their definitions and the tags stay on the customers.
Errors you will meet
Every one is a Problem body: 403 insufficient_scope without profiles:read; 404 not_found for an unknown, foreign or removed person, or a foreign or deleted quiz in a filter or a term; 422 validation_error for to before from, a malformed cursor or limit, a body with no term and no person fact, too many groups or terms, an op the kind does not take, a missing or extra operand, an unknown question, dimension, page or option, or a search that ran past 10 seconds; 429 rate_limited when the allowance is spent or all 15 search slots are taken.
