Analytics
Events
| Event | Written by | When |
|---|---|---|
sent | worker | the transport accepted the push (APNs 200, FCM 200, push service 201) |
failed | worker | the transport rejected it after all retries; meta.code = provider code, meta.invalidated = whether the subscription was killed |
skipped | worker | a delivery rule held it back; meta.reason = frequency_cap / quiet_hours |
delivered | SDK | iOS NSE / Android handler / service worker push |
opened | SDK | body tapped |
clicked | SDK | action button tapped |
dismissed | SDK | swiped away — Android and web only; iOS does not report it |
Events are stored in a MongoDB time-series collection (ts, campaignId meta, seconds granularity) with a TTL of EVENT_RETENTION_DAYS (180). Each event also increments the campaign's stats counters (and stats.variants.<id> for A/B), so reports never scan the collection for totals.
How the SDK pings arrive
Every push carries a tracking id:
base64url("appId|campaignId|userId|subscriptionId|platform|variant") . hmac16
POST /v1/e/<d|o|c|x>/<msgId> verifies the HMAC, writes the event, bumps the counters and emits a webhook — no database lookup, no API key. Rate-limited per IP.
Rates
| Rate | Formula | Why |
|---|---|---|
| Delivery rate | delivered / sent | Of what the provider accepted, how much reached a device. |
| Open rate | opened / delivered | A push that never arrived cannot be opened. Dividing by sent — as many tools do — understates every campaign and hides delivery problems inside engagement numbers. |
| Click rate | clicked / delivered | Buttons only. |
null (shown as —) when the denominator is zero.
Where the numbers show
| Place | Content |
|---|---|
| App overview | active subscribers by platform, 7-day sent/delivered/failed, 30-day growth, recent campaigns |
| Campaign report | tiles, funnel, hourly series, A/B table, failure codes, skip reasons, platform split |
GET /v1/campaigns/:id/stats | stats + rates for your own dashboards |
| Webhooks | every event in near real time, to your warehouse |
Platform caveats
| Platform | delivered | dismissed | Notes |
|---|---|---|---|
| iOS | needs the Notification Service Extension | ❌ | Without the NSE, iOS delivery rate reads 0 % while opens still count. |
| Android | ✅ | ✅ | Data-only messages make this possible; a force-stopped app cannot report. |
| Web | ✅ | ✅ | The service worker reports both. Safari has no buttons, so no clicked. |
Export
- Per campaign: Download events CSV →
ts, type, platform, userId, subscriptionId, reason, code, invalidated. - Users and subscriptions: CSV from their list pages, honouring the current filter.
- Tokens and endpoints are never in any export.
For everything else, the events collection is yours to query ({ appId, campaignId, type, ts } is the shape); or stream it out with webhooks as it happens.