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:
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:
- Auth (A2) — the write-key is validated against
app.api_keys. The tenant ID derived from the key overwrites anytenantIdin the body, making cross-tenant injection impossible. - Normalise — a missing
eventIdbecomes a fresh UUID; a missing or invalidtraceparentbecomes a fresh W3C sampled traceparent. - Validate — the envelope is parsed against
EventEnvelopeSchema(Zod). A bad shape returns 422. - R1 — identity resolve —
externalIdis resolved to a denseUInt32surrogate via the Supabase RPCresolve_external_id. New users are created on first sight; returning users get the same surrogate every time. - 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. - OTel span (ingest.http) — a span is written to
otel_tracesin ClickHouse, recording itsspanIdso 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:
- R2 — dedup gate (DedupDO) — before any write, a
DedupDODurable 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'sinsert_deduplication_tokenprovides a second guarantee at the storage layer. - 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, orproperties(raw). Type is never inferred from the value — unknown properties go raw; type mismatches produce a logged warning, not a drop. - ClickHouse insert — one row is written to the
eventstable with all five typed columns, context fields (sdk, app version, OS, timezone, locale, install environment), and the traceparent. - 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 isapp_openorsession_started. - R3 — trace bridge — two OTel spans are written:
queue.consume(parented on the ingest span) andclickhouse.insert(child of consume). The same W3C trace ID crosses the Worker → queue boundary.
#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 (
sendstable). - 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.
| Filter | Rule | Suppression reason |
|---|---|---|
| R10 — marketing consent | Pushlane 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 dedup | The 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 cap | Both flow-level and global sliding-window caps are evaluated. A capped user is suppressed with a logged reason, not silently skipped. | frequency_cap |
| Time window | If 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 |
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.
.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 name | Service | What it covers |
|---|---|---|
ingest.http | loop-ingest | HTTP handler, identity resolve, enqueue |
queue.consume | loop-ingest-consumer | Dedup gate, catalogue coerce, ClickHouse insert |
clickhouse.insert | loop-ingest-consumer | The INSERT duration |
do.flow.advance | loop-user-do | Flow step execution in the Durable Object |
delivery.send | loop-delivery-apns | Consent 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.