A win-back list of the people who left at one page
Goal: a CSV of the people who answered the first questions, stopped on one page (the one that asks for a shade, say) and never came back, with an email and the marketing checkbox ticked, so a campaign can invite them to finish.
Uses: People search with a session term. Scope: profiles:read.
The search
A session term with value: "abandoned" and a page_key finds people with a run of the quiz that ended on that page. has_email and has_marketing_consent keep the list to people you may write to; from limits it to people seen in the last 30 days. The row’s completed_count tells the finishers apart: the list keeps the people who never completed a quiz.
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_email": true, "has_marketing_consent": true, "from": "2026-08-14", "groups": [{ "all": [ { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "kind": "session", "op": "equals", "value": "abandoned", "page_key": "shade" } ] }] }'import csvimport datetime as dtimport osimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7"PAGE = "shade" # the page they stopped onsince = (dt.date.today() - dt.timedelta(days=30)).isoformat()body = { "has_email": True, "has_marketing_consent": True, "from": since, "groups": [{"all": [{"quiz_id": QUIZ, "kind": "session", "op": "equals", "value": "abandoned", "page_key": PAGE}]}],}
params = {"limit": 100}people = []while True: r = httpx.post("https://api.octaneai.com/v1/profiles/search", json=body, params=params, headers=HEADERS, timeout=30) r.raise_for_status() page = r.json() people += [p for p in page["data"] if p["completed_count"] == 0] # never reached a result page; a finisher is not a win-back if not page["has_more"]: break params["cursor"] = page["next_cursor"] # the body goes with every page; the cursor is positional
with open(f"winback-{PAGE}-{dt.date.today()}.csv", "w", newline="") as f: w = csv.writer(f) w.writerow(["email", "last_seen_at", "sessions"]) for p in people: w.writerow([p["email"], p["last_seen_at"], p["sessions_count"]])print(page["total"], "left there,", len(people), "never finished") # 1690 left there, 1203 never finishedimport { writeFileSync } from "node:fs";
const HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}`, "Content-Type": "application/json" };const QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7";const PAGE = "shade"; // the page they stopped onconst since = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);const body = { has_email: true, has_marketing_consent: true, from: since, groups: [{ all: [{ quiz_id: QUIZ, kind: "session", op: "equals", value: "abandoned", page_key: PAGE }] }],};
const people: Array<{ email: string; last_seen_at: string; sessions_count: number }> = [];let cursor: string | null = null;let page: any;do { const qs = new URLSearchParams({ limit: "100", ...(cursor ? { cursor } : {}) }); const res = await fetch(`https://api.octaneai.com/v1/profiles/search?${qs}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); page = await res.json(); people.push(...page.data.filter((p: any) => p.completed_count === 0)); // never reached a result page; a finisher is not a win-back cursor = page.next_cursor; // the body goes with every page; the cursor is positional} while (page.has_more);
const csv = ["email,last_seen_at,sessions", ...people.map((p) => `${p.email},${p.last_seen_at},${p.sessions_count}`)].join("\n") + "\n";writeFileSync(`winback-${PAGE}-${new Date().toISOString().slice(0, 10)}.csv`, csv);console.log(page.total, "left there,", people.length, "never finished"); // 1690 left there, 1203 never finishedpackage main
import ( "bytes" "encoding/csv" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "time")
const QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7"const PAGE = "shade" // the page they stopped on
type person struct { Email *string `json:"email"` LastSeenAt string `json:"last_seen_at"` SessionsCount int `json:"sessions_count"` CompletedCount int `json:"completed_count"`}
func main() { since := time.Now().AddDate(0, 0, -30).Format("2006-01-02") body, _ := json.Marshal(map[string]any{ "has_email": true, "has_marketing_consent": true, "from": since, "groups": []any{map[string]any{"all": []any{map[string]any{ "quiz_id": QUIZ, "kind": "session", "op": "equals", "value": "abandoned", "page_key": PAGE}}}}, }) params := url.Values{"limit": {"100"}} people := []person{} var page struct { Data []person `json:"data"` HasMore bool `json:"has_more"` NextCursor *string `json:"next_cursor"` Total int `json:"total"` } for { req, _ := http.NewRequest("POST", "https://api.octaneai.com/v1/profiles/search?"+params.Encode(), 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) } if res.StatusCode != 200 { log.Fatalf("status %d", res.StatusCode) } json.NewDecoder(res.Body).Decode(&page) res.Body.Close() for _, p := range page.Data { if p.CompletedCount == 0 { // never reached a result page; a finisher is not a win-back people = append(people, p) } } if !page.HasMore { break } params.Set("cursor", *page.NextCursor) // the body goes with every page; the cursor is positional }
f, _ := os.Create("winback-" + PAGE + "-" + time.Now().Format("2006-01-02") + ".csv") defer f.Close() w := csv.NewWriter(f) w.Write([]string{"email", "last_seen_at", "sessions"}) for _, p := range people { w.Write([]string{*p.Email, p.LastSeenAt, fmt.Sprint(p.SessionsCount)}) } w.Flush() fmt.Println(page.Total, "left there,", len(people), "never finished") // 1690 left there, 1203 never finished}The file:
email,last_seen_at,sessionsada@example.com,2026-09-11T14:03:22Z,2Errors 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 page_key (the body names the legal pages in errors[]); a session term with an op other than equals or a value other than completed / abandoned; “The search took too long; narrow the filter”. | Fix the term; for the timeout, shorten from/to or run the same body as an export. |
429 rate_limited | The allowance is spent (a search costs 6 units), or all 15 search slots are busy. | Sleep for Retry-After seconds and resend the same page. |
Things to know
- Abandoned means the run ended there. A session counts as abandoned once it has been idle for 30 minutes without reaching the result page (the same rule as the
quiz.abandonedwebhook).page_keyis the page the shopper was on when it ended; without it the term matches anyone who abandoned anywhere. - Any run counts, so read
completed_count. The defaultmatch: any_sessionmatches a person who left on that page once and finished on a later visit, and there is no “and never completed” term; the row’scompleted_count(completed runs across all your quizzes) is the cheap test, and it errs on the safe side on a store with several quizzes.match: latest_completed_per_quizcannot be used here: it looks only at completed runs, which an abandoned term never matches. - Consent is the checkbox at capture time.
has_marketing_consentis the marketing checkbox beside the newest email or phone the person gave; your messaging tool’s own subscription status decides who is actually mailed, so import the file into a list that honours it rather than sending from the file. fromis the person’s last visit. Not the day they abandoned. A person who left at the shade page in June and looked at another quiz yesterday is in a 30-day list.- No resume link. The quiz cannot reopen a person’s unfinished run from a link; the campaign links to the quiz page and they start again. A prefilled start is possible from your page with
prefill()on the Storefront JS API. - Big cohorts.
totalis exact to 5,000 andtotal_cappedsays when it is more; a cohort above that pages fine but each statement has 10 seconds, and the same body onPOST /v1/exportsbuilds the whole file in the background instead.
