Skip to content

Webhooks

Webhooks tell your server what happened in your store’s quizzes the moment it happens: Octane AI POSTs a signed JSON event to an HTTPS address you own, retries when it does not get through, and keeps 30 days of deliveries you can inspect and resend.

You manage endpoints with a key that carries webhooks:manage (see Authentication) or from the dashboard under Developer > Webhooks; the endpoint, delivery and event fields are in the reference. Webhooks are available on the Plus and Enterprise plans.

Topics

TopicFires whenThe object
quiz.startedA shopper’s first answer is savedThe start of the session: ids, quiz context, where they came from, the device
quiz.finishedA shopper finishes a quiz (what counts as finished); sent about a minute later so the products the result page showed, and a contact detail they were still typing, are includedThe full session: answers, points, formulas, result page, products shown
quiz.abandonedA session that had at least one answer has been idle for 30 minutesThe session so far: answers, the last page
identity.capturedA shopper leaves an email or phone in a quiz; sent at once, once per distinct value per session. Only endpoints with contact details on (include_pii: true) can subscribeThe session as of that moment plus the identity captured
profile.createdA shopper leaves a contact detail and becomes a new person on this storeThe person
profile.updatedA known person adds a contact detail (a second email, a phone) or changes oneThe person
quiz.publishedA version is published, from the editor or the APIThe quiz and the version
export.completedAn export requested with the API or from the dashboard has finished buildingThe export, without a download link
webhook.endpoint.disabledOne of your endpoints was disabled by Octane AI, after five days of failures or a 410 answer; sent to the store’s other endpoints subscribed to itThe endpoint and the reason

Every subscription is a list of these topics. A verification ping is sent when you ask for a test; it is not a topic you subscribe to, but it shows in the endpoint’s deliveries.

The envelope

Every delivery is one event (the object is abbreviated here; the full objects are under Payloads):

{
"id": "evt_6ba7b8109dad11d180b400c04fd430c8",
"type": "quiz.finished",
"created": "2026-09-07T09:11:58Z",
"api_version": "v1",
"data": {
"object": { "...": "..." }
}
}

id is the event’s evt_ id, the same on every attempt and redelivery; type is the topic; created is when the event happened; data.object is the object described below. The envelope carries no store id: one endpoint belongs to one store. The storefront JS API uses an envelope with the same keys in the browser (its own event ids and objects, plus element).

Payloads

quiz.started

{
"id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e",
"quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7",
"quiz_name": "Find your routine",
"version_id": "ver_15ce0be06b904d0180b1155da02a6c6f",
"version_number": 2,
"visitor_id": "vis_0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
"channel": "email",
"placement": "inline",
"country": "US",
"region": "CA",
"page_url": "https://shop.example.com/pages/quiz",
"utm_source": "newsletter",
"utm_medium": null,
"utm_campaign": null,
"utm_content": null,
"utm_term": null,
"started_at": "2026-09-07T09:08:00Z",
"device": {
"class": "mobile",
"family": "iPhone",
"vendor": "Apple",
"model": "iPhone",
"browser": "Mobile Safari",
"browser_version": "17.5",
"os": "iOS",
"os_version": "17.5"
}
}

quiz.started describes the start and nothing else: no answers, no identities. page_url is the page the quiz opened on (origin and path, no query string; null when the embed did not report one). The device block is derived when the session opened, from the user agent, the browser’s client hints and screen and touch heuristics: class is always one of the analytics device classes; the other seven fields are null when nothing said, never a placeholder.

quiz.finished and quiz.abandoned

Both carry the session object: the ids and quiz context, visitor_id, started_at, ended_at, channel, device, country, page_url, the five utm_* fields, answers, last_page and answered_count, plus identities when the endpoint includes contact details. Only quiz.finished adds completed_at, formulas, points, terminal_page, products_shown and property_mappings. What counts as a finished quiz says when a visit is finished and what a shopper may leave unanswered.

