Appearance
Insights
Behavioral analytics computed from your event stream — included on every plan. Dashboard → Insights, or the API.
Subscriber growth
New subscribers per whole UTC day or Monday-based week, with the running total at the end of each bucket:
GET /v1/apps/:appId/insights/subscriber-growth?days=30&interval=daypoints covers every bucket in the window (1–365 days), zero-filled, the last one flagged partial because it is still in progress; added is the window's total, previousAdded the preceding window of equal elapsed length (null while the app is younger than that), and subscribers the count now. Subscribers are rows, not events, so events retention never trims this series; erasure does, because it deletes the row — an erased subscriber leaves every bucket they were in, so the series is today's audience by join date rather than a frozen history. Dashboard → Insights → "Subscriber growth".
When people engage (hour-of-day heatmap)
Deliveries, opens and clicks by day of week × hour of day, each event placed at the subscriber's own local time from their stored timezone:
GET /v1/apps/:appId/insights/engagement-heatmap?days=30The answer is all 168 cells (dow 0 = Monday, hour 0–23, each with delivered, opened, clicked), zero-filled, for the last days days ending now (1–90). Rows whose subscriber has no timezone — or that have no subscriber at all — sit on the UTC clock, and utcFallback says how many that was, so you can tell a real pattern from a clock artefact.
These are counts, not rates: the clicks at 12:00 are not the clicks of the deliveries at 12:00, so read clicks against delivered — a peak that mirrors your send hour is you, not them. opened arrives from email (the open pixel) and from channels with read receipts (WhatsApp); push and web report delivered and clicked, email reports clicks through its tracked links, and WhatsApp reports them when a person taps a quick-reply button, a reply button or a list row on a message you sent. Delivery events are counted as recorded (a push logs one on provider accept and one when the device displays it).
Dashboard → Insights → "When people engage" draws it as a grid with a clicks / opens / deliveries switch. Optimal-time delivery already sends to each person at their own best hour; this is the aggregate picture for scheduling everything else.
Delivery failures (dead letters)
What did not go out after every retry, why, and whether it has since:
GET /v1/apps/:appId/insights/delivery-failures?days=7&limit=50The worker retries a failing send through the queue's backoff. When the queue says an attempt is its last, every target that still could not be sent is recorded as failed — a failed event on the person's timeline and a row here — with the reason: throttled (the provider asked us to slow down for longer than the retries lasted), provider_error, timeout (no answer in time), transport_error (the provider could not be reached), config_error (the channel is not configured for the app, or its content could not be built — yours to fix) or rate_limited (your org's fair share of the platform send rate was exhausted for the whole retry window; a redrive sends them). An ordinary retry is never a failure; only exhaustion is. Under Pub/Sub the delivery-attempt counter is approximate, so very rarely a failure is filed one attempt early — a later attempt that lands closes it.
open failures have had nothing land since. The chunk still dead-letters on our side, and when an operator redrive later delivers or bounces the send, its row is stamped recoveredAt — so "failed" in a campaign's funnel (failed on GET /campaigns/:id/stats, in /campaigns/stats, the CSV export and the delivery-issues list) always means retries exhausted and still not delivered, never a failure that was quietly fixed an hour later. timeout sends are not repeated (a repeat could double-send), so they stay open; treat them as unknown. byReason and byLane count open failures in the window; recent lists the newest limit rows (1–200) open and recovered alike, each with its lane, campaign / journey / notification id, subscriber, device, channel, reason, the provider's message (truncated) and the attempts made. Rows are kept for the app's events retention.
Dashboard → Insights → "Delivery failures" shows the last seven days. Outbound webhooks can subscribe to message.failed, and campaign.chunk_processed now carries counts.failed.
Channel health
Per-channel delivery health over a trailing window — what a serious sender checks daily:
GET /v1/apps/:appId/insights/channel-health?days=30For each channel of the devices receipts landed on: delivered, bounced, suppressed, failed (retries exhausted with nothing landed since, from the dead-letter ledger — a channel that is failing outright, nothing delivered or bounced, still shows) and bounceRate = bounced / (delivered + bounced). Sustained bounce rates above 5% usually mean stale tokens or list quality. Dashboard → Insights → "Channel health".
Funnels
Sequential conversion between 2–5 events, ordered and windowed — step N counts subscribers who did it after their first step-N−1 event, within the window:
GET /v1/apps/:appId/insights/funnel?steps=signup,activate,purchase&window=72&days=30Cohort retention
Weekly acquisition cohorts × weekly activity — the classic retention grid:
GET /v1/apps/:appId/insights/cohorts?weeks=8RFM segments
Recency / Frequency / Monetary scoring over 90 days of activity (monetary = meta.amount on purchase events), quintile-scored and mapped to the classic segment names — Champions, Loyal, Promising, At risk, Hibernating, Needs attention:
GET /v1/apps/:appId/insights/rfm?monetaryEvent=purchaseCampaign-level measurement
- Funnel per campaign:
GET /campaigns/:id/stats - Several at once, for a side-by-side:
GET /campaigns/stats?ids=a,b,c(up to ten; ids not in the app are omitted) — Dashboard → Campaigns → tick rows → "Compare ticked" - Everything as a spreadsheet:
GET /campaigns/export(text/csv, optional?status=) — one row per campaign with message, audience, status, channels, schedule and the rollup funnel with rates; figures are the scheduler's rollups, refreshed within a minute of activity. Cells are quoted and formula triggers defused, so a message titled=HYPERLINK(...)opens as text. Dashboard → Campaigns → "Export CSV" - A/B variants + winner:
GET /campaigns/:id/variants - Conversion attribution:
GET /campaigns/:id/outcomes?event=purchase&window=24 - Journey stats:
GET /journeys/:id/stats
Predictive bands
The scheduler refreshes these auto-managed tags hourly — churn/engagement from the last 90 days of events, CLV/propensity from the last 180 days of purchase events (the same meta.amount revenue signal RFM uses):
| Tag | Bands / value | Meaning |
|---|---|---|
_churn_risk | low / medium / high | Last activity within 7 days / 7–21 days / silent 21+ days |
_engagement | high / medium / low | ≥8 opens+clicks in 90d / 2–7 / ≤1 |
_clv | numeric | Predicted 12-month value. Data-rich apps (≥30 buyers, ≥10 repeaters) are scored by a fitted BG/NBD model (Fader–Hardie "Buy Till You Die": expected future purchases × the buyer's own order value); thinner apps use a transparent heuristic (avg-order-value × purchases/month × a recency-banded horizon) until they graduate |
_clv_band | high / medium / low | Per-app CLV quintiles: top 20% / next 40% / bottom 40% |
_purchase_propensity | high / medium / low | Repeat-buy likelihood. Model path: P(alive) + expected purchases from the fit (high requires having actually repeated); heuristic path: purchase recency + frequency |
Subscribers with no activity at all get no engagement/churn bands, and non-buyers get no CLV/propensity — you can't churn from, or predict the value of, a relationship that never started.
Because they're ordinary tags, they work everywhere tags do with no new syntax: segment filters ({ key: "_churn_risk", op: "eq", value: "high" } targets a win-back campaign), journey condition branches, and Liquid. GET /v1/apps/:id/insights/predictive returns the band distribution, also shown on the dashboard's Insights page. The banding is transparent statistics, not a black box — thresholds are documented above and will evolve behind the same tags.
Warehouse export (BigQuery)
Postgres stays the system of record; set EVENTS_BIGQUERY_DATASET + EVENTS_BIGQUERY_PROJECT (or GOOGLE_CLOUD_PROJECT) on the ingest and worker services and every event — SDK beacons, custom events, delivery receipts — is also streamed to BigQuery dataset.events (columns: app_id, type, subscriber_id, device_id, campaign_id, ts, meta). Mirroring is best-effort by design: a warehouse outage never fails ingestion.
Backfill history with pnpm backfill-bigquery [--app id] [--days 90]. Create the table first:
sql
CREATE TABLE wingblaze.events (
app_id STRING, type STRING, subscriber_id STRING, device_id STRING,
campaign_id STRING, ts TIMESTAMP, meta STRING
) PARTITION BY DATE(ts);