A VIP file of top scorers who buy
Goal: a file of the people who scored above a threshold on one dimension of a points quiz (the “expert” dimension, say) and have an order linked to a quiz session, with their email, spend and scores, for a loyalty tier, early access or a thank-you.
Uses: People search with a points term to size the cohort, then the same filter on Exports for the file. Scope: profiles:read.
1. Size it
A points term names a dimension_id (the editor assigns it; read it off any person’s latest_results[].points[]) and compares the person’s total on it. has_order: true keeps buyers only.
curl -X POST "https://api.octaneai.com/v1/profiles/search?limit=5" \ -H "Authorization: Bearer $OCTANE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "has_order": true, "groups": [{ "all": [ { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "kind": "points", "op": "gt", "dimension_id": "dry", "value": 80 } ] }] }'import osimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7"SEARCH = {"has_order": True, "groups": [{"all": [{"quiz_id": QUIZ, "kind": "points", "op": "gt", "dimension_id": "dry", "value": 80}]}]}
r = httpx.post("https://api.octaneai.com/v1/profiles/search", json=SEARCH, params={"limit": 5}, headers=HEADERS, timeout=30)r.raise_for_status()found = r.json()print(found["total"], "VIPs" + ("+" if found["total_capped"] else "")) # 312 VIPsfor p in found["data"]: print(p["email"], p["orders_count"], p["spent"]["amount"], p["spent"]["currency"]) # ada@example.com 2 141.00 USDconst HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}`, "Content-Type": "application/json" };const QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7";const SEARCH = { has_order: true, groups: [{ all: [{ quiz_id: QUIZ, kind: "points", op: "gt", dimension_id: "dry", value: 80 }] }] };
const res = await fetch("https://api.octaneai.com/v1/profiles/search?limit=5", { method: "POST", headers: HEADERS, body: JSON.stringify(SEARCH) });if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);const found = await res.json();console.log(found.total, "VIPs" + (found.total_capped ? "+" : "")); // 312 VIPsfor (const p of found.data) console.log(p.email, p.orders_count, p.spent.amount, p.spent.currency); // ada@example.com 2 141.00 USDpackage main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os")
const QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7"
func main() { body := []byte(`{"has_order": true, "groups": [{"all": [{"quiz_id": "` + QUIZ + `", "kind": "points", "op": "gt", "dimension_id": "dry", "value": 80}]}]}`) req, _ := http.NewRequest("POST", "https://api.octaneai.com/v1/profiles/search?limit=5", 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 found struct { Data []struct { Email *string `json:"email"` OrdersCount int `json:"orders_count"` Spent *struct { Amount string `json:"amount"` Currency string `json:"currency"` } `json:"spent"` } `json:"data"` Total int `json:"total"` TotalCapped bool `json:"total_capped"` } json.NewDecoder(res.Body).Decode(&found) suffix := "" if found.TotalCapped { suffix = "+" } fmt.Println(found.Total, "VIPs"+suffix) // 312 VIPs for _, p := range found.Data { fmt.Println(*p.Email, p.OrdersCount, p.Spent.Amount, p.Spent.Currency) // ada@example.com 2 141.00 USD }}Move the threshold until the count is the size of the tier you want; a threshold nobody reaches answers total: 0, not an error.
2. Export it
The same search goes on POST /v1/exports with dataset: "profiles" and the quiz_id on the export, so the file has one row per person with that quiz’s latest completed result: email, orders, spent, points (a JSON cell of every dimension), total_points and top_match. Poll it until completed, then download; the export.completed webhook is the alternative to polling (the warehouse recipe shows it).
import csvimport gzipimport ioimport jsonimport osimport timeimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7"SEARCH = {"has_order": True, "groups": [{"all": [{"quiz_id": QUIZ, "kind": "points", "op": "gt", "dimension_id": "dry", "value": 80}]}]}
r = httpx.post("https://api.octaneai.com/v1/exports", json={"dataset": "profiles", "quiz_id": QUIZ, "search": SEARCH}, headers=HEADERS, timeout=30)if r.status_code == 409: raise SystemExit("a profiles export is already being built; poll that one")r.raise_for_status()export = r.json()while export["status"] in ("pending", "running"): time.sleep(5) r = httpx.get(f"https://api.octaneai.com/v1/exports/{export['id']}", headers=HEADERS, timeout=30) r.raise_for_status() export = r.json()if export["status"] != "completed": raise SystemExit(export["error"])
text = gzip.decompress(httpx.get(export["download_url"], timeout=300).content).decode("utf-8-sig") # signed URL: no Authorization headerrows = list(csv.DictReader(io.StringIO(text)))with open("vips.csv", "w", newline="") as f: w = csv.writer(f) w.writerow(["email", "orders", "spent", "currency", "dry_points", "top_match"]) for row in rows: points = {p["dimension_id"]: p["total"] for p in json.loads(row["points"])} if row["points"] else {} w.writerow([row["email"], row["orders"], row["spent"], row["currency"], points.get("dry"), row["top_match"]])print(export["row_count"], "rows ->", "vips.csv") # 312 rows -> vips.csvimport { writeFileSync } from "node:fs";import { gunzipSync } from "node:zlib";
const HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}`, "Content-Type": "application/json" };const QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7";const SEARCH = { has_order: true, groups: [{ all: [{ quiz_id: QUIZ, kind: "points", op: "gt", dimension_id: "dry", value: 80 }] }] };
let res = await fetch("https://api.octaneai.com/v1/exports", { method: "POST", headers: HEADERS, body: JSON.stringify({ dataset: "profiles", quiz_id: QUIZ, search: SEARCH }) });if (res.status === 409) throw new Error("a profiles export is already being built; poll that one");if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);let exp = await res.json();while (exp.status === "pending" || exp.status === "running") { await new Promise((r) => setTimeout(r, 5000)); res = await fetch(`https://api.octaneai.com/v1/exports/${exp.id}`, { headers: HEADERS }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); exp = await res.json();}if (exp.status !== "completed") throw new Error(exp.error);
const raw = new Uint8Array(await fetch(exp.download_url).then((r) => r.arrayBuffer())); // signed URL: no Authorization headerconst text = gunzipSync(raw).toString("utf8").replace(/^\uFEFF/, "");// parse the CSV with your usual parser; here every cell is read back with a small splitter that honours quotesconst parse = (line: string) => { const out: string[] = []; let cell = "", q = false; for (let i = 0; i < line.length; i++) { const c = line[i]; if (q) { if (c === '"' && line[i + 1] === '"') { cell += '"'; i++; } else if (c === '"') q = false; else cell += c; } else if (c === '"') q = true; else if (c === ",") { out.push(cell); cell = ""; } else cell += c; } out.push(cell); return out; };const [header, ...lines] = text.trimEnd().split("\n").map(parse);const col = (name: string) => header.indexOf(name);const out = ["email,orders,spent,currency,dry_points,top_match"];for (const row of lines) { const points = row[col("points")] ? Object.fromEntries(JSON.parse(row[col("points")]).map((p: any) => [p.dimension_id, p.total])) : {}; out.push([row[col("email")], row[col("orders")], row[col("spent")], row[col("currency")], points.dry ?? "", row[col("top_match")]].join(","));}writeFileSync("vips.csv", out.join("\n") + "\n");console.log(exp.row_count, "rows ->", "vips.csv"); // 312 rows -> vips.csvpackage main
import ( "bytes" "compress/gzip" "encoding/csv" "encoding/json" "fmt" "log" "net/http" "os" "strings" "time")
const QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7"
type export struct { ID string `json:"id"` Status string `json:"status"` RowCount *int `json:"row_count"` Error *string `json:"error"` DownloadURL *string `json:"download_url"`}
func call(method, url string, body []byte) (*http.Response, error) { req, _ := http.NewRequest(method, url, bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) req.Header.Set("Content-Type", "application/json") return http.DefaultClient.Do(req)}
func main() { body := []byte(`{"dataset": "profiles", "quiz_id": "` + QUIZ + `", "search": {"has_order": true, "groups": [{"all": [{"quiz_id": "` + QUIZ + `", "kind": "points", "op": "gt", "dimension_id": "dry", "value": 80}]}]}}`) res, err := call("POST", "https://api.octaneai.com/v1/exports", body) if err != nil { log.Fatal(err) } if res.StatusCode == http.StatusConflict { log.Fatal("a profiles export is already being built; poll that one") } if res.StatusCode != 202 { log.Fatalf("status %d", res.StatusCode) } var exp export json.NewDecoder(res.Body).Decode(&exp) res.Body.Close() for exp.Status == "pending" || exp.Status == "running" { time.Sleep(5 * time.Second) res, err = call("GET", "https://api.octaneai.com/v1/exports/"+exp.ID, nil) if err != nil || res.StatusCode != 200 { log.Fatal("poll failed", err) } json.NewDecoder(res.Body).Decode(&exp) res.Body.Close() } if exp.Status != "completed" { log.Fatal(*exp.Error) }
file, _ := http.Get(*exp.DownloadURL) // signed URL: no Authorization header defer file.Body.Close() gz, _ := gzip.NewReader(file.Body) r := csv.NewReader(gz) header, _ := r.Read() header[0] = strings.TrimPrefix(header[0], "\uFEFF") col := map[string]int{} for i, name := range header { col[name] = i } out, _ := os.Create("vips.csv") defer out.Close() w := csv.NewWriter(out) w.Write([]string{"email", "orders", "spent", "currency", "dry_points", "top_match"}) for { row, err := r.Read() if err != nil { break } dry := "" if row[col["points"]] != "" { var points []struct { DimensionID string `json:"dimension_id"` Total float64 `json:"total"` } json.Unmarshal([]byte(row[col["points"]]), &points) for _, p := range points { if p.DimensionID == "dry" { dry = fmt.Sprint(p.Total) } } } w.Write([]string{row[col["email"]], row[col["orders"]], row[col["spent"]], row[col["currency"]], dry, row[col["top_match"]]}) } w.Flush() fmt.Println(*exp.RowCount, "rows ->", "vips.csv") // 312 rows -> vips.csv}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. |
409 conflict | A profiles export is already being built for the store. | Poll that one; one file of a kind at a time. |
422 validation_error | An unknown dimension_id (the legal ids come back in errors[]); quiz_id, from or to inside search instead of on the export; a points op the kind does not take. | Fix the body. |
429 rate_limited | A search costs 6 units, an export request 2, a poll 1. | Sleep for Retry-After seconds. |
Things to know
- Points are per dimension and per run. The term compares the person’s total on that dimension; with the default
match: any_sessionany completed run counts, withmatch: latest_completed_per_quizonly their latest. The file’spointscell is the latest completed run’s totals whatever the match mode. has_orderis an order Octane AI linked to a quiz session, not the customer’s whole order history; a buyer who never took the quiz before buying is not in this file.- The file is the truth for big tiers.
totalstops at 5,000 (total_capped), the export does not:row_countsays how many rows the file holds, one per person withquiz_idon the export. spentandpointsare strings in the file.spentis a decimal string withcurrencybeside it;pointsis a JSON array of{dimension_id, name, total}. Parse both; never add spend as a float.- Once per person. If the tier grants something (a code, a tag in your loyalty tool), keep a ledger of who already got it; the same person is in every run of the file until their score or orders change.
