Offer help when a shopper is stuck
Goal: a shopper who cannot get past a page (a required question they do not understand, a value the quiz keeps refusing) gets a way to ask a person, and the ticket that opens already says which quiz, which page and which fields, so the reply does not start with “what were you doing?”.
Uses: the Storefront JS API: page.rejected (once per refused Next, with page_key and fields[] of {component_key, reason}), identity.captured to prefill the email, and page.viewed to take the offer away once they get through. Your own endpoint receives the context and creates the ticket in your helpdesk. Nothing to install on the quiz side; events fire on the Plus and Enterprise plans.
On the page
Put the control on the page next to the quiz. It stays hidden until the same page has refused the shopper twice; the email field is prefilled when the quiz already captured one.
<form id="octane-help" hidden> <p>Stuck on this question? Leave your email and we will help.</p> <input type="email" name="email" placeholder="you@example.com" required> <button type="submit">Ask an expert</button></form><script> window.octaneai = window.octaneai || []; window.octaneai.push(function (octaneai) { var form = document.getElementById('octane-help'); var refusals = {}; // page_key -> refused attempts in this session var context = null; // what the ticket will carry
octaneai.on('page.rejected', function (event) { var page = event.data.object; refusals[page.page_key] = (refusals[page.page_key] || 0) + 1; if (refusals[page.page_key] < 2) return; context = { quiz_id: page.quiz_id, quiz_name: page.quiz_name, session_id: page.session_id, page_key: page.page_key, fields: page.fields, // [{ component_key, reason }], never the value page_url: location.href }; event.element.insertAdjacentElement('afterend', form); // right under the quiz form.hidden = false; });
octaneai.on('identity.captured', function (event) { if (event.data.object.kind === 'email' && !form.email.value) form.email.value = event.data.object.value; });
octaneai.on('page.viewed', function () { form.hidden = true; }); // they got through
form.addEventListener('submit', function (e) { e.preventDefault(); var button = form.querySelector('button'); button.disabled = true; fetch('/apps/help/tickets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({ email: form.email.value }, context)) }).then(function (r) { if (!r.ok) throw new Error(r.status); form.innerHTML = '<p>Thanks, we will be in touch by email.</p>'; }).catch(function () { button.disabled = false; button.textContent = 'Something went wrong, try again'; }); }); });</script>page.rejected fires for a refused Next only: a required reason means nothing was answered, invalid means the value was refused (by the page’s own check or by the server); fields is [] when the server refused the page without naming a field. A skip never refuses. The event never carries the value, so nothing the shopper typed leaves the page through this path except the email they choose to give.
On your server
/apps/help/tickets is yours: a Shopify app proxy, or any endpoint on a domain your store may call. It writes the ticket. The example composes the ticket text from the context and hands it to a helpdesk’s create-ticket call (Gorgias, Zendesk, Front: set HELPDESK_TICKET_URL and its credentials to the one you use); a ticket needs a reply address, so a request without an email is refused.
import osimport reimport httpxfrom flask import Flask, request
app = Flask(__name__)HELPDESK = os.environ["HELPDESK_TICKET_URL"] # your helpdesk's create-ticket endpointHELPDESK_AUTH = (os.environ["HELPDESK_USER"], os.environ["HELPDESK_TOKEN"])
@app.post("/apps/help/tickets")def ticket(): ctx = request.get_json(silent=True) or {} email = ctx.get("email", "") if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email): return {"error": "email required"}, 422 fields = ", ".join(f"{f['component_key']} ({f['reason']})" for f in ctx.get("fields", [])) or "no field named" body = ( f"A shopper asked for help in the quiz '{ctx.get('quiz_name')}' ({ctx.get('quiz_id')}).\n" f"Stuck on page {ctx.get('page_key')}: {fields}.\n" f"Session {ctx.get('session_id')}, page {ctx.get('page_url')}." ) r = httpx.post(HELPDESK, auth=HELPDESK_AUTH, json={ "subject": f"Quiz help: {ctx.get('quiz_name')}", "requester_email": email, "body": body, "tags": ["quiz-help", str(ctx.get("quiz_id"))], }, timeout=30) r.raise_for_status() print("ticket for", ctx.get("session_id"), "page", ctx.get("page_key")) # ticket for sess_... page skin return "", 201
if __name__ == "__main__": app.run(port=8080)import express from "express";
const HELPDESK = process.env.HELPDESK_TICKET_URL!; // your helpdesk's create-ticket endpointconst HELPDESK_AUTH = "Basic " + Buffer.from(`${process.env.HELPDESK_USER}:${process.env.HELPDESK_TOKEN}`).toString("base64");
const app = express();app.post("/apps/help/tickets", express.json(), async (req, res) => { const ctx = req.body ?? {}; if (typeof ctx.email !== "string" || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(ctx.email)) return res.status(422).json({ error: "email required" }); const fields = (ctx.fields ?? []).map((f: { component_key: string; reason: string }) => `${f.component_key} (${f.reason})`).join(", ") || "no field named"; const body = `A shopper asked for help in the quiz '${ctx.quiz_name}' (${ctx.quiz_id}).\nStuck on page ${ctx.page_key}: ${fields}.\nSession ${ctx.session_id}, page ${ctx.page_url}.`; const r = await fetch(HELPDESK, { method: "POST", headers: { Authorization: HELPDESK_AUTH, "Content-Type": "application/json" }, body: JSON.stringify({ subject: `Quiz help: ${ctx.quiz_name}`, requester_email: ctx.email, body, tags: ["quiz-help", String(ctx.quiz_id)] }), }); if (!r.ok) return res.status(502).send(`helpdesk ${r.status}`); console.log("ticket for", ctx.session_id, "page", ctx.page_key); // ticket for sess_... page skin res.sendStatus(201);});app.listen(8080);package main
import ( "bytes" "encoding/json" "fmt" "net/http" "os" "regexp" "strings")
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
type context struct { Email string `json:"email"` QuizID string `json:"quiz_id"` QuizName string `json:"quiz_name"` SessionID string `json:"session_id"` PageKey string `json:"page_key"` PageURL string `json:"page_url"` Fields []struct { ComponentKey string `json:"component_key"` Reason string `json:"reason"` } `json:"fields"`}
func ticket(w http.ResponseWriter, r *http.Request) { var ctx context json.NewDecoder(r.Body).Decode(&ctx) if !emailRe.MatchString(ctx.Email) { http.Error(w, `{"error": "email required"}`, http.StatusUnprocessableEntity) return } names := []string{} for _, f := range ctx.Fields { names = append(names, f.ComponentKey+" ("+f.Reason+")") } fields := strings.Join(names, ", ") if fields == "" { fields = "no field named" } body := fmt.Sprintf("A shopper asked for help in the quiz '%s' (%s).\nStuck on page %s: %s.\nSession %s, page %s.", ctx.QuizName, ctx.QuizID, ctx.PageKey, fields, ctx.SessionID, ctx.PageURL) payload, _ := json.Marshal(map[string]any{ "subject": "Quiz help: " + ctx.QuizName, "requester_email": ctx.Email, "body": body, "tags": []string{"quiz-help", ctx.QuizID}, }) req, _ := http.NewRequest("POST", os.Getenv("HELPDESK_TICKET_URL"), bytes.NewReader(payload)) // your helpdesk's create-ticket endpoint req.SetBasicAuth(os.Getenv("HELPDESK_USER"), os.Getenv("HELPDESK_TOKEN")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil || res.StatusCode >= 300 { http.Error(w, "helpdesk failed", http.StatusBadGateway) return } res.Body.Close() fmt.Println("ticket for", ctx.SessionID, "page", ctx.PageKey) // ticket for sess_... page skin w.WriteHeader(http.StatusCreated)}
func main() { http.HandleFunc("/apps/help/tickets", ticket) http.ListenAndServe(":8080", nil)}The ticket the agent sees:
A shopper asked for help in the quiz 'Find your routine' (quiz_7c9e6679742540de944be07fc1f90ae7).Stuck on page skin: image_choice-48su5 (required).Session sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e, page https://your-store.com/pages/quiz.With profiles:read, the agent’s tooling can turn the session into the person: GET /v1/profiles?q=<email> finds them, and GET /v1/profiles/{profile_id} lists their sessions[] (the one with this session_id, its last_page_key) and, once they finish, the answers. See People.
Errors to handle
| Where | What | What to do |
|---|---|---|
| Browser | The quiz is on a plan without the API: octaneai.enabled is false, on() registers, nothing fires. | The control never shows; nothing breaks. |
| Browser | page.rejected with fields: []: the server refused the page without naming a field. | Open the ticket anyway; the page key is the context. |
| Browser | The POST fails (network, 5xx). | The button re-enables with a retry label, as above. |
| Server | No valid email in the body. | 422; a ticket without a reply address helps nobody. |
| Server | The helpdesk answers 4xx or 5xx. | Answer 502 so the shopper sees the retry label; log the helpdesk’s body. |
Things to know
- Two refusals on the same page, not two overall. A shopper who fixes page one and stumbles on page three is not stuck; the counter is per
page_key, andpage.viewedhides the form once they get past the page. - Only what you need in the ticket.
page.rejectedcarries keys and reasons, never values; the recipe adds the email the shopper types into the form. Do not copy answers fromgetState()into the ticket: a text answer is the shopper’s own words, and a support tool is not where they agreed to have them stored. - The endpoint is public. Anything on the internet can POST to it: validate the body (an email, a
quiz_idshaped likequiz_plus 32 hex characters), rate-limit by IP, and treat the text as untrusted when it lands in the ticket. - App proxy or your own domain. A Shopify app proxy keeps the call same-origin (
/apps/help/...) and signs it; a call to your own domain needs CORS for the store’s origin. session_idcan benull. When the analytics capture did not run for the session, the event carriessession_id: nulland the ticket has no session to look up; the page key and quiz still say where they were.
