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 finished', 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.

Events fire on the Plus and Enterprise plans (see Who can use it). On another plan window.octaneai still exists with every method, octaneai.enabled is false, listeners register but no event fires, and the console shows one line saying the API is available on Plus. The methods (navigate, prefill, …) work on every plan. 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.

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. 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 or tapcart.
  • visitor_id: null when consent is denied or no visitor id exists.

Fires when the first answer is saved, 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: the marketing checkbox next to the field: true/false, null when there is none.
  • 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.

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 when the Tapcart bridge accepted the lines (Tapcart placement only for now). Its field is items[] of { variant_id, product_id, quantity, selling_plan_id, product }.

Fires when the Tapcart bridge failed. Its field is code (tapcart_add_failed).

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.

Anything running on the page can read an event’s payload off the document mirror, including an email or phone the shopper just typed. You own what runs on your page.

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. 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.

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. 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. A quiz button whose action is set in the editor to call a named handler runs fn with the button’s params when the shopper clicks it. Register before the quiz mounts (through the queue) so the handler exists when the page renders.

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, 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).

There is no restart method on the API.

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.

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" | "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 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;
prefill(values: Record<string, unknown>, quizId?: string): void;
setIdentity(identity: { email?: string; phone?: string }, quizId?: string): void;
register(name: string, fn: (params?: unknown) => 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.