Developer documentation

Build with Lauther

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

Base URL: https://europe-west1-beacon-app-491068.cloudfunctions.net/api

pushpushover-compat webhookregister sign in 2FA verifytap-to-open SSO topics appriseerrors

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 …/api/v1/push \
  -H "Content-Type: application/json" \
  -d '{
    "token":    "bpt_…",
    "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
actionsarrayup to 3 {id,title} buttons — see below

Delivery is stored in the user's inbox as well — notifications survive dismissal.

Interactive notifications (two-way, anonymous)

Attach actions and the notification becomes a question. The user taps a choice; you poll for the answer with the same push token — you learn which option, never who. Priority <= -1 stays quiet on the user's device if they enabled bundling; actionable pushes always alert.

POST …/api/v1/push
  {"token":"bpt_…","title":"Acme","message":"Table for 2 at 7pm — coming?",
   "actions":[{"id":"confirm","title":"Confirm"},
              {"id":"cancel","title":"Cancel"}]}
→ {"ok":true,"id":"<notificationId>","delivered":1}

# poll for the user's choice (every ~2s)
POST …/api/v1/action/poll  {"token":"bpt_…","id":"<notificationId>"}
→ {"response":"confirm"}   // null until they pick
Buttons appear in the shade on Android; on iOS the push arrives as a normal alert (reliable) and the choices appear when it's opened. Either way the choice is recorded once and can't be changed.

Pushover-compatible endpoint

Tools with a built-in Pushover integration (Radarr, Uptime Kuma, Grafana, Healthchecks, OctoPrint, …) work by overriding the API URL — if the tool lets you set one. The user pastes their bpt_… token into the user key field (or the app-token field; either is accepted).

POST …/api/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.

Tool hardcodes api.pushover.net with no URL field? Use its generic webhook option with the endpoint below instead.

Lenient webhook

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 …/api/v1/hook/bpt_YOURTOKEN

# or a bare URL from anything that can open one:
GET  …/api/v1/hook/bpt_YOURTOKEN?title=Hi&message=It+works
# optional on either: &priority=-2..2

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. One key, kept server-side forever.

POST …/api/v1/services/register  {"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 bpt_… token.

Sign in with Lauther

OAuth-grade sign-in with two endpoints and a QR code. 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 …/api/v1/pair/start
     {"scopes":["identify","notify"],
      "url":"https://acme.example",     // enables tap-to-open SSO
      "dataScopes":["email","address"]} // optional consent scopes
→ {code, pollToken, deepLink, expiresInMs}

# 2 · show the code/QR (deepLink for mobile:
#     intent://pair/CODE#Intent;scheme=lauther;package=com.beaconapp.beacon;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 …/api/v1/pair/poll  {code, pollToken}
→ {"status":"approved","beaconId":"bcn_…","nickname":"alias",
   "pushToken":"bpt_…","sharedData":{"address":"…"}}

Store the values against your user record. pushToken is your credential for notifications and re-verification; beaconId 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".
Consent scopes. dataScopes may request email, name, phone, address. Each appears as an optional, unchecked box on the user's approval sheet; sharedData contains only what they ticked — possibly nothing. Sign-in always succeeds regardless. Design for the empty case; treat every granted field as a gift. Lauther relays values once and does not store 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 …/api/v1/identity/verify  {"token":"bpt_…"}
→ {requestId, pollToken}

POST …/api/v1/identity/poll    {requestId, pollToken}
→ {"status":"approved","beaconId":"bcn_…","nickname":"alias"}

Haven't paired with this person yet but know the name they use with you? Challenge by name instead (Authorization: Bearer lsk_… required) — scoped to your own service's aliases, never a global directory:

POST …/api/v1/identity/verify  {"nickname":"nova"}
→ {requestId, pollToken}   // 404 if nobody registered that name with you

Tap-to-open SSO

If you passed url at pairing, users can open your site from the Lauther app pre-authenticated. You'll receive a one-time code as ?beacon_session=bsc_… — redeem it server-side within two minutes:

POST …/api/v1/session/claim  {"code":"bsc_…"}
→ {"beaconId":"bcn_…","nickname":"alias"}   // single-use

Look up the beaconId in your user table and start your own session. Codes are 128-bit, single-use, app-bound.

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 …/api/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 …/api/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? A Lauther plugin provides the lauther://-style URL syntax:

apprise --plugin-path beacon_standalone.py \
  -t "Backup done" -b "Nightly job finished" \
  beacon://bpt_YOURTOKEN

Grab the plugin from the app's integrations folder (upstream PR pending).

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).