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
- 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.
quiz.finishedarrives on your server withidentities[](the email),answersandterminal_page.shown_products[](the products the result page showed, as GIDs withvariant_id).- The server finds the Shopify customer by that email and, on the customer, the company contact and the company location the branch answer names.
draftOrderCreatewithpurchasingEntity.purchasingCompanyand 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).- Sales reviews the draft and sends the invoice, or your job calls
draftOrderInvoiceSendat once.
The job
This is the job the receiver from Verify a webhook signature enqueues; it receives the parsed event.
import osimport 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 locationsADMIN_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)const QUIZ_ID = "quiz_7c9e6679742540de944be07fc1f90ae7";const QUANTITY_KEY = "number_input-qty";const BRANCH_KEY = "single_choice-branch"; // options are the branch names as Shopify knows the company locationsconst ADMIN_URL = `https://${process.env.SHOPIFY_SHOP}/admin/api/2025-07/graphql.json`;const ADMIN_HEADERS = { "X-Shopify-Access-Token": process.env.SHOPIFY_ADMIN_TOKEN!, "Content-Type": "application/json" };
type Session = { id: string; quiz_id: string; quiz_name: string | null; answers: Record<string, { value: unknown; values: string[] }>; identities?: { kind: string; value: string }[]; terminal_page: { shown_products: { variant_id: string | null }[] } | null;};
async function admin(query: string, variables: Record<string, unknown>) { const res = await fetch(ADMIN_URL, { method: "POST", headers: ADMIN_HEADERS, body: JSON.stringify({ query, variables }) }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const body = await res.json(); if (body.errors) throw new Error(JSON.stringify(body.errors)); return body.data;}
async function purchasingCompany(email: string, branch: string | null) { const data = await admin( `query ($q: String!) { customers(first: 1, query: $q) { nodes { id companyContactProfiles { id company { id name locations(first: 20) { nodes { id name } } } } } } }`, { q: `email:${email}` }, ); const contact = data.customers.nodes[0]?.companyContactProfiles[0]; if (!contact) return null; const locations = contact.company.locations.nodes as { id: string; name: string }[]; const location = locations.find((l) => l.name === branch) ?? locations[0]; if (!location) return null; return { companyId: contact.company.id, companyContactId: contact.id, companyLocationId: location.id };}
async function createQuote(s: Session, company: Record<string, string>) { const quantity = s.answers[QUANTITY_KEY] ? Number(s.answers[QUANTITY_KEY].value) : 1; const lineItems = s.terminal_page!.shown_products.filter((p) => p.variant_id).map((p) => ({ variantId: p.variant_id, quantity })); const data = await 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, tags: ["quiz-quote"], customAttributes: [{ key: "quiz_session", value: s.id }], note: `Quote from the quiz ${s.quiz_name}`, } }, ); if (data.draftOrderCreate.userErrors.length) throw new Error(JSON.stringify(data.draftOrderCreate.userErrors)); const draft = data.draftOrderCreate.draftOrder; console.log(draft.name, draft.totalPriceSet.shopMoney, draft.lineItems.nodes.map((l: any) => [l.title, l.quantity, l.originalUnitPriceSet.shopMoney.amount])); // #D42 { amount: '1440.0', currencyCode: 'USD' } [ [ 'Hydrating Serum - 30 ml', 48, '30.0' ] ] return draft.id as string;}
export async function handle(event: { type: string; data: { object: Session } }) { if (event.type !== "quiz.finished") return; const s = event.data.object; if (s.quiz_id !== QUIZ_ID || !s.terminal_page) return; const email = (s.identities ?? []).find((i) => i.kind === "email"); if (!email) return; const company = await purchasingCompany(email.value, s.answers[BRANCH_KEY]?.values[0] ?? null); if (!company) { console.log(s.id, email.value, "no company contact: not a B2B buyer"); return; } await createQuote(s, company);}package main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os")
const quizID = "quiz_7c9e6679742540de944be07fc1f90ae7"const quantityKey = "number_input-qty"const branchKey = "single_choice-branch" // options are the branch names as Shopify knows the company locations
type session struct { ID string `json:"id"` QuizID string `json:"quiz_id"` QuizName *string `json:"quiz_name"` Answers map[string]struct { Value any `json:"value"` Values []string `json:"values"` } `json:"answers"` Identities []struct { Kind string `json:"kind"` Value string `json:"value"` } `json:"identities"` TerminalPage *struct { ShownProducts []struct { VariantID *string `json:"variant_id"` } `json:"shown_products"` } `json:"terminal_page"`}
func admin(query string, variables map[string]any, into any) error { body, _ := json.Marshal(map[string]any{"query": query, "variables": variables}) req, _ := http.NewRequest("POST", "https://"+os.Getenv("SHOPIFY_SHOP")+"/admin/api/2025-07/graphql.json", bytes.NewReader(body)) req.Header.Set("X-Shopify-Access-Token", os.Getenv("SHOPIFY_ADMIN_TOKEN")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { return err } defer res.Body.Close() var out struct { Data json.RawMessage `json:"data"` Errors []any `json:"errors"` } json.NewDecoder(res.Body).Decode(&out) if len(out.Errors) > 0 { return fmt.Errorf("%v", out.Errors) } return json.Unmarshal(out.Data, into)}
func purchasingCompany(email, branch string) (map[string]string, error) { var data struct { Customers struct { Nodes []struct { CompanyContactProfiles []struct { ID string `json:"id"` Company struct { ID string `json:"id"` Locations struct { Nodes []struct { ID string `json:"id"` Name string `json:"name"` } `json:"nodes"` } `json:"locations"` } `json:"company"` } `json:"companyContactProfiles"` } `json:"nodes"` } `json:"customers"` } err := admin(`query ($q: String!) { customers(first: 1, query: $q) { nodes { id companyContactProfiles { id company { id name locations(first: 20) { nodes { id name } } } } } } }`, map[string]any{"q": "email:" + email}, &data) if err != nil || len(data.Customers.Nodes) == 0 || len(data.Customers.Nodes[0].CompanyContactProfiles) == 0 { return nil, err } contact := data.Customers.Nodes[0].CompanyContactProfiles[0] if len(contact.Company.Locations.Nodes) == 0 { return nil, nil } location := contact.Company.Locations.Nodes[0] for _, l := range contact.Company.Locations.Nodes { if l.Name == branch { location = l } } return map[string]string{"companyId": contact.Company.ID, "companyContactId": contact.ID, "companyLocationId": location.ID}, nil}
func createQuote(s session, company map[string]string) error { quantity := 1 if a, ok := s.Answers[quantityKey]; ok { if n, ok := a.Value.(float64); ok { quantity = int(n) } } lines := []map[string]any{} for _, p := range s.TerminalPage.ShownProducts { if p.VariantID != nil { lines = append(lines, map[string]any{"variantId": *p.VariantID, "quantity": quantity}) } } var data struct { DraftOrderCreate struct { DraftOrder *struct { ID string `json:"id"` Name string `json:"name"` TotalPriceSet struct { ShopMoney struct { Amount string `json:"amount"` CurrencyCode string `json:"currencyCode"` } `json:"shopMoney"` } `json:"totalPriceSet"` } `json:"draftOrder"` UserErrors []any `json:"userErrors"` } `json:"draftOrderCreate"` } err := admin(`mutation ($input: DraftOrderInput!) { draftOrderCreate(input: $input) { draftOrder { id name totalPriceSet { shopMoney { amount currencyCode } } } userErrors { field message } } }`, map[string]any{"input": map[string]any{ "purchasingEntity": map[string]any{"purchasingCompany": company}, "lineItems": lines, "tags": []string{"quiz-quote"}, "customAttributes": []map[string]string{{"key": "quiz_session", "value": s.ID}}, }}, &data) if err != nil { return err } if len(data.DraftOrderCreate.UserErrors) > 0 { return fmt.Errorf("%v", data.DraftOrderCreate.UserErrors) } d := data.DraftOrderCreate.DraftOrder fmt.Println(d.Name, d.TotalPriceSet.ShopMoney.Amount, d.TotalPriceSet.ShopMoney.CurrencyCode) // #D42 1440.0 USD return nil}
func handle(topic string, data json.RawMessage) error { if topic != "quiz.finished" { return nil } var wrapper struct { Object session `json:"object"` } json.Unmarshal(data, &wrapper) s := wrapper.Object if s.QuizID != quizID || s.TerminalPage == nil { return nil } for _, id := range s.Identities { if id.Kind != "email" { continue } branch := "" if a, ok := s.Answers[branchKey]; ok && len(a.Values) > 0 { branch = a.Values[0] } company, err := purchasingCompany(id.Value, branch) if err != nil { return err } if company == nil { fmt.Println(s.ID, id.Value, "no company contact: not a B2B buyer") return nil } return createQuote(s, company) } return nil}func main() { var event struct { Type string `json:"type"` Data json.RawMessage `json:"data"` } if err := json.NewDecoder(os.Stdin).Decode(&event); err != nil { log.Fatal(err) } if err := handle(event.Type, event.Data); err != nil { log.Fatal(err) }}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
| Where | What | Do |
|---|---|---|
POST /v1/webhooks | 403 insufficient_scope | include_pii: true needs profiles:read as well as webhooks:manage. |
customers query | errors with ACCESS_DENIED | The app needs read_customers; companyContactProfiles needs the store’s B2B access. |
draftOrderCreate | errors with ACCESS_DENIED | The app needs write_draft_orders. |
draftOrderCreate | userErrors on lineItems | A variant the location’s catalog does not include, or a deleted variant; the quiz’s result page names a product the company cannot buy. |
draftOrderCreate | userErrors on purchasingEntity | The 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:
acceptdeduplicates onwebhook-id, andcustomAttributes.quiz_sessionon the draft lets a job checkdraftOrders(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.finishedis sent 30 seconds after completion so the result page’s products are on the payload; a quote is never earlier than that.
