Send every finished quiz to your CRM
Goal: the moment a shopper finishes a quiz and leaves an email, a lead exists in the CRM with their contact details, their answers and the result they got, without anything running in the browser.
Uses: Webhooks: quiz.finished on an endpoint created with include_pii: true (a key with webhooks:manage and profiles:read). The receiver is the one from Verify a webhook signature; this page is the job its accept enqueues. The CRM side is one HTTP call; Salesforce’s REST API is shown, and any CRM with an upsert works the same way.
1. Subscribe with contact details
curl -X POST https://api.octaneai.com/v1/webhooks \ -H "Authorization: Bearer $OCTANE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://hooks.example.com/octane", "topics": ["quiz.finished"], "include_pii": true, "description": "CRM feed"}'import osimport httpx
r = httpx.post( "https://api.octaneai.com/v1/webhooks", headers={"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}, json={"url": "https://hooks.example.com/octane", "topics": ["quiz.finished"], "include_pii": True, "description": "CRM feed"}, timeout=30)r.raise_for_status()created = r.json()print(created["endpoint"]["id"], created["endpoint"]["include_pii"]) # whe_... Trueprint(created["secret"]) # whsec_...: put it in OCTANEAI_WEBHOOK_SECRET on the receiverconst res = await fetch("https://api.octaneai.com/v1/webhooks", { method: "POST", headers: { Authorization: `Bearer ${process.env.OCTANE_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ url: "https://hooks.example.com/octane", topics: ["quiz.finished"], include_pii: true, description: "CRM feed" }),});if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);const created = await res.json();console.log(created.endpoint.id, created.endpoint.include_pii); // whe_... trueconsole.log(created.secret); // whsec_...: put it in OCTANEAI_WEBHOOK_SECRET on the receiverpackage main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os")
func main() { body := []byte(`{"url": "https://hooks.example.com/octane", "topics": ["quiz.finished"], "include_pii": true, "description": "CRM feed"}`) req, _ := http.NewRequest("POST", "https://api.octaneai.com/v1/webhooks", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } defer res.Body.Close() if res.StatusCode >= 400 { log.Fatalf("status %d", res.StatusCode) } var created struct { Endpoint struct { ID string `json:"id"` IncludePII bool `json:"include_pii"` } `json:"endpoint"` Secret string `json:"secret"` } json.NewDecoder(res.Body).Decode(&created) fmt.Println(created.Endpoint.ID, created.Endpoint.IncludePII) // whe_... true fmt.Println(created.Secret) // whsec_...: put it in OCTANEAI_WEBHOOK_SECRET on the receiver}Without include_pii: true the session carries no identities[] and no email or phone answers, and there is nothing to make a lead from.
2. Flatten the session into a lead
The quiz.finished object (described in Webhooks) is nested: answers keyed by component key, each with value, values and label; identities[] with kind, value, consent; terminal_page with the result page; products_shown[]; the utm_* fields. A CRM wants one flat record. The job reads the parts it needs and writes one lead per session, upserted on the session id so a redelivery updates instead of duplicating.
import osimport httpx
CRM_URL = os.environ["CRM_URL"] # https://yourorg.my.salesforce.com/services/data/v62.0CRM_HEADERS = {"Authorization": f"Bearer {os.environ['CRM_TOKEN']}"}FIELDS = {"image_choice-48su5": "Skin_Type__c", "number_input-re853": "Age__c"} # component key -> CRM field
def lead_from(session: dict) -> dict | None: email = next((i for i in session.get("identities") or [] if i["kind"] == "email"), None) if not email: return None answers = session["answers"] def text(key): return ", ".join(answers[key]["values"]) if key in answers else None lead = { "Email": email["value"], "FirstName": text("text_input-fname"), "LastName": text("text_input-lname") or "-", # LastName is required on a Salesforce Lead "Company": "-", # so is Company "LeadSource": "Quiz", "HasOptedOutOfEmail": email["consent"] is not True, "Octane_Session__c": session["id"], "Octane_Quiz__c": session["quiz_name"], "Octane_Result__c": session["terminal_page"] and session["terminal_page"]["title"], "Octane_Completed_At__c": session["completed_at"], "Octane_UTM_Campaign__c": session["utm_campaign"], } for key, field in FIELDS.items(): lead[field] = text(key) return lead
def send_to_crm(lead: dict) -> None: session_id = lead.pop("Octane_Session__c") r = httpx.patch(f"{CRM_URL}/sobjects/Lead/Octane_Session__c/{session_id}", headers=CRM_HEADERS, json=lead, timeout=30) r.raise_for_status() # 201 created, 200 updated (a redelivery), 204 no change
def handle(event): if event["type"] != "quiz.finished": return lead = lead_from(event["data"]["object"]) if lead is None: return # no email: nothing to feed send_to_crm(dict(lead)) print(lead["Octane_Session__c"], lead["Email"], lead["Skin_Type__c"]) # sess_9b2d... ada@example.com Dryconst CRM_URL = process.env.CRM_URL!; // https://yourorg.my.salesforce.com/services/data/v62.0const CRM_HEADERS = { Authorization: `Bearer ${process.env.CRM_TOKEN}`, "Content-Type": "application/json" };const FIELDS: Record<string, string> = { "image_choice-48su5": "Skin_Type__c", "number_input-re853": "Age__c" }; // component key -> CRM field
type Answer = { values: string[] };type Session = { id: string; quiz_name: string | null; completed_at: string; utm_campaign: string | null; answers: Record<string, Answer>; identities?: { kind: string; value: string; consent: boolean | null }[]; terminal_page: { title: string } | null;};
function leadFrom(s: Session): Record<string, unknown> | null { const email = (s.identities ?? []).find((i) => i.kind === "email"); if (!email) return null; const text = (key: string) => (s.answers[key] ? s.answers[key].values.join(", ") : null); const lead: Record<string, unknown> = { Email: email.value, FirstName: text("text_input-fname"), LastName: text("text_input-lname") ?? "-", // LastName is required on a Salesforce Lead Company: "-", // so is Company LeadSource: "Quiz", HasOptedOutOfEmail: email.consent !== true, Octane_Session__c: s.id, Octane_Quiz__c: s.quiz_name, Octane_Result__c: s.terminal_page?.title ?? null, Octane_Completed_At__c: s.completed_at, Octane_UTM_Campaign__c: s.utm_campaign, }; for (const [key, field] of Object.entries(FIELDS)) lead[field] = text(key); return lead;}
async function sendToCrm(lead: Record<string, unknown>) { const { Octane_Session__c: sessionId, ...body } = lead; const res = await fetch(`${CRM_URL}/sobjects/Lead/Octane_Session__c/${sessionId}`, { method: "PATCH", headers: CRM_HEADERS, body: JSON.stringify(body) }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // 201 created, 200 updated (a redelivery), 204 no change}
export async function handle(event: { type: string; data: { object: Session } }) { if (event.type !== "quiz.finished") return; const lead = leadFrom(event.data.object); if (!lead) return; // no email: nothing to feed await sendToCrm(lead); console.log(lead.Octane_Session__c, lead.Email, lead.Skin_Type__c); // sess_9b2d... ada@example.com Dry}package main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os" "strings")
var fields = map[string]string{"image_choice-48su5": "Skin_Type__c", "number_input-re853": "Age__c"} // component key -> CRM field
type session struct { ID string `json:"id"` QuizName *string `json:"quiz_name"` CompletedAt string `json:"completed_at"` UTMCampaign *string `json:"utm_campaign"` Answers map[string]struct { Values []string `json:"values"` } `json:"answers"` Identities []struct { Kind string `json:"kind"` Value string `json:"value"` Consent *bool `json:"consent"` } `json:"identities"` TerminalPage *struct { Title string `json:"title"` } `json:"terminal_page"`}
func leadFrom(s session) (map[string]any, string) { for _, id := range s.Identities { if id.Kind != "email" { continue } text := func(key string) any { if a, ok := s.Answers[key]; ok { return strings.Join(a.Values, ", ") } return nil } last := text("text_input-lname") if last == nil { last = "-" // LastName is required on a Salesforce Lead } lead := map[string]any{ "Email": id.Value, "FirstName": text("text_input-fname"), "LastName": last, "Company": "-", "LeadSource": "Quiz", "HasOptedOutOfEmail": id.Consent == nil || !*id.Consent, "Octane_Quiz__c": s.QuizName, "Octane_Completed_At__c": s.CompletedAt, "Octane_UTM_Campaign__c": s.UTMCampaign, } if s.TerminalPage != nil { lead["Octane_Result__c"] = s.TerminalPage.Title } for key, field := range fields { lead[field] = text(key) } return lead, s.ID } return nil, ""}
func sendToCRM(lead map[string]any, sessionID string) error { body, _ := json.Marshal(lead) req, _ := http.NewRequest("PATCH", os.Getenv("CRM_URL")+"/sobjects/Lead/Octane_Session__c/"+sessionID, bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("CRM_TOKEN")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode >= 400 { return fmt.Errorf("crm %d", res.StatusCode) } return nil // 201 created, 200 updated (a redelivery), 204 no change}
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) lead, sessionID := leadFrom(wrapper.Object) if lead == nil { return nil // no email: nothing to feed } if err := sendToCRM(lead, sessionID); err != nil { return err } fmt.Println(sessionID, lead["Email"], lead["Skin_Type__c"]) // sess_9b2d... ada@example.com Dry 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) }}values are the labels a person reads (["Dry"]), joined for a multi-select; value is the stored option id. FIELDS is your own map from question keys (in the editor, and on any person’s latest_results[].answers[].component_key) to CRM fields; leave free-text answers out unless the CRM field is meant for them.
3. The CRM side
Salesforce: PATCH /services/data/v62.0/sobjects/Lead/<external id field>/<value> upserts on a custom field marked External ID (here Octane_Session__c), so the same session sent twice is one lead. The token comes from the org’s OAuth flow (a connected app with the client-credentials flow for a server job). HubSpot, Klaviyo, Pipedrive and the rest have the same shape: an upsert keyed by email or by an external id. Prefer the session id as the key when the CRM allows it; a person who takes the quiz twice is two sessions and, usually, two records the CRM merges by email.
Errors to handle
| Where | What | Do |
|---|---|---|
POST /v1/webhooks | 403 insufficient_scope | include_pii: true needs profiles:read as well as webhooks:manage. |
POST /v1/webhooks | 403 plan_required | The store’s plan has no API access, or it is paused. Webhooks are on the Plus and Enterprise plans. |
| The receiver | A 2xx later than 10 seconds | Counted as a failure and retried; answer first, call the CRM from the job. |
| The CRM | 401 | The token expired; refresh it and let the job retry. The delivery is already acknowledged, so the retry is yours. |
| The CRM | 400 on a field | A field name or type in FIELDS does not exist on the object; fix the map, then POST /v1/webhooks/{endpoint_id}/redeliver-failed is not what you need (the delivery succeeded), replay from your own job queue. |
Things to know
- Deliveries are at least once.
acceptdeduplicates onwebhook-idbefore the job runs, and the upsert on the session id makes the CRM call safe to repeat; both are needed, one for the retry that arrives twice, one for the job that ran twice. quiz.finishedis sent 30 seconds after completion so the result page’s products are inproducts_shown; a lead is never earlier than that.consentis the checkbox beside the email field in that session:true,false(left unticked) ornull(no checkbox). The lead’s opt-out flag comes from it;nullis not consent.- The endpoint receives every quiz’s finishes; filter on
quiz_idin the job when only one quiz feeds the CRM. GET /v1/webhooks/{endpoint_id}/deliveriesshows each delivery’spayloadand your response;POST .../deliveries/{delivery_id}/redeliversends one again, with the samewebhook-id, soacceptdrops it unless you cleared the id first.
