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 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.#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 (trial_started, subscription_started, etc.) can be forwarded automatically via the RevenueCat integration — you do not need to call Pushlane.track() for them when that integration is active.
#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.
// 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.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. Version attributes (app_version, os_version, install_env) are the built-in user attributes. | app_version |
#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.