People who answered X but never bought
Goal: the people who told the quiz they have dry skin (any answer you pick), finished it, and never bought, as a count against the buyers who answered the same, and as a list you can write to.
Uses: People search with a choice term, has_order and match: latest_completed_per_quiz. Scope: profiles:read.
The search
Two calls with the same term: has_order: false for the people to write to (with has_marketing_consent: true), has_order: true for the buyers. match: latest_completed_per_quiz reads only each person’s latest completed run, so a retake that changed the answer moves them.
curl -X POST "https://api.octaneai.com/v1/profiles/search?limit=100" \ -H "Authorization: Bearer $OCTANE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "has_order": false, "has_marketing_consent": true, "match": "latest_completed_per_quiz", "groups": [{ "all": [ { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "kind": "choice", "op": "in", "page_key": "skin", "component_key": "image_choice-48su5", "option_labels": ["Dry"] } ] }] }'import osimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7"TERM = {"quiz_id": QUIZ, "kind": "choice", "op": "in", "page_key": "skin", "component_key": "image_choice-48su5", "option_labels": ["Dry"]}
def search(has_order, **extra): body = {"has_order": has_order, "match": "latest_completed_per_quiz", "groups": [{"all": [TERM]}], **extra} r = httpx.post("https://api.octaneai.com/v1/profiles/search", json=body, params={"limit": 100}, headers=HEADERS, timeout=30) r.raise_for_status() return r.json()
buyers = search(True)non_buyers = search(False, has_marketing_consent=True)count = lambda page: f"{page['total']}{'+' if page['total_capped'] else ''}"print("answered Dry:", count(buyers), "bought,", count(non_buyers), "did not and may be written to") # answered Dry: 1155 bought, 5000+ did not and may be written tofor p in non_buyers["data"][:3]: print(p["email"], p["last_seen_at"]) # ada@example.com 2026-09-11T14:03:22Z# page the rest with params["cursor"] = non_buyers["next_cursor"] while non_buyers["has_more"]const HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}`, "Content-Type": "application/json" };const QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7";const TERM = { quiz_id: QUIZ, kind: "choice", op: "in", page_key: "skin", component_key: "image_choice-48su5", option_labels: ["Dry"] };
async function search(has_order: boolean, extra: Record<string, unknown> = {}) { const body = { has_order, match: "latest_completed_per_quiz", groups: [{ all: [TERM] }], ...extra }; const res = await fetch("https://api.octaneai.com/v1/profiles/search?limit=100", { method: "POST", headers: HEADERS, body: JSON.stringify(body) }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json();}
const buyers = await search(true);const nonBuyers = await search(false, { has_marketing_consent: true });const count = (page: any) => `${page.total}${page.total_capped ? "+" : ""}`;console.log("answered Dry:", count(buyers), "bought,", count(nonBuyers), "did not and may be written to"); // answered Dry: 1155 bought, 5000+ did not and may be written tofor (const p of nonBuyers.data.slice(0, 3)) console.log(p.email, p.last_seen_at); // ada@example.com 2026-09-11T14:03:22Z// page the rest with ?cursor=<nonBuyers.next_cursor> while nonBuyers.has_morepackage main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os")
const QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7"
type page struct { Data []struct { Email *string `json:"email"` LastSeenAt string `json:"last_seen_at"` } `json:"data"` Total int `json:"total"` TotalCapped bool `json:"total_capped"` HasMore bool `json:"has_more"`}
func search(hasOrder bool, extra string) page { body := []byte(`{"has_order": ` + fmt.Sprint(hasOrder) + `, "match": "latest_completed_per_quiz"` + extra + `, "groups": [{"all": [{"quiz_id": "` + QUIZ + `", "kind": "choice", "op": "in", "page_key": "skin", "component_key": "image_choice-48su5", "option_labels": ["Dry"]}]}]}`) req, _ := http.NewRequest("POST", "https://api.octaneai.com/v1/profiles/search?limit=100", 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 != 200 { log.Fatalf("status %d", res.StatusCode) } var p page json.NewDecoder(res.Body).Decode(&p) return p}
func count(p page) string { if p.TotalCapped { return fmt.Sprintf("%d+", p.Total) } return fmt.Sprint(p.Total)}
func main() { buyers := search(true, "") nonBuyers := search(false, `, "has_marketing_consent": true`) fmt.Println("answered Dry:", count(buyers), "bought,", count(nonBuyers), "did not and may be written to") // answered Dry: 1155 bought, 5000+ did not and may be written to for i, p := range nonBuyers.Data { if i == 3 { break } fmt.Println(*p.Email, p.LastSeenAt) // ada@example.com 2026-09-11T14:03:22Z } // page the rest with ?cursor=<next_cursor> while nonBuyers.HasMore}Errors to handle
| Status | Why | What to do |
|---|---|---|
403 insufficient_scope | The key lacks profiles:read. | Mint a key with the scope. |
404 not_found | The quiz_id is not this store’s or was deleted. | Re-list with GET /v1/quizzes. |
422 validation_error | An unknown option label (errors[] lists the legal ones: one of: Combo, Dry), an unknown question (“the quiz has no such question”), or “The search took too long; narrow the filter”. | Read the keys off GET /v1/quizzes/{quiz_id}/analytics/answers; for the timeout add from/to or run the body as an export. |
429 rate_limited | Two searches at 6 units each, or all 15 search slots busy. | Sleep for Retry-After seconds. |
Things to know
has_order: falsemeans no order Octane AI linked to a quiz session. A person who bought without the quiz knowing (a different email at checkout, a purchase before the quiz existed) counts as a non-buyer here. Treat the count as “no attributed order”, and let your messaging tool’s own purchase data exclude known customers if that matters.- Labels or ids.
option_labelsare matched trim- and case-insensitively across the quiz’s published versions, so a renamed option still finds its old answers;option_idsis exact. Where the keys come from:GET /v1/quizzes/{quiz_id}/analytics/answerslists the questions withpage_key,component_keyand every option’soption_idandlabel. - Latest run wins. With
match: latest_completed_per_quizsomeone who answered Dry in June and Oily yesterday is out of both counts; the defaultany_sessionwould keep them in. - Two counts, two searches. The buyer count is only there to size the opportunity (“1,155 of the people who said Dry bought”);
totalis exact to 5,000 and reads “5,000 or more” past that, so a share computed from a capped number is a floor. - Consent is the capture-time checkbox.
has_marketing_consentis the checkbox beside the newest email or phone they gave; the list goes into a tool that honours current subscription status, it is not a send list on its own. - A cohort to write to in bulk is an export: the same
groups,has_order,has_marketing_consentandmatchonPOST /v1/exportswithdataset: "profiles"build the whole file with every person’s answers.
