Events & catalogue
How events flow in and how the catalogue is built from them.
#Sending an event from the SDK
Call Pushlane.track() anywhere after Pushlane.identify(). The SDK builds an EventEnvelope with a fresh UUID event ID, a W3C traceparent, the current timestamp, and a full context block (app version, OS version, timezone, locale, install environment), then ships it to the ingest endpoint.
// Minimal — no properties
Pushlane.track("paywall_viewed")
// With typed properties
Pushlane.track("paywall_viewed", [
"placement": "onboarding",
"paywall_id": "pw_annual_v2"
])Pushlane.identify(yourUserId) before Pushlane.track(). Events sent before an identity is set are dropped and a one-time warning is printed to the Xcode console.Property value types — the Swift PushlaneValue enum accepts:
| Swift literal / case | Catalogue type | Stored column |
|---|---|---|
PushlaneValue.string("…") | string | prop_str |
PushlaneValue.int(42) | number | prop_num |
PushlaneValue.double(3.14) | number | prop_num |
PushlaneValue.bool(true) | boolean | prop_bool |
PushlaneValue.array([…]) | array | properties (raw JSON) |
String, integer, float, and boolean literals are converted automatically, so you rarely need to write PushlaneValue.string(…) explicitly.
#Auto-emitted events
Two lifecycle events are emitted by the SDK itself — do not re-instrument them:
| Event name | When it fires | Properties |
|---|---|---|
app_open | Cold launch — emitted once per process by PushlaneInApp.start(). Distinct from session_started: the two never double-count. | session_id: string |
session_started | Foreground return after ≥30 seconds in the background. A quick app-switch does not trigger it. | session_id: string |
Both events carry context.appVersion and context.osVersion, which the ingest worker projects onto the user as targetable attributes (app_version, os_version, install_env) in ClickHouse. These attributes power the audience builder's version picker.
PushlaneInApp.start() inside application(_:didFinishLaunchingWithOptions:) after Pushlane.configure(…) and Pushlane.identify(…) to get the app_open event on the first launch.Three more names are written by Pushlane's own clients when a user interacts with a notification — opened, dismissed and received. They are reserved, and they are documented in the next section.
#Notification interaction events
These are the events that measure what happened after a push left Pushlane. They are reserved names: the iOS SDK and the drop-in clients emit them wherever the platform lets them — which is not everywhere, so read the availability table below before you assume a number exists. Where a client does emit one, do not re-instrument it; and never rename these events, whichever way they reach Pushlane.
| Event name | When it fires | Properties |
|---|---|---|
opened | The user tapped the notification — cold-launch tap, warm tap, or a tap on a foreground banner. The same tap is only ever counted once. | message_id: string, flow_id: string, node_id: string |
dismissed | The user explicitly cleared the notification (Clear / ✕ / Clear All). Apple reports explicit clears only — never a swipe-away, an ignore, or an OS auto-clear — so dismiss counts always under-report. | message_id: string, flow_id: string, node_id: string |
received | The push arrived on the device, before any interaction. Emitted from the native iOS Notification Service Extension, which runs in a separate process — see the availability table below. | message_id: string, flow_id: string, node_id: string |
push_registration_failed | Registering the device for push did not produce a token. Emitted instead of failing silently, so a zero-device app is visible in the catalogue. | reason: string — permission_denied, not_configured, or token_unavailable from a drop-in client; the iOS SDK sends error: string instead. |
Pushlane stamps message_id, flow_id and node_id at the top level of every push payload it sends, alongside aps. Reading those keys back on the interaction event is the entire attribution mechanism.
message_id is what makes an open countable. The reports open rate joins sends to opened events on properties['message_id'] = the send's decision ID. An opened event without message_id — or an interaction hand-rolled under any other name (notification_opened, push_tapped, …) — cannot be matched to a send. It is not counted as a miss: it is invisible, and the open rate reads as if that tap never happened. Use the client's built-in tracking, or Pushlane.handleNotificationOpen(…) on the drop-in clients, which fills the three properties for you. There is no Pushlane.trackOpen().Availability per client. Open tracking landed in drop-in client v1.3.0 — if you pasted an earlier drop-in, re-copy it from the quickstart. What each client can actually report:
| Client | opened | dismissed | received |
|---|---|---|---|
Native iOS SDK (PushlanePush) | Automatic from PushlanePush.register() — unless your app already owns the notification-center delegate (see below) | Automatic — explicit clears only | Only with the PushlaneNotificationService extension installed |
| Expo drop-in v1.3.0+ | Automatic from Pushlane.configure() — cold start and warm taps | iOS explicit clears only (Android has no dismiss signal) | Not available |
| React Native drop-in v1.3.0+ | Android automatic. iOS not reachable — manual only | Not available | Not available |
| Flutter drop-in v1.3.0+ | Wired, but nothing arrives yet — see the delivery note below | Not available | Not available |
| Android (Kotlin) drop-in v1.3.0+ | Manual — you must call Pushlane.handleNotificationOpen(intent) | Not available | Not available |
@react-native-firebase/messaging only claims the iOS notification-response delegate for Firebase-marked notifications, and Pushlane delivers raw APNs — so an iOS tap never reaches the drop-in. It warns once at runtime rather than leaving the gap silent; the metric side stays honest too, reporting "open tracking isn't reporting yet" rather than a fabricated 0%. Call Pushlane.handleNotificationOpen(response) from your own notification handler, or use the native iOS SDK.Intent's extras. Call Pushlane.handleNotificationOpen(intent) in both Activity.onCreate and Activity.onNewIntent, or taps from one of the two paths are lost. An Intent with no message_id is ignored, so an ordinary app launch logs nothing.received is native iOS only. Measuring arrival requires a Notification Service Extension — a separate app target that runs in its own process. That cannot be pasted into a single-file drop-in, so no drop-in client emits received. Without it, a push that was delivered but never touched leaves no trace at all.UNUserNotificationCenter.delegate, the SDK's opened / dismissed events do not fire — track them yourself from your delegate, copying message_id / flow_id / node_id out of response.notification.request.content.userInfo into a Pushlane.track("opened", …) call. The reports join needs nothing more than the name and message_id.#Recommended event taxonomy
These are the high-signal events that unlock the most useful flows for subscription apps. Use the exact snake_case names — the catalogue types them automatically on first sight.
| Event name | When to track | Key properties |
|---|---|---|
signup_completed | Account created | method: string |
onboarding_completed | First-run onboarding finished | duration_sec: number |
paywall_viewed | A paywall is shown to the user | placement: string (required), paywall_id: string |
trial_started | A free trial began (often via RevenueCat webhook) | plan: string (required), product_id: string |
subscription_started | First paid period began | plan: string (required), product_id: string, price_usd: number |
subscription_renewed | A paid period renewed | plan: string |
subscription_cancelled | Churn signal — auto-renew turned off or plan expired | plan: string, reason: string |
lesson_finished | Your app's core action. Rename to fit: workout_finished, recipe_saved, … | lesson_id: string, duration_sec: number |
Revenue events arrive on their own once the RevenueCat integration is connected — you do not need to call Pushlane.track() for trials, purchases, renewals or cancels. They land under their own revenuecat. names, though: revenuecat.trial_started, revenuecat.initial_purchase, revenuecat.cancellation, and so on. A flow triggering on the bare trial_started above will not fire from RevenueCat — pick the revenuecat. name in the trigger picker instead, or track your own event if you want one name for both paths.
#Mirror your existing analytics into Pushlane.track
If your app already calls an analytics library (analytics.track, logEvent, Amplitude / Mixpanel / Segment), the fastest way to give Pushlane real signal is to mirror those calls into Pushlane.track additively — keep your existing analytics, and add a Pushlane.track alongside each call. Three rules keep the mirror honest:
- Add, don't replace. Your analytics keeps working; Pushlane just also receives the event.
- Rename reserved names.
message_sentis written server-side by Pushlane (delivery telemetry) and can never trigger a flow, so a chat app's own event must be renamed (e.g.chat_message_sent). Otherwise your real event is silently un-triggerable and under-counted. - Skip the auto-emitted names.
app_openandsession_startedare already emitted by the SDK (or a drop-in client'sstart()). Mirroring an “App Opened” / “Session Start” from your analytics would double-count. - Never hand-roll notification interactions.
opened,dismissedandreceivedare emitted for you (see above), and the reports open rate matches them onmessage_id. A “Push Opened” mirrored from your analytics under its own name carries nomessage_id, so it is invisible to the metric — and a second listener under the right name would log two events per tap.
// existing analytics — keep it
analytics.track('paywall_viewed', { placement: 'home' });
// mirror into Pushlane, additively
Pushlane.track('paywall_viewed', { placement: 'home' });
// a reserved name MUST be renamed for Pushlane:
// analytics.track('message_sent', …) → Pushlane.track('chat_message_sent', …)
// do NOT mirror app_open / session_started — the SDK already emits them.
// do NOT hand-roll a push-open event either. This one is INVISIBLE to open rate:
// analytics.track('Push Opened', { campaign: 'trial_day_3' });
// Pushlane's client already emits 'opened' with message_id / flow_id / node_id.Reconcile every event name and property type you mirror against the catalogue below so you reuse existing types instead of creating drift. The Pushlane MCP's plan_instrumentation tool does this reconciliation for you — it flags reserved-name collisions and auto-emitted duplicates against your live catalogue.
#Sending events from a server (HTTP API)
You can also POST events directly from your backend — useful for server-side billing events or webhooks. The shape is identical to what the SDK sends:
curl -X POST https://ingest.pushlane.io/v1/events \
-H "Authorization: Bearer lpk_live_…" \
-H "Content-Type: application/json" \
-d '{
"externalId": "user_abc123",
"name": "subscription_started",
"properties": {
"plan": "annual",
"product_id": "com.yourapp.annual",
"price_usd": 49.99
},
"occurredAt": 1719878400000
}'eventId and traceparent are optional on server calls — the ingest worker generates them when absent. occurredAt is epoch milliseconds. The context block is also optional from a server.#How the catalogue works
The catalogue is the typed registry of event names and property names for your tenant. It fills in automatically as events arrive — no manual schema step is required.
When the queue consumer processes an event:
- If the event name has never been seen, it is upserted into
catalogue_eventswithdeclared=false. - Properties are coerced against any
declared=trueentry incatalogue_properties. If no declared type exists, the raw value is stored and the property is surfaced as undeclared. - You can promote any auto-discovered event or property to
declared=truein the dashboard and set an explicit type. Once declared, mismatches produce a coercion warning in the trace log — the event is never dropped.
| Catalogue type | Maps to column | Swift type(s) |
|---|---|---|
| string | prop_str | String |
| number | prop_num | Int, Double |
| boolean | prop_bool | Bool |
| datetime | prop_dt | Int (epoch ms) |
| array | properties (raw JSON) | Array |
The catalogue is also the data source for the Audience builder and the flow condition editor — a property only appears in those pickers once it has been seen at least once in an event.
#Property kinds
The catalogue distinguishes two kinds of properties:
| Kind | What it is | Example |
|---|---|---|
event_property | Belongs to a specific event name — scoped to the event in the catalogue. | placement on paywall_viewed |
user_attribute | A persistent trait on the user, not tied to a single event. Written via setAttributes or POST /v1/attributes. Three are auto-populated: app_version, os_version, install_env. | first_name |
User attributes are what {{ name | fallback }} tokens in notification copy resolve against at send time. An event property is scoped to one occurrence and is not available as a token.
#Writing user attributes
Call Pushlane.setAttributes after Pushlane.identify to persist traits you want to use in notification personalisation — first name, onboarding answers, plan, preferences. Values are written most-recent-wins.
// Drop-in clients (Expo / React Native / Flutter / Android)
Pushlane.setAttributes({
first_name: user.firstName, // string
plan: 'growth', // string
trial_days_left: 7, // number
});
// null explicitly unsets an attribute (stops it from resolving in templates):
Pushlane.setAttributes({ trial_days_left: null });// iOS SDK — literals infer their PushlaneValue case; variables use the
// explicit case (.string(...) / .int(...)).
Pushlane.setAttributes([
"first_name": .string(user.firstName),
"plan": "growth", // literal → .string
"trial_days_left": 7, // literal → .int
])From a server, use POST /v1/attributes. The same Bearer write-key authenticates the request, and the same externalId ties the attributes to the user:
curl -X POST https://ingest.pushlane.io/v1/attributes \
-H "Authorization: Bearer lpk_live_…" \
-H "Content-Type: application/json" \
-d '{
"externalId": "user_42",
"attributes": {
"first_name": "Alice",
"plan": "growth",
"trial_days_left": 7
}
}'| Constraint | Value |
|---|---|
| Max keys per call | 32 |
| Name pattern | [a-zA-Z0-9_.]{1,128} |
| String max length | 2 048 chars |
| Value types | string, number, boolean, or null (unset) |
| Arrays | Rejected (422) in v1 |
setAttributes is a no-op until Pushlane.identify has been called — the same guard as Pushlane.track. Attributes are keyed by externalId, so they follow the user across devices once identified.#Marketing consent — opt-out model
Pushlane uses an opt-out model for marketing notifications. A device that has granted push permission (registering via PushlanePush.register() / POST /v1/register) is considered opted-in by default. Flows will send to that device unless the user has recorded an explicit opt-out.
To record a user's preference call Pushlane.setMarketingConsent(false) (opt out) or Pushlane.setMarketingConsent(true) (re-opt-in) from the iOS SDK. From your server, POST to /v1/consent:
curl -X POST https://ingest.pushlane.io/v1/consent \
-H "Authorization: Bearer lpk_live_…" \
-H "Content-Type: application/json" \
-d '{
"externalId": "user_42",
"action": "opt_out",
"category": "marketing"
}'opt_out is always honoured — a user who has opted out will never receive a marketing push until they record an explicit opt_in. The invariant is enforced at the delivery gate, which reads the current consent at send time and fails closed on a read error.Pushlane.setMarketingConsent(true) only after the user explicitly consents.#Event deduplication
Pushlane deduplicates events at two layers:
- DedupDO (in-process) — a Cloudflare Durable Object keyed by
`${tenantId}:${eventId}`gates the event before any ClickHouse write. A duplicate is acknowledged immediately and skipped. - ClickHouse dedup token — the same tenant-scoped key is passed as
insert_deduplication_tokento ClickHouse Cloud's SharedMergeTree, providing a storage-layer guarantee for events that made it past the DedupDO on a retry.
The SDK generates a fresh UUID eventId on each Pushlane.track() call. If you POST from a server and generate the ID yourself, use the same ID on retries to get idempotency for free.