Alert when a page loses shoppers after a publish
Goal: when an edit to a question makes shoppers leave, know which page it was within a day, without watching the funnel by hand.
Uses: the quiz.published webhook to know the moment a version went live, and GET /v1/quizzes/{quiz_id}/analytics/funnel from Analytics for each page’s dropoff_share_of_starts before and after. Scopes: webhooks:manage to create the endpoint, analytics:read for the funnel.
The flow
- Subscribe an endpoint to
quiz.publishedonce. - When a publish arrives, the receiver reads the funnel for the seven closed days before
published_atand stores it as the baseline for that quiz:startsand, per page,dropoff_share_of_starts. Those days will not change any more, so the snapshot can be taken at once. - A daily job reads the funnel from the publish day to today for every quiz published in the last seven days and compares each page with its baseline: a page whose share of starts lost rose by five points or more, with enough starts to mean it, is the alert.
1. Subscribe
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.published"], "description": "Funnel watch"}'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.published"], "description": "Funnel watch"}, timeout=30)r.raise_for_status()print(r.json()["endpoint"]["id"], r.json()["secret"][:6]) # whe_... whsec_: keep the whole secret in OCTANEAI_WEBHOOK_SECRETconst 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.published"], description: "Funnel watch" }),});if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);const created = await res.json();console.log(created.endpoint.id, created.secret.slice(0, 6)); // whe_... whsec_: keep the whole secret in OCTANEAI_WEBHOOK_SECRETpackage main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "os")
func main() { body := []byte(`{"url": "https://hooks.example.com/octane", "topics": ["quiz.published"], "description": "Funnel watch"}`) 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 >= 300 { log.Fatalf("status %d", res.StatusCode) } var created struct { Endpoint struct { ID string `json:"id"` } `json:"endpoint"` Secret string `json:"secret"` } json.NewDecoder(res.Body).Decode(&created) fmt.Println(created.Endpoint.ID, created.Secret[:6]) // whe_... whsec_: keep the whole secret in OCTANEAI_WEBHOOK_SECRET}The event the endpoint will receive:
{ "id": "evt_6ba7b8109dad11d180b400c04fd430c8", "type": "quiz.published", "created": "2026-09-03T09:00:00Z", "api_version": "v1", "data": { "object": { "quiz_id": "quiz_7c9e6679742540de944be07fc1f90ae7", "quiz_name": "Find your routine", "version_id": "ver_15ce0be06b904d0180b1155da02a6c6f", "version_number": 2, "published_at": "2026-09-03T09:00:00Z" } }}2. Snapshot the funnel when a publish arrives
The receiver verifies the signature as Verify a webhook signature does, then reads the funnel for the seven days before the publish and writes baseline-<quiz_id>.json (Flask and httpx, pip install flask httpx; Express; Go’s net/http):
import base64import datetime as dtimport hashlibimport hmacimport jsonimport osimport timefrom zoneinfo import ZoneInfoimport httpxfrom flask import Flask, request
app = Flask(__name__)SECRET = os.environ["OCTANEAI_WEBHOOK_SECRET"] # whsec_...HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}API = "https://api.octaneai.com/v1"TZ = "America/New_York" # the timezone the daily job uses too
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): pass return False
def snapshot(published: dict) -> None: day = dt.datetime.fromisoformat(published["published_at"].replace("Z", "+00:00")).astimezone(ZoneInfo(TZ)).date() r = httpx.get(f"{API}/quizzes/{published['quiz_id']}/analytics/funnel", headers=HEADERS, timeout=30, params={"from": day - dt.timedelta(days=7), "to": day - dt.timedelta(days=1), "tz": TZ}) r.raise_for_status() funnel = r.json() baseline = { "quiz_id": published["quiz_id"], "quiz_name": published["quiz_name"], "version_number": published["version_number"], "published_day": day.isoformat(), "starts": funnel["starts"], "pages": {p["page_key"]: {"title": p["title"], "dropoff_share": p["dropoff_share_of_starts"]} for p in funnel["pages"] if not p["removed"]}, } with open(f"baseline-{published['quiz_id']}.json", "w") as f: json.dump(baseline, f) print("baseline", published["quiz_id"], "version", published["version_number"], funnel["starts"], "starts") # baseline quiz_... version 2 1840 starts
@app.post("/octane")def octane(): if not verify(SECRET, request.headers, request.get_data()): return "bad signature", 401 event = request.get_json() if event["type"] == "quiz.published": snapshot(event["data"]["object"]) # in production: enqueue, then answer; see the signature recipe return "", 200
if __name__ == "__main__": app.run(port=8080)import express from "express";import { createHmac, timingSafeEqual } from "node:crypto";import { writeFileSync } from "node:fs";
const SECRET = process.env.OCTANEAI_WEBHOOK_SECRET!; // whsec_...const HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}` };const API = "https://api.octaneai.com/v1";const TZ = "America/New_York"; // the timezone the daily job uses too
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;}
const dayIn = (date: Date) => new Intl.DateTimeFormat("en-CA", { timeZone: TZ }).format(date); // YYYY-MM-DD
async function snapshot(published: { quiz_id: string; quiz_name: string; version_number: number; published_at: string }) { const at = new Date(published.published_at); const qs = new URLSearchParams({ from: dayIn(new Date(at.getTime() - 7 * 86400000)), to: dayIn(new Date(at.getTime() - 86400000)), tz: TZ }); const res = await fetch(`${API}/quizzes/${published.quiz_id}/analytics/funnel?${qs}`, { headers: HEADERS }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const funnel = await res.json(); const pages: Record<string, { title: string; dropoff_share: number | null }> = {}; for (const p of funnel.pages) if (!p.removed) pages[p.page_key] = { title: p.title, dropoff_share: p.dropoff_share_of_starts }; const baseline = { quiz_id: published.quiz_id, quiz_name: published.quiz_name, version_number: published.version_number, published_day: dayIn(at), starts: funnel.starts, pages }; writeFileSync(`baseline-${published.quiz_id}.json`, JSON.stringify(baseline)); console.log("baseline", published.quiz_id, "version", published.version_number, funnel.starts, "starts"); // baseline quiz_... version 2 1840 starts}
const app = express();app.post("/octane", express.raw({ type: "application/json" }), async (req, res) => { const headers = { "webhook-id": req.get("webhook-id"), "webhook-timestamp": req.get("webhook-timestamp"), "webhook-signature": req.get("webhook-signature") }; if (!verify(SECRET, headers, req.body)) return res.status(401).send("bad signature"); const event = JSON.parse(req.body.toString("utf8")); if (event.type === "quiz.published") await snapshot(event.data.object); // in production: enqueue, then answer res.sendStatus(200);});app.listen(8080);package main
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "log" "math" "net/http" "net/url" "os" "strconv" "strings" "time")
const API = "https://api.octaneai.com/v1"const TZ = "America/New_York" // the timezone the daily job uses too
var secret = os.Getenv("OCTANEAI_WEBHOOK_SECRET") // whsec_...
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}
type Published struct { QuizID string `json:"quiz_id"` QuizName string `json:"quiz_name"` VersionNumber int `json:"version_number"` PublishedAt string `json:"published_at"`}
type Page struct { Title string `json:"title"` DropoffShare *float64 `json:"dropoff_share"`}
type Baseline struct { QuizID string `json:"quiz_id"` QuizName string `json:"quiz_name"` VersionNumber int `json:"version_number"` PublishedDay string `json:"published_day"` Starts int `json:"starts"` Pages map[string]Page `json:"pages"`}
func snapshot(p Published) { loc, _ := time.LoadLocation(TZ) at, _ := time.Parse(time.RFC3339, p.PublishedAt) day := at.In(loc) q := url.Values{"from": {day.AddDate(0, 0, -7).Format("2006-01-02")}, "to": {day.AddDate(0, 0, -1).Format("2006-01-02")}, "tz": {TZ}} req, _ := http.NewRequest("GET", API+"/quizzes/"+p.QuizID+"/analytics/funnel?"+q.Encode(), nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil || res.StatusCode != 200 { log.Println("funnel read failed", err) return } defer res.Body.Close() var funnel struct { Starts int `json:"starts"` Pages []struct { PageKey string `json:"page_key"` Title string `json:"title"` Removed bool `json:"removed"` DropoffShare *float64 `json:"dropoff_share_of_starts"` } `json:"pages"` } json.NewDecoder(res.Body).Decode(&funnel) b := Baseline{QuizID: p.QuizID, QuizName: p.QuizName, VersionNumber: p.VersionNumber, PublishedDay: day.Format("2006-01-02"), Starts: funnel.Starts, Pages: map[string]Page{}} for _, page := range funnel.Pages { if !page.Removed { b.Pages[page.PageKey] = Page{Title: page.Title, DropoffShare: page.DropoffShare} } } out, _ := json.Marshal(b) os.WriteFile("baseline-"+p.QuizID+".json", out, 0o644) fmt.Println("baseline", p.QuizID, "version", p.VersionNumber, funnel.Starts, "starts") // baseline quiz_... version 2 1840 starts}
func octane(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) if !verify(secret, r.Header, body) { http.Error(w, "bad signature", http.StatusUnauthorized) return } var event struct { Type string `json:"type"` Data struct { Object Published `json:"object"` } `json:"data"` } json.Unmarshal(body, &event) if event.Type == "quiz.published" { go snapshot(event.Data.Object) // in production: enqueue durably, then answer; see the signature recipe } w.WriteHeader(http.StatusOK)}
func main() { http.HandleFunc("/octane", octane) http.ListenAndServe(":8080", nil)}3. Compare, once a day
For every baseline younger than seven days, read the funnel from the publish day to today and compare page by page:
import datetime as dtimport globimport jsonimport osfrom zoneinfo import ZoneInfoimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}API = "https://api.octaneai.com/v1"SLACK = os.environ["SLACK_WEBHOOK_URL"]TZ = "America/New_York"MIN_STARTS, MIN_POINTS = 100, 5.0 # enough starts to mean it; a rise in the share of starts lost, in points
today = dt.datetime.now(ZoneInfo(TZ)).date()alerts = []for path in glob.glob("baseline-*.json"): with open(path) as f: before = json.load(f) published = dt.date.fromisoformat(before["published_day"]) if (today - published).days > 7: continue r = httpx.get(f"{API}/quizzes/{before['quiz_id']}/analytics/funnel", headers=HEADERS, timeout=30, params={"from": published, "to": today, "tz": TZ}) r.raise_for_status() after = r.json() print(before["quiz_name"], "version", before["version_number"], "starts before", before["starts"], "after", after["starts"]) if after["starts"] < MIN_STARTS: continue for page in after["pages"]: if page["removed"] or page["dropoff_share_of_starts"] is None: continue was = before["pages"].get(page["page_key"]) if was is None: print(f" new page {page['page_key']} ({page['title']}): {page['dropoff_share_of_starts']}% of starts leave here") continue rise = page["dropoff_share_of_starts"] - (was["dropoff_share"] or 0) print(f" {page['page_key']} {was['dropoff_share']}% -> {page['dropoff_share_of_starts']}%") if rise >= MIN_POINTS: alerts.append(f"{before['quiz_name']} v{before['version_number']}: page '{page['title']}' ({page['page_key']}) now loses " f"{page['dropoff_share_of_starts']:.1f}% of starts, was {was['dropoff_share']:.1f}% the week before the publish.")if alerts: httpx.post(SLACK, json={"text": "\n".join(alerts)}, timeout=30).raise_for_status()print(len(alerts), "alerts")import { readdirSync, readFileSync } from "node:fs";
const HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}` };const API = "https://api.octaneai.com/v1";const SLACK = process.env.SLACK_WEBHOOK_URL!;const TZ = "America/New_York";const MIN_STARTS = 100, MIN_POINTS = 5; // enough starts to mean it; a rise in the share of starts lost, in points
const today = new Intl.DateTimeFormat("en-CA", { timeZone: TZ }).format(new Date()); // YYYY-MM-DDconst alerts: string[] = [];for (const path of readdirSync(".").filter((f) => /^baseline-.*\.json$/.test(f))) { const before = JSON.parse(readFileSync(path, "utf8")); if ((Date.parse(today) - Date.parse(before.published_day)) / 86400000 > 7) continue; const qs = new URLSearchParams({ from: before.published_day, to: today, tz: TZ }); const res = await fetch(`${API}/quizzes/${before.quiz_id}/analytics/funnel?${qs}`, { headers: HEADERS }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const after = await res.json(); console.log(before.quiz_name, "version", before.version_number, "starts before", before.starts, "after", after.starts); if (after.starts < MIN_STARTS) continue; for (const page of after.pages) { if (page.removed || page.dropoff_share_of_starts === null) continue; const was = before.pages[page.page_key]; if (!was) { console.log(` new page ${page.page_key} (${page.title}): ${page.dropoff_share_of_starts}% of starts leave here`); continue; } const rise = page.dropoff_share_of_starts - (was.dropoff_share ?? 0); console.log(` ${page.page_key} ${was.dropoff_share}% -> ${page.dropoff_share_of_starts}%`); if (rise >= MIN_POINTS) alerts.push(`${before.quiz_name} v${before.version_number}: page '${page.title}' (${page.page_key}) now loses ${page.dropoff_share_of_starts.toFixed(1)}% of starts, was ${(was.dropoff_share ?? 0).toFixed(1)}% the week before the publish.`); }}if (alerts.length) await fetch(SLACK, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: alerts.join("\n") }) });console.log(alerts.length, "alerts");package main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "net/url" "os" "path/filepath" "strings" "time")
const API = "https://api.octaneai.com/v1"const TZ = "America/New_York"const MIN_STARTS, MIN_POINTS = 100, 5.0 // enough starts to mean it; a rise in the share of starts lost, in points
type Page struct { Title string `json:"title"` DropoffShare *float64 `json:"dropoff_share"`}
type Baseline struct { QuizID string `json:"quiz_id"` QuizName string `json:"quiz_name"` VersionNumber int `json:"version_number"` PublishedDay string `json:"published_day"` Starts int `json:"starts"` Pages map[string]Page `json:"pages"`}
func share(p *float64) float64 { if p == nil { return 0 } return *p}
func main() { loc, _ := time.LoadLocation(TZ) today := time.Now().In(loc).Format("2006-01-02") files, _ := filepath.Glob("baseline-*.json") alerts := []string{} for _, path := range files { raw, _ := os.ReadFile(path) var before Baseline json.Unmarshal(raw, &before) published, _ := time.ParseInLocation("2006-01-02", before.PublishedDay, loc) if time.Now().In(loc).Sub(published).Hours() > 7*24 { continue } q := url.Values{"from": {before.PublishedDay}, "to": {today}, "tz": {TZ}} req, _ := http.NewRequest("GET", API+"/quizzes/"+before.QuizID+"/analytics/funnel?"+q.Encode(), nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } if res.StatusCode != 200 { log.Fatalf("status %d", res.StatusCode) } var after struct { Starts int `json:"starts"` Pages []struct { PageKey string `json:"page_key"` Title string `json:"title"` Removed bool `json:"removed"` DropoffShare *float64 `json:"dropoff_share_of_starts"` } `json:"pages"` } json.NewDecoder(res.Body).Decode(&after) res.Body.Close() fmt.Println(before.QuizName, "version", before.VersionNumber, "starts before", before.Starts, "after", after.Starts) if after.Starts < MIN_STARTS { continue } for _, page := range after.Pages { if page.Removed || page.DropoffShare == nil { continue } was, ok := before.Pages[page.PageKey] if !ok { fmt.Printf(" new page %s (%s): %.1f%% of starts leave here\n", page.PageKey, page.Title, *page.DropoffShare) continue } fmt.Printf(" %s %.1f%% -> %.1f%%\n", page.PageKey, share(was.DropoffShare), *page.DropoffShare) if *page.DropoffShare-share(was.DropoffShare) >= MIN_POINTS { alerts = append(alerts, fmt.Sprintf("%s v%d: page '%s' (%s) now loses %.1f%% of starts, was %.1f%% the week before the publish.", before.QuizName, before.VersionNumber, page.Title, page.PageKey, *page.DropoffShare, share(was.DropoffShare))) } } } if len(alerts) > 0 { msg, _ := json.Marshal(map[string]string{"text": strings.Join(alerts, "\n")}) http.Post(os.Getenv("SLACK_WEBHOOK_URL"), "application/json", bytes.NewReader(msg)) } fmt.Println(len(alerts), "alerts")}The alert reads: Find your routine v2: page 'Your skin' (skin) now loses 19.4% of starts, was 11.1% the week before the publish.
Errors to handle
| Status | Why | What to do |
|---|---|---|
401 from your receiver | The signature did not verify: a wrong secret, a replay older than five minutes, or a body you modified before hashing. | Compare the raw bytes; rotate the secret from the dashboard if it leaked. |
403 insufficient_scope | The key lacks webhooks:manage (subscribe) or analytics:read (funnel). | Mint a key with both. |
404 not_found | The quiz was deleted between the publish and the comparison. | Drop its baseline. |
422 validation_error | to before from (a publish dated in the future by a wrong tz), or country on funnel, which takes no country filter. | Use one tz in both jobs. |
429 rate_limited | Many quizzes published the same week. | Sleep for Retry-After and continue; every read is idempotent. |
Things to know
- What
dropoff_share_of_startsis.dropoffscounts the visits that ended on the page: the shopper closed the quiz, went idle or started again. The share divides it by the quiz’sstartsin the range so pages and ranges of different sizes compare; a shopper can leave the first page without answering, which counts as a drop-off but not as a start, so a busy first page can read above 100. It is the number to compare;dropoffsalone grows with traffic. - Two ranges, one timezone. The baseline is the seven closed days before the publish day; the comparison runs from the publish day to today (today is read live). Both jobs send the same
tz, or the publish day lands in different days on each side. The publish day itself mixes both versions; a job that runs from the day after is stricter. - A second publish resets the baseline. Each
quiz.publishedoverwritesbaseline-<quiz_id>.json, so the comparison always reads against the version before the latest one. The quiz overview’smarkers[]lists every publish with its day if you want the history. - Pages that changed. A page the new version no longer has answers
removed: trueand is skipped; a page the old version did not have has no baseline and is reported as new rather than compared. A renamed page keeps itspage_key, so it is compared normally. - A new endpoint is
pendinguntil its ping lands. Events are queued only toactiveendpoints: the endpoint answers the verification ping with a2xxfirst (GET /v1/webhooks/{endpoint_id}showsstatus), so start the receiver before you subscribe, or a publish made in between is not delivered. Creating an endpoint with a URL you already registered answers200with the existing endpoint instead of a second one. - Deliveries are at least once. A redelivery of the same publish rewrites the same baseline with the same numbers, so the receiver needs no dedup; keep the
webhook-idcheck from the signature recipe if the handler ever does more. - Cost. A funnel read is 3 units; the snapshot and the daily comparison are one read per published quiz.
