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:
POST /v1/events
Authorization: Bearer lpk_live_xxxxxxxxxxxxxxxx
Content-Type: application/jsonThe 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.
#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.
occurredAt is required and must be epoch milliseconds. The SDK always sends it; direct HTTP callers must supply it.{
"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"
}
}// HTTP 202 Accepted
{
"eventId": "01939e7c-aaaa-7000-bbbb-000000000001",
"surrogate": 12345,
"traceparent":"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"shard": 2
}| Field | Type | Required | Notes |
|---|---|---|---|
| tenantId | UUID string | Yes* | Overridden by the key's tenant server-side. |
| externalId | string (min 1, max 256) | Yes | Your stable user identifier — or the anonymous id you minted and persisted for this install. |
| name | string (min 1) | Yes | Snake_case event name, e.g. paywall_viewed. |
| occurredAt | number (epoch ms) | Yes | When the event happened on-device. |
| properties | object | No | Key/value pairs; values may be string, number, boolean, null, or an array of those. |
| eventId | UUID string | No | Auto-generated if absent. Send your own for idempotency. |
| traceparent | W3C string | No | Auto-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.
{
"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.
{
"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.
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.-- 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.
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.
{
"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
}// 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.
{
"tenantId": "YOUR_TENANT_UUID",
"externalId":"user_42"
}// 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).
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).
{
"externalId": "user_42",
"attributes": {
"first_name": "Alice",
"plan": "growth",
"trial_days_left": 7,
"opted_into_beta": true
}
}// HTTP 200 OK
{
"ok": true,
"written": 4,
"canonicalId":"3fa85f64-5717-4562-b3fc-2c963f66afa6",
"surrogate": 12345
}| Field | Type | Required | Notes |
|---|---|---|---|
| externalId | string (min 1) | Yes | Your stable user identifier. The user is auto-identified if not yet seen. |
| attributes | object | Yes | Key/value map of attributes to write. At least one key required. |
| tenantId | UUID string | No | Overridden by the key's tenant server-side. |
| Attribute constraint | Limit |
|---|---|
| Max keys per call | 32 |
| Name pattern | [a-zA-Z0-9_.]{1,128} |
| Value types | string (≤ 2 048 chars), number, boolean, or null |
| null | Explicit unset — the attribute stops resolving in templates |
| Arrays | Rejected (422) in v1 |
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.{{ 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.
/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.{
"externalId": "user_42",
"action": "opt_out", // "opt_out" | "opt_in"
"category": "marketing" // optional — defaults to "marketing"
}// HTTP 200 OK (opt_out recorded)
{ "ok": true, "optedIn": false }
// HTTP 200 OK (opt_in recorded)
{ "ok": true, "optedIn": true }| Field | Type | Required | Notes |
|---|---|---|---|
| externalId | string (min 1) | Yes | Your stable user identifier. Must already exist in Pushlane. |
| action | "opt_in" | "opt_out" | Yes | opt_out suppresses all future marketing pushes. opt_in re-enables them. |
| category | string | No | Consent category. Defaults to "marketing". Other categories are ignored by the flow engine today. |
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.
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.
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.
// 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).
| HTTP | error | Cause | Fix |
|---|---|---|---|
401 | write_key_required | No key presented and auth enforcement is on. | Add Authorization header. |
401 | invalid_write_key | Key does not match any active row. | Check the key — it may be revoked. |
502 | write_key_verification_failed | Transient Supabase lookup error. | Retry; not a bad key. |
400 | invalid_json | Body could not be parsed. | Check Content-Type and body encoding. |
422 | validation_failed | Body failed schema validation. | Check the issues array in the response. |
400 | tenantId_and_externalId_required | /v1/identify missing a field. | Supply both fields. |
400 | tenantId_externalId_deviceToken_required | /v1/register missing a field. | Supply all three fields. |
400 | pushEnvironment_must_be_sandbox_or_production | Invalid pushEnvironment value. | Use "sandbox" or "production". |
502 | identity_resolve_failed | Could not resolve externalId → canonical. | Transient; retry. |
502 | enqueue_failed | Queue write failed. | Transient; retry. |
404 | unknown_user | /v1/consent: externalId not yet registered or identified. | Call /v1/register or /v1/identify first. |
422 | invalid_consent_body | /v1/consent: body failed schema validation. | Supply externalId (string) and action ('opt_in' | 'opt_out'). |
502 | consent_failed | /v1/consent: transient Supabase error. | Retry; not a bad key or body. |
422 | invalid_attributes | /v1/attributes: name pattern violation, > 32 keys, array value, or empty map. | Check the issues array. |
502 | attributes_failed | /v1/attributes: transient identity-resolve or ClickHouse write error. | Retry; not a bad key or body. |
Next: iOS SDK reference →