All endpoints require Authorization: Bearer <token> unless noted. Tokens are issued with scopes:

ScopeGrants
notify:sendPOST /v1/notify
device:managedevice registration and management
admineverything, 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
}
fieldtyperequirednotes
titlestringone of title/messageshown on the notification
messagestringone of title/messagebody
prioritystringnonormal (default) or high. High only for genuine alerts.
channelstringnoAndroid notification channel id
devicestringnodevice id or name; empty/all = every active device
ttlintnoseconds the message remains deliverable (max 604800)
actionsarraynooptional, 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_id not a canonical lowercase UUIDv7 → 400
  • ttl above 604800 seconds → 400
  • created_at outside the clock-skew window → 400
  • key_version mismatch → 409 stale_key
  • duplicate message_id → 409 duplicate_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.

MethodPathAuthNotes
POST/v1/auth/registernone{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/loginnone{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/totpnone{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/mesessioncurrent account
POST/v1/auth/logoutsessionrevokes the session, expires the cookie
GET/v1/auth/sessionssessionlist active sessions: {id, user_agent, created_at, expires_at, current} (no tokens)
DELETE/v1/auth/sessions/{id}sessionrevoke one session (404 if it belongs to another account)
DELETE/v1/auth/sessionssessionrevoke every session except the current one
POST/v1/auth/forgotnone{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/resetnone{token, password} → 204. Single-use token; on success revokes every session and API token for the account
POST/v1/auth/verifynone{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-verificationsessionemails 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

MethodPathAuthNotes
DELETE/v1/accountsession{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/passwordsession{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/setupsessionstarts 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/enablesession{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/disablesession{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

MethodPathAuthNotes
POST/v1/account/tokenssessioncreate an API token for the account
GET/v1/account/tokenssessionlist (prefixes only)
DELETE/v1/account/tokens/{id}sessionrevoke

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

MethodPathAuthNotes
GET/v1/billingsession (owner)plan, trial, usage, limits
GET/v1/billing/paymentssessionpayment history: [{id, stripe_invoice_id, amount_cents, currency, status, created_at}]
GET/v1/billing/planssessionplan catalog with Stripe prices ({plans:[...]})
POST/v1/billing/checkoutsession (owner){plan, interval} (interval: monthly|annual) → Stripe checkout URL; 409 subscription_exists when a live subscription already exists
POST/v1/billing/portalsession (owner)Stripe billing portal URL
POST/v1/billing/syncsession (owner)pull subscription state from Stripe; heals a lost webhook → {synced, changed}
POST/v1/billing/webhookStripe-signedsubscription 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.

MethodPathAuthNotes
GET/v1/account/devicessessionlist devices (same shape as GET /v1/devices)
DELETE/v1/account/devices/{id}sessionrevoke 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.

MethodPathAuthNotes
POST/v1/team/invitessession (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/invitessession (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/memberssession (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/joinsession{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

On this page