Exports
An export is a file of your people, your quiz responses or your quiz analytics, built in the background and downloadable for up to 30 days (erasing the store’s shopper data removes it earlier). Request one, poll it until it is completed, then follow its download_url; when it is ready the export.completed webhook fires. The same files can be requested from the dashboard under Developer > Exports (every plan, no key needed). The request and response fields are in the reference.
profiles and responses need profiles:read, quiz_analytics needs analytics:read; listing works with any key. A request costs 2 units, polling and listing 1 (see Rate limits).
Two formats
format picks the file: csv (the default) or json. Both are gzipped; the download is named <dataset>-<date>.csv.gz or <dataset>-<date>.json.gz.
csv | json | |
|---|---|---|
| Layout | A header row then one row per line, behind a UTF-8 byte-order mark so spreadsheets open it with the right encoding. answers, points and formulas are JSON in one cell each. | One object: {"dataset", "filters", "generated_at", "items": [...]}. Parse the whole file, then read items. |
profiles rows | One row per person and quiz with the person’s latest completed result for that quiz: contact, counts, spend, then answers, points, formulas, top_match, total_points, result_page. | One item per person, shaped like a GET /v1/profiles row: the person and the quizzes they took, without their answers. |
responses rows | One row per captured session, finished or not. | The same fields per session, with answers, points and formulas as nested objects instead of JSON strings; a cell that is empty in the CSV is "" here. |
quiz_analytics rows | One row per quiz over the range. | One item per quiz, shaped like a GET /v1/analytics/quizzes row (money as {amount, currency}). |
Pick csv for spreadsheets and warehouse loaders, and whenever you need answers per person: only the CSV profiles file carries them. Pick json when you already parse the API’s objects and want the same shapes without a CSV parser, or for responses, where the answers arrive as objects you can read directly.
Request a file, poll it, download it
curl -X POST https://api.octaneai.com/v1/exports \ -H "Authorization: Bearer $OCTANE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dataset": "profiles", "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "from": "2026-08-01", "to": "2026-08-31"}'
# then, until "status" is "completed":curl https://api.octaneai.com/v1/exports/exp_3f2504e04f8911d39a0c0305e82c3301 \ -H "Authorization: Bearer $OCTANE_API_KEY"
# then, with no Authorization header:curl -o profiles.csv.gz "<download_url>"import csvimport gzipimport ioimport osimport timeimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}
r = httpx.post("https://api.octaneai.com/v1/exports", headers=HEADERS, json={ "dataset": "profiles", "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "from": "2026-08-01", "to": "2026-08-31",}, timeout=30)r.raise_for_status()export = r.json()
while export["status"] in ("pending", "running"): time.sleep(5) r = httpx.get(export["urls"]["self"], headers=HEADERS, timeout=30) r.raise_for_status() export = r.json()if export["status"] != "completed": raise RuntimeError(export["error"])
raw = httpx.get(export["download_url"], timeout=120).content # signed URL: no Authorization headerrows = list(csv.DictReader(io.StringIO(gzip.decompress(raw).decode("utf-8-sig"))))print(export["row_count"], len(rows)) # 1240 1240The request answers 202 with the export in pending status and a Location header pointing at it:
{ "id": "exp_3f2504e04f8911d39a0c0305e82c3301", "dataset": "profiles", "format": "csv", "source": "api", "status": "pending", "filters": { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "from": "2026-08-01", "to": "2026-08-31" }, "row_count": null, "expires_at": "2026-10-01T12:00:00Z", "download_url": null, "urls": { "self": "https://api.octaneai.com/v1/exports/exp_3f2504e04f8911d39a0c0305e82c3301" }}What the filters mean depends on the dataset: for profiles and responses, from and to are UTC days on the person’s last visit or on the session; for quiz_analytics they are the analytics range in tz (UTC when absent), the last 30 days when both are absent. quiz_id narrows every dataset to one quiz. search, for profiles only, is the body of a People search without its quiz_id, from and to, which stay on the export (see A filtered people file).
statusgoespending,running, thencompletedorfailed. On completionrow_countandbytesare set; on failureerrorreads “The file could not be built” (or “Could not queue the export” when the job never started).- Reading a
completedexport answers a freshdownload_urlon every call, good for 15 minutes (download_url_expires_at); the list never carries links. The URL is signed on its own: do not send your API key to it. - One file of a kind builds at a time per store: requesting a dataset that is still being built answers
409 conflict; poll that one instead. - After
expires_at(30 days from the request) the export answers404and leaves the list. - The admin who requested a file from the dashboard is emailed when it is ready; a file requested with a key is not, so subscribe to
export.completedor poll.
The same request as JSON
curl -X POST https://api.octaneai.com/v1/exports \ -H "Authorization: Bearer $OCTANE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"dataset": "responses", "format": "json", "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "from": "2026-08-01", "to": "2026-08-31"}'The file, once gunzipped, cut to one item:
{ "dataset": "responses", "filters": { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "from": "2026-08-01", "to": "2026-08-31" }, "generated_at": "2026-09-01T12:00:09Z", "items": [ { "session_id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e", "quiz": "Find your routine", "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "version": 2, "status": "completed", "started_at": "2026-08-14T12:00:00Z", "completed_at": "2026-08-14T12:04:00Z", "channel": "email", "device": "mobile", "country": "US", "answers": { "image_choice-48su5": { "label": "Skin type?", "kind": "image", "value": "dry", "values": ["Dry"], "image_urls": [null] } }, "points": [{ "dimension_id": "dry", "name": "Dry", "total": 14 }], "formulas": [{ "component_key": "formula-tidtk", "label": "Hydration score", "value": 72 }], "top_match": "Dry", "total_points": "20", "result_page": "Your match" } ]}The CSV columns
The header never changes, whatever the filters.
profiles:person_id(prof_),removed,removed_at,email,phone,first_seen_at,last_seen_at,sessions,completed,returning,orders,spent,currency,quiz,quiz_id(quiz_),version,completed_at,answers,points,formulas,top_match,total_points,result_page. One row per person and quiz; one row per person whenquiz_idis set.responses:session_id(sess_),quiz,quiz_id,version,status,started_at,completed_at,channel,device,country,answers,points,formulas,top_match,total_points,result_page. Labels are those of the version the session ran on.quiz_analytics:quiz_id,name,status,views,starts,completions,completion_rate,email_opt_ins,phone_opt_ins,orders,revenue,currency,attributed_share,avg_time_to_complete_ms,median_time_to_complete_ms.
answers, points and formulas hold the same values the people API returns in a compact shape: answers is an object keyed by component key with label, kind, value, values and, for an image choice, image_urls; points is [{dimension_id, name, total}]; formulas is [{component_key, label, value}], numbers as numbers. The three cells are empty when the person has no completed result for the quiz.
import json
# one row of a profiles file, as csv.DictReader yields itanswers = json.loads(row["answers"]) if row["answers"] else {}print(answers["image_choice-48su5"]["values"]) # ['Dry']A filtered people file
Add search to a profiles export and the file holds exactly the people the search matches, however many. A live People search is built for pages of results and has 10 seconds per statement; a filter that matches a large share of your people runs past that and answers 422 validation_error whose export field carries the url and ready-to-send body of the export request that builds the same people as a file. An export takes no search slot and has no time limit, so anything you plan to process in bulk starts here.
curl -X POST https://api.octaneai.com/v1/exports \ -H "Authorization: Bearer $OCTANE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "dataset": "profiles", "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "search": { "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"] } ] }] } }'quiz_id, from and to inside search answer 422 validation_error (“put quiz_id, from and to on the export, not on search”); search with another dataset is refused the same way; a quiz you do not own inside a term answers 404.
Errors you will meet
Every one is a Problem body: 403 insufficient_scope when the key lacks the dataset’s scope (required names it); 404 not_found for an unknown, foreign or expired export, or a foreign quiz in quiz_id or in search; 409 conflict while an export of that dataset is still being built; 422 validation_error for an unknown dataset or format, an unknown tz, to before from, or quiz_id, from or to inside search.
