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
| Topic | Fires when | The object |
|---|---|---|
quiz.started | A shopper’s first answer is saved | The start of the session: ids, quiz context, where they came from, the device |
quiz.finished | A 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 included | The full session: answers, points, formulas, result page, products shown |
quiz.abandoned | A session that had at least one answer has been idle for 30 minutes | The session so far: answers, the last page |
identity.captured | A 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 subscribe | The session as of that moment plus the identity captured |
profile.created | A shopper leaves a contact detail and becomes a new person on this store | The person |
profile.updated | A known person adds a contact detail (a second email, a phone) or changes one | The person |
quiz.published | A version is published, from the editor or the API | The quiz and the version |
export.completed | An export requested with the API or from the dashboard has finished building | The export, without a download link |
webhook.endpoint.disabled | One 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 it | The 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": []}answersis keyed bycomponent_key. Each answer carriesvalueas stored,option_idsfor a choice,valuesas the labels a person reads (the same labels the people API sends; the storefront’svaluesadd a slider’s unit),kindandlabel(the question). A number or slider answer’svalueis a JSON number and itsvaluesthe display string (["42"]); a date keeps its{date, time}object.points[].totalandformulas[].valueare JSON numbers.terminal_pageis the result page reached, with the products it showed (shown_products[]), in the same shape as the people API’sresult_page.products_shown[]lists every product the session saw:product_idandvariant_idas Shopify GIDs,block_key,page_key,position,source(how the product was picked), andhandle,title,variant_title,price,currency,imagefrom the catalog (nullwhen 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_idisnullwhen the version row is gone andquiz_nameisnullonly when the quiz row itself is gone (a soft-deleted quiz still names it); onquiz.publishedboth 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.
Contact details and consent
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
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
201with 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 andinclude_pii, answers200with the existing endpoint and its secret; the same URL with a different configuration answers409 webhook_conflict. - A new endpoint is
pendingand receives nothing until it is verified: send a test ping (see Send a test ping) and the endpoint becomesactivewhen your server answers it with a2xx. Events are delivered toactiveendpoints only. - The URL must be a public
httpsaddress without credentials or a fragment (422 webhook_url_unsafeotherwise). Redirects are not followed. - A store may have 5 endpoints on Plus and 25 on Enterprise (
webhook_endpointsonGET /v1/store); one more answers409 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.1Host: hooks.example.comContent-Type: application/jsonUser-Agent: OctaneAI-CarrierPigeon/1.0 (+https://developers.octaneai.com/webhooks)Accept-Encoding: identityX-Octane-Topic: quiz.finishedwebhook-id: evt_6ba7b8109dad11d180b400c04fd430c8webhook-timestamp: 1788772318webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pfHZVlhb2iFtjg=X-Octane-Topicrepeats the event’stype, so you can route before parsing the body.webhook-idis the event id, the same on every attempt of the same event.webhook-timestampis a fresh Unix timestamp per attempt.webhook-signatureisv1,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 base64import hashlibimport hmacimport 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 itsecret = "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)) # Trueprint(verify(secret, {}, body)) # Falseimport { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, headers: Record<string, string | undefined>, body: Buffer): boolean { const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sigs = headers["webhook-signature"]; if (!id || !ts || !sigs || !/^\d+$/.test(ts)) return false; if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; const key = Buffer.from(secret.slice("whsec_".length), "base64"); const expected = createHmac("sha256", key).update(`${id}.${ts}.`).update(body).digest(); for (const sig of sigs.split(" ")) { const [version, value] = sig.split(","); if (version !== "v1" || !value) continue; const given = Buffer.from(value, "base64"); if (given.length === expected.length && timingSafeEqual(given, expected)) return true; } return false;}
// a self-check with a secret of your own: the signature is computed the way Octane AI computes itconst raw = Buffer.from("0123456789abcdef01234567");const secret = "whsec_" + raw.toString("base64");const body = Buffer.from('{"id":"evt_6ba7b8109dad11d180b400c04fd430c8","type":"ping","created":"2026-09-07T09:11:58Z","api_version":"v1","data":{"object":{"endpoint_id":"whe_3f2504e04f8911d39a0c0305e82c3301"}}}');const ts = String(Math.floor(Date.now() / 1000));const sig = "v1," + createHmac("sha256", raw).update(`evt_6ba7b8109dad11d180b400c04fd430c8.${ts}.`).update(body).digest("base64");console.log(verify(secret, { "webhook-id": "evt_6ba7b8109dad11d180b400c04fd430c8", "webhook-timestamp": ts, "webhook-signature": sig }, body)); // trueconsole.log(verify(secret, {}, body)); // falsepackage main
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "math" "net/http" "strconv" "strings" "time")
func verify(secret string, h http.Header, body []byte) bool { key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature") sent, err := strconv.ParseInt(ts, 10, 64) if id == "" || err != nil || math.Abs(float64(time.Now().Unix()-sent)) > 300 { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(body) expected := mac.Sum(nil) for _, sig := range strings.Split(sigs, " ") { version, value, ok := strings.Cut(sig, ",") if !ok || version != "v1" { continue } if given, err := base64.StdEncoding.DecodeString(value); err == nil && hmac.Equal(given, expected) { return true } } return false}
func main() { // a self-check with a secret of your own: the signature is computed the way Octane AI computes it raw := []byte("0123456789abcdef01234567") secret := "whsec_" + base64.StdEncoding.EncodeToString(raw) body := []byte(`{"id":"evt_6ba7b8109dad11d180b400c04fd430c8","type":"ping","created":"2026-09-07T09:11:58Z","api_version":"v1","data":{"object":{"endpoint_id":"whe_3f2504e04f8911d39a0c0305e82c3301"}}}`) ts := strconv.FormatInt(time.Now().Unix(), 10) mac := hmac.New(sha256.New, raw) mac.Write([]byte("evt_6ba7b8109dad11d180b400c04fd430c8." + ts + ".")) mac.Write(body) h := http.Header{} h.Set("webhook-id", "evt_6ba7b8109dad11d180b400c04fd430c8") h.Set("webhook-timestamp", ts) h.Set("webhook-signature", "v1,"+base64.StdEncoding.EncodeToString(mac.Sum(nil))) fmt.Println(verify(secret, h, body), verify(secret, http.Header{}, body)) // true 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 answer | What happens |
|---|---|
2xx | Delivered. |
408 or 429, a 5xx, a timeout or a network error | Retried. |
410 | The delivery is dead and the endpoint is disabled. |
Any other 4xx | The 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.
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.
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.