{
"id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e",
"quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7",
"quiz_name": "Find your routine",
"version_id": "ver_15ce0be06b904d0180b1155da02a6c6f",
"version_number": 2,
"visitor_id": "vis_0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d",
"started_at": "2026-09-07T09:08:00Z",
"ended_at": "2026-09-07T09:11:30Z",
"completed_at": "2026-09-07T09:11:30Z",
"channel": "email",
"device": { "class": "mobile", "family": "iPhone", "vendor": "Apple", "model": "iPhone", "browser": "Mobile Safari", "browser_version": "17.5", "os": "iOS", "os_version": "17.5" },
"country": "US",
"page_url": "https://shop.example.com/pages/quiz",
"utm_source": "newsletter",
"utm_medium": null,
"utm_campaign": null,
"utm_content": null,
"utm_term": null,
"answers": {
"image_choice-48su5": {
"page_key": "skin",
"kind": "image",
"option_ids": ["dry"],
"value": "dry",
"values": ["Dry"],
"label": "Skin type?"
}
},
"last_page": "result",
"answered_count": 1,
"formulas": [{ "page_key": "result", "component_key": "formula-tidtk", "label": "Hydration score", "value": 72 }],
"points": [{ "dimension_id": "dry", "name": "Dry", "total": 14 }],
"terminal_page": null,
"products_shown": [],
"property_mappings": []
}
  • answers is keyed by component_key. Each answer carries value as stored, option_ids for a choice, values as the labels a person reads (the same labels the people API sends; the storefront’s values add a slider’s unit), kind and label (the question). A number or slider answer’s value is a JSON number and its values the display string (["42"]); a date keeps its {date, time} object.
  • points[].total and formulas[].value are JSON numbers.
  • terminal_page is the result page reached, with the products it showed (shown_products[]), in the same shape as the people API’s result_page.
  • products_shown[] lists every product the session saw: product_id and variant_id as Shopify GIDs, block_key, page_key, position, source (how the product was picked), and handle, title, variant_title, price, currency, image from the catalog (null when unknown).
  • property_mappings[] are the quiz’s answer-to-customer-property mappings (question_key, question_label, property_name, option_values).
  • On the session payloads version_id is null when the version row is gone and quiz_name is null only when the quiz row itself is gone (a soft-deleted quiz still names it); on quiz.published both are always set.

identity.captured

The same session object as quiz.finished (answers so far, points, formulas, identities, every product shown so far) plus one field:

"identity": { "kind": "email", "value": "shopper@example.com", "consent": true, "page_key": "result", "component_key": "email-k3x9q" }

value is normalized (trimmed lowercase email, E.164 phone), the same string identities[] and the people API show; the value as typed is answers[component_key].value. consent is the marketing checkbox beside that field: true, false when it was left unticked, null when the field has none; an identity with consent false is still sent.

This event is NOT a finish. completed_at and terminal_page are null and products_shown is empty until the session completes. It may arrive before or after quiz.finished, and before or after profile.created; tie them by session_id and read completed_at to know whether the shopper finished. identities[] always lists the identity just captured, even when the event is built before the session has been tied to its person; its seen timestamps are then the capture moment.

Only an endpoint with contact details on (include_pii: true) can subscribe: creating or updating one onto this topic without it answers 422 webhook_pii_required. The 30 days of retained deliveries hold the contact value, as any quiz.finished with contact details does. A value replaced before its delivery was built is not sent; its replacement is.

Which event to use. profile.created and profile.updated fire only when the person set changes (a new person, a contact attached, moved or rewritten) and carry the person: ids, seen times, the capturing session and quiz ids, the identity kinds that session contributed, and identities on endpoints with contact details on (include_pii: true). They do not fire when a shopper types a contact the store already knows for that person, nor on a re-typed value. identity.captured fires every time a session identifies itself with a new value for that session, whether or not the person changed, and carries the session as of that moment plus the identity. Use the profile events to keep a CRM’s people in sync; use identity.captured to act on a shopper the moment they identify themselves in a quiz (a welcome flow, an abandoned-quiz nudge).

profile.created and profile.updated

