docs
Documentation menu

HTTP API

The ingest and flow endpoints, request by request.

#Base URL and authentication

Every request goes to your worker URL (shown in the dashboard under Settings › API). The base URL in production is https://ingest.pushlane.io by default, but changes once you add a custom domain.

All write-key-gated endpoints require a publishable write-key in one of two forms:

http
POST /v1/events
Authorization: Bearer lpk_live_xxxxxxxxxxxxxxxx
Content-Type: application/json

The key format is lpk_live_…. The worker derives your tenant server-side from the key — the tenantId you pass in the body is overridden by the key's tenant. You can also use the header X-Pushlane-Key: lpk_live_… instead of Authorization.

Heads up
Write-keys are publishable (they ship inside your app), not secret. They identify a tenant and scope ingestion to it. They cannot read or modify tenant settings.

#POST /v1/events

Ingest a single event. The worker normalises it (generating an eventId and traceparent if absent), resolves the identity, and enqueues it for async processing. Returns 202 Accepted immediately — processing is asynchronous.

Note
occurredAt is required and must be epoch milliseconds. The SDK always sends it; direct HTTP callers must supply it.
json
{
  "tenantId":   "YOUR_TENANT_UUID",   // overridden server-side from the key
  "externalId": "user_42",            // your stable user id
  "name":       "paywall_viewed",
  "occurredAt": 1751400000000,        // epoch ms — required
  "properties": {
    "placement": "onboarding",
    "paywall_id": "main"
  }
}
json
// HTTP 202 Accepted
{
  "eventId":    "01939e7c-aaaa-7000-bbbb-000000000001",
  "surrogate":  12345,
  "traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
  "shard":      2
}
FieldTypeRequiredNotes
tenantIdUUID stringYes*Overridden by the key's tenant server-side.
externalIdstring (min 1, max 256)YesYour stable user identifier — or the anonymous id you minted and persisted for this install.
namestring (min 1)YesSnake_case event name, e.g. paywall_viewed.
occurredAtnumber (epoch ms)YesWhen the event happened on-device.
propertiesobjectNoKey/value pairs; values may be string, number, boolean, null, or an array of those.
eventIdUUID stringNoAuto-generated if absent. Send your own for idempotency.
traceparentW3C stringNoAuto-generated if absent or invalid.

#Reporting notification opens

Pushlane stamps three keys into every push it sends — message_id, flow_id and node_id — at the top level of the APNs payload, as siblings of aps (not inside it). message_id identifies the exact send that produced this notification.

json
{
  "aps": {
    "alert": { "title": "Your trial ends tomorrow", "body": "Keep your streak." },
    "sound": "default",
    "category": "LOOP_DEFAULT"
  },
  "message_id": "01939e7c-aaaa-7000-bbbb-000000000001",  // top-level, sibling of "aps"
  "flow_id":    "5f1d7c02-3b1e-4a9f-9a41-2c9a1e77b0d3",
  "node_id":    "push_day2"
}

To report an open by hand, echo that message_id back to /v1/events under the event name opened, in properties.message_id. It is an ordinary event — same endpoint, same write-key, same 202 Accepted. There is no dedicated open endpoint and no trackOpen method.

json
{
  "externalId": "user_42",
  "name":       "opened",              // exactly this name
  "occurredAt": 1751400123000,
  "properties": {
    "message_id": "01939e7c-aaaa-7000-bbbb-000000000001",  // from the push payload
    "flow_id":    "5f1d7c02-3b1e-4a9f-9a41-2c9a1e77b0d3",  // optional
    "node_id":    "push_day2"                              // optional
  }
}

Only message_id is load-bearing. flow_id and node_id are optional — echo them if you want them for debugging.

Heads up
This is what the open-rate metric joins on. Open rate is sends (rows in decision_log with the send action) LEFT JOINed to events named opened on toString(decision_event_id) = properties["message_id"]. An open reported under any other name (push_opened, notification_opened, …) or without a message_id is invisible to the metric: it is not counted as a miss, it simply cannot be matched to a send. Nothing is inferred from proximity in time.
sql
-- how open rate is computed (tenant/period filters omitted)
SELECT countIf(i.mid != '') / count() AS open_rate
FROM decision_log AS d
LEFT JOIN (
  SELECT DISTINCT properties['message_id'] AS mid
  FROM events
  WHERE name = 'opened' AND properties['message_id'] != ''
) AS i ON toString(d.decision_event_id) = i.mid
WHERE d.action = 'send'

dismissed works exactly the same way — same property, same join — for an explicit clear (Clear, ✕, Clear All). Apple reports explicit clears only, never a swipe-away, an ignore, or an OS auto-clear, so dismiss rate structurally under-counts.

received (the notification landed on the device) is recognised under the same convention, but nothing reports it for you: on iOS it requires a native Notification Service Extension, so none of the pasteable drop-in clients emit it. If you build one, report it with the same message_id property.

