Skip to content

Storefront JS API

With the storefront JS API your own code on the store’s page can hear what a shopper does in a quiz (page views, answers, products shown, the finish) and drive the quiz (move to the next page, prefill answers, open or close it).

The API is part of the quiz embed script that is already on any page with an Octane AI quiz. There is no second script. Your code reaches it through window.octaneai:

<script>
window.octaneai = window.octaneai || [];
window.octaneai.push(function (octaneai) {
octaneai.on('quiz.finished', function (event) {
console.log('Quiz completed', event.data.object);
});
});
</script>
  • Before the quiz script loads, window.octaneai is a plain array; push queues your function.
  • When the quiz script loads it replaces the array with the API object and runs the queued functions in order, each with the API object as its argument.
  • After that, window.octaneai.push(fn) runs fn at once, so the same snippet works whether your code is on the page before or after the quiz script.
  • A function that throws is logged in the console as [OctaneAI] callback threw and never breaks the quiz or the other functions.
  • octaneai.version is 'v1'.

If your code needs the quiz’s state rather than just its events, wait for ready:

window.octaneai.push(function (octaneai) {
octaneai.ready(function (api) {
console.log(api.getState());
});
});

ready(fn) runs fn once the first quiz on the page is mounted and has its session; if that already happened, fn runs immediately. A listener registered inside ready is too late for quiz.viewed; register that one through the queue, as in the first snippet.

The dashboard shows the same snippet and the event list under Developer > Storefront JS. If the quiz script ends up on the page twice, the first copy owns window.octaneai; a quiz container of another store on the same page is not mounted.

The API works on the Plus and Enterprise plans (see Who can use it), events and commands alike. On another plan window.octaneai still exists with every method, but every call (on, getState, navigate, prefill, register, …) logs an error in the browser console and does nothing: listeners never fire, getState() returns null, and a handler registered for a “Call registered handler” button is never called. The error is logged once per method per page load. octaneai.enabled is false until the first quiz on the page has its session, and true after that only when the plan carries the API; reading it never logs.

window.octaneai = window.octaneai || [];
window.octaneai.push(function (octaneai) {
function handler(event) { /* ... */ }
var stop = octaneai.on('question.answered', handler);
octaneai.once('quiz.finished', function (event) { /* ... */ });
stop(); // removes this one registration
octaneai.off('question.answered', handler); // removes every registration of handler for that type
});
  • on(type, fn, { quizId }) registers a listener and returns a function that removes that registration. off(type, fn) removes every registration of fn for the type. once(type, fn, { quizId }) hears one event, removes itself, and also returns the remover. The third argument is optional; see Two quizzes on one page.
  • An unknown type logs [OctaneAI] on() ignored: unknown event and registers nothing; nothing throws.
  • A listener that throws is logged as [OctaneAI] listener for <type> threw and the remaining listeners still run.
  • Every event is also dispatched on document as a bubbling CustomEvent named octaneai:<type> with the same envelope as event.detail, so a tag manager trigger or a listener added before the script loads can hear it too. Listeners on octaneai.on run first, then the document event.
document.addEventListener('octaneai:quiz.finished', function (e) {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ event: 'octane_quiz_finished', quiz_id: e.detail.data.object.quiz_id });
});

Every listener receives an envelope:

{
"id": "evt_6f1d2c3b-4a59-4877-8695-a4b3c2d1e0f9",
"type": "question.answered",
"created": "2026-09-11T14:03:22.418Z",
"api_version": "v1",
"data": {
"object": {
"session_id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e",
"quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7",
"quiz_name": "Find your routine",
"version_id": "ver_15ce0be06b904d0180b1155da02a6c6f",
"version_number": 2,
"page_key": "skin",
"component_key": "image_choice-48su5",
"type_id": "image_choice",
"value": ["Dry"],
"option_ids": ["dry"],
"values": ["Dry"]
}
},
"element": "<the quiz's container element>"
}

The envelope has the same keys as the one the webhooks deliver (id here is evt_ plus a dashed uuid minted in the browser), plus element: the DOM element the quiz is rendered in (your .octane-ai-app container), a live reference you can move, hide or replace. data.object is a frozen copy: changing it does not change the quiz, and two listeners see the same values.

