Skip to content

A B2B quote from a quiz

Goal: a wholesale buyer answers a short quiz (which line, how many units, which branch) and a draft order appears in Shopify for their company location, at the prices that location’s catalog gives them, for your sales team to review and send as an invoice. A branch manager reorders the same way, with the branch prefilled.

Uses: Webhooks (quiz.finished with contact details: a key with webhooks:manage and profiles:read, an endpoint with include_pii: true) and Shopify’s Admin API (customers, draftOrderCreate, draftOrderInvoiceSend) with read_customers and write_draft_orders. B2B resources on the Admin API are available on Shopify Plus stores and on development stores.

The flow

  1. The buyer takes the quiz signed in (or leaves the email their company contact has). The quiz asks the quantity as a number question and the branch as a choice; the result page shows the products the answers call for.
  2. quiz.finished arrives on your server with identities[] (the email), answers and terminal_page.shown_products[] (the products the result page showed, as GIDs with variant_id).
  3. The server finds the Shopify customer by that email and, on the customer, the company contact and the company location the branch answer names.
  4. draftOrderCreate with purchasingEntity.purchasingCompany and one line per shown product. Shopify prices the lines by that location’s settings (“If a draft order has a B2B customer and a company location assigned to it, then the prices, payment terms, and checkout options automatically reflect the settings for that company”, Shopify Help).
  5. Sales reviews the draft and sends the invoice, or your job calls draftOrderInvoiceSend at once.

The job

This is the job the receiver from Verify a webhook signature enqueues; it receives the parsed event.

import os
import httpx
QUIZ_ID = "quiz_7c9e6679742540de944be07fc1f90ae7"
QUANTITY_KEY = "number_input-qty"
BRANCH_KEY = "single_choice-branch" # options are the branch names as Shopify knows the company locations
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"]}
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 purchasing_company(email: str, branch: str | None) -> dict | None:
data = admin(
"""query ($q: String!) { customers(first: 1, query: $q) { nodes { id
companyContactProfiles { id company { id name locations(first: 20) { nodes { id name } } } } } } }""",
{"q": f"email:{email}"},
)
customers = data["customers"]["nodes"]
if not customers or not customers[0]["companyContactProfiles"]:
return None
contact = customers[0]["companyContactProfiles"][0]
locations = contact["company"]["locations"]["nodes"]
location = next((l for l in locations if l["name"] == branch), locations[0] if locations else None)
if location is None:
return None
return {"companyId": contact["company"]["id"], "companyContactId": contact["id"], "companyLocationId": location["id"]}
def create_quote(session: dict, company: dict) -> str:
quantity = int(session["answers"][QUANTITY_KEY]["value"]) if QUANTITY_KEY in session["answers"] else 1
lines = [{"variantId": p["variant_id"], "quantity": quantity}
for p in session["terminal_page"]["shown_products"] if p["variant_id"]]
data = admin(
"""mutation ($input: DraftOrderInput!) {
draftOrderCreate(input: $input) {
draftOrder { id name totalPriceSet { shopMoney { amount currencyCode } } lineItems(first: 10) { nodes { title quantity originalUnitPriceSet { shopMoney { amount } } } } }
userErrors { field message } } }""",
{"input": {
"purchasingEntity": {"purchasingCompany": company},
"lineItems": lines,
"tags": ["quiz-quote"],
"customAttributes": [{"key": "quiz_session", "value": session["id"]}],
"note": f"Quote from the quiz {session['quiz_name']}",
}},
)
payload = data["draftOrderCreate"]
if payload["userErrors"]:
raise RuntimeError(payload["userErrors"])
draft = payload["draftOrder"]
print(draft["name"], draft["totalPriceSet"]["shopMoney"], [(l["title"], l["quantity"], l["originalUnitPriceSet"]["shopMoney"]["amount"]) for l in draft["lineItems"]["nodes"]])
# #D42 {'amount': '1440.0', 'currencyCode': 'USD'} [('Hydrating Serum - 30 ml', 48, '30.0')]
return draft["id"]
def handle(event):
if event["type"] != "quiz.finished":
return
s = event["data"]["object"]
if s["quiz_id"] != QUIZ_ID or not s["terminal_page"]:
return
email = next((i for i in s.get("identities") or [] if i["kind"] == "email"), None)
if not email:
return
branch = s["answers"].get(BRANCH_KEY)
company = purchasing_company(email["value"], branch and branch["values"][0])
if company is None:
print(s["id"], email["value"], "no company contact: not a B2B buyer")
return
create_quote(s, company)

terminal_page.shown_products[] are the result page’s products with product_id and variant_id as GIDs, straight into lineItems[].variantId; a product-level card has variant_id: null and is skipped. The quantity answer’s value is a JSON number; the branch answer’s values[0] is the option label, which is how the quiz’s branch options are named after the company locations in Shopify.

The branch reorder

A branch manager’s link prefills the branch and the last quantities (Prefill what the shopper already told you): /pages/reorder?q_single_choice-branch=north&q_number_input-qty=48&skip=1. The quiz shows the line’s products on its result page; the job above creates the draft for that branch. For a reorder without a quiz at all, the person’s latest_results[].result_page.shown_products on GET /v1/profiles/{profile_id} is the same list.

Errors to handle

WhereWhatDo
POST /v1/webhooks403 insufficient_scopeinclude_pii: true needs profiles:read as well as webhooks:manage.
customers queryerrors with ACCESS_DENIEDThe app needs read_customers; companyContactProfiles needs the store’s B2B access.
draftOrderCreateerrors with ACCESS_DENIEDThe app needs write_draft_orders.
draftOrderCreateuserErrors on lineItemsA variant the location’s catalog does not include, or a deleted variant; the quiz’s result page names a product the company cannot buy.
draftOrderCreateuserErrors on purchasingEntityThe contact is not assigned to that location; pick a location the contact has or leave companyLocationId out to let Shopify use the contact’s default.

Things to know

  • Deliveries are at least once: accept deduplicates on webhook-id, and customAttributes.quiz_session on the draft lets a job check draftOrders(query: "tag:quiz-quote") for an existing quote before creating another.
  • The buyer’s email must be the company contact’s email in Shopify; a quiz taken with a personal address finds no company and the job stops, with a log line for sales to follow up.
  • The draft carries no prices from the quiz: the location’s catalog, volume pricing and payment terms are applied by Shopify at creation. Read the draft back (lineItems[].originalUnitPriceSet) to show the contracted price in a confirmation email.
  • Sending the invoice is a separate call, draftOrderInvoiceSend(id:), or a click in the admin. Leave it to sales when quotes need a look; call it from the job when they do not.
  • quiz.finished is sent 30 seconds after completion so the result page’s products are on the payload; a quote is never earlier than that.