Note
Where opens can come from today. Pushlane delivers over APNs (iOS). Android and Flutter register an FCM token honestly, but delivery is not live there yet — so there is nothing to open on those platforms. The zero-dependency drop-in clients (sdkVersion 1.3.0) already make this call where the platform allows it, and each one exposes handleNotificationOpen(…) for taps you route yourself. POST the event by hand for raw-HTTP or native integrations — but never do both for the same tap, or the open is logged twice.

#POST /v1/register

Register or refresh a device push token. One subscription per device token per tenant. Calling this again with the same token updates the record (app version, OS version, last-seen timestamp).

The pushEnvironment field is critical for correct APNs routing. The iOS SDK auto-detects it from the provisioning profile — never derive it from #if DEBUG. If you are calling this endpoint directly, match what Apple's Certificates, Identifiers & Profiles shows for the token's environment.

Android / FCM tokens are accepted and stored honestly (platform: "fcm", derived from the token shape when the field is omitted): the device shows up in Audience and attribution works, but push delivery is APNs-only today — the response carries pushDelivery: "not_yet_supported" and sends to FCM-only users are suppressed with the logged reason fcm_delivery_unsupported (visible in Logs), never silently dropped. Suppressed sends are not queued: they will not be replayed retroactively when FCM delivery ships — flows evaluate again on their next trigger.

json
{
  "tenantId":       "YOUR_TENANT_UUID",
  "externalId":     "user_42",
  "deviceToken":    "a1b2c3d4...",        // hex APNs token, or an FCM token (Android)
  "pushEnvironment":"sandbox",            // "sandbox" | "production"
  "platform":       "apns",              // optional: "apns" | "fcm" (derived from token shape when omitted)
  "appVersion":     "2.1.0",             // optional, recommended
  "osVersion":      "17.5.0"             // optional, recommended
}
json
// HTTP 200 OK
{
  "subscriptionId": "550e8400-e29b-41d4-a716-446655440000",
  "canonicalId":    "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "surrogate":      12345,   // present only on first registration
  "platform":       "apns",  // "fcm" responses also carry pushDelivery: "not_yet_supported"
  "updated":        false
}

#POST /v1/identify

Resolve an externalId to its Pushlane canonical identity (stable UUID + dense surrogate integer). The canonical identity is created on first call. Useful for verifying a user exists in Pushlane before activating a flow.

json
{
  "tenantId":  "YOUR_TENANT_UUID",
  "externalId":"user_42"
}
json
// HTTP 200 OK
{
  "tenantId":   "YOUR_TENANT_UUID",
  "externalId": "user_42",
  "canonicalId":"3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "surrogate":  12345,
  "firstSeen":  false
}

firstSeen is true when the identity was created by this call (i.e. this is the first event or register for this user).

Note
Anonymous ids are first-class. Pushlane never invents an identifier for you: externalId is always required and must be non-empty (≤ 256 characters). If your app has no sign-up, mint one stable random id per install, persist it in a durable store, and send that — the drop-in SDKs do exactly this under the key com.loop.sdk.anonymousId. A per-session id would fragment every flow and inflate your reachable-MAU bill, so pick a store that survives cold starts. When the user later signs in, call /v1/identify with the real id and /v1/register again with the same device token: the device moves onto the real identity, and both ids stay linked as aliases.

/v1/events, /v1/register, and /v1/identify all create the identity on first sight. /v1/consent is the exception — it returns 404 unknown_user for an externalId Pushlane has never seen, so call one of the other three first.

#POST /v1/attributes

Write persistent user attributes — first name, plan, onboarding answers, custom traits — that the flow engine resolves into {{ name | fallback }} tokens at send time. Attributes are stored most-recent-wins per user; sending the same key again overwrites the previous value. Call this from your server whenever you know an attribute value (e.g. after a subscription event, after onboarding completes).

json
{
  "externalId": "user_42",
  "attributes": {
    "first_name": "Alice",
    "plan": "growth",
    "trial_days_left": 7,
    "opted_into_beta": true
  }
}
json
// HTTP 200 OK
{
  "ok":         true,
  "written":    4,
  "canonicalId":"3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "surrogate":  12345
}
FieldTypeRequiredNotes
externalIdstring (min 1)YesYour stable user identifier. The user is auto-identified if not yet seen.
attributesobjectYesKey/value map of attributes to write. At least one key required.
tenantIdUUID stringNoOverridden by the key's tenant server-side.
Attribute constraintLimit
Max keys per call32
Name pattern[a-zA-Z0-9_.]{1,128}
Value typesstring (≤ 2 048 chars), number, boolean, or null
nullExplicit unset — the attribute stops resolving in templates
ArraysRejected (422) in v1
Note
The written field in the response is the number of attribute keys accepted. The endpoint auto-registers each new attribute name in the tenant catalogue as a user_attribute (kind) with declared=false — it surfaces in the audience builder and the variable picker without any manual schema step.
Heads up
Attributes set via this endpoint are resolved at send time, not at flow-activation time. A notification token ({{ plan }}) reads the attribute value the moment the flow engine is about to dispatch the push — so attributes written after the trigger event still take effect for deferred steps (delays, windows).

