Skip to content

Pick the packing insert from the quiz result

Goal: the box of a shopper who took the quiz carries the insert for their routine (a “dry skin: how to use this” card, a sample), chosen by the quiz result, without anyone at the packing station looking it up.

Uses: People (GET /v1/profiles, GET /v1/profiles/{profile_id}, key with profiles:read) on your server; Shopify’s Admin API (orders, metafieldsSet, tagsAdd) with read_orders and write_orders. The result reaches the packing app as an order tag (insert:dry-routine) and an order metafield (octane.packing_insert); use whichever your packing app reads (ShipStation, Shippo and most WMS apps filter on tags; some read metafields).

Two ways to know the result

  1. On the order. When the quiz’s result rides to the cart (Put the quiz result on the order), every order from that cart carries quiz_result in customAttributes. No lookup, exact, and it works for guests.
  2. On the person. When the attribute is missing (an accelerated checkout dropped it, the quiz was taken on another device, an order placed days later), the order’s email finds the person, and latest_results[] names the result page they reached.

The script below does 1 first and falls back to 2. Run it every few minutes (a cron, a scheduled job), or call pick_insert from your own orders/create webhook handler; either way it only touches orders that carry no insert: tag yet.

No code at all

If the attribute is always there (way 1), Shopify Flow does this without a server: trigger Order created, condition order.customAttributes has a key quiz_result, action Add order tags with insert:{{ order.customAttributes.quiz_result }}. The script is for the fallback to the person, and for stores without Flow.

The script

import os
import httpx
OCTANE = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}
QUIZ_ID = "quiz_7c9e6679742540de944be07fc1f90ae7"
ADMIN_URL = f"https://{os.environ['SHOPIFY_SHOP']}/admin/api/2025-07/graphql.json"
ADMIN_HEADERS = {"X-Shopify-Access-Token": os.environ["SHOPIFY_ADMIN_TOKEN"]}
INSERTS = {"result-dry": "dry-routine", "result-oily": "oily-routine", "result-combination": "combination-routine"} # result page key -> insert
def admin(query: str, variables: dict) -> dict:
r = httpx.post(ADMIN_URL, headers=ADMIN_HEADERS, json={"query": query, "variables": variables}, timeout=30)
r.raise_for_status()
body = r.json()
if body.get("errors"):
raise RuntimeError(body["errors"])
return body["data"]
def result_from_person(email: str) -> str | None:
r = httpx.get("https://api.octaneai.com/v1/profiles", params={"q": email, "quiz_id": QUIZ_ID}, headers=OCTANE, timeout=30)
r.raise_for_status()
rows = r.json()["data"]
if not rows:
return None
r = httpx.get(f"https://api.octaneai.com/v1/profiles/{rows[0]['id']}", headers=OCTANE, timeout=30)
r.raise_for_status()
result = next((x for x in r.json()["latest_results"] if x["quiz_id"] == QUIZ_ID), None)
return result and result["result_page"] and result["result_page"]["page_key"]
def pick_insert(order: dict) -> str | None:
attributes = {a["key"]: a["value"] for a in order["customAttributes"]}
result = attributes.get("quiz_result") or (order["email"] and result_from_person(order["email"]))
return INSERTS.get(result or "")
def tag_order(order_id: str, insert: str) -> None:
admin(
"""mutation ($id: ID!, $tags: [String!]!, $metafields: [MetafieldsSetInput!]!) {
tagsAdd(id: $id, tags: $tags) { userErrors { field message } }
metafieldsSet(metafields: $metafields) { userErrors { field message } } }""",
{"id": order_id, "tags": [f"insert:{insert}"],
"metafields": [{"ownerId": order_id, "namespace": "octane", "key": "packing_insert", "type": "single_line_text_field", "value": insert}]},
)
def run(since: str) -> None:
data = admin(
"""query ($q: String!) { orders(first: 50, query: $q, sortKey: CREATED_AT) {
nodes { id name email tags customAttributes { key value } } } }""",
{"q": f"created_at:>='{since}' -tag:insert:*"},
)
for order in data["orders"]["nodes"]:
if any(tag.startswith("insert:") for tag in order["tags"]):
continue # tagged by a run the search index has not caught up with
insert = pick_insert(order)
if insert:
tag_order(order["id"], insert)
print(order["name"], insert) # #1042 dry-routine
if __name__ == "__main__":
run("2026-09-07T00:00:00Z")

result_page.page_key is the key of the result page the person reached on their latest completed run of the quiz, the same value terminal_page carries on the storefront and on the quiz.finished webhook, so INSERTS is keyed once whichever way the result arrives. The map is yours: a result page per routine, an insert per result page.

Errors to handle

WhereWhatDo
GET /v1/profiles403 insufficient_scopeThe key needs profiles:read.
GET /v1/profiles404 not_foundquiz_id is not this store’s or the quiz is deleted.
GET /v1/profiles429 rate_limitedWait Retry-After; the batch is not lost, the next run picks the orders up (they still carry no insert: tag).
GET /v1/profiles/{profile_id}404 not_foundThe person asked to be removed between the two calls; ship without an insert.
orders queryerrors with ACCESS_DENIEDThe app needs read_orders (read_all_orders for orders older than 60 days).
tagsAdd / metafieldsSetuserErrorsThe order is archived or the metafield key is malformed; log and skip, never retry in a loop.

Things to know

  • The tag is the marker that an order has been handled. Shopify’s order search runs on an index that trails the store by up to a minute: a just-placed order is not in the results yet, and an order tagged seconds ago still comes back, which is why the script also skips any row that already carries an insert: tag. Run it on a schedule with a window wider than the interval (every 5 minutes over the last hour) and every order is picked up once. An order that maps to no insert stays untagged and is looked at on every run; tag those insert:none if the list grows.
  • orders with sortKey: CREATED_AT and a created_at:>= window returns at most 50 here; page with pageInfo { hasNextPage endCursor } when a run can see more than that.
  • A person has one latest_results entry per quiz, their most recent completed run. A shopper who retook the quiz after ordering gets the newer result; run the script soon after the order.
  • The shopper’s email goes to GET /v1/profiles?q=, nowhere else; the insert name is the only thing written to the order. Do not copy answers onto the order metafield: every staff account and every app with read_orders reads it.
  • Packing apps import tags at order sync. Run this before the packing app syncs (a few minutes after the order) or trigger their re-sync; a tag added after the shipment is printed changes nothing.