Skip to content

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.

Terminal window
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",
"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

Terminal window
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": null }
],
"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",
"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", "value": 72 }],
"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", "order": { "order_id": "5820135686408", "order_name": "#1042", "amount": { "amount": "79.97", "currency": "USD" }, "minutes_after": 36, "matched_tier": "session" } }
],
"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": "session" }
]
}

identities[] holds one entry per stored contact (kind is email or phone; a person who used two emails has two entries). 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. consent is the marketing-consent checkbox beside the newest answered field of that kind: true when ticked, false when the field carries a checkbox and none was ticked, null when no field of that kind has one. The webhook payloads use the same three values. Consent is what the shopper ticked at capture time; your messaging tool’s own subscription status decides who you may contact.

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.

kindvaluevalues
choiceThe stored option value; a list for a multi-select (["sleep", "energy"])The option labels in option order (["Sleep", "Energy"])
imageAs choice; image_urls holds one entry per label, the option’s image or nullThe option labels
numberA JSON number (42, 1.5); null when noneIts display string (["42"])
dateThe {"date", "time"} object as storedThe date (["1990-05-04"])
text, email, phoneThe string[the string]

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 and formula values are JSON numbers; product and variant ids are Shopify GIDs.

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.

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

Terminal window
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_in means 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.
  • between includes both bounds. A JSON true or false where a number is expected is refused, never read as 1 or 0.
  • matches is a case-insensitive regular expression of at most 64 characters that must contain a run of three plain letters or digits (blue|green works, ^.*$ does not), without lookarounds, inline flags or backreferences.
  • A session term matches how a run ended (completed or abandoned), and with page_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.

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.