{
"id": "prof_50e1b7e458f14dfca7a2a5b971af2821",
"created_at": "2026-09-01T12:00:00Z",
"first_seen_at": "2026-09-01T12:00:00Z",
"last_seen_at": "2026-09-07T09:11:30Z",
"session_id": "sess_9b2d4a5e6f7a4b8c9d0e1f2a3b4c5d6e",
"quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7",
"identities_from_session": ["email"],
"urls": { "dashboard": "https://editor.octaneai.com/bot_123/analytics/people/50e1b7e4-58f1-4dfc-a7a2-a5b971af2821" },
"identities": [
{ "kind": "email", "value": "shopper@example.com", "first_seen_at": "2026-09-01T12:00:00Z", "last_seen_at": "2026-09-07T09:11:30Z", "consent": true }
]
}

session_id and quiz_id name the session that captured the contact detail (null when none did); identities_from_session lists every identity kind that session contributed so far, not only what this event changed. identities is present only on an endpoint that includes contact details. first_seen_at may be null.

An endpoint is created with include_pii: false by default. That strips exactly two things: the contact identities (identities is absent on session and profile payloads) and the answers to email and phone fields (and the property mappings of those fields). Every other answer stays, free-text answers included, so a text question that asks for a name still travels. Set include_pii: true to receive the contact details too; that needs a key with profiles:read (the permission that exposes personal data) as well as webhooks:manage.

Every identity carries consent: the marketing-consent checkbox beside the field. On a session event it is that session’s checkbox (true, false when the field had a checkbox left unticked, null when the field has none); on a profile event, and on GET /v1/profiles/{profile_id}, it is the checkbox beside the newest answered field of that kind across the person’s sessions. The storefront’s identity.captured reports the checkbox of the field just answered. The identity.captured webhook carries the same field as identity.consent.

quiz.published

{
"quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7",
"quiz_name": "Find your routine",
"version_id": "ver_15ce0be06b904d0180b1155da02a6c6f",
"version_number": 2,
"published_at": "2026-09-03T09:00:00Z"
}

export.completed

{
"id": "exp_3f2504e04f8911d39a0c0305e82c3301",
"dataset": "profiles",
"format": "csv",
"source": "api",
"status": "completed",
"filters": { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "from": "2026-08-01", "to": "2026-08-31" },
"row_count": 1240,
"bytes": 58311,
"completed_at": "2026-09-01T12:00:09Z",
"expires_at": "2026-10-01T12:00:00Z",
"download_url": null,
"download_url_expires_at": null,
"urls": { "self": "https://api.octaneai.com/v1/exports/exp_3f2504e04f8911d39a0c0305e82c3301" }
}

The object is the export exactly as reading it returns it, with download_url and download_url_expires_at left null: fetch urls.self with a key that holds the dataset’s scope and it answers a fresh download_url, good for 15 minutes (see Exports). A failed export sends nothing; poll it or read the dashboard.

webhook.endpoint.disabled

{
"endpoint_id": "whe_3f2504e04f8911d39a0c0305e82c3301",
"reason": "failing_five_days",
"disabled_at": "2026-09-12T09:00:00Z"
}

reason is failing_five_days or gone (your server answered 410); it is also the endpoint’s disabled_reason, which reads merchant when you disabled it yourself.

ping

{ "endpoint_id": "whe_3f2504e04f8911d39a0c0305e82c3301" }

Create an endpoint

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", "profile.created"],
"include_pii": false,
"description": "Order desk"
}'
{
"endpoint": {
"id": "whe_3f2504e04f8911d39a0c0305e82c3301",
"url": "https://hooks.example.com/octane",
"topics": ["quiz.finished", "profile.created"],
"status": "pending",
"include_pii": false,
"description": "Order desk",
"verified_at": null,
"consecutive_failures": 0,
"failing_since": null
},
"secret": "whsec_XwwbKj1OX2BxgpOktbxtfvmgscLT5PUG"
}
  • The answer is 201 with the endpoint and its signing secret; keep the secret with your other secrets. Creating an endpoint with a URL you already registered, with the same topics and include_pii, answers 200 with the existing endpoint and its secret; the same URL with a different configuration answers 409 webhook_conflict.
  • A new endpoint is pending and receives nothing until it is verified: send a test ping (see Send a test ping) and the endpoint becomes active when your server answers it with a 2xx. Events are delivered to active endpoints only.
  • The URL must be a public https address without credentials or a fragment (422 webhook_url_unsafe otherwise). Redirects are not followed.
  • A store may have 5 endpoints on Plus and 25 on Enterprise (webhook_endpoints on GET /v1/store); one more answers 409 endpoint_limit.

