Send a notification
Users create tokens in the app (Apps → + → New token) or mint one for your site automatically at pairing. Either way, one request delivers:
curl -X POST https://api.lauther.id/v1/push \ -H "Content-Type: application/json" \ -d '{ "token": "lpt_…", "title": "Order shipped", "message": "Your package is on the way", "priority": 1, "color": "#D9EF00", "url": "https://example.com/orders/123", "tag": "orders" }' → {"ok":true,"id":"…","delivered":1}
| field | type | notes |
|---|---|---|
token | string | required — the push credential |
title | string | required (or message) |
message | string | body text, ≤2000 chars |
priority | int | −2…2 · 2 = urgent, bypasses quiet hours |
icon, color, sound | string | appearance overrides |
url | string | tap-through link |
tag | string | grouping / collapse key |
path | string | a page on your site — tapping opens it signed in. Needs an app with a paired site URL, and must resolve to the same origin as it. |
Delivery is stored in the user's inbox as well — notifications survive dismissal.
Authorization: Bearer lpt_… instead of the
body field — use that where you control the client, so the credential stays
out of logs and dashboards. Body and URL forms exist for clients that can't
set headers.Three kinds of notification, and no fourth
Which kind you get is decided by what you send, never by a flag you can set. That is deliberate: if a caller could declare its own push an approval, a marketing message could help itself to the buttons, the urgent channel and the quiet-hours bypass.
| kind | you send | what the user gets |
|---|---|---|
| approval | POST /v1/approve or
/v1/identity/verify | Approve / Deny in the shade. The only kind with buttons, and the only one that can demand a biometric check. |
| link | /v1/push with path | No buttons. Tapping opens that page on your site, already signed in as their pairwise identity. |
| message | /v1/push with neither | Nothing to answer. Opens in the Lauther app. |
actions on /v1/push is gone and now returns
400 {"code":"actions_removed"} rather than silently dropping half
a two-way exchange. A shade button is a decision surface, and decisions are
what deserve a fingerprint — so buttons live on approvals only. Anything
conversational takes path instead: the user taps, lands on your
page signed in, and answers there with a real keyboard.# conversational: point the notification at your own page POST …/v1/push {"token":"lpt_…","title":"Your table is nearly ready", "message":"About five minutes — tap to tell us if you're coming.", "path":"/waitlist?ticket=8f3k2m"} → {"ok":true,"id":"<notificationId>","delivered":1} # the tap arrives on your page as a one-time session code GET /waitlist?ticket=8f3k2m&lauther_session=lsc_… POST …/v1/session/claim {"code":"lsc_…"} → {"pairId":"lid_…","nickname":"quiet-fox","pushToken":"lpt_…"}
path is rejected with 400 {"code":"cross_origin_path"}
unless it resolves to the same origin as the site you paired with. That check
is load-bearing, not politeness: a session code hands over the pairing's full
credential, so a code delivered to someone else's origin is a takeover.
The same paragraph has a quieter consequence worth stating plainly: an app
with no paired site URL has nothing for a path to resolve
against, so one sent on a personal token
comes back 400 "This app has no site URL, so `path` cannot be
resolved." That is the expected answer rather than a fault —
path belongs to a service that has a page to land on. Omit it
and the notification sends as a plain message.
See all three working under See it work.
Pushover-compatible endpoint
Lauther implements Pushover's message API. Anything that lets you change
the Pushover server URL works by pointing it at
https://api.lauther.id, with your lpt_… token in
the user key field (or the app-token field; either is accepted).
POST …/1/messages.json # form-encoded or JSON; fields: user|token, message (required), # title, priority, sound, url, url_title, device → {"status":1,"request":"…"}
POST /1/users/validate.json is implemented for clients that
verify the recipient first.
api.pushover.net with no URL field. For those, use their
generic webhook option with the endpoint below instead. Same result,
one URL.Lenient webhook · compatibility mode
For sources that send their own fixed payload — or where you can only paste a URL. The token lives in the path; Lauther makes sense of whatever arrives (recognizes common title/message field names, has an Alertmanager special case, falls back to a compact dump so no event is lost).
# any JSON / form / text body: POST https://api.lauther.id/v1/hook/lpt_YOURTOKEN # or a bare URL from anything that can open one: GET https://api.lauther.id/v1/hook/lpt_YOURTOKEN?title=Hi&message=It+works # optional on either: &priority=-2..2
/v1/push with Authorization: Bearer. Blast radius
is contained either way: a lpt_… token only pushes to its one
owner, who can revoke or regenerate it in two taps.Human approval — one call
The interactive-notification pattern, packaged for AI agents, CI pipelines and scripts: one request pushes an Approve/Deny question and (optionally) blocks until the human answers. A universal human-in-the-loop endpoint for anything automated.
curl -X POST https://api.lauther.id/v1/approve \ -H "Content-Type: application/json" \ -d '{"token":"lpt_…","title":"Deploy v2.4 to production?", "message":"14 commits · staging green","wait":25}' → {"ok":true,"id":"…","response":"approve"} // or "deny", or null
| field | type | notes |
|---|---|---|
token | string | required (body or Bearer header) |
title | string | required — the question |
message | string | context under the question |
approveTitle, denyTitle | string | button labels (default Approve/Deny) |
wait | int | 0–25 s to block for the answer; response is null if time runs out |
Undecided after wait? Keep polling
/v1/action/poll with the returned id — the buttons
stay live on the phone until answered.
Register your service
Pairing and name-based verification require a registered service — this closes the gap where anyone could previously call the API claiming to be any site. Registration is tied to your Lauther account, so the easiest route is the developer console: sign in with an email link, name the service, copy the key. The API form takes your account's ID token:
POST https://api.lauther.id/v1/services/register // Authorization: Bearer <Firebase ID token> {"name":"Acme"} → {"serviceId":"…","apiKey":"lsk_…"} // apiKey shown once — store it
Send apiKey as Authorization: Bearer lsk_… on
every /v1/pair/* and name-based /v1/identity/verify
call below. Push (/v1/push, Pushover-compat, webhooks) is
unaffected — those already authenticate via the user's own lpt_…
token.
Verify your domain
Anyone can register a service named anything — so until you prove your domain, the user's approval sheet marks you unverified. Prove control once and pairing requests show ✓ acme.example instead. Publish either proof, then call verify:
# proof A — a DNS TXT record on your domain: # lauther-verify=<your serviceId> # proof B — an HTTPS file: # https://acme.example/.well-known/lauther.json → {"serviceId":"<your serviceId>"} POST https://api.lauther.id/v1/services/verify // Authorization: Bearer lsk_… {"domain":"acme.example"} → {"ok":true,"verifiedDomain":"acme.example","method":"dns-txt"} // 422 {"code":"not_verified", "expected":{…}} tells you exactly what to publish
Manage your services
The developer console lists your services, rotates or revokes keys, and edits the contact address. The same four operations exist as API calls, authenticated like registration — with your account's ID token, never the service key:
# all: Authorization: Bearer <Firebase ID token> POST …/v1/services/mine // your services — safe fields only POST …/v1/services/rotate {"serviceId":"…"} // new lsk_ key, shown once POST …/v1/services/update {"serviceId":"…","contactEmail":"…"} POST …/v1/services/revoke {"serviceId":"…"} // permanent
serviceId, pairings, aliases and domain verification all carry
over. Only the bearer secret changes, and the old one stops working the
moment the new one exists. The service's name stays fixed here: it is
what pairing sheets displayed when your users said yes.Sign in with Lauther
Full sign-in with two endpoints and a QR code — deliberately simpler than OAuth, no SDK, no redirect dance. Each user is pairwise: your app receives an ID and alias generated only for you — two services can never correlate the same person.
# 1 · create a pairing request (Authorization: Bearer lsk_…) POST …/v1/pair/start {"scopes":["identify","notify"], "url":"https://acme.example"} // enables tap-to-open SSO → {code, pollToken, deepLink, expiresInMs} # 2 · show the code/QR (deepLink for mobile: # intent://pair/CODE#Intent;scheme=lauther;package=id.lauther;end # on Android, or the deepLink as-is on iOS). # The user approves in the app and picks a name for YOUR service. # 3 · poll until resolved (every ~2s, 5 min TTL) POST …/v1/pair/poll {code, pollToken} → {"status":"approved","pairId":"lid_…","nickname":"alias", "pushToken":"lpt_…"}
Store the values against your user record. pushToken is your
credential for notifications and re-verification; pairId is
the stable pairwise identifier.
{"code":"alias_taken"} means someone else registered with you
under that name already; the pairing request stays pending, so just ask for
a different one and resolve again with the same code. Two
different services can freely have their own user both known as "nova"./v1/pair/info returns existing) and offers
“Sign in as @nova” alongside “Create a separate identity”. On
the first, it resolves with {"reuse": true} and
/v1/pair/poll returns the same pairId,
alias and pushToken as before, plus "reused": true
so you can say “welcome back” instead of creating a duplicate row.
This used to be a dead end: re-scanning minted a stranger, and a user who typed the name they already went by was told it was taken — by their own earlier pairing. Nothing changes for your integration except that
reused exists and duplicates stop appearing.Identity verification (passwordless 2FA)
Challenge the user you paired with — their phone shows “Approve sign-in to Acme as @alias?” with Approve/Deny. Two-minute TTL.
POST …/v1/identity/verify {"token":"lpt_…"} → {requestId, pollToken} POST …/v1/identity/poll {requestId, pollToken} → {"status":"approved","pairId":"lid_…","nickname":"alias"}
Signing a returning user back in
Pairing happens once. Everything after it is a sign-in, and there are exactly three routes home. Build all three and a user is never stranded; build none and your integration silently only works on the browser they first paired with.
1 · They start from the app — the common case
If you passed url at pairing, the user opens Lauther, taps your
entry, and lands on your site already authenticated. Nothing is typed. You
receive a one-time code as ?lauther_session=lsc_… — redeem it
server-side within two minutes:
POST …/v1/session/claim {"code":"lsc_…"} → {"pairId":"lid_…","nickname":"alias","pushToken":"lpt_…"} // single-use
The pushToken comes back too, so a browser that had cleared
everything regains the ability to notify, not just to identify. Codes are
128-bit, single-use and app-bound; the claim is transactional, because deep
links get prefetched by mail clients and chat previews before a human ever
taps them.
2 · They type the name you know them by
Your login form takes an alias instead of an email, and there is no password
field at all. Requires Authorization: Bearer lsk_…, and resolves
only inside your service's alias namespace — there is no global
directory to enumerate, and an unregistered caller cannot probe it.
POST …/v1/identity/verify {"nickname":"nova","requireBiometric":true} → {requestId, pollToken} // 404 if nobody registered that name with you POST …/v1/identity/poll {requestId, pollToken} → {"status":"approved","pairId":"lid_…","nickname":"nova", "confirmedBy":"biometric","pushToken":"lpt_…"}
pushToken restores what you already had rather than
disclosing anything new — but polling is a loop, so it is dropped after the
first successful read. Grab it on the response that says
"approved".3 · They scan your QR again
Identical to first-time pairing (see above). Because
/v1/pair/info reports an existing pairing, the app offers to
sign in as that identity, and /v1/pair/poll hands you back the
same pairId with "reused": true.
Broadcast topics
Fan a message out to many subscribers with zero knowledge of who they are. Subscribers join client-side (via a code), so you can reach them but never enumerate them — ideal for a public status page, a feed, or an alert channel.
# create a topic — keep the publishKey secret, share the code POST …/v1/topics/create {"name":"Acme Status"} → {"code":"K7Q2-9F3D","publishKey":"ltk_…","topicId":"t_…"} # users subscribe in the Lauther app with the code (nothing stored server-side) # broadcast to every subscriber POST …/v1/topics/publish {"publishKey":"ltk_…","title":"Acme","message":"All systems operational"} → {"ok":true}
priority (−2…2) is honoured.Apprise
Using Apprise or a tool that
embeds it? Lauther ships inside it. As of
Apprise
1.13.1, lauther:// is a native notification target:
# nothing to download — it is in the package pip install "apprise>=1.13.1" apprise -t "Backup done" -b "Nightly job finished" lauther://lpt_YOURTOKEN
The optional query parameters map straight onto the fields above:
?icon=, ?color=, ?group= (sent as
tag) and ?route= (sent as path).
Bodies are truncated at 2000 characters, matching message.
route=. It carries the same rule as path: it
resolves against the paired site URL of the app the token belongs to. A
personal token has no paired site, so a URL like
lauther://lpt_…?route=/orders comes back 400 —
leave route= off there and the notification sends normally. It
earns its keep on a service token, where the tap lands the user on your own
page already signed in.--plugin-path, or drop it into
~/.apprise/plugins/ where Apprise auto-loads it.Security model — limits included
The short version of our threat model, including the parts that favor us less. Questions or findings: we want them.
Account recovery & multiple devices
Your Lauther identity is anchored to one email address — the one place email survives, precisely because it's the recovery path everyone already understands. Sign-in is a link sent to that address; there is no password to choose, phish or forget. Lose your phone? Sign in on the new one and every pairing, token and inbox entry is restored. Several signed-in devices all receive every notification. Losing a device never means losing your accounts — and that email is never shown to any service.
Rotation & revocation
Every credential is individually killable, by you, instantly: revoke a pairing ("burn this identity" — the service's ID, alias and push channel all die together), regenerate a manual token (old one stops working immediately), or delete an email alias. Nothing requires support tickets.
What a service holds — and what a breach leaks
A paired service stores an opaque pairwise ID, the alias you chose for it, and a push token. A breach of that service exposes your relationship with it — not your email, not your other accounts, not your real-world identity. That's a radical reduction, not zero: pseudonymous data is still data, and we won't pretend otherwise.
What Lauther itself can see
Pairwise IDs protect you from services correlating you. Lauther's backend, by construction, knows which pairs belong to one account — that's what makes one-tap revocation, recovery and multi-device possible. So the precise claim is pairwise pseudonymity toward services, not anonymity from the operator. Notification content currently transits our infrastructure unencrypted-at-the-application-layer (TLS in transit, of course); end-to-end encrypted payloads are on the roadmap for sensitive verticals.
What Lauther does not do
We prove you're the same pseudonymous person as last time — never that you're a unique human. Pairwise identity is the opposite of Sybil resistance: one person can hold many identities by design. Don't build one-person-one-vote, one-coupon or age-verification on Lauther alone.
Service authenticity
Service registration is self-serve, so names are claims, not facts — trust the verified domain line on the approval sheet, not the display name. Unverified services are explicitly marked.
Errors
| status | meaning |
|---|---|
400 | missing/invalid fields |
401 | missing credential |
403 | invalid or revoked token |
404 | unknown code / nickname / request |
409 | alias already taken for this service — retry with a different one |
410 | expired or already used |
All errors return {"error":"human-readable reason"}
(Pushover-compat endpoints use Pushover's
{"status":0,"errors":[…]} shape).