#POST /v1/consent

Record an explicit marketing consent decision for a user. Pushlane uses an opt-out model: a registered device (push notification permission granted) is treated as opted-in by default. This endpoint records the only state the engine treats as authoritative to suppress: an explicit opt_out — and the way back in (opt_in).

An opt_out row is always honoured — a user who has explicitly unsubscribed will never receive a marketing push until they opt back in.

Heads up
The user must already exist in Pushlane (registered via /v1/register or identified via /v1/identify) before you can record consent. Consent for an unknown externalId returns 404 unknown_user. No phantom user is created.
json
{
  "externalId": "user_42",
  "action":     "opt_out",   // "opt_out" | "opt_in"
  "category":   "marketing"  // optional — defaults to "marketing"
}
json
// HTTP 200 OK  (opt_out recorded)
{ "ok": true, "optedIn": false }

// HTTP 200 OK  (opt_in recorded)
{ "ok": true, "optedIn": true }
FieldTypeRequiredNotes
externalIdstring (min 1)YesYour stable user identifier. Must already exist in Pushlane.
action"opt_in" | "opt_out"Yesopt_out suppresses all future marketing pushes. opt_in re-enables them.
categorystringNoConsent category. Defaults to "marketing". Other categories are ignored by the flow engine today.
Note
EU / GDPR: in certain jurisdictions (EU) 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 and explicit opt-in/opt-out recording. When your audience includes EU users, record an explicit opt_in (or call Pushlane.setMarketingConsent(true)) after the user consents.

From the SDK: Pushlane.setMarketingConsent(false) calls this endpoint with action: "opt_out"; Pushlane.setMarketingConsent(true) calls it with action: "opt_in". The dashboard Preference Center also writes consent rows via this endpoint.

#POST /v1/flows/activate

Compile and activate a flow from a FlowIR document. Activated flows are stored in Supabase and evaluated on every ingested event for the tenant. This endpoint is called by the Pushlane dashboard when you click Activate on the canvas — you rarely need to call it directly.

A successful activation returns 200 with { flowId, version, warnings }. A schema validation failure returns 422 with an { errors, warnings } object describing each problem.

#GET /v1/catalogue

Returns the tenant's typed event and property catalogue — every event name and property that has been declared or auto-discovered from ingested events. Requires a write-key.

bash
curl https://<YOUR_WORKER>/v1/catalogue \
  -H "Authorization: Bearer lpk_live_…"

The response shape is { tenantId, events: [...], properties: [...] }. Each event has name, declared, description, volume30d, and lastSeenAt. Each property has name, kind (event_property | user_attribute), type, and optional eventName.

#GET /v1/install-status

Live proof the SDK is sending events for the tenant. Reports distinct event names, total event count, last event time, and connected device counts from ClickHouse and Supabase respectively. Each source reports its own ok/error so a partial outage still answers honestly. Requires a write-key.

bash
curl https://<YOUR_WORKER>/v1/install-status \
  -H "Authorization: Bearer lpk_live_…"

sdkDetected is true once any event or device registration exists for the tenant.

#GET /healthz

Health check. No authentication required. Returns 200 when the worker is up.

json
// HTTP 200 OK
{ "ok": true, "service": "loop-ingest" }

#Error reference

All error responses are JSON with an error string key. Some include an issues array (422); server-side failures return a generic error plus a requestId you can quote to support (raw internal detail is never exposed).

HTTPerrorCauseFix
401write_key_requiredNo key presented and auth enforcement is on.Add Authorization header.
401invalid_write_keyKey does not match any active row.Check the key — it may be revoked.
502write_key_verification_failedTransient Supabase lookup error.Retry; not a bad key.
400invalid_jsonBody could not be parsed.Check Content-Type and body encoding.
422validation_failedBody failed schema validation.Check the issues array in the response.
400tenantId_and_externalId_required/v1/identify missing a field.Supply both fields.
400tenantId_externalId_deviceToken_required/v1/register missing a field.Supply all three fields.
400pushEnvironment_must_be_sandbox_or_productionInvalid pushEnvironment value.Use "sandbox" or "production".
502identity_resolve_failedCould not resolve externalId → canonical.Transient; retry.
502enqueue_failedQueue write failed.Transient; retry.
404unknown_user/v1/consent: externalId not yet registered or identified.Call /v1/register or /v1/identify first.
422invalid_consent_body/v1/consent: body failed schema validation.Supply externalId (string) and action ('opt_in' | 'opt_out').
502consent_failed/v1/consent: transient Supabase error.Retry; not a bad key or body.
422invalid_attributes/v1/attributes: name pattern violation, > 32 keys, array value, or empty map.Check the issues array.
502attributes_failed/v1/attributes: transient identity-resolve or ClickHouse write error.Retry; not a bad key or body.

Next: iOS SDK reference →