Skip to content

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

Terminal window
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"}'

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 os
import httpx
CRM_URL = os.environ["CRM_URL"] # https://yourorg.my.salesforce.com/services/data/v62.0
CRM_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 Dry

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

WhereWhatDo
POST /v1/webhooks403 insufficient_scopeinclude_pii: true needs profiles:read as well as webhooks:manage.
POST /v1/webhooks403 plan_requiredThe store’s plan has no API access, or it is paused. Webhooks are on the Plus and Enterprise plans.
The receiverA 2xx later than 10 secondsCounted as a failure and retried; answer first, call the CRM from the job.
The CRM401The token expired; refresh it and let the job retry. The delivery is already acknowledged, so the retry is yours.
The CRM400 on a fieldA 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. accept deduplicates on webhook-id before 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.finished is sent 30 seconds after completion so the result page’s products are in products_shown; a lead is never earlier than that.
  • consent is the checkbox beside the email field in that session: true, false (left unticked) or null (no checkbox). The lead’s opt-out flag comes from it; null is not consent.
  • The endpoint receives every quiz’s finishes; filter on quiz_id in the job when only one quiz feeds the CRM.
  • GET /v1/webhooks/{endpoint_id}/deliveries shows each delivery’s payload and your response; POST .../deliveries/{delivery_id}/redeliver sends one again, with the same webhook-id, so accept drops it unless you cleared the id first.