REST · JSON · Server keys

Lotse, called from your server.From PHP, Node, Python or curl.

Your backend creates links, sends campaigns and pushes to single users, reports events and deletes users. REST with JSON over HTTPS, with a server key that can do only what its scopes allow.

Coming nextBuilt and tested. The acceptance on the production instance is still open.

What your backend can do.Each with a scope of its own.

Links

Create a deep link per product, article or invitation, read it back with its URL, and delete it. The number of links per project depends on the plan, 5,000 on Free.

Campaigns

Create a campaign for a saved segment, send it now or plan it, and read its report. The same campaigns, quotas and reports as in the console.

Pushes to one user

A push to one user or one install, such as “Your order has shipped”, with the screen of your app it opens.

Server events

Report what your backend knows first, such as a purchase or a renewal. Funnels, segments and journeys count it like an event from the app.

Deleting users

When someone closes their account with you, delete them in Lotse as well, with their installs and everything those sent.

Deliberately not

  • Editing. Links and users can be deleted, nothing can be changed.
  • A push to many user ids in one call. That is what campaigns are for.
  • Journeys and their A/B variants. They live in the console.
  • Calls from a browser. A key in a web page would be a key for everyone.
  • Generated client libraries. curl and the OpenAPI description do the job.

The other direction: webhooks

Server keys

A server key belongs to a system, not to a person, so it keeps working when the colleague who created it leaves the project. Only the owner of a project creates, rolls and revokes keys; members see them.

  1. 1

    Open Developers

    In the console, open your project, then Developers and Server keys, and choose Create key. Give it a name you will recognise, such as “Shop backend”. A project has up to ten active keys.

  2. 2

    Pick scopes and a lifetime

    Start from a template (Links, Campaigns, Transactional pushes, Server events, User deletion) or pick the scopes yourself. They are fixed once the key exists. The key expires after 90 or 365 days, or never. The console shows when each key was last used.

  3. 3

    Copy it, once

    The key starts with lt_srv_ and is shown exactly once. Lotse keeps only a hash of it. It belongs on your server, never in an app or a web page.

  4. 4

    Roll or revoke

    Roll creates a new key with the same scopes. The old one keeps working for 24 hours while you deploy the new one, or stops at once if it may have leaked. Revoke ends a key for good.

Scopes

links:read
Read links.
links:write
Create and delete links.
campaigns:read
Read saved segments, and campaigns with their reports.
campaigns:write
Create campaign drafts.
campaigns:send
Send and schedule campaigns.
messages:send
Send a push to one user or install, and read it back.
events:write
Report events from your server.
users:delete
Delete a user with their devices and data.

Requests

Every route lives under https://api.lotse.dev/v1/. The key alone decides the project: no path, query or body names one.

A bearer token

Send the key in the header Authorization: Bearer lt_srv_…. Without a valid key the answer is 401; the public key of your app and agent tokens are none.

JSON both ways

Bodies are JSON objects in UTF-8. Times are ISO 8601 with a time zone, such as 2026-10-20T09:00:00Z, and come back in UTC. A field the route does not know is refused, so a typo does not go unnoticed.

Only from servers

Browsers send an Origin header, and a request with one gets 403, whatever its key.

Lists in pages

Lists come newest first as {"data": [...], "nextCursor": "..."}. limit sets the page size, 100 by default, and the nextCursor of one page, passed as cursor, gives the next. null means it was the last.

Terminal
curl "https://api.lotse.dev/v1/links?limit=20" \
  -H "Authorization: Bearer $LOTSE_SERVER_KEY"

Routes

Each route needs one scope. Every field and every answer is in the OpenAPI 3.1 description of the API.

Links

  • GET /v1/linkslinks:read

    Links, newest first.

  • GET /v1/links/{id}links:read

    One link with its URL.

  • POST /v1/linkslinks:write

    Creates a link: slug and targetPath, optionally campaign, source, medium and a fallback per platform.

  • DELETE /v1/links/{id}links:write

    Deletes a link.

Segments and campaigns

  • GET /v1/segmentscampaigns:read

    The saved segments of the project, to address a campaign.

  • GET /v1/campaignscampaigns:read

    Campaigns, newest first, without the variants of journeys.

  • GET /v1/campaigns/{id}campaigns:read

    One campaign with its report. ?goalEvent= adds who reached that event within 24 hours.

  • POST /v1/campaignscampaigns:write

    Creates a draft: title, body, optionally linkId, and the audience as segmentId, segment or "audience": "all".

  • POST /v1/campaigns/{id}/sendcampaigns:send

    Sends a draft now, within the 20 sends per 24 hours that API and console share.

  • POST /v1/campaigns/{id}/schedulecampaigns:send

    Plans the send for sendAt, at least a minute and at most 30 days ahead.

Messages

  • POST /v1/messagesmessages:send

    A push to one userId or installId: title, body, optionally targetPath.

  • GET /v1/messages/{id}messages:send

    A message with the state of its deliveries, and whether it was opened.

Events

  • POST /v1/eventsevents:write

    1 to 100 events of your users, by user id.