Every data.object carries session_id, quiz_id, quiz_name, version_id and version_number, spelled as the REST API and the webhooks spell them: session_id is the sess_ id of the analytics session (the one GET /v1/profiles/{profile_id} and the webhooks name), quiz_id the quiz_ id, version_id the ver_ id of the published version the session is pinned to. session_id is null when the analytics capture did not run when the session was created, and visitor_id is null whenever session_id is; the events still fire. Every data.object also carries source_session_id and source_kind: the sess_ id of the session this one was started from and how, restart (the Restart button or restart()) or share (a press on a shared results page); both are null on a session the shopper started themselves. Product and variant ids are Shopify GIDs (gid://shopify/Product/123, gid://shopify/ProductVariant/456). The remaining fields per event:

Fires when the quiz has its session; register through the queue to hear it.

  • placement: inline, fullscreen, tapcart or app_block.
  • visitor_id: null when consent is denied or no visitor id exists.

Fires on the shopper’s first interaction with the quiz - the first answer or the first step forward, whichever comes first - once per session (the same moment the quiz.started webhook fires). Its field is started_at.

Fires when the first page shows, and every later page the server confirmed.

  • page_key.
  • page_index.
  • previous_page_key: null on the first page.
  • terminal_page: the page key when this landing completed the quiz, else null.

Fires when a next (the Next button or navigate('next')) was refused, once per refused attempt, by the page’s required check or by the server’s validation; skip never refuses.

  • page_key.
  • fields[] of { component_key, reason } where reason is required (nothing answered) or invalid (the value was refused); never the value itself; fields is [] when the server refused the page without naming a field.

Fires when the page’s answers are saved by a next navigate (skip saves nothing and fires nothing), or by the autosave that completed a single-page quiz; one per component, sent again only when the value changed.

  • page_key.
  • component_key.
  • type_id.
  • value, as the page holds it: a choice answer is an array of the picked labels, one item for a single select, ["Dry"]; a text, email, phone or date answer a string; a number or slider answer a number.
  • option_ids: the option ids of a choice answer, else [].
  • values: the labels the quiz shows for the picked options, in option order, each once, as the webhooks and the people API send them; a numeric slider reports its value with its unit here, "42 kg", where the server surfaces send the bare number; a text, email, phone or number answer reports its value as a string, ["Ada"]; a date answer reports its date, ["2026-01-01"]; [] for an empty value.

Fires alongside question.answered for an email or phone field.

  • kind: email or phone.
  • value.
  • consent: true only when the shopper ticked the marketing checkbox next to the field, otherwise false.
  • page_key.
  • component_key.

Fires when the server reports the session finished, once per session, after the terminal page’s page.viewed; see what counts as a finished quiz.

  • terminal_page.
  • answers[]: the latest acknowledged answer per component, same fields as question.answered.
  • points: dimension id to total, as numbers.
  • formulas: formula component key to value; numeric formulas are numbers.
  • products[]: every product.shown sent, each with its product.
  • discount_codes[]: same fields as discount.issued.
  • identities[]: the latest identity.captured per field.
  • results_url: the session’s results link, the page the quiz runs on plus #pqs= and session_id, without the page’s query or fragment. null when session_id is.

Fires when someone opens a shared results link and the result is on screen, once per page load. It is the only event for that view: no quiz.viewed, page.viewed, product.shown or quiz.finished fires, because nobody finished a quiz. The fields are the ones quiz.finished carries, so one handler can serve both. It fires as soon as the result is on screen, so a listener registered later misses it: register it through the queue, as in the first snippet and as for quiz.viewed.

  • terminal_page: the result page on screen.
  • answers[]: the sharer’s answers, same fields as question.answered, without any email or phone the quiz collects.
  • points and formulas: as on quiz.finished.
  • products[]: the products on the result page, each with its product.
  • discount_codes[] and identities[]: always empty. The reader is not the sharer, so the sharer’s code and contact details are never sent.
  • results_url: the results link of the session on screen, the page plus #pqs= and source_session_id, without the page’s query or fragment.
  • session_id is null: the reader has no session. source_session_id is the sess_ id the link carries and source_kind is share.
{
"session_id": null,
"quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7",
"quiz_name": "Find your routine",
"version_id": "ver_15ce0be06b904d0180b1155da02a6c6f",
"version_number": 2,
"experiment_id": null,
"arm_id": null,
"source_session_id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e",
"source_kind": "share",
"terminal_page": "result",
"answers": [
{ "page_key": "skin", "component_key": "image_choice-48su5", "type_id": "image_choice", "value": ["Dry"], "option_ids": ["dry"], "values": ["Dry"] }
],
"points": { "hydration": 12 },
"formulas": {},
"products": [
{ "page_key": "result", "block_key": "shopify_product_block-2kd81", "product_id": "gid://shopify/Product/123", "variant_id": null, "position": 0, "recommendation_source": "points", "product": { "product_id": "gid://shopify/Product/123", "title": "Hydrating serum", "price": "48.00" } }
],
"discount_codes": [],
"identities": [],
"results_url": "https://shop.example.com/pages/quiz#pqs=sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e"
}

The product above is shortened; it is the full product object.

Fires when a landed page shows a product card, plus a card revealed by a rule on the same page; one per page, block and product.

  • page_key.
  • block_key.
  • product_id.
  • variant_id.
  • position: index in the block; null for a card revealed later.
  • recommendation_source: manual, rankings, points or smart.
  • product: see below.

Fires when the product card’s call to action is clicked.

  • page_key.
  • block_key.
  • product_id.
  • variant_id.
  • action: add_to_cart, checkout, product_page or external.
  • product.

Fires on an add-to-cart click, before the cart call; a notification only, it cannot cancel the add.

  • page_key.
  • block_key.
  • items[] of { variant_id, product_id, quantity, product } (quantity is the card’s quantity at the click).

Fires once the lines are in the cart: after Shopify’s cart call answered on the web, or once the Tapcart bridge accepted them inside the app. Its field is items[] of { variant_id, product_id, quantity, selling_plan_id, product }. The theme’s drawer does not always react; see Open your theme’s cart drawer after a quiz add.

Fires when the add failed. Its field is code: cart_add_rejected (Shopify answered and refused the line, a sold-out variant for instance), cart_add_failed (the request never got an answer) or tapcart_add_failed (the Tapcart bridge threw).

Fires when a discount code reaches the quiz, once per discount.

  • discount_id.
  • code.
  • ends_at: null without an expiry.

Fires when the shopper copies a coupon’s code. Its fields are discount_id, code, ends_at, page_key and component_key.

Fires when a fullscreen quiz is closed. The overlay’s X fires it and then navigates the host page to / at once, so only a listener that does its work synchronously hears it; octaneai.close() fires it and keeps the page. It carries no fields of its own.

Fires when a navigate failed or there was no session to navigate with; never a stack, URL, token or submitted value.

  • code: navigate_failed or session_unavailable.
  • message.
  • request_id: null unless the server’s answer carried one.

Every event that names a product carries a product object with what the quiz holds for that card, from the same data it renders the card with (no extra request):

FieldWhat it is
product_id, handle, title, descriptionThe Shopify product; description is null when it has none.
urlThe product page the card’s button opens, with ?variant= for the selected variant; null when the product has no handle.
price, compare_at_price, currencyThe selected variant’s prices as decimal strings ("48.00"). Without a selected variant price is the product’s own price and compare_at_price is null.
availableWhether the product is available for sale; null when unknown.
images[]{ src } entries; the product image.
optionsThe product’s options, name to values ({ "Size": ["S", "M"] }).
variants[]{ variant_id, title, price, compare_at_price, available, image, options, selling_plans }; options maps option name to value ({ "Size": "M" }).
selected_variant_idThe event’s variant, else the first available one, else the first.
selling_plans[]{ id, name } subscription plans of the product; each variant lists its own.

product is null when the quiz never received the product (a product the store no longer sells, or a card revealed before its product arrived). A card locked to one variant reports the whole parent product with that variant selected. Vendor, product type, tags and SKU are not included.

Page, answer, identity, discount and finish events describe what the server confirmed, not what the page shows optimistically; click, copy, close and add-requested events are the shopper’s interactions as they happen. A page-link button’s jump is client-side and is not reported; the next confirmed page names the last confirmed page as previous_page_key. The add-all-to-cart button and a multi-item checkout carry no single product, so neither is reported as cart.add_requested or product.clicked. product.shown lists the products the page was built to show; a product since deleted in Shopify is still listed.

identity.captured and quiz.finished (in its identities[]) carry the shopper’s email or phone, and the document mirror puts them within reach of every script on the page, your third-party tags included. Keep that in mind before you build on those two events. shared_result.viewed never carries an email, a phone or an identity, but the sharer’s other answers, free text included, reach the page’s scripts and tags on a shared view exactly as they do on quiz.finished.

Returns a frozen copy of { quiz_id, session_id, page_key, page_index, terminal_page, answers, points, discount_codes }, or null when no quiz is mounted or the quiz on screen is a shared results page. answers maps component keys to the values on the page so far, saved or not (answer components only, no empty values); after a back the previous page comes back with its answer kept. terminal_page is the page key once the quiz is finished (what counts as finished).

Moves the quiz the way its buttons do. next validates required fields like the Next button, so an unanswered required question blocks it (and fires page.rejected). back lands on the previous page with its saved answer cleared, exactly like the Back button: getState().answers is empty there and a next is refused with page.rejected until the shopper answers again. Any other direction logs [OctaneAI] navigate() ignored: unknown direction and does nothing.

Starts the quiz over, the same as the quiz’s Restart button: a new session for the same shopper on the first page, carrying the session it came from as source_session_id with source_kind: "restart". The session it left stays exactly as it was, finished or not, and keeps its own sess_ id on the REST API and the webhooks. quiz.viewed fires for the new session as for any other. A restart is not a navigate direction.

Stages values into the page’s answers. Keys that are not answer components are ignored; the values go through the normal validation when the page is submitted. A choice question (multiple choice, image choice, checkbox) holds a list of option values; a bare value staged on one becomes a one-item list, so 'green' and ['green'] both select Green. prefill(...) followed by navigate('next') in the same call submits the prefilled answers.

Prefills the first email and phone fields of the quiz. It never sets marketing consent.

Stores fn under name. Two buttons in the editor can call it: a Simple Button whose Custom Action names the handler, and a “Call registered handler” action in a product block’s Calls to Action. On a click the quiz runs fn(params, ctx):

  • params is the button’s params field, parsed as JSON when it is valid JSON, otherwise the raw string; undefined when the field is empty.
  • ctx carries session_id and quiz_id (the same sess_ and quiz_ ids the events carry), page_key, component_key and label (the button’s text). A product button adds block_key, product_id and variant_id (Shopify GIDs), quantity (the card’s selector) and product, the product object of the card the shopper clicked.

Register before the quiz mounts (through the queue) so the handler exists when the page renders. A click on a button whose handler is not registered logs one warning and shows the shopper nothing. A handler that throws is logged; the quiz continues. The editor never calls a handler. A handler click is reported as product.clicked with action: 'handler' when it happened on a product.

window.octaneai = window.octaneai || [];
window.octaneai.push(function (octaneai) {
octaneai.register('quickBuy', function (params, ctx) {
openQuickBuy(ctx.product, ctx.variant_id, ctx.quantity);
});
});

Shows or hides a fullscreen quiz’s overlay; close() also fires quiz.closed and leaves the host page where it is (the overlay’s own X navigates to / after firing it). Both do nothing on an inline quiz.

A method that acts on a quiz (getState, navigate, restart, prefill, setIdentity, open, close) called before any quiz is mounted logs [OctaneAI] no quiz on this page yet ([OctaneAI] no quiz <id> on this page when you passed a quizId) and returns nothing (getState() returns null).

A shopper who finishes a quiz can share a link to their results (see Shareable results link). Someone who opens it sees the sharer’s result page without a session of their own, so on that page getState() returns null, and prefill() and setIdentity() change nothing. One event fires for the view: shared_result.viewed, with the sharer’s result in the shape of quiz.finished. No session, completion or analytics is recorded for the reader, and quiz.finished does not fire. Any press on the page (Next, Back, Restart) starts the reader’s own session from the first page, with source_session_id naming the shared session and source_kind: "share"; from then on the events and methods behave as on any quiz.

One quiz per page is the recommended setup. Two quizzes of one store on one page share window.octaneai; every event carries quiz_id, and every method that acts on a quiz accepts the quiz’s id as an optional last argument (octaneai.getState(quizId), octaneai.navigate('next', quizId)). Without it the call goes to the first quiz whose session arrived, which is not always the first one in the page’s DOM order, so pass quizId whenever two quizzes share a page; on and once take { quizId } to hear one quiz, and hear every quiz on the page without it. quizId takes either spelling: the quiz_... id the events carry or the bare uuid in the container’s data-app-id. points totals and rankings recommendations are page-wide, so the two quizzes share them.

By default the quiz renders inside a shadow root on its .octane-ai-app container, so the theme’s CSS and scripts cannot reach the quiz’s elements. Set data-shadow-dom="off" on the container to render the quiz in the page’s own DOM instead:

<div class="octane-ai-app" data-app-id="7c9e6679-7425-40de-944b-e07fc1f90ae7"
data-bot-id="5f3a2b1c9d8e7f6a5b4c3d2e" data-embed-type="inline" data-shadow-dom="off"></div>

Use it only when a theme or a tool has to see the quiz’s elements:

  • Click tracking and heatmap tools report a click inside a shadow root as a click on the container; without the shadow root they see the button or card that was clicked.
  • A theme that must style the quiz from its own stylesheet.

What changes for the quiz:

  • The theme’s resets and its !important rules reach the quiz and can change its layout, spacing, fonts and buttons. Check every page of the quiz on the live theme after adding the attribute.
  • Theme scripts that scan the page (lazy loaders, click handlers bound to every button or form, quick-add scripts) now find the quiz’s elements too.
  • Two quizzes on one page, or the same quiz inline and fullscreen, share element ids, so a label or an accessibility hint in the second quiz can point at a field of the first.

To send quiz activity to analytics or a tag manager, prefer the events over element clicks: they keep the shadow root and name the page, answer or product (see Send quiz events to a tag manager).

The first on() or once() on the page (before or after the quiz mounts) records one api_listener_registered event on the session’s analytics once the quiz is running, so Octane AI knows the API is in use; nothing else about your listeners leaves the page.

For typed storefront code, declare the queue-or-API shape once (the signatures mirror the bundle’s public interface; push accepts a callback in both states, before and after the bundle loads):

type OctaneEventType =
| "quiz.viewed" | "quiz.started" | "page.viewed" | "page.rejected" | "question.answered"
| "identity.captured" | "quiz.finished" | "shared_result.viewed" | "product.shown" | "product.clicked"
| "cart.add_requested" | "cart.added" | "cart.failed" | "discount.issued" | "discount.copied"
| "quiz.closed" | "error";
type OctaneEvent = {
id: string; // evt_ + a uuid minted in the browser
type: OctaneEventType;
created: string; // ISO 8601, UTC
api_version: "v1";
data: { object: Record<string, unknown> }; // the fields listed per event above; frozen
element: Element; // the quiz's container on your page
};
type OctaneState = {
quiz_id: string; session_id: string | null; page_key: string; page_index: number;
terminal_page: string | null; answers: Record<string, unknown>;
points: Record<string, number>; discount_codes: unknown[];
};
type HandlerContext = {
session_id: string | null; quiz_id: string; page_key: string; component_key: string; label: string;
block_key?: string; product_id?: string; variant_id?: string | null; quantity?: number; product?: unknown;
};
type OctaneApi = {
readonly version: "v1";
readonly enabled: boolean;
push(fn: (api: OctaneApi) => void): void;
on(type: OctaneEventType, fn: (event: OctaneEvent) => void, opts?: { quizId?: string }): () => void;
once(type: OctaneEventType, fn: (event: OctaneEvent) => void, opts?: { quizId?: string }): () => void;
off(type: OctaneEventType, fn: (event: OctaneEvent) => void): void;
ready(fn: (api: OctaneApi) => void): void;
getState(quizId?: string): OctaneState | null;
navigate(direction: "next" | "back" | "skip", quizId?: string): void;
restart(quizId?: string): void;
prefill(values: Record<string, unknown>, quizId?: string): void;
setIdentity(identity: { email?: string; phone?: string }, quizId?: string): void;
register(name: string, fn: (params: unknown, ctx: HandlerContext) => void): void;
open(quizId?: string): void;
close(quizId?: string): void;
};
declare global {
interface Window { octaneai: OctaneApi | Array<(api: OctaneApi) => void>; }
}

Set localStorage['octaneai:debug'] = '1' in the browser console and every event is logged as [OctaneAI] <type> <envelope>. It takes effect without a reload.

See Redirect after a quiz finishes for a complete example that reads the answers.