docs
Documentation menu

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.

swift
// Minimal — no properties
Pushlane.track("paywall_viewed")

// With typed properties
Pushlane.track("paywall_viewed", [
    "placement": "onboarding",
    "paywall_id": "pw_annual_v2"
])
Heads up
Call 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 / caseCatalogue typeStored column
PushlaneValue.string("…")stringprop_str
PushlaneValue.int(42)numberprop_num
PushlaneValue.double(3.14)numberprop_num
PushlaneValue.bool(true)booleanprop_bool
PushlaneValue.array([…])arrayproperties (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 nameWhen it firesProperties
app_openCold launch — emitted once per process by PushlaneInApp.start(). Distinct from session_started: the two never double-count.session_id: string
session_startedForeground 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.

Note
Call 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 nameWhen it firesProperties
openedThe 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
dismissedThe 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
receivedThe 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_failedRegistering 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: stringpermission_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.

Heads up
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:

Clientopeneddismissedreceived
Native iOS SDK (PushlanePush)Automatic from PushlanePush.register() — unless your app already owns the notification-center delegate (see below)Automatic — explicit clears onlyOnly with the PushlaneNotificationService extension installed
Expo drop-in v1.3.0+Automatic from Pushlane.configure() — cold start and warm tapsiOS explicit clears only (Android has no dismiss signal)Not available
React Native drop-in v1.3.0+Android automatic. iOS not reachable — manual onlyNot availableNot available
Flutter drop-in v1.3.0+Wired, but nothing arrives yet — see the delivery note belowNot availableNot available
Android (Kotlin) drop-in v1.3.0+Manual — you must call Pushlane.handleNotificationOpen(intent)Not availableNot available
Heads up
Pushlane delivers over APNs (iOS) today. Android and Flutter clients register their FCM token honestly and it shows up in your audience, but push delivery to those devices is not live — so there is nothing to open there yet. The tap wiring ships ahead of the delivery, not the other way round.
Heads up
React Native, iOS: @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.
Heads up
Android (Kotlin): FCM has no tap callback — the payload lands in the launch 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.
Note
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.
Note
On the native iOS SDK, Pushlane installs its notification-center delegate only if your app has not already set one. If your app owns 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.

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 nameWhen to trackKey properties
signup_completedAccount createdmethod: string
onboarding_completedFirst-run onboarding finishedduration_sec: number
paywall_viewedA paywall is shown to the userplacement: string (required), paywall_id: string
trial_startedA free trial began (often via RevenueCat webhook)plan: string (required), product_id: string
subscription_startedFirst paid period beganplan: string (required), product_id: string, price_usd: number
subscription_renewedA paid period renewedplan: string
subscription_cancelledChurn signal — auto-renew turned off or plan expiredplan: string, reason: string
lesson_finishedYour 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:

  1. Add, don't replace. Your analytics keeps working; Pushlane just also receives the event.
  2. Rename reserved names. message_sent is 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.
  3. Skip the auto-emitted names. app_open and session_started are already emitted by the SDK (or a drop-in client's start()). Mirroring an “App Opened” / “Session Start” from your analytics would double-count.
  4. Never hand-roll notification interactions. opened, dismissed and received are emitted for you (see above), and the reports open rate matches them on message_id. A “Push Opened” mirrored from your analytics under its own name carries no message_id, so it is invisible to the metric — and a second listener under the right name would log two events per tap.
typescript
// 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:

bash
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
  }'
Note
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:

  1. If the event name has never been seen, it is upserted into catalogue_events with declared=false.
  2. Properties are coerced against any declared=true entry in catalogue_properties. If no declared type exists, the raw value is stored and the property is surfaced as undeclared.
  3. You can promote any auto-discovered event or property to declared=true in the dashboard and set an explicit type. Once declared, mismatches produce a coercion warning in the trace log — the event is never dropped.
Catalogue typeMaps to columnSwift type(s)
stringprop_strString
numberprop_numInt, Double
booleanprop_boolBool
datetimeprop_dtInt (epoch ms)
arrayproperties (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:

KindWhat it isExample
event_propertyBelongs to a specific event name — scoped to the event in the catalogue.placement on paywall_viewed
user_attributeA 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.

typescript
// 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 });
swift
// 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:

bash
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
    }
  }'
ConstraintValue
Max keys per call32
Name pattern[a-zA-Z0-9_.]{1,128}
String max length2 048 chars
Value typesstring, number, boolean, or null (unset)
ArraysRejected (422) in v1
Note
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.

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:

bash
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"
  }'
Heads up
An explicit 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.
Note
EU / GDPR: in certain jurisdictions (EU) an explicit opt-in may be legally required for marketing communications. That is the responsibility of the app. Pushlane provides both mechanisms — send-by-default for opted-in devices, and explicit opt-in/opt-out recording. When building for EU audiences, call 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_token to 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.

Next: Audiences →