Users

  • DELETE /v1/users/{userId}users:delete

    Deletes a user with their installs and data.

Description

  • GET /v1/openapi.jsonno key

    The OpenAPI 3.1 description of all routes. Public, also for a browser.

OpenAPI 3.1 description

Examples

With curl, and the key in the variable LOTSE_SERVER_KEY. Every POST may carry an Idempotency-Key, so a retry after a timeout does not do the work twice.

Send a campaign

Look up the saved segment, create a draft for it and send it. Without segmentId, segment or "audience": "all" a campaign is refused, so nobody pushes to every device by accident.

Request · curl
# The saved segments of the project, with their ids
curl https://api.lotse.dev/v1/segments \
  -H "Authorization: Bearer $LOTSE_SERVER_KEY"

# A draft for segment 7
curl https://api.lotse.dev/v1/campaigns \
  -H "Authorization: Bearer $LOTSE_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: spring-sale-draft" \
  -d '{
    "title": "Spring sale ends tonight",
    "body": "Your basket is still waiting.",
    "linkId": 318,
    "segmentId": 7
  }'

# Send it now, or plan it with POST .../schedule and {"sendAt": "..."}
curl -X POST https://api.lotse.dev/v1/campaigns/57/send \
  -H "Authorization: Bearer $LOTSE_SERVER_KEY" \
  -H "Idempotency-Key: spring-sale-send"
Answer · 200 OK
{
  "id": 57,
  "title": "Spring sale ends tonight",
  "body": "Your basket is still waiting.",
  "status": "sending",
  "linkId": 318,
  "segment": {
    "all": [{ "field": "platform", "op": "eq", "value": "android" }]
  },
  "createdAt": "2026-10-21T09:14:03.512Z",
  "scheduledAt": "2026-10-21T09:15:40.077Z"
}

Push to one user

Reaches the installs of u_42 that identified with a valid user hash. Tapped, the push opens /orders/1042.

Request · curl
curl https://api.lotse.dev/v1/messages \
  -H "Authorization: Bearer $LOTSE_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-shipped" \
  -d '{
    "userId": "u_42",
    "title": "Your order has shipped",
    "body": "Order 1042 is on its way.",
    "targetPath": "/orders/1042"
  }'
Answer · 201 Created
{
  "id": 905,
  "status": "queued",
  "userId": "u_42",
  "title": "Your order has shipped",
  "body": "Order 1042 is on its way.",
  "targetPath": "/orders/1042",
  "createdAt": "2026-10-21T09:14:03.512Z",
  "deliveries": {
    "pending": 2, "sending": 0, "sent": 0, "failed": 0, "invalidToken": 0
  },
  "opened": 0
}

Report an event

A purchase your backend confirmed. With an id, a second call with the same event stores nothing and counts it under duplicates.

Request · curl
curl https://api.lotse.dev/v1/events \
  -H "Authorization: Bearer $LOTSE_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "name": "purchase",
        "userId": "u_42",
        "id": "order-1042",
        "ts": "2026-10-21T09:12:00Z",
        "props": { "amount": "89.90", "currency": "EUR" }
      }
    ]
  }'
Answer · 200 OK
{ "accepted": 1, "duplicates": 0 }

Delete a user

The answer is 204 without a body, or 404 for a user the project does not know.

Request · curl
# The user id as the app passes it to identify, URL-encoded
curl -X DELETE https://api.lotse.dev/v1/users/u_42 \
  -H "Authorization: Bearer $LOTSE_SERVER_KEY"
Answer · 204 No Content
HTTP/1.1 204 No Content

Pushes to one user

A message goes to exactly one target: userId, the id your app passes to Lotse.identify, or installId, the id of one install.

userId reaches verified installs only
Without that rule, anyone could call identify("u_42") in their own copy of your app and receive the order updates of u_42. So a push to a userId goes only to installs whose identify carried a valid user hash. If the user has none, the answer is 422 identity_verification_required.
installId always works
Only your backend knows an install id, because your app reported it there with Lotse.instance.installId. Use it when your project does not verify identities.
No device is no error
If no install of the target can be reached, the answer is still 201, with "status": "no_devices", and the webhook message.failed follows. GET /v1/messages/{id} shows the deliveries and whether the push was opened.
SDK 0.2.0 or later
From 0.2.0 the SDK opens the targetPath of a tapped message and reports push_open with its messageId. With an older SDK the notification still shows, and a tap only opens the app.
Visible like campaigns
Messages are listed in the console under Campaigns, tab Messages, and push_sent and push_open land in the event stream. Title and text are deleted after 30 days, the message after 90.

The user hash

Generate the identity secret (lt_is_…) in the console under Settings, Identity verification. Your backend computes the hash for the signed-in user and hands it to the app, which passes it to identify. The secret never goes into the app.

JavaScript · Node.js
import crypto from 'node:crypto';

// HMAC-SHA256 of the user id, trimmed as Lotse stores it, keyed with the
// whole identity secret (lt_is_…), as lower-case hex.
const userHash = crypto
  .createHmac('sha256', process.env.LOTSE_IDENTITY_SECRET)
  .update(userId.trim())
  .digest('hex');