Updating an endpoint changes topics, include_pii, description and enabled. enabled: false disables it (status: disabled, disabled_reason: merchant); enabled: true brings a verified endpoint back to active, and a never-verified one back to pending, waiting for a test. Deleting removes the endpoint and its deliveries.

Secrets and signing

The secret is whsec_ followed by 32 base64 characters (24 random bytes). It is shown when the endpoint is created and again by POST /v1/webhooks/{endpoint_id}/reveal-secret. POST .../rotate-secret answers a new secret; for the next 24 hours every delivery is signed with both, so you can switch at your own pace. A second rotation inside that window drops the older secret and starts a fresh 24-hour overlap.

Every attempt is signed following Standard Webhooks (the signature value below is illustrative):

POST /octane HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: OctaneAI-CarrierPigeon/1.0 (+https://developers.octaneai.com/webhooks)
Accept-Encoding: identity
X-Octane-Topic: quiz.finished
webhook-id: evt_6ba7b8109dad11d180b400c04fd430c8
webhook-timestamp: 1788772318
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pfHZVlhb2iFtjg=
  • X-Octane-Topic repeats the event’s type, so you can route before parsing the body.
  • webhook-id is the event id, the same on every attempt of the same event.
  • webhook-timestamp is a fresh Unix timestamp per attempt.
  • webhook-signature is v1, followed by the base64 HMAC-SHA256, keyed with the secret’s decoded bytes, of {webhook-id}.{webhook-timestamp}.{raw body}. During a rotation window two space-separated signatures are sent; accept the delivery if any matches.

Verify against the raw request bytes (a re-serialized body will not match), compare in constant time, reject a timestamp more than five minutes from your clock, and answer false (never throw) on a missing header, a bad timestamp or invalid base64. With nothing but the standard library:

import base64
import hashlib
import hmac
import time
def verify(secret: str, headers, body: bytes) -> bool:
try:
key = base64.b64decode(secret[len("whsec_"):], validate=True)
msg_id, ts, sigs = headers["webhook-id"], headers["webhook-timestamp"], headers["webhook-signature"]
if abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
for sig in sigs.split(" "):
version, _, value = sig.partition(",")
if version == "v1" and hmac.compare_digest(base64.b64decode(value, validate=True), expected):
return True
except (KeyError, ValueError): # a missing header, a bad timestamp, invalid base64
pass
return False
# a self-check with a secret of your own: the signature is computed the way Octane AI computes it
secret = "whsec_" + base64.b64encode(b"0123456789abcdef01234567").decode()
body = b'{"id":"evt_6ba7b8109dad11d180b400c04fd430c8","type":"ping","created":"2026-09-07T09:11:58Z","api_version":"v1","data":{"object":{"endpoint_id":"whe_3f2504e04f8911d39a0c0305e82c3301"}}}'
ts = str(int(time.time()))
sig = "v1," + base64.b64encode(hmac.new(b"0123456789abcdef01234567", f"evt_6ba7b8109dad11d180b400c04fd430c8.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
print(verify(secret, {"webhook-id": "evt_6ba7b8109dad11d180b400c04fd430c8", "webhook-timestamp": ts, "webhook-signature": sig}, body)) # True
print(verify(secret, {}, body)) # False

This verify() is the one implementation; the Verify a webhook signature recipe repeats it verbatim inside a complete receiver with deduplication. You can also match the User-Agent in your firewall rules; it is the same on every delivery.

Delivery and retries

Answer with a 2xx within 10 seconds and do the work afterwards; each attempt has a 10-second total deadline, and only the first 1,024 bytes of your response are stored.

Your answerWhat happens
2xxDelivered.
408 or 429, a 5xx, a timeout or a network errorRetried.
410The delivery is dead and the endpoint is disabled.
Any other 4xxThe delivery is dead; no retry.

Retries follow the first attempt after 60 seconds, then 5, 30 minutes, 2, 5, 10 and 10 hours, each with 20 percent jitter; the eighth failed attempt marks the delivery dead. Delivery is at least once: a redelivery or a slow worker can send the same event twice, so deduplicate on webhook-id. Deliveries are kept for 30 days from the event.

An endpoint has at most ten deliveries in flight and sends at most 50 per second on Plus or 200 on Enterprise (webhook_sends_per_second on GET /v1/store). Five failures in a row pause the endpoint for 60 seconds before one probe tries again. consecutive_failures and failing_since on the endpoint say how it is doing; a successful delivery clears them. An endpoint that has been failing for five days is disabled (disabled_reason: failing_five_days; gone after a 410), the store’s admins are emailed, and webhook.endpoint.disabled goes to the store’s other endpoints that subscribe to it.

Inspect and resend

Read an endpoint’s deliveries

The endpoint’s deliveries list newest first, each with its status (pending, retrying, delivering, delivered, dead), attempt count, the last status code, response snippet and error, and payload, the exact envelope that was signed and sent. A session or profile event’s payload is null until its first attempt is made; pings, quiz.published, export.completed and webhook.endpoint.disabled carry it from the start.

Terminal window
curl "https://api.octaneai.com/v1/webhooks/whe_3f2504e04f8911d39a0c0305e82c3301/deliveries?limit=5" \
-H "Authorization: Bearer $OCTANE_API_KEY"
{
"data": [
{
"id": "whd_7c9e6679742540de944be07fc1f90ae7",
"event_id": "evt_6ba7b8109dad11d180b400c04fd430c8",
"topic": "quiz.finished",
"status": "delivered",
"attempts": 1,
"last_status_code": 200,
"last_response": "ok",
"last_error": null,
"delivered_at": "2026-09-07T09:12:00Z",
"payload": { "id": "evt_6ba7b8109dad11d180b400c04fd430c8", "type": "quiz.finished", "created": "2026-09-07T09:11:58Z", "api_version": "v1", "data": { "object": { "...": "..." } } }
}
],
"has_more": false,
"next_cursor": null
}

Resend one delivery

Redelivering queues that event again (202). It keeps the event id and the stored payload, replaces the delivery’s previous outcome and keeps its attempt count: a redelivered event that fails again is dead at once past the eighth attempt. Deduplicate on webhook-id on your side.

Terminal window
curl -X POST https://api.octaneai.com/v1/webhooks/whe_3f2504e04f8911d39a0c0305e82c3301/deliveries/whd_7c9e6679742540de944be07fc1f90ae7/redeliver \
-H "Authorization: Bearer $OCTANE_API_KEY"

Resend everything that failed

redeliver-failed queues every dead or retrying delivery created since the since you send ({"since": "2026-09-05T00:00:00Z"}; the last 7 days without a body), leaving out the ones skipped by a plan pause, and answers {"count": n} (202).

Send a test ping

test queues a signed ping (202); it is how a new or re-enabled endpoint gets verified. On a disabled endpoint it answers 409 webhook_conflict.

The store’s event feed

GET /v1/events lists the store’s distinct retained events across endpoints (id, type, topic, the endpoint_id of one of the endpoints it was queued for, created), pings left out, with an optional topic filter.

Plan changes

A store whose plan loses the API gets no deliveries: nothing is recorded for it while the plan is off, and a delivery already queued dies with last_error: plan_paused; that is not counted as a failure and cannot be redelivered (409 webhook_conflict). Nothing is replayed when the plan returns; new events flow again from then on. While paused, GET /v1/webhooks and GET /v1/webhooks/{endpoint_id} stay readable and show status: paused_plan (an endpoint you disabled keeps disabled); every other webhook route answers 403 plan_required. Disconnecting the store from Octane AI, or erasing its shopper data, deletes every endpoint and its deliveries.

Errors you will meet

Every one is a Problem body: 403 insufficient_scope when the key lacks webhooks:manage, or asks for include_pii: true without profiles:read; 403 plan_required for a write on a paused plan; 404 not_found for an unknown or foreign endpoint or delivery; 409 endpoint_limit at the quota; 409 webhook_conflict when the URL exists with a different configuration, a plan_paused delivery is asked to be redelivered, or a test is sent to a disabled endpoint; 422 webhook_url_unsafe when the URL is not a public https address; 422 webhook_pii_required when identity.captured is subscribed without include_pii: true; 422 validation_error for an unknown topic, an empty topic list, a description over 200 characters or a since that is not a date-time.