A daily digest of the whole store
Goal: one Slack message every morning with yesterday’s numbers across every quiz, the quizzes that earned the most, and the products the quizzes sold most, so the team sees the day without opening the dashboard.
Uses: Analytics: the store-wide GET /v1/analytics/overview and GET /v1/analytics/quizzes, then GET /v1/quizzes/{quiz_id}/analytics/products for the quizzes that earned. Scope: analytics:read. For one quiz’s digest see the completion alert; this recipe covers the store.
The flow
- Pick one timezone (
TZ) and compute yesterday in it; send it astzon every request so all three routes describe the same day. GET /v1/analytics/overview?from=yesterday&to=yesterday:totalsholds the store’s views, starts, completions, orders and revenue.GET /v1/analytics/quizzes?sort=revenue&dir=desc&limit=5for the same day: the quizzes that earned, with theircompletion_rate.- For each of those quizzes,
GET /v1/quizzes/{quiz_id}/analytics/products?sort=units&limit=20, then add the rows up byproduct_id: one product can be recommended by several quizzes. - Post the lines to a Slack incoming webhook.
import datetime as dtimport osfrom decimal import Decimalfrom zoneinfo import ZoneInfoimport httpx
API = "https://api.octaneai.com/v1"HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}SLACK = os.environ["SLACK_WEBHOOK_URL"]TZ = "America/New_York"
def get(path, **params): r = httpx.get(f"{API}{path}", params={**params, "tz": TZ}, headers=HEADERS, timeout=30) r.raise_for_status() return r.json()
yesterday = (dt.datetime.now(ZoneInfo(TZ)).date() - dt.timedelta(days=1)).isoformat()day = {"from": yesterday, "to": yesterday}t = get("/analytics/overview", **day)["totals"]quizzes = get("/analytics/quizzes", sort="revenue", dir="desc", limit=5, **day)["data"]
products = {} # product_id -> {title, units, revenue}for quiz in quizzes: if quiz["revenue"] is None or Decimal(quiz["revenue"]["amount"]) == 0: continue for row in get(f"/quizzes/{quiz['quiz_id']}/analytics/products", sort="units", limit=20, **day)["rows"]: p = products.setdefault(row["product_id"], {"title": row["title"] or row["product_id"], "units": 0, "revenue": Decimal(0)}) p["units"] += row["units"] p["revenue"] += Decimal(row["line_revenue"]["amount"])
revenue = f"{t['revenue']['amount']} {t['revenue']['currency']}" if t["revenue"] else "no revenue" # money can be nulllines = [f"*{yesterday}*: {t['views']} views, {t['starts']} starts, {t['completions']} completions, {t['orders']} orders, {revenue}"]for q in quizzes: if q["starts"] == 0: continue # every quiz is listed, drafts and quiet ones with zeros rate = f"{q['completion_rate']:.0f}%" if q["completion_rate"] is not None else "no starts" lines.append(f"- {q['name']}: {q['starts']} starts, {rate} completed, {q['orders']} orders, {q['revenue']['amount']} {q['revenue']['currency']}")for p in sorted(products.values(), key=lambda p: p["units"], reverse=True)[:5]: lines.append(f" - {p['title']}: {p['units']} units, {p['revenue']}")print("\n".join(lines))httpx.post(SLACK, json={"text": "\n".join(lines)}, timeout=30).raise_for_status()const API = "https://api.octaneai.com/v1";const HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}` };const SLACK = process.env.SLACK_WEBHOOK_URL!;const TZ = "America/New_York";
async function get(path: string, params: Record<string, string>) { const res = await fetch(`${API}${path}?${new URLSearchParams({ ...params, tz: TZ })}`, { headers: HEADERS }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); return res.json();}
const yesterday = new Intl.DateTimeFormat("en-CA", { timeZone: TZ }).format(new Date(Date.now() - 86400000)); // YYYY-MM-DDconst day = { from: yesterday, to: yesterday };const t = (await get("/analytics/overview", day)).totals;const quizzes = (await get("/analytics/quizzes", { ...day, sort: "revenue", dir: "desc", limit: "5" })).data;
const products = new Map<string, { title: string; units: number; cents: bigint }>(); // money as integer cents, never a floatfor (const quiz of quizzes) { if (!quiz.revenue || quiz.revenue.amount === "0") continue; for (const row of (await get(`/quizzes/${quiz.quiz_id}/analytics/products`, { ...day, sort: "units", limit: "20" })).rows) { const p = products.get(row.product_id) ?? { title: row.title ?? row.product_id, units: 0, cents: 0n }; p.units += row.units; p.cents += BigInt(Math.round(Number(row.line_revenue.amount) * 100)); products.set(row.product_id, p); }}
const money = (cents: bigint) => `${cents / 100n}.${String(cents % 100n).padStart(2, "0")}`;const revenue = t.revenue ? `${t.revenue.amount} ${t.revenue.currency}` : "no revenue"; // money can be nullconst lines = [`*${yesterday}*: ${t.views} views, ${t.starts} starts, ${t.completions} completions, ${t.orders} orders, ${revenue}`];for (const q of quizzes) { if (q.starts === 0) continue; // every quiz is listed, drafts and quiet ones with zeros const rate = q.completion_rate === null ? "no starts" : `${Math.round(q.completion_rate)}%`; lines.push(`- ${q.name}: ${q.starts} starts, ${rate} completed, ${q.orders} orders, ${q.revenue.amount} ${q.revenue.currency}`);}for (const p of [...products.values()].sort((a, b) => b.units - a.units).slice(0, 5)) lines.push(` - ${p.title}: ${p.units} units, ${money(p.cents)}`);console.log(lines.join("\n"));await fetch(SLACK, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: lines.join("\n") }) });package main
import ( "bytes" "encoding/json" "fmt" "log" "math/big" "net/http" "net/url" "os" "sort" "strings" "time")
const API = "https://api.octaneai.com/v1"const TZ = "America/New_York"
type Money struct { Amount string `json:"amount"` Currency string `json:"currency"`}
func get(path string, q url.Values, into any) { q.Set("tz", TZ) req, _ := http.NewRequest("GET", API+path+"?"+q.Encode(), nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) 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) } json.NewDecoder(res.Body).Decode(into)}
type product struct { title string units int revenue *big.Rat // decimal strings added exactly, never as floats}
func main() { loc, _ := time.LoadLocation(TZ) yesterday := time.Now().In(loc).AddDate(0, 0, -1).Format("2006-01-02") day := func() url.Values { return url.Values{"from": {yesterday}, "to": {yesterday}} }
var overview struct { Totals struct { Views, Starts, Completions, Orders int Revenue *Money `json:"revenue"` // money can be null } `json:"totals"` } get("/analytics/overview", day(), &overview)
var quizzes struct { Data []struct { QuizID string `json:"quiz_id"` Name string `json:"name"` Starts, Orders int CompletionRate *float64 `json:"completion_rate"` Revenue *Money `json:"revenue"` } `json:"data"` } q := day() q.Set("sort", "revenue") q.Set("dir", "desc") q.Set("limit", "5") get("/analytics/quizzes", q, &quizzes)
products := map[string]*product{} for _, quiz := range quizzes.Data { if quiz.Revenue == nil || quiz.Revenue.Amount == "0" { continue } var body struct { Rows []struct { ProductID string `json:"product_id"` Title *string `json:"title"` Units int `json:"units"` LineRevenue Money `json:"line_revenue"` } `json:"rows"` } pq := day() pq.Set("sort", "units") pq.Set("limit", "20") get("/quizzes/"+quiz.QuizID+"/analytics/products", pq, &body) for _, row := range body.Rows { p, ok := products[row.ProductID] if !ok { title := row.ProductID if row.Title != nil { title = *row.Title } p = &product{title: title, revenue: new(big.Rat)} products[row.ProductID] = p } p.units += row.Units amount, _ := new(big.Rat).SetString(row.LineRevenue.Amount) p.revenue.Add(p.revenue, amount) } }
t := overview.Totals revenue := "no revenue" if t.Revenue != nil { revenue = t.Revenue.Amount + " " + t.Revenue.Currency } lines := []string{fmt.Sprintf("*%s*: %d views, %d starts, %d completions, %d orders, %s", yesterday, t.Views, t.Starts, t.Completions, t.Orders, revenue)} for _, quiz := range quizzes.Data { if quiz.Starts == 0 { continue // every quiz is listed, drafts and quiet ones with zeros } rate := "no starts" if quiz.CompletionRate != nil { rate = fmt.Sprintf("%.0f%%", *quiz.CompletionRate) } lines = append(lines, fmt.Sprintf("- %s: %d starts, %s completed, %d orders, %s %s", quiz.Name, quiz.Starts, rate, quiz.Orders, quiz.Revenue.Amount, quiz.Revenue.Currency)) } top := make([]*product, 0, len(products)) for _, p := range products { top = append(top, p) } sort.Slice(top, func(i, j int) bool { return top[i].units > top[j].units }) for i, p := range top { if i == 5 { break } lines = append(lines, fmt.Sprintf(" - %s: %d units, %s", p.title, p.units, p.revenue.FloatString(2))) } fmt.Println(strings.Join(lines, "\n")) msg, _ := json.Marshal(map[string]string{"text": strings.Join(lines, "\n")}) http.Post(os.Getenv("SLACK_WEBHOOK_URL"), "application/json", bytes.NewReader(msg))}The message reads:
*2026-09-12*: 18853 views, 16979 starts, 12009 completions, 253 orders, 13991.00 USD- Find your routine: 9840 starts, 70% completed, 141 orders, 7812.00 USD- Gift finder: 7139 starts, 69% completed, 112 orders, 6179.00 USD - Hydrating Serum: 96 units, 3360.00 - Night Cream: 71 units, 2911.00Errors to handle
| Status | Why | What to do |
|---|---|---|
403 insufficient_scope | The key lacks analytics:read. | Mint a key with the scope. |
422 validation_error | An unknown tz, or to before from. | Fix the request; nothing to retry. |
429 rate_limited | The allowance is spent (a digest over many quizzes). | Sleep for Retry-After seconds and continue; every call here is idempotent. |
503 limiter_unavailable | Limits could not be checked. | Retry after Retry-After. |
Things to know
- Money is a decimal string.
{"amount": "12.50", "currency": "USD"}; add it up with a decimal type (Decimal,big.Rat, integer cents), never a float. A quiz with no orders reports"0";revenueisnullonly when the store’s currency is not known yet. - One product, several quizzes. The products route is per quiz, so the same product appears once per quiz that showed it; the digest adds the rows up by
product_id.unitsandline_revenuecount the product’s lines on attributed orders;times_shownis how often a result page showed it. - Yesterday is closed.
freshness.closed_days_throughis yesterday in the request’s timezone by the time a morning job runs, so the numbers will not change; the same request tomorrow answers a304if you send theETagback. - Cost. Each call that answers a body costs 3 units: 2 + one per quiz that earned, against a Plus allowance of 72 refilled at 12 a second. A store with hundreds of quizzes should keep
limit=5on the quizzes call, as here. - Every quiz is a row.
GET /v1/analytics/quizzeslists every quiz of the store, drafts included, with zeros on a quiet day;statussays which are published. A deleted quiz is not listed.