Dart · in the app
await Lotse.instance.identify(userId, userHash: userHash);

Server events

POST /v1/events takes 1 to 100 events per call, each with name and userId, optionally with ts, id and props. One faulty event refuses the whole call; the message names its index.

On the install seen last
Lotse resolves the user when the event arrives: it lands on the install of the user seen most recently, among those that existed at the time of the event. Funnels, segments, journey triggers and goals and campaign conversions count it like an event from the app.
Users without an install
An unknown userId creates a user without an install, up to 100,000 per project. Such events count in the event explorer, not in segments or journeys, and an install that comes later does not take them over.
Time
ts is optional and defaults to now. It may lie up to 30 days back and 5 minutes ahead of the server clock. Outside that the call is refused, so a wrong clock shows at once.
Duplicates
Give each event an id of up to 128 visible ASCII characters, such as order-1042. An id the project has stored before is skipped and costs nothing; the answer counts it under duplicates.
Names and props
Names have up to 100 characters, props are up to 20 pairs of strings of up to 200 characters, as in the SDK. Server events count against the same 500,000 events per project and day.

Names that Lotse and its SDK write themselves are reserved:

  • session_start
  • identify
  • push_sent
  • push_open
  • link_click
  • attribution_match
  • survey_answered
  • feedback_submitted
  • feature_voted
  • feature_unvoted
  • rage_tap

Deleting a user

DELETE /v1/users/{userId} does what the delete button in the console does, in one transaction. A suspended project can still delete.

What is deleted

  • the user with their traits, the events your backend reported for them, and their messages
  • every install identified as this user, with its events, feedback and replies, screenshots and replays, votes, survey answers, heatmap taps, the link click matched to it, push tokens, deliveries and journey entries
  • the content of the webhook events of the last 7 days that name the user or one of these installs, sent or not

What stays

An install identified as someone else since then stays, with everything it sent. Ideas on the feature board stay without these votes. The webhook user.deleted tells your other systems, with the user id and the time and nothing else.

Retries without doubles

Send the header Idempotency-Key with any POST: 1 to 255 printable ASCII characters, one per piece of work, such as order-1042-shipped. Lotse keeps it for 24 hours per server key.

A retry gets the first answer

The same key with the same request returns the stored answer with the header Idempotent-Replayed: true, and nothing happens twice.

A different request gets 409

The same key with another path or body is a conflict. While the first request still runs, the 409 comes with Retry-After.

429 and 503 free the key

A retry with the same key runs again; after a 503 it does what is missing and nothing twice. After a 500 or a timeout nothing runs a second time: the retry gets the stored answer, or 409, with Retry-After while the first request may still run, then saying its outcome is unknown. Check the resource with GET, then send with a new key.

Errors

Every answer outside 2xx is JSON with a code and an English sentence for you, never a stack trace.

JSON · 403 Forbidden
{
  "error": "forbidden",
  "message": "This server key lacks the scope messages:send. Scopes are fixed when a key is created: create a key with it in the Lotse console."
}
400invalid_input
A field is missing, unknown, or has a wrong type or value. The message names the field.
401unauthorized
No key, or one that is unknown, revoked or expired.
403forbidden
The key lacks the scope of the route, and the message names it. Or the request came from a browser.
403project_suspended
The project is suspended. Reading and deleting still work.
404not_found
No such path, or nothing with that id in your project.
405method_not_allowed
The path takes other methods; the header Allow lists them.
409conflict
Not possible in the current state, such as a slug that is taken, or an Idempotency-Key used for another request.
413invalid_input
The body is larger than 64 KiB, or 256 KiB for /v1/events.
422identity_verification_required
A push to a userId without a verified install.
429quota_exceeded
A quota of the project, the same as in the console, such as the links of its plan, 5,000 on Free. Comes without Retry-After.
429rate_limited
A budget of calls or writes is used up. Retry-After says how many seconds to wait.
500internal_error
A failure on the Lotse server. Try again later.
503unavailable
Work that could not be finished right now, such as starting a send. Repeat the same request after Retry-After: it does what is missing and nothing twice.

Every call with a valid key that did not get a 2xx stays in the console for 7 days, under Developers, Request log: time, key, method, path, status, code and duration, never a body or a header. Refusals by a budget are only sampled. A user id in the path is not kept.

Limits

Generous for a backend, tight enough that one stuck in a loop is slowed down. Over a limit the answer is 429.

600 calls an hour

Per key. Refused and failed calls count too.

60 writes an hour

Per key, within the 600: every POST and DELETE that reaches its route. A retry answered from its Idempotency-Key costs none.

6,000 calls an hour

Per project, shared by all its keys.

64 KiB per request

And 256 KiB for /v1/events, room for 100 typical events.

The quotas of the project

Links per plan, 5,000 on Free. 20 campaign sends in 24 hours, shared with the console. 10 messages per user an hour and 10,000 per project a day. 100,000 users without an install.

Suspended means read and delete

While a project is suspended, its keys still read and delete. Everything that creates or sends gets 403 project_suspended.

Set your course.First project in ten minutes.

Free during the beta. Sign up, create a project, add the SDK.