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.
REST · JSON · Server keys
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.
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.
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.
A push to one user or one install, such as “Your order has shipped”, with the screen of your app it opens.
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.
When someone closes their account with you, delete them in Lotse as well, with their installs and everything those sent.
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.
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.
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.
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.
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.
links:readlinks:writecampaigns:readcampaigns:writecampaigns:sendmessages:sendevents:writeusers:deleteEvery route lives under https://api.lotse.dev/v1/. The key alone decides the project: no path, query or body names one.
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.
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.
Browsers send an Origin header, and a request with one gets 403, whatever its key.
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.
curl "https://api.lotse.dev/v1/links?limit=20" \
-H "Authorization: Bearer $LOTSE_SERVER_KEY"
Each route needs one scope. Every field and every answer is in the OpenAPI 3.1 description of the API.
GET /v1/linkslinks:readLinks, newest first.
GET /v1/links/{id}links:readOne link with its URL.
POST /v1/linkslinks:writeCreates a link: slug and targetPath, optionally campaign, source, medium and a fallback per platform.
DELETE /v1/links/{id}links:writeDeletes a link.
GET /v1/segmentscampaigns:readThe saved segments of the project, to address a campaign.
GET /v1/campaignscampaigns:readCampaigns, newest first, without the variants of journeys.
GET /v1/campaigns/{id}campaigns:readOne campaign with its report. ?goalEvent= adds who reached that event within 24 hours.
POST /v1/campaignscampaigns:writeCreates a draft: title, body, optionally linkId, and the audience as segmentId, segment or "audience": "all".
POST /v1/campaigns/{id}/sendcampaigns:sendSends a draft now, within the 20 sends per 24 hours that API and console share.
POST /v1/campaigns/{id}/schedulecampaigns:sendPlans the send for sendAt, at least a minute and at most 30 days ahead.
POST /v1/messagesmessages:sendA push to one userId or installId: title, body, optionally targetPath.
GET /v1/messages/{id}messages:sendA message with the state of its deliveries, and whether it was opened.
POST /v1/eventsevents:write1 to 100 events of your users, by user id.
DELETE /v1/users/{userId}users:deleteDeletes a user with their installs and data.
GET /v1/openapi.jsonno keyThe OpenAPI 3.1 description of all routes. Public, also for a browser.
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.
One link per product, created when the product goes online. The answer carries the full URL on your link domain.
curl https://api.lotse.dev/v1/links \
-H "Authorization: Bearer $LOTSE_SERVER_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: product-1042" \
-d '{
"slug": "rain-jacket",
"targetPath": "/products/1042",
"campaign": "catalogue",
"source": "shop",
"fallbackWeb": "https://shop.example.com/products/1042"
}'
{
"id": 318,
"slug": "rain-jacket",
"url": "https://harbor.lotse.dev/l/rain-jacket",
"targetPath": "/products/1042",
"campaign": "catalogue",
"source": "shop",
"fallbackWeb": "https://shop.example.com/products/1042",
"createdAt": "2026-10-21T09:14:03.512Z"
}
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.
# 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"
{
"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"
}
Reaches the installs of u_42 that identified with a valid user hash. Tapped, the push opens /orders/1042.
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"
}'
{
"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
}
A purchase your backend confirmed. With an id, a second call with the same event stores nothing and counts it under duplicates.
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" }
}
]
}'
{ "accepted": 1, "duplicates": 0 }
The answer is 204 without a body, or 404 for a user the project does not know.
# 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"
HTTP/1.1 204 No Content
A message goes to exactly one target: userId, the id your app passes to Lotse.identify, or installId, the id of one install.
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.
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');
await Lotse.instance.identify(userId, userHash: userHash);
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.
Names that Lotse and its SDK write themselves are reserved:
session_startidentifypush_sentpush_openlink_clickattribution_matchsurvey_answeredfeedback_submittedfeature_votedfeature_unvotedrage_tapDELETE /v1/users/{userId} does what the delete button in the console does, in one transaction. A suspended project can still delete.
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.
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.
The same key with the same request returns the stored answer with the header Idempotent-Replayed: true, and nothing happens twice.
The same key with another path or body is a conflict. While the first request still runs, the 409 comes with Retry-After.
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.
Every answer outside 2xx is JSON with a code and an English sentence for you, never a stack trace.
{
"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."
}
invalid_inputunauthorizedforbiddenproject_suspendednot_foundmethod_not_allowedconflictinvalid_inputidentity_verification_requiredquota_exceededrate_limitedinternal_errorunavailableEvery 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.
Generous for a backend, tight enough that one stuck in a loop is slowed down. Over a limit the answer is 429.
Per key. Refused and failed calls count too.
Per key, within the 600: every POST and DELETE that reaches its route. A retry answered from its Idempotency-Key costs none.
Per project, shared by all its keys.
And 256 KiB for /v1/events, room for 100 typical events.
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.
While a project is suspended, its keys still read and delete. Everything that creates or sends gets 403 project_suspended.
Free during the beta. Sign up, create a project, add the SDK.