docs
Documentation menu

How Pushlane works

The ingest → decision → delivery pipeline, end to end.

#Step 1 — Ingest (POST /v1/events)

The iOS SDK serialises an EventEnvelope and POSTs it to POST /v1/events on the Cloudflare Worker. Every request must carry a write-key:

http
POST https://ingest.pushlane.io/v1/events
Authorization: Bearer lpk_live_…
Content-Type: application/json

{
  "tenantId": "<derived from key — body value is overridden>",
  "externalId": "user_abc123",
  "name": "paywall_viewed",
  "properties": { "placement": "onboarding" },
  "occurredAt": 1719878400000,
  "context": {
    "sdk": "pushlane-ios",
    "sdkVersion": "0.3.0",
    "appVersion": "2.1.0",
    "os": "iOS",
    "osVersion": "18.0.1",
    "environment": "production",
    "installSource": "appstore",
    "tzIana": "Europe/Paris",
    "locale": "fr_FR"
  }
}

The worker runs these steps before returning 202 Accepted:

  1. Auth (A2) — the write-key is validated against app.api_keys. The tenant ID derived from the key overwrites any tenantId in the body, making cross-tenant injection impossible.
  2. Normalise — a missing eventId becomes a fresh UUID; a missing or invalid traceparent becomes a fresh W3C sampled traceparent.
  3. Validate — the envelope is parsed against EventEnvelopeSchema (Zod). A bad shape returns 422.
  4. R1 — identity resolveexternalId is resolved to a dense UInt32 surrogate via the Supabase RPC resolve_external_id. New users are created on first sight; returning users get the same surrogate every time.
  5. R9 — shard & enqueue — the message is placed on one of four Cloudflare Queues, sharded by murmur3_32(externalId) % 4. All events for the same user always land on the same shard, preserving per-user ordering.
  6. OTel span (ingest.http) — a span is written to otel_traces in ClickHouse, recording its spanId so the queue consumer can parent off it on the same trace.

The response body carries the eventId, the resolved surrogate, the active traceparent, and the shard number.

#Step 2 — Queue consumer

The queue consumer pulls batches off all four shards. For each message:

  1. R2 — dedup gate (DedupDO) — before any write, a DedupDO Durable Object keyed by `${tenantId}:${eventId}` is checked. If the event was already seen, it is acknowledged and skipped. The dedup marker is written only after a successful ClickHouse insert, so a crash before that point triggers a safe retry. ClickHouse's insert_deduplication_token provides a second guarantee at the storage layer.
  2. R6 — catalogue coerce — properties are coerced against the tenant's typed catalogue. Each property is routed into one of five typed columns: prop_num, prop_bool, prop_dt, prop_str, or properties (raw). Type is never inferred from the value — unknown properties go raw; type mismatches produce a logged warning, not a drop.
  3. ClickHouse insert — one row is written to the events table with all five typed columns, context fields (sdk, app version, OS, timezone, locale, install environment), and the traceparent.
  4. Catalogue auto-discovery — if the event name is new, it is upserted into catalogue_events (declared=false). Version attributes (app_version, os_version, install_env) are projected onto the user as targetable attributes when the event name is app_open or session_started.
  5. R3 — trace bridge — two OTel spans are written: queue.consume (parented on the ingest span) and clickhouse.insert (child of consume). The same W3C trace ID crosses the Worker → queue boundary.
Heads up
The queue consumer throws on any failure, which retries the message. After the configured maximum retries the message lands on the DLQ. Both the DedupDO and the ClickHouse dedup token make replays safe.

#Step 3 — Flow routing (FlowUserDO)

After the ClickHouse insert, the consumer checks whether any active flow has an event entry trigger matching this event name. If so, it calls POST /enter on the user's FlowUserDO — one Cloudflare Durable Object per `${tenantId}:${externalId}`.

The DO is the execution engine for that user. It holds:

  • All flow instances for this user — which step each is on and its current state.
  • Timers for delay and wait-until nodes, stored as Durable Object alarms at epoch-millisecond precision.
  • Frequency-cap sliding-window send history in the DO's SQLite (sends table).
  • Send dedup markers so an alarm re-fire never double-sends a message.

The DO advances the flow step by step. A delay node sets an alarm and pauses; when the alarm fires the DO resumes. A message node triggers the send path below.

#Step 4 — Decision & delivery

Before a push is sent, the engine enforces a set of hard filters. Every outcome — send or suppress — is logged as a decision_log row in ClickHouse (R7). Nothing is ever silently dropped.

FilterRuleSuppression reason
R10 — marketing consentPushlane uses an opt-out model: a registered device (push permission granted by the user) is considered opted-in by default. The app.consent_current view is queried for (canonical_id, 'marketing'). A send is suppressed only when the most recent consent row has action = opt_out. No row (new device) = send proceeds. An explicit opt_out is always honoured.consent_opt_out
R2 — send dedupThe send dedup key is checked in the DO's SQLite. A key already present means an alarm re-fired after a successful send — the second attempt is skipped.already_delivered
R7 — frequency capBoth flow-level and global sliding-window caps are evaluated. A capped user is suppressed with a logged reason, not silently skipped.frequency_cap
Time windowIf the message node declares a delivery window (days of week + minute range in the user's timezone), sends outside the window are delayed to the next opening — not suppressed.n/a — delay only
Heads up
EU / GDPR note: in certain jurisdictions an explicit opt-in may be legally required before sending marketing notifications. That is the responsibility of the app. Pushlane provides both mechanisms: opt-out by default (send to registered devices) and explicit opt-in/opt-out recording via Pushlane.setMarketingConsent() or POST /v1/consent. When your audience includes EU users, call POST /v1/consent action=opt_in after the user explicitly accepts, and rely on the opt-out default only where permitted by local law.

If all filters pass, the ApnsDeliveryAdapter POSTs a DeliveryCommand to the Go APNs sender on Fly.io. The sender holds the per-tenant .p8 key (AES-256-GCM encrypted at rest) and talks directly to Apple APNs.

Note
The Go sender requires a per-tenant APNs .p8 key uploaded in Settings → APNS setup. Without one, sends are skipped with reason no_tenant_apns_credentials — never faked as sent.

#Observability — one trace end to end

Every event produces a continuous OTel trace from SDK call to APNs acknowledgement on a single W3C traceparent. The spans written to the otel_traces table are:

Span nameServiceWhat it covers
ingest.httploop-ingestHTTP handler, identity resolve, enqueue
queue.consumeloop-ingest-consumerDedup gate, catalogue coerce, ClickHouse insert
clickhouse.insertloop-ingest-consumerThe INSERT duration
do.flow.advanceloop-user-doFlow step execution in the Durable Object
delivery.sendloop-delivery-apnsConsent recheck and APNs dispatch

Traces and decision_log rows are visible in the Logs tab. The decision_log table records every send and every suppression with the exact reason, so you can audit why any message was or was not delivered to a specific user.

Next: Events & catalogue →