Pushlanedocs
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 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.

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 (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:

  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.
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.

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. Version attributes (app_version, os_version, install_env) are the built-in user attributes.app_version

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 →