Skip to content

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

  1. 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, with times_shown, product_id (a Shopify GID) and in_store.
  2. A row with in_store: false is a product the store no longer has: report it at once.
  3. For the rest, one Admin API call, nodes(ids: [...]), answers each product’s status and whether any variant is availableForSale; a product that is not ACTIVE, or has no sellable variant, is the other half of the list.
  4. Sort by times_shown and print (or post) the list.
import datetime as dt
import os
import 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.com
SHOPIFY_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 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

WhereErrorWhyWhat to do
Octane AI403 insufficient_scopeThe key lacks analytics:read.Mint a key with the scope.
Octane AI404 not_foundThe quiz_id is unknown, deleted or not this store’s.Re-list with GET /v1/quizzes.
Octane AI422 validation_errorA dimension filter (channel, device, …) on products; it takes none.Drop it.
Octane AI429 rate_limitedThe allowance is spent (a job over many quizzes).Sleep for Retry-After seconds and retry.
Shopifyerrors[] with ACCESS_DENIEDThe custom app lacks read_products.Add the scope and reinstall the app.
ShopifyHTTP 429 or THROTTLEDThe GraphQL cost budget is spent.nodes with 200 ids is cheap; retry after a second.

Things to know

  • in_store says 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 answers in_store: false and keeps the title and image it had when it was deleted. A sold-out, draft or archived product is still in_store: true, which is what the Admin call is for.
  • times_shown counts 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. units and line_revenue describe orders, so a sold-out product drops in those first.
  • Sort and limit. sort=units (or revenue, orders) ranks the rows before limit (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.
  • nodes is one call. The whole list goes in one query; an id Shopify no longer knows comes back null, which the in_store check already caught.