Skip to content

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.

Terminal window
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 }
] }]
}'

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 csv
import gzip
import io
import json
import os
import time
import 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 header
rows = 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.csv

Errors to handle

StatusWhyWhat to do
403 insufficient_scopeThe key lacks profiles:read.Mint a key with the scope.
404 not_foundThe quiz_id is not this store’s or was deleted.Re-list with GET /v1/quizzes.
409 conflictA profiles export is already being built for the store.Poll that one; one file of a kind at a time.
422 validation_errorAn 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_limitedA 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_session any completed run counts, with match: latest_completed_per_quiz only their latest. The file’s points cell is the latest completed run’s totals whatever the match mode.
  • has_order is 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. total stops at 5,000 (total_capped), the export does not: row_count says how many rows the file holds, one per person with quiz_id on the export.
  • spent and points are strings in the file. spent is a decimal string with currency beside it; points is 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.