Developer documentation

Build with Lauther

Push notifications, anonymous sign-in, passwordless verification and one-tap SSO — all over plain HTTPS. No SDK required.

The short pitch: Lauther is the missing link between your web app and a phone. A browser tab can't reliably reach a user who closed it, can't ask for a fingerprint, and can't put a button in the notification shade — a native app can. Building one costs you two codebases, two store accounts, review queues and a permanent maintenance tax. Here, your users install one app once, for every site that speaks Lauther, and you borrow it with an HTTP call. You stay a web app; your users get native reach.

Base URL: https://api.lauther.id (the long …cloudfunctions.net/api URL keeps working)

Working code: lauther-examples on GitHub — copy-pasteable recipes for curl, Python, Node, Home Assistant, Uptime Kuma and GitHub Actions, including a deploy pipeline that waits for a human before it ships.
pushpushover-compat webhookapprove register sign in 2FA verifysigning back in topics apprisesecurity errors

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}
fieldtypenotes
tokenstringrequired — the push credential
titlestringrequired (or message)
messagestringbody text, ≤2000 chars
priorityint−2…2 · 2 = urgent, bypasses quiet hours
icon, color, soundstringappearance overrides
urlstringtap-through link
tagstringgrouping / collapse key
pathstringa 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.

Prefer the header. The token is also accepted as 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.

kindyou sendwhat the user gets
approvalPOST /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.
Breaking change: 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.

Fair warning: many popular tools — Uptime Kuma, Grafana and Radarr among them — hardcode 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
A URL-borne token can end up in proxy and access logs, so treat this as the compatibility mode for clients that can't set a header or body — anything smarter should use /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
fieldtypenotes
tokenstringrequired (body or Bearer header)
titlestringrequired — the question
messagestringcontext under the question
approveTitle, denyTitlestringbutton labels (default Approve/Deny)
waitint0–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
The proof names your serviceId, so nobody can verify a domain they don't control — and no service can impersonate a verified one. Do this before going live: users are told to distrust unverified prompts.

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
Rotation keeps your integration alive. The new key inherits the service's identity — its 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.

Names are scoped to your service. The alias a user picks is unique among your users only — a 409 {"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".
A second scan is a sign-in, not a second person. When the user already has an identity with you, the app sees it (/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.
Lauther does not relay personal data. No name, email, phone or address passes through it — if you need those, ask for them on your own site, where the user can see your form and your privacy policy. All you get here is a pairwise identifier, a name the user chose for you, and a way to reach them.

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_…"}
The token is handed back once. On this branch the alias only resolves because you minted it, so returning 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".
Don't lean on this as the primary route. The app suggests a random alias by default, so most users cannot type theirs from memory — which is fine, because the app is the thing that remembers. Offer this for the borrowed-laptop case and put route 1 first.

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}
Delivery is fire-and-forget over FCM's native topic fan-out. There's no subscriber count and no read receipt — that opacity is the feature. Optional 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.

One thing to know about 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.
On an older Apprise? The standalone plugin still works: download lauther_standalone.py and pass it with --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

statusmeaning
400missing/invalid fields
401missing credential
403invalid or revoked token
404unknown code / nickname / request
409alias already taken for this service — retry with a different one
410expired or already used

All errors return {"error":"human-readable reason"} (Pushover-compat endpoints use Pushover's {"status":0,"errors":[…]} shape).