A filtered people file for your warehouse
Goal: a gzipped CSV of the people who took a quiz last month and answered a given way, loaded into BigQuery, Snowflake or a spreadsheet, with the exact rule that built it stored beside it.
Uses: Exports with a search filter, and the export.completed webhook to know when the file is ready. Scopes: profiles:read (the file carries emails and phone numbers); webhooks:manage for the webhook variant.
1. Request the file
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", "search": { "groups": [{ "all": [ { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "kind": "choice", "op": "in", "page_key": "gifting", "component_key": "choice-gift1", "option_labels": ["Corporate gifting"] } ] }] } }'import osimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7"SEARCH = {"groups": [{"all": [{"quiz_id": QUIZ, "kind": "choice", "op": "in", "page_key": "gifting", "component_key": "choice-gift1", "option_labels": ["Corporate gifting"]}]}]}
r = httpx.post("https://api.octaneai.com/v1/exports", headers=HEADERS, json={"dataset": "profiles", "quiz_id": QUIZ, "from": "2026-08-01", "to": "2026-08-31", "search": SEARCH}, 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()print(export["id"], export["status"]) # exp_3f2504e04f8911d39a0c0305e82c3301 pendingconst HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}`, "Content-Type": "application/json" };const QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7";const SEARCH = { groups: [{ all: [{ quiz_id: QUIZ, kind: "choice", op: "in", page_key: "gifting", component_key: "choice-gift1", option_labels: ["Corporate gifting"] }] }] };
const res = await fetch("https://api.octaneai.com/v1/exports", { method: "POST", headers: HEADERS, body: JSON.stringify({ dataset: "profiles", quiz_id: QUIZ, from: "2026-08-01", to: "2026-08-31", 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()}`);const exp = await res.json();console.log(exp.id, exp.status); // exp_3f2504e04f8911d39a0c0305e82c3301 pendingpackage main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os")
const QUIZ = "quiz_7c9e6679742540de944be07fc1f90ae7"
func main() { body := []byte(`{"dataset": "profiles", "quiz_id": "` + QUIZ + `", "from": "2026-08-01", "to": "2026-08-31", "search": {"groups": [{"all": [{"quiz_id": "` + QUIZ + `", "kind": "choice", "op": "in", "page_key": "gifting", "component_key": "choice-gift1", "option_labels": ["Corporate gifting"]}]}]}}`) req, _ := http.NewRequest("POST", "https://api.octaneai.com/v1/exports", 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 == 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 export struct { ID string `json:"id"` Status string `json:"status"` } json.NewDecoder(res.Body).Decode(&export) fmt.Println(export.ID, export.Status) // exp_3f2504e04f8911d39a0c0305e82c3301 pending}The answer is 202 with the export in pending status and a Location header pointing at it. quiz_id, from and to stay on the export (from/to are the person’s last visit); inside search they answer 422.
2a. Be told when it is ready: the export.completed webhook
Subscribe an endpoint to export.completed once (webhooks:manage); the store’s other topics are unaffected:
curl -X POST https://api.octaneai.com/v1/webhooks \ -H "Authorization: Bearer $OCTANE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://hooks.example.com/octane", "topics": ["export.completed"], "description": "Warehouse loader"}'import osimport httpx
r = httpx.post( "https://api.octaneai.com/v1/webhooks", headers={"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}, json={"url": "https://hooks.example.com/octane", "topics": ["export.completed"], "description": "Warehouse loader"}, timeout=30)r.raise_for_status()print(r.json()["endpoint"]["id"], r.json()["secret"]) # whe_... whsec_...: put the secret in OCTANEAI_WEBHOOK_SECRETconst res = await fetch("https://api.octaneai.com/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.OCTANE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ url: "https://hooks.example.com/octane", topics: ["export.completed"], description: "Warehouse loader" }),});if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);const created = await res.json();console.log(created.endpoint.id, created.secret); // whe_... whsec_...: put the secret in OCTANEAI_WEBHOOK_SECRETpackage main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os")
func main() { body := []byte(`{"url": "https://hooks.example.com/octane", "topics": ["export.completed"], "description": "Warehouse loader"}`) req, _ := http.NewRequest("POST", "https://api.octaneai.com/v1/webhooks", 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 >= 400 { log.Fatalf("status %d", res.StatusCode) } var created struct { Endpoint struct { ID string `json:"id"` } `json:"endpoint"` Secret string `json:"secret"` } json.NewDecoder(res.Body).Decode(&created) fmt.Println(created.Endpoint.ID, created.Secret) // whe_... whsec_...: put the secret in OCTANEAI_WEBHOOK_SECRET}When the file is built, the endpoint receives the export object (no download link; see the payload):
{ "id": "evt_6ba7b8109dad11d180b400c04fd430c8", "type": "export.completed", "created": "2026-09-01T12:00:09Z", "api_version": "v1", "data": { "object": { "id": "exp_3f2504e04f8911d39a0c0305e82c3301", "dataset": "profiles", "status": "completed", "filters": { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "from": "2026-08-01", "to": "2026-08-31", "tz": null }, "row_count": 1240, "bytes": 58311, "error": null, "source": "api", "requested_at": "2026-09-01T12:00:00Z", "started_at": "2026-09-01T12:00:02Z", "completed_at": "2026-09-01T12:00:09Z", "expires_at": "2026-10-01T12:00:00Z", "download_url": null, "download_url_expires_at": null, "urls": { "self": "https://api.octaneai.com/v1/exports/exp_3f2504e04f8911d39a0c0305e82c3301" } } }}The receiver (Flask and httpx, pip install flask httpx; Express; Go’s net/http) verifies the signature exactly as Verify a webhook signature does, checks that data.object.id is the export it asked for (the id you stored in step 1), then fetches urls.self with your key for a fresh 15-minute download_url and loads the file:
import base64import csvimport gzipimport hashlibimport hmacimport ioimport osimport timeimport httpxfrom flask import Flask, request
app = Flask(__name__)SECRET = os.environ["OCTANEAI_WEBHOOK_SECRET"] # whsec_...HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}WANTED = "exp_3f2504e04f8911d39a0c0305e82c3301" # the id step 1 printed; keep it where the receiver can read it
def verify(secret: str, headers, body: bytes) -> bool: try: key = base64.b64decode(secret[len("whsec_"):], validate=True) msg_id, ts, sigs = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"] if abs(time.time() - int(ts)) > 300: return False expected = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest() for sig in sigs.split(" "): version, _, value = sig.partition(",") if version == "v1" and hmac.compare_digest(base64.b64decode(value, validate=True), expected): return True except (KeyError, ValueError): pass return False
def load(export_id: str) -> None: r = httpx.get(f"https://api.octaneai.com/v1/exports/{export_id}", headers=HEADERS, timeout=30) r.raise_for_status() export = r.json() # status completed, download_url good for 15 minutes raw = httpx.get(export["download_url"], timeout=300).content # signed URL: no Authorization header text = gzip.decompress(raw).decode("utf-8-sig") rows = list(csv.DictReader(io.StringIO(text))) with open(f"people-{export_id}.csv", "w", newline="") as f: f.write(text) # load this file; keep the search body next to it as the rule that built it print(export["row_count"], len(rows)) # 1240 1240
@app.post("/octane")def octane(): if not verify(SECRET, request.headers, request.get_data()): return "bad signature", 401 event = request.get_json() if event["type"] == "export.completed" and event["data"]["object"]["id"] == WANTED: load(WANTED) # in production: enqueue, then answer; see the signature recipe return "", 200
if __name__ == "__main__": app.run(port=8080)import express from "express";import { createHmac, timingSafeEqual } from "node:crypto";import { writeFileSync } from "node:fs";import { gunzipSync } from "node:zlib";
const SECRET = process.env.OCTANEAI_WEBHOOK_SECRET!; // whsec_...const HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}` };const WANTED = "exp_3f2504e04f8911d39a0c0305e82c3301"; // the id step 1 printed; keep it where the receiver can read it
function verify(secret: string, headers: Record<string, string | undefined>, body: Buffer): boolean { const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sigs = headers["webhook-signature"]; if (!id || !ts || !sigs || !/^\d+$/.test(ts)) return false; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const key = Buffer.from(secret.slice("whsec_".length), "base64"); const expected = createHmac("sha256", key).update(`${id}.${ts}.`).update(body).digest(); for (const sig of sigs.split(" ")) { const [version, value] = sig.split(","); if (version !== "v1" || !value) continue; const given = Buffer.from(value, "base64"); if (given.length === expected.length && timingSafeEqual(given, expected)) return true; } return false;}
async function load(exportId: string) { const res = await fetch(`https://api.octaneai.com/v1/exports/${exportId}`, { headers: HEADERS }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const exp = await res.json(); // status completed, download_url good for 15 minutes const raw = new Uint8Array(await fetch(exp.download_url).then((r) => r.arrayBuffer())); // signed URL: no Authorization header const text = gunzipSync(raw).toString("utf8").replace(/^\uFEFF/, ""); writeFileSync(`people-${exportId}.csv`, text); // load this file; keep SEARCH next to it as the rule that built it console.log(exp.row_count, text.trimEnd().split("\n").length - 1); // 1240 1240}
const app = express();app.post("/octane", express.raw({ type: "application/json" }), async (req, res) => { const headers = { "webhook-id": req.get("webhook-id"), "webhook-timestamp": req.get("webhook-timestamp"), "webhook-signature": req.get("webhook-signature") }; if (!verify(SECRET, headers, req.body)) return res.status(401).send("bad signature"); const event = JSON.parse(req.body.toString("utf8")); if (event.type === "export.completed" && event.data.object.id === WANTED) await load(WANTED); // in production: enqueue, then answer res.sendStatus(200);});app.listen(8080);package main
import ( "compress/gzip" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "log" "math" "net/http" "os" "strconv" "strings" "time")
var ( secret = os.Getenv("OCTANEAI_WEBHOOK_SECRET") // whsec_... wanted = "exp_3f2504e04f8911d39a0c0305e82c3301" // the id step 1 printed; keep it where the receiver can read it)
func verify(secret string, h http.Header, body []byte) bool { key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature") sent, err := strconv.ParseInt(ts, 10, 64) if id == "" || err != nil || math.Abs(float64(time.Now().Unix()-sent)) > 300 { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(body) expected := mac.Sum(nil) for _, sig := range strings.Split(sigs, " ") { version, value, ok := strings.Cut(sig, ",") if !ok || version != "v1" { continue } if given, err := base64.StdEncoding.DecodeString(value); err == nil && hmac.Equal(given, expected) { return true } } return false}
func load(exportID string) { req, _ := http.NewRequest("GET", "https://api.octaneai.com/v1/exports/"+exportID, nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil || res.StatusCode != 200 { log.Println("export fetch failed", err) return } defer res.Body.Close() var export struct { RowCount int `json:"row_count"` DownloadURL string `json:"download_url"` // good for 15 minutes } json.NewDecoder(res.Body).Decode(&export) file, err := http.Get(export.DownloadURL) // signed URL: no Authorization header if err != nil { log.Println(err) return } defer file.Body.Close() gz, _ := gzip.NewReader(file.Body) out, _ := os.Create("people-" + exportID + ".csv") defer out.Close() n, _ := io.Copy(out, gz) // load this file; keep the search body next to it as the rule that built it fmt.Println(export.RowCount, n) // 1240 <bytes written>}
func octane(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) if !verify(secret, r.Header, body) { http.Error(w, "bad signature", http.StatusUnauthorized) return } var event struct { Type string `json:"type"` Data struct { Object struct { ID string `json:"id"` } `json:"object"` } `json:"data"` } json.Unmarshal(body, &event) if event.Type == "export.completed" && event.Data.Object.ID == wanted { go load(wanted) // in production: enqueue durably, then answer; see the signature recipe } w.WriteHeader(http.StatusOK)}
func main() { http.HandleFunc("/octane", octane) http.ListenAndServe(":8080", nil)}A failed export sends no webhook: keep the polling variant below as the fallback for that case, and dedupe on webhook-id as the signature recipe shows, since a retry can deliver the same event twice.
2b. Or poll, then download
For a setup without a public endpoint, poll GET /v1/exports/{export_id} until status is completed:
import csvimport gzipimport ioimport osimport timeimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}EXPORT = "https://api.octaneai.com/v1/exports/exp_3f2504e04f8911d39a0c0305e82c3301" # the id step 1 printed
while True: r = httpx.get(EXPORT, headers=HEADERS, timeout=30) r.raise_for_status() export = r.json() if export["status"] not in ("pending", "running"): break time.sleep(10)if export["status"] != "completed": raise SystemExit(export["error"])
raw = httpx.get(export["download_url"], timeout=300).content # signed URL: no Authorization headertext = gzip.decompress(raw).decode("utf-8-sig")rows = list(csv.DictReader(io.StringIO(text)))print(export["row_count"], "rows,", export["bytes"], "bytes") # 1240 rows, 58311 bytes
with open(f"people-{export['id']}.csv", "w", newline="") as f: f.write(text) # load this file; keep the search body next to it as the rule that built itimport { writeFileSync } from "node:fs";import { gunzipSync } from "node:zlib";
const HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}` };const EXPORT = "https://api.octaneai.com/v1/exports/exp_3f2504e04f8911d39a0c0305e82c3301"; // the id step 1 printed
let exp: any;while (true) { const res = await fetch(EXPORT, { headers: HEADERS }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); exp = await res.json(); if (exp.status !== "pending" && exp.status !== "running") break; await new Promise((r) => setTimeout(r, 10000));}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/, "");console.log(exp.row_count, "rows,", exp.bytes, "bytes"); // 1240 rows, 58311 byteswriteFileSync(`people-${exp.id}.csv`, text); // load this file; keep SEARCH next to it as the rule that built itpackage main
import ( "compress/gzip" "encoding/json" "fmt" "io" "log" "net/http" "os" "time")
const EXPORT = "https://api.octaneai.com/v1/exports/exp_3f2504e04f8911d39a0c0305e82c3301" // the id step 1 printed
func main() { var export struct { ID string `json:"id"` Status string `json:"status"` RowCount *int `json:"row_count"` Bytes *int `json:"bytes"` Error *string `json:"error"` DownloadURL *string `json:"download_url"` } for { req, _ := http.NewRequest("GET", EXPORT, nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) 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(&export) res.Body.Close() if export.Status != "pending" && export.Status != "running" { break } time.Sleep(10 * time.Second) } if export.Status != "completed" { log.Fatal(*export.Error) }
file, _ := http.Get(*export.DownloadURL) // signed URL: no Authorization header defer file.Body.Close() gz, _ := gzip.NewReader(file.Body) out, _ := os.Create("people-" + export.ID + ".csv") defer out.Close() io.Copy(out, gz) // load this file; keep the search body next to it as the rule that built it fmt.Println(*export.RowCount, "rows,", *export.Bytes, "bytes") // 1240 rows, 58311 bytes}Load people-<id>.csv with your warehouse’s CSV loader (the file starts with a UTF-8 byte-order mark). The three JSON cells (answers, points, formulas) load as strings; parse them in SQL (JSON_EXTRACT in BigQuery, PARSE_JSON in Snowflake) or in the job.
What is in the file
One row per person (one per person and quiz without quiz_id), with their latest completed result for the quiz: person_id, removed, removed_at, email, phone, first_seen_at, last_seen_at, sessions, completed, returning, orders, spent, currency, quiz, quiz_id, version, completed_at, answers, points, formulas, top_match, total_points, result_page. The header never changes, so a loader can be written once. A person in the range with no completed result for the quiz still gets a row, with the result cells blank.
import json
# one row of the file, as csv.DictReader yields itrow = {"answers": '{"number_input-re853": {"label": "Budget", "kind": "number", "value": 120, "values": ["120"]}}'}answers = json.loads(row["answers"]) if row["answers"] else {}print(answers.get("number_input-re853", {}).get("value")) # 120// one row of the file, as your CSV parser yields itconst row = { answers: '{"number_input-re853": {"label": "Budget", "kind": "number", "value": 120, "values": ["120"]}}' };const answers = row.answers ? JSON.parse(row.answers) : {};console.log(answers["number_input-re853"]?.value); // 120package main
import ( "encoding/json" "fmt")
func main() { // the answers cell of one row of the file cell := `{"number_input-re853": {"label": "Budget", "kind": "number", "value": 120, "values": ["120"]}}` var answers map[string]struct { Value any `json:"value"` } if cell != "" { json.Unmarshal([]byte(cell), &answers) } fmt.Println(answers["number_input-re853"].Value) // 120}Things to know
- One file of a kind at a time. A second
profilesexport while one is building answers409 conflict; poll the running one. Requesting costs 2 units, polling 1. - The link is short-lived.
download_urlis good for 15 minutes;GET /v1/exports/{export_id}mints a fresh one on every call while the export iscompleted, which is why the webhook payload carries none. The file itself is kept up to 30 days (expires_at), then the export answers404. - The count is the file’s. A paged search caps
totalat 5,000 and bounds each of its statements at 10 seconds; the export has neither limit, holds every matching person, androw_countsays how many. - Nightly. Run it with yesterday as both
fromandtoto get the people last seen that day, one file per day. - No email for API requests. Only a file requested from the dashboard emails the admin; your job listens for
export.completedor polls.
