All endpoints require Authorization: Bearer <token> unless noted. Tokens are
issued with scopes:
| Scope | Grants |
|---|---|
notify:send | POST /v1/notify |
device:manage | device registration and management |
admin | everything, including token management |
An admin token overrides all scopes.
Errors
All errors use a consistent envelope:
{
"error": {
"code": "invalid_request",
"message": "title or message is required"
}
}Common codes: invalid_token (401), forbidden (403),
email_not_verified (403), account_suspended (403), not_found (404),
invalid_request (400), stale_key (409), duplicate_message (409),
subscription_exists (409), payload_too_large (413), rate_limited (429),
plan_limit_reached (429), payment_required (402), internal (500). Key
rotation additionally returns proof_invalid (403) — see the rotate
endpoints below. Registration additionally returns signup_disabled (403)
when PRELAY_SIGNUP_ENABLED=false, signup_limit_reached (403) once
PRELAY_MAX_SIGNUPS accounts exist, and captcha_failed (403) when the
Turnstile CAPTCHA is enabled and the token is missing, invalid, or the
verifier is unreachable.
email_not_verified (403) blocks token creation, /v1/notify, and checkout
until the account's email is verified (SaaS). account_suspended (403) blocks
/v1/notify for a suspended account.
payload_too_large is returned by POST /v1/notify when the serialized
envelope exceeds 3840 bytes: push delivery caps the payload at 4KB (FCM
data-message limit), so a larger notification can never be delivered. Keep
the combined title/message/actions (Mode A) or ciphertext (Mode B) small
enough to fit — roughly 2.5KB of plaintext.
Health
GET /healthz
Liveness probe (no auth).
{ "status": "ok" }Server metadata
GET /v1/meta — server metadata
Unauthenticated, but the build version is disclosed only to authenticated callers (a valid session or Bearer token) so anonymous clients cannot fingerprint the exact build (issue #121). No account or billing data is ever included:
{ "version": "v0.4.4", "saas": false, "support_email": "[email protected]",
"signup_enabled": true, "signup_limit_reached": false,
"turnstile_site_key": "" }version is omitted for unauthenticated requests; saas reports
PRELAY_SAAS_MODE. signup_enabled mirrors PRELAY_SIGNUP_ENABLED and
signup_limit_reached is true once the account count hits
PRELAY_MAX_SIGNUPS (always false when the cap is unlimited) — the signup
form uses both to show a closed notice. turnstile_site_key is the public
Cloudflare Turnstile site key, empty when the CAPTCHA is disabled (the signup
form renders the widget only when it is non-empty).
GET /v1/fcm-config — Firebase client config
Auth: any valid Bearer token (no specific scope). Returns the app-side
Firebase values the Android app fetches at onboarding to initialize FCM at
runtime — there is no build-time google-services.json in the app:
{
"sender_id": "…",
"api_key": "…",
"app_id": "1:…:android:…",
"project_id": "…"
}Returns 404 not_found when the server has no
PRELAY_FCM_GOOGLE_SERVICES configured. Each deployment uses its own
Firebase project — see
docs/deployment.md.
GET /v1/public/pricing — public plan catalog
Unauthenticated. Returns the live Stripe-backed catalog so the landing page and clients never advertise a price checkout won't charge:
{
"plans": [
{
"id": "pro",
"name": "Pro",
"message_limit": 30000,
"device_limit": 15,
"seat_limit": 3,
"price_monthly": 1200,
"price_annual": 11500,
"currency": "eur"
}
]
}Amounts are minor units (cents). Returns 404 not_found when billing is
disabled. Rate-limited per client IP and served with
Cache-Control: public, max-age=300.
Devices
POST /v1/devices — register a device
Scope: device:manage
{
"name": "Pixel 9 Pro",
"public_key": "<base64url 32-byte X25519 public key>",
"fcm_token": "<optional FCM registration token>"
}The public key is generated on the device; the private key never leaves it.
key_fingerprint is computed server-side. Returns 201 with the device:
{
"id": "0193f8e2-...",
"name": "Pixel 9 Pro",
"public_key": "…",
"key_version": 1,
"key_fingerprint": "1a2b3c4d…",
"status": "active",
"last_seen_at": 1710000000,
"created_at": 1710000000,
"updated_at": 1710000000
}The fcm_token is write-only: it is accepted on registration and PATCH
but never returned in any device response — it is a bearer credential
within the Firebase project and no client needs to read it back.
GET /v1/devices — list devices
Scope: device:manage. Returns 200 with an array of devices, newest first.
GET /v1/devices/{id} — get a device
Scope: device:manage. Returns 200 with the device or 404.
GET /v1/devices/{id}/public-key — get a device's public key
Scope: notify:send. Returns 200 with {id, public_key, key_version}.
The public key is not secret — senders need it to encrypt locally (Mode B,
zero-knowledge) — so this read-only endpoint works without device management.
PATCH /v1/devices/{id} — update a device
Scope: device:manage. Currently supports updating fcm_token (called by the
app when its push token refreshes):
{ "fcm_token": "<new token>" }Returns 200 with the updated device.
DELETE /v1/devices/{id} — revoke a device
Scope: device:manage. Returns 204. Revoked devices no longer receive
notifications.
POST /v1/devices/{id}/rotate/challenge — get a rotation challenge
Scope: device:manage
Returns an ephemeral X25519 challenge the device must prove possession of its
current private key against. Returns 200:
{
"challenge_id": "0193f8e2-…",
"challenge_key": "<base64url ephemeral X25519 public key>"
}Challenges are single-use, expire after 5 minutes, and are held in memory only (server restart invalidates them — just request a new one). Requesting a new challenge invalidates the previous one for that device.
POST /v1/devices/{id}/rotate — rotate a device key
Scope: device:manage
{
"public_key": "<new base64url X25519 public key>",
"challenge_id": "<from rotate/challenge>",
"proof": "<base64url 32-byte proof of possession of the CURRENT key>"
}The proof binds the new key and the device id to the current private key:
shared = X25519(current_priv, challenge_key)
proof = base64url( HKDF-SHA256(ikm=shared,
salt=SHA256(new_pub || device_id),
info="prelay-rotate-proof-v1", L=32) )This means a stolen API token alone cannot redirect a device's traffic to an
attacker key. A missing challenge_id/proof or an invalid public_key →
400 invalid_request; an undecodable or wrong-length proof, or an unknown,
expired, or already-used challenge, or a wrong proof → 403 proof_invalid
(the challenge is consumed on any verification attempt — request a fresh one
to retry). See §9 of docs/protocol.md.
Replaces the stored key and increments key_version. After rotation, senders
encrypting to the old key get 409 + stale_key, and the device is notified
on the old key (best-effort). There is deliberately no force path: if the
current private key is lost, recovery is revoke + re-onboard.
Tokens
POST /v1/tokens — create a token
Scope: admin
{ "scopes": ["notify:send"], "device_id": "<optional device restriction>" }Returns 201. The full token is returned once and never stored:
{
"token": "prelay_1_…",
"record": {
"id": "…",
"prefix": "prelay_1_ab12",
"scopes": ["notify:send"],
"device_id": "",
"revoked": false,
"created_at": 1710000000,
"last_used_at": 0
}
}Scope exclusivity: notify:send and device:manage are mutually exclusive
(least privilege — an app token can never send, a sending token can never
manage devices); a token with both is rejected with 400 unless it also has
admin. Use a device:manage token in the Android app and a notify:send
token for senders.
GET /v1/tokens — list tokens
Scope: admin. Returns 200 with token records (prefixes only, never full
tokens).
DELETE /v1/tokens/{id} — revoke a token
Scope: admin. Returns 204.
Notifications
POST /v1/notify — send a notification
Scope: notify:send
Two modes, selected by the encrypted flag.
Mode A — plaintext (server encrypts)
The server reads the plaintext, encrypts it for the target device, and routes
it. Default when encrypted is absent:
{
"title": "Deployment failed",
"message": "Production deployment failed.",
"priority": "high",
"channel": "deploy",
"device": "phone",
"ttl": 3600
}| field | type | required | notes |
|---|---|---|---|
title | string | one of title/message | shown on the notification |
message | string | one of title/message | body |
priority | string | no | normal (default) or high. High only for genuine alerts. |
channel | string | no | Android notification channel id |
device | string | no | device id or name; empty/all = every active device |
ttl | int | no | seconds the message remains deliverable (max 604800) |
actions | array | no | optional, max 3: {"label": string ≤ 24 chars, "type": "open_url" | "ack", "url": string}; open_url needs an https URL. The server validates this in Mode A; Mode B senders must validate before encrypting. |
Returns 202 Accepted:
{
"message_id": "0193f8e2-…",
"devices": 1,
"failed": 0,
"status": "accepted"
}failed counts target devices the push transport did not accept; a request
with at least one accepted device is still 202. A device selector that
matches no active device returns 404 not_found; a sender token bound via
device_id may only target its own device (403 forbidden otherwise).
Mode B — encrypted envelope (zero-knowledge)
The sender encrypts with the device's public key; the server only validates and routes. Required for zero-knowledge deployments (the SaaS enforces this mode):
{
"device": "0193f8e2-…",
"encrypted": true,
"payload": { "…envelope…" }
}The envelope format is defined in docs/protocol.md §4. Validation:
- unsupported
version/algorithm→400 message_idnot a canonical lowercase UUIDv7 →400ttlabove 604800 seconds →400created_atoutside the clock-skew window →400key_versionmismatch →409stale_key- duplicate
message_id→409duplicate_message
Returns 202 with the envelope's message_id.
Rate limiting
Requests are rate-limited per token (PRELAY_RATE_LIMIT_RPS / _BURST,
defaults 20/s and 40). Excess requests return 429 rate_limited.
The Bearer-token surface is additionally rate-limited per client IP (5× the per-token limits, floored at 50/s and burst 100), so unknown or revoked tokens — which have no per-token limiter entry — cannot flood the token lookup. Unknown tokens are also remembered briefly (60s) so repeats skip the database lookup entirely.
Delivery state
A notification is accepted immediately and pushed asynchronously. The server
keeps a delivery record (routing metadata only — never plaintext) for the
configured PRELAY_DELIVERY_TTL (default 24h), after which it is purged.
Replay protection is separate: a repeated message_id is rejected for
max(envelope ttl, PRELAY_DELIVERY_TTL) — up to the 7-day envelope maximum —
so the guarantee outlives the delivery records themselves.
SaaS (Prelay Cloud)
Enabled with PRELAY_SAAS_MODE=1 (PostgreSQL required). Adds accounts,
dashboard sessions, billing, and zero-knowledge-only notify (Mode A is
rejected with 403 zero_knowledge_required).
Auth (dashboard sessions)
Dashboard sessions are transported exclusively via an HttpOnly cookie
(prelay_session: Secure, SameSite=Lax, Path=/, lifespan
PRELAY_SESSION_TTL). The cookie is set by register/login and cleared by
logout; the raw session token is never included in any response body, so it is
unreachable from JavaScript (issue #6). Authorization: Bearer applies to API
tokens only — it is no longer accepted for sessions.
CSRF defense is layered on the cookie: unsafe methods (non-GET/HEAD/OPTIONS)
must arrive without an Origin/Referer (non-browser clients) or with an
origin whose host matches the request host or is listed in
PRELAY_CORS_ORIGINS. Anything else is rejected with
403 csrf_origin_invalid. Safe methods are exempt, and the Stripe webhook
(server-to-server, signature-verified) is not affected.
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /v1/auth/register | none | {email, password, name, tos_accepted, immediate_performance, turnstile_token} → {account} + session cookie; grants a trial (subject to the per-IP trial allowance, PRELAY_TRIAL_PER_IP_PER_DAY). tos_accepted and immediate_performance must both be true; email must be a single valid address (RFC 5322, ≤254 chars). Refused with signup_disabled (403) when PRELAY_SIGNUP_ENABLED=false, signup_limit_reached (403) once PRELAY_MAX_SIGNUPS accounts exist, or captcha_failed (403) when the Turnstile CAPTCHA is enabled (turnstile_token missing/invalid, or the verifier unreachable) |
| POST | /v1/auth/login | none | {email, password} → {account} + session cookie. When 2FA is enabled the response is instead 200 {totp_required: true, totp_token} — finish the login with /v1/auth/login/totp |
| POST | /v1/auth/login/totp | none | {totp_token, code} → {account} + session cookie; code is a TOTP or recovery code. The challenge lives 5 minutes and tolerates at most 5 wrong codes |
| GET | /v1/auth/me | session | current account |
| POST | /v1/auth/logout | session | revokes the session, expires the cookie |
| GET | /v1/auth/sessions | session | list active sessions: {id, user_agent, created_at, expires_at, current} (no tokens) |
| DELETE | /v1/auth/sessions/{id} | session | revoke one session (404 if it belongs to another account) |
| DELETE | /v1/auth/sessions | session | revoke every session except the current one |
| POST | /v1/auth/forgot | none | {email} → always 202 (the response never reveals whether the account exists); emails a reset link when it does. At most one reset email per address per 5 minutes — inside that window the response is the same 202 and no new link is issued (the previous one stays valid) |
| POST | /v1/auth/reset | none | {token, password} → 204. Single-use token; on success revokes every session and API token for the account |
| POST | /v1/auth/verify | none | {token} → 204. Confirms the account's email with the one-time token from the verification email (400 invalid_token when wrong/expired) |
| POST | /v1/auth/resend-verification | session | emails a fresh verification link, invalidating the previous one → 202; 409 when already verified; 503 mailer_unavailable when email delivery is not configured |
Account resources are scoped to the session's account. Expired sessions are purged by the janitor.
Account
| Method | Path | Auth | Notes |
|---|---|---|---|
| DELETE | /v1/account | session | {password} → 204. Permanently deletes the account and all its data; any active Stripe subscription is cancelled first. Gated on the account password — a hijacked session cookie alone is not enough — and shares the login lockout (401 invalid_credentials, 429 rate_limited); a failed Stripe cancellation returns 502 stripe_cancel_failed and the account is left intact |
| POST | /v1/account/password | session | {current_password, new_password} → 204. Shares the login lockout (per-email) so a stolen session cannot grind the password; revokes every other session, keeps the current one — API tokens stay valid |
| POST | /v1/account/totp/setup | session | starts 2FA enrollment → 200 {secret, otpauth_url} (render otpauth_url as the QR code for authenticator apps); 409 totp_enabled if already on |
| POST | /v1/account/totp/enable | session | {password, code} completes enrollment: the password is re-proved and a current code proves the authenticator works → 200 {recovery_codes: [10 one-time codes]} — returned exactly once; 409 totp_setup_required without a prior setup |
| POST | /v1/account/totp/disable | session | {password, code} → 204. The password is always required; while 2FA is on, code must be a current TOTP or recovery code. Drops the secret and all remaining recovery codes |
Account tokens
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /v1/account/tokens | session | create an API token for the account |
| GET | /v1/account/tokens | session | list (prefixes only) |
| DELETE | /v1/account/tokens/{id} | session | revoke |
Account tokens require a verified email (403 email_not_verified) and never
carry the admin scope (400 if requested); use prelayd admin-token for a
bootstrap admin token.
Billing
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /v1/billing | session (owner) | plan, trial, usage, limits |
| GET | /v1/billing/payments | session | payment history: [{id, stripe_invoice_id, amount_cents, currency, status, created_at}] |
| GET | /v1/billing/plans | session | plan catalog with Stripe prices ({plans:[...]}) |
| POST | /v1/billing/checkout | session (owner) | {plan, interval} (interval: monthly|annual) → Stripe checkout URL; 409 subscription_exists when a live subscription already exists |
| POST | /v1/billing/portal | session (owner) | Stripe billing portal URL |
| POST | /v1/billing/sync | session (owner) | pull subscription state from Stripe; heals a lost webhook → {synced, changed} |
| POST | /v1/billing/webhook | Stripe-signed | subscription lifecycle → plan/status; checkout.session.completed subscribes immediately |
Plans (PRD §30.6): solo (2 devices, 300 notifications/mo, 1 seat), pro
(15 devices, 30k notifications/mo, 3 seats), team (50 devices, 150k
notifications/mo, 8 seats). Seat limits count the owner plus members and are
enforced on team invites and joins. New accounts get a 14-day Pro trial.
Exceeding limits returns 429 plan_limit_reached; an expired trial without
a subscription returns 402 payment_required. Annual billing requires the
corresponding annual price ID to be configured
(PRELAY_STRIPE_PRICE_*_ANNUAL).
Account devices
Dashboard view of the tenant's devices: same data as the token-authenticated
/v1/devices, but session-authenticated and scoped to the session's tenant —
team members see the team's devices.
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /v1/account/devices | session | list devices (same shape as GET /v1/devices) |
| DELETE | /v1/account/devices/{id} | session | revoke a device → 204 |
Teams
Team routes operate on the session's own account as the team. Only the owner
— an account that is not a member of another account's team — may manage it;
members get 403 forbidden.
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /v1/team/invites | session (owner) | {email} → 201 {link, email}. Single-use invite link, valid 7 days; 409 already_invited when an invite for the same email is already pending; 429 plan_limit_reached when the plan's seat limit is full |
| GET | /v1/team/invites | session (owner) | {invites: [{id, email, role, created_at, expires_at}]} pending invitations, newest first |
| DELETE | /v1/team/invites/{id} | session (owner) | revoke a pending invitation → 204; 404 if absent or not this team's |
| GET | /v1/team/members | session (owner) | [{id, email, name, role, created_at}] |
| DELETE | /v1/team/members/{id} | session (owner) | remove a member, freeing a seat → 204; 404 if not a member of this team |
| POST | /v1/team/join | session | {invite} → 200 {team}. The signed-in account's email must match the invite (403 otherwise); 404 for an invalid invite, 400 for an expired one; 409 if the account already belongs to a team; 429 plan_limit_reached when the team is full |