Recommended products that are out of stock
Goal: a short list, every morning, of the products the quiz showed most in the last week that a shopper cannot buy today, so the result page never sends people to a sold-out product.
Uses: Analytics: GET /v1/quizzes/{quiz_id}/analytics/products for what was shown and whether it is still in the store, then the Shopify Admin GraphQL API for what is sellable now; or, with no Admin token, the Storefront JS API’s product.shown on the page. Scopes: analytics:read on the Octane AI key; read_products on the Shopify custom app for the Admin call.
The flow
GET /v1/quizzes/{quiz_id}/analytics/products?from=<7 days ago>&to=<yesterday>&sort=units&limit=200: every product the quiz’s result pages showed, withtimes_shown,product_id(a Shopify GID) andin_store.- A row with
in_store: falseis a product the store no longer has: report it at once. - For the rest, one Admin API call,
nodes(ids: [...]), answers each product’sstatusand whether any variant isavailableForSale; a product that is notACTIVE, or has no sellable variant, is the other half of the list. - Sort by
times_shownand print (or post) the list.
import datetime as dtimport osimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}QUIZ = "https://api.octaneai.com/v1/quizzes/quiz_7c9e6679742540de944be07fc1f90ae7"SHOPIFY = f"https://{os.environ['SHOPIFY_SHOP']}/admin/api/2025-07/graphql.json" # your-store.myshopify.comSHOPIFY_HEADERS = {"X-Shopify-Access-Token": os.environ["SHOPIFY_ADMIN_TOKEN"], "Content-Type": "application/json"}MIN_SHOWN = 20 # ignore products the quiz hardly shows
today = dt.date.today()r = httpx.get(f"{QUIZ}/analytics/products", headers=HEADERS, timeout=30, params={"from": today - dt.timedelta(days=7), "to": today - dt.timedelta(days=1), "sort": "units", "limit": 200})r.raise_for_status()rows = [row for row in r.json()["rows"] if row["times_shown"] >= MIN_SHOWN]
problems = [(row, "no longer in the store") for row in rows if not row["in_store"]]ids = [row["product_id"] for row in rows if row["in_store"]]if ids: query = """query ($ids: [ID!]!) { nodes(ids: $ids) { ... on Product { id status variants(first: 100) { nodes { availableForSale } } } } }""" s = httpx.post(SHOPIFY, headers=SHOPIFY_HEADERS, json={"query": query, "variables": {"ids": ids}}, timeout=30) s.raise_for_status() body = s.json() if body.get("errors"): raise SystemExit(body["errors"]) # a missing read_products scope shows up here, as ACCESS_DENIED by_id = {row["product_id"]: row for row in rows} for node in body["data"]["nodes"]: if node is None: continue # nodes() answers null for an id Shopify no longer knows; in_store already covers it row = by_id[node["id"]] if node["status"] != "ACTIVE": problems.append((row, f"status {node['status']}")) elif not any(v["availableForSale"] for v in node["variants"]["nodes"]): problems.append((row, "no variant available for sale"))
problems.sort(key=lambda p: p[0]["times_shown"], reverse=True)for row, why in problems: print(f"{row['title'] or row['product_id']}: shown {row['times_shown']}x on {row['shown_on']['page_key']}, {why}")print(len(rows), "products checked,", len(problems), "to fix") # 6 products checked, 1 to fixconst HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}` };const QUIZ = "https://api.octaneai.com/v1/quizzes/quiz_7c9e6679742540de944be07fc1f90ae7";const SHOPIFY = `https://${process.env.SHOPIFY_SHOP}/admin/api/2025-07/graphql.json`; // your-store.myshopify.comconst SHOPIFY_HEADERS = { "X-Shopify-Access-Token": process.env.SHOPIFY_ADMIN_TOKEN!, "Content-Type": "application/json" };const MIN_SHOWN = 20; // ignore products the quiz hardly shows
const day = (offset: number) => new Date(Date.now() - offset * 86400000).toISOString().slice(0, 10);const res = await fetch(`${QUIZ}/analytics/products?${new URLSearchParams({ from: day(7), to: day(1), sort: "units", limit: "200" })}`, { headers: HEADERS });if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);type Row = { product_id: string; title: string | null; in_store: boolean; times_shown: number; shown_on: { page_key: string } | null };const rows: Row[] = (await res.json()).rows.filter((row: Row) => row.times_shown >= MIN_SHOWN);
const problems: Array<[Row, string]> = rows.filter((row) => !row.in_store).map((row) => [row, "no longer in the store"]);const ids = rows.filter((row) => row.in_store).map((row) => row.product_id);if (ids.length) { const query = `query ($ids: [ID!]!) { nodes(ids: $ids) { ... on Product { id status variants(first: 100) { nodes { availableForSale } } } } }`; const s = await fetch(SHOPIFY, { method: "POST", headers: SHOPIFY_HEADERS, body: JSON.stringify({ query, variables: { ids } }) }); if (!s.ok) throw new Error(`shopify ${s.status} ${await s.text()}`); const body = await s.json(); if (body.errors) throw new Error(JSON.stringify(body.errors)); // a missing read_products scope shows up here, as ACCESS_DENIED const byId = new Map(rows.map((row) => [row.product_id, row])); for (const node of body.data.nodes) { if (node === null) continue; // nodes() answers null for an id Shopify no longer knows; in_store already covers it const row = byId.get(node.id)!; if (node.status !== "ACTIVE") problems.push([row, `status ${node.status}`]); else if (!node.variants.nodes.some((v: { availableForSale: boolean }) => v.availableForSale)) problems.push([row, "no variant available for sale"]); }}
problems.sort((a, b) => b[0].times_shown - a[0].times_shown);for (const [row, why] of problems) console.log(`${row.title ?? row.product_id}: shown ${row.times_shown}x on ${row.shown_on?.page_key}, ${why}`);console.log(rows.length, "products checked,", problems.length, "to fix"); // 6 products checked, 1 to fixpackage main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "sort" "time")
const QUIZ = "https://api.octaneai.com/v1/quizzes/quiz_7c9e6679742540de944be07fc1f90ae7"const MIN_SHOWN = 20 // ignore products the quiz hardly shows
type Row struct { ProductID string `json:"product_id"` Title *string `json:"title"` InStore bool `json:"in_store"` TimesShown int `json:"times_shown"` ShownOn *struct { PageKey string `json:"page_key"` } `json:"shown_on"`}
type problem struct { row Row why string}
func main() { day := func(offset int) string { return time.Now().AddDate(0, 0, -offset).Format("2006-01-02") } q := url.Values{"from": {day(7)}, "to": {day(1)}, "sort": {"units"}, "limit": {"200"}} req, _ := http.NewRequest("GET", QUIZ+"/analytics/products?"+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) } var products struct { Rows []Row `json:"rows"` } json.NewDecoder(res.Body).Decode(&products) rows := []Row{} for _, row := range products.Rows { if row.TimesShown >= MIN_SHOWN { rows = append(rows, row) } }
problems := []problem{} ids := []string{} byID := map[string]Row{} for _, row := range rows { if !row.InStore { problems = append(problems, problem{row, "no longer in the store"}) continue } ids = append(ids, row.ProductID) byID[row.ProductID] = row } if len(ids) > 0 { query := `query ($ids: [ID!]!) { nodes(ids: $ids) { ... on Product { id status variants(first: 100) { nodes { availableForSale } } } } }` body, _ := json.Marshal(map[string]any{"query": query, "variables": map[string]any{"ids": ids}}) sreq, _ := http.NewRequest("POST", "https://"+os.Getenv("SHOPIFY_SHOP")+"/admin/api/2025-07/graphql.json", bytes.NewReader(body)) // your-store.myshopify.com sreq.Header.Set("X-Shopify-Access-Token", os.Getenv("SHOPIFY_ADMIN_TOKEN")) sreq.Header.Set("Content-Type", "application/json") sres, err := http.DefaultClient.Do(sreq) if err != nil { log.Fatal(err) } defer sres.Body.Close() if sres.StatusCode != 200 { log.Fatalf("shopify status %d", sres.StatusCode) } var answer struct { Errors []any `json:"errors"` Data struct { Nodes []*struct { ID string `json:"id"` Status string `json:"status"` Variants struct { Nodes []struct { AvailableForSale bool `json:"availableForSale"` } `json:"nodes"` } `json:"variants"` } `json:"nodes"` } `json:"data"` } json.NewDecoder(sres.Body).Decode(&answer) if len(answer.Errors) > 0 { log.Fatal(answer.Errors) // a missing read_products scope shows up here, as ACCESS_DENIED } for _, node := range answer.Data.Nodes { if node == nil { continue // nodes() answers null for an id Shopify no longer knows; in_store already covers it } row := byID[node.ID] sellable := false for _, v := range node.Variants.Nodes { sellable = sellable || v.AvailableForSale } if node.Status != "ACTIVE" { problems = append(problems, problem{row, "status " + node.Status}) } else if !sellable { problems = append(problems, problem{row, "no variant available for sale"}) } } }
sort.Slice(problems, func(i, j int) bool { return problems[i].row.TimesShown > problems[j].row.TimesShown }) for _, p := range problems { title := p.row.ProductID if p.row.Title != nil { title = *p.row.Title } fmt.Printf("%s: shown %dx on %s, %s\n", title, p.row.TimesShown, p.row.ShownOn.PageKey, p.why) } fmt.Println(len(rows), "products checked,", len(problems), "to fix") // 6 products checked, 1 to fix}Without an Admin token: catch it on the page
The quiz already knows whether a card it shows can be bought: every product.shown event of the Storefront JS API carries product.available and each variant’s available, from the same data the card is rendered with. A few lines on the store page report the cards a shopper saw that were not for sale, so the list builds itself from real result pages, with no Shopify call:
<script> window.octaneai = window.octaneai || []; window.octaneai.push(function (octaneai) { var reported = {}; octaneai.on('product.shown', function (event) { var shown = event.data.object; var product = shown.product; if (!product || product.available !== false || reported[shown.product_id]) return; // null means unknown, not sold out reported[shown.product_id] = true; fetch('/apps/stock/unavailable', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quiz_id: shown.quiz_id, page_key: shown.page_key, block_key: shown.block_key, product_id: shown.product_id, title: product.title, recommendation_source: shown.recommendation_source }) }); }); });</script>Your endpoint counts the reports per product_id and per day; a product reported many times is the one to fix first. It is the shopper’s browser reporting, so validate the body and rate-limit the endpoint as any public route. available is null when the quiz does not know, which the snippet leaves alone.
Errors to handle
| Where | Error | Why | What to do |
|---|---|---|---|
| Octane AI | 403 insufficient_scope | The key lacks analytics:read. | Mint a key with the scope. |
| Octane AI | 404 not_found | The quiz_id is unknown, deleted or not this store’s. | Re-list with GET /v1/quizzes. |
| Octane AI | 422 validation_error | A dimension filter (channel, device, …) on products; it takes none. | Drop it. |
| Octane AI | 429 rate_limited | The allowance is spent (a job over many quizzes). | Sleep for Retry-After seconds and retry. |
| Shopify | errors[] with ACCESS_DENIED | The custom app lacks read_products. | Add the scope and reinstall the app. |
| Shopify | HTTP 429 or THROTTLED | The GraphQL cost budget is spent. | nodes with 200 ids is cheap; retry after a second. |
Things to know
in_storesays whether the product still exists, not whether it sells. It is read from the catalog Octane AI keeps in sync with Shopify: a product deleted from the store answersin_store: falseand keeps the title and image it had when it was deleted. A sold-out, draft or archived product is stillin_store: true, which is what the Admin call is for.times_showncounts cards a shopper saw. One per session, block and product (the first impression; a card hidden by a rule is not counted, a card revealed by an answer is counted when it appears). It says how often the quiz sent a shopper to the product, and is the number to rank the list by.unitsandline_revenuedescribe orders, so a sold-out product drops in those first.- Sort and limit.
sort=units(orrevenue,orders) ranks the rows beforelimit(at most 200) cuts them; the route does not page, so a quiz that shows more than 200 distinct products needs a narrower range. - Which quizzes. Run it per published quiz from
GET /v1/quizzes?status=published(quizzes:read); a product recommended by two quizzes is listed under each. nodesis one call. The whole list goes in one query; an id Shopify no longer knows comes backnull, which thein_storecheck already caught.
