API

thesada-app serves a versioned JSON REST API at /api/v1. The web dashboard and the client apps use the same contract. All request and response bodies are JSON.

Authentication

Most endpoints require authentication. Two credentials are accepted:

  • Bearer token - Authorization: Bearer <token>. Obtain one from POST /auth/login.
  • Session cookie - set by the same login; used by web-origin clients.

Tokens and session tokens are stored only as SHA-256 hashes server-side.

Status Meaning
400 malformed request - bad id, invalid JSON body, or invalid field value
401 no valid credential presented
403 authenticated but missing the required role (e.g. a non-super-admin calling pair)
404 resource not found, or outside your tenant

On the unauthenticated enrollment endpoints, 403 means something else: one uniform refusal covering every reason a device enrollment did not proceed. See Device enrollment.

Errors

Every error response is a JSON object with a single error string:

{ "error": "device not found" }

Success bodies vary by endpoint (an object, an array, or { "status": "ok" }).

List limits

List endpoints accept ?limit= - default 100, maximum 500. Out-of-range or unparseable values fall back to the default.

Endpoints

Method Path Auth OK Purpose
GET /healthz none 200 liveness probe
GET /readyz none 200 / 503 readiness probe (DB + broker)
GET /version none 200 running CalVer, commit, and build time
POST /auth/login none 200 password login - returns a bearer token + user
POST /auth/logout optional 200 revoke the presented token and/or cookie
POST /auth/signup none 200 join the waitlist
GET /devices yes 200 list the tenant’s devices
GET /devices/{id} yes 200 one device
POST /devices/{id}/pair super-admin 200 issue a device client certificate
POST /devices/enroll none 200 device announces itself, gets a challenge
POST /devices/enroll/verify none 200 device returns a signature over the challenge
POST /devices/enroll/cert none 200 / 204 device collects its certificate once claimed
POST /devices/enroll/ack none 200 device confirms the certificate is stored
GET /devices/{id}/telemetry yes 200 telemetry readings
GET /devices/{id}/alerts yes 200 a device’s alerts
GET /alerts yes 200 the tenant’s alerts
GET /alert-subscriptions yes 200 the caller’s alert subscriptions
POST /alert-subscriptions yes 201 create a subscription
DELETE /alert-subscriptions/{id} yes 204 delete a subscription

POST /auth/magic-link is reserved and not yet available.

Auth

POST /auth/login - body { "email": ..., "password": ... }. On success returns the bearer token, its expiry, and the redacted user, and also sets the session cookie. Bad credentials return 401.

{
  "token": "<bearer token>",
  "expires_at": "2026-06-22T18:00:00Z",
  "user": {
    "id": "<uuid>",
    "email": "you@example.com",
    "display_name": "You",
    "tenant_id": "default",
    "is_admin": false,
    "is_super_admin": false
  }
}

POST /auth/logout - revokes whichever credential you present (bearer and/or cookie) and clears the cookie. Idempotent; returns { "status": "ok" }.

POST /auth/signup - body { "email": ..., "note": "<optional>" }. Always returns { "status": "ok" } - it never reveals whether an email is already known.

Devices

GET /devices returns an array of the caller tenant’s devices. GET /devices/{id} returns one (404 if it is not in your tenant). Device shape:

{
  "id": "<uuid>",
  "device_id": "<factory id>",
  "display_name": "Boiler node",
  "hardware_type": "esp32-owb",
  "firmware_version": "1.5.0",
  "paired_at": "...",
  "last_seen_at": "...",
  "created_at": "...",
  "last_uptime_seconds": 86400,
  "last_uptime_at": "..."
}

POST /devices/{id}/pair (super-admin) issues and stores a new client certificate for the device and returns it. The private key is returned once and is never stored server-side.

{
  "cn": "thesada-<tenant>-<device>",
  "serial_hex": "...",
  "not_before": "...",
  "not_after": "...",
  "cert_pem": "-----BEGIN CERTIFICATE----- ...",
  "private_key_pem": "-----BEGIN PRIVATE KEY----- ...",
  "ca_pem": "-----BEGIN CERTIFICATE----- ..."
}

GET /devices/{id}/telemetry - with no parameters, the latest reading per metric. With ?metric=<name>, the recent readings of that single metric (newest first), bounded by ?limit=. Reading shape:

{ "metric": "temp.boiler", "received_at": "...", "value_num": 72.5, "value_text": null }

GET /devices/{id}/alerts - a device’s alerts, newest first. Optional ?severity=info|warn|crit filter and ?limit=. Alert shape:

{ "id": 1234, "received_at": "...", "severity": "warn", "code": "battery_low",
  "message": "Battery low for 60 s", "delivered_email": true, "delivered_telegram": false }

Device enrollment

The four /devices/enroll* endpoints are the only unauthenticated device-facing surface in the API. A factory-fresh device has no credential to authenticate with, so these are guarded by rate limits, a claim token, and an Ed25519 proof of key possession instead. The narrative version of the flow is in Provisioning.

Every refusal on this surface is the same 403 with the same body, whatever went wrong:

{ "error": "enrollment refused" }

Unknown device id, wrong claim token, stale or already-consumed challenge, bad signature, an already-redeemed enrollment and a spent rate-limit bucket are indistinguishable to the caller. That is deliberate: device ids are derived from sequentially assigned factory MACs, so a caller who guesses one must not be able to tell a real id from a fabricated one. The detail goes to the server log, not the response.

Rate limits, spent before anything else is judged:

Scope Budget Applies to
client IP 60/hour all four endpoints
(device_id, pubkey) 30/hour all four endpoints
device_id alone 30/hour announce only - the endpoint that creates rows

Request bodies are capped at 4096 bytes. The client IP is taken from X-Forwarded-For only when the peer is inside THESADA_TRUSTED_PROXIES, otherwise from the connection itself.

POST /devices/enroll - body { "device_id": ..., "pubkey": ..., "claim_token": ... }. pubkey is the device’s Ed25519 public key as lowercase hex; claim_token is the plaintext claim token (the device’s setup portal will surface it once the firmware enrollment client ships - the endpoints on this page are live today). Returns a fresh challenge to sign:

{ "challenge": "<64 hex chars>", "expires_in": 300 }

Any well-formed device_id and pubkey get a challenge, always, whatever state the enrollment is in - refusing here would answer the one question this surface must not answer, which ids are real. Re-announcing is expected and replaces the outstanding challenge, so an old one cannot be answered later. The claim token may rotate on re-announce, but only until the enrollment is verified; after that the stored token stands and the change is ignored silently.

POST /devices/enroll/verify - body { "device_id": ..., "pubkey": ..., "signature": ... }. signature is the Ed25519 signature over the challenge bytes, hex-encoded. Returns { "status": "verified" }.

Every endpoint after announce names its row by (device_id, pubkey) - that pair is the row’s identity, and a claim token never selects a row. The signature proves the caller holds the row’s private key. The challenge is consumed whether or not the signature checks out, so a failed attempt burns the nonce rather than leaving it grindable for the rest of its five minutes.

POST /devices/enroll/cert - body { "device_id": ..., "pubkey": ..., "claim_token": ... }. The claim token must additionally match the row - the pubkey is public, so the primary key alone is not a credential. Three outcomes:

Status Meaning
200 claimed - the certificate bundle is in the body
204 verified but nobody has claimed the device yet; keep polling
403 anything else
{
  "cert_pem": "-----BEGIN CERTIFICATE----- ...",
  "key_pem": "-----BEGIN PRIVATE KEY----- ...",
  "tenant": "<tenant slug>",
  "device_id": "thesada-0123456789ab",
  "topic_prefix": "<root>/<tenant>/<device-id>",
  "mqtt_host": "<broker host>",
  "mqtt_port": 8884
}

The tenant, topic prefix, broker host and mTLS port travel with the certificate because without them the device holds a credential it cannot use: the CN, the broker ACL and the app’s ingest are all keyed on the tenant, and the firmware’s default topic prefix belongs to no tenant.

There is no ca_pem. That would be the private device CA the broker uses to verify client certificates; the device verifies the broker against public roots it already carries, and handing it the device CA invites it to overwrite its own trust anchor and lose MQTT and OTA.

POST /devices/enroll/ack - body { "device_id": ..., "pubkey": ..., "claim_token": ... }. Returns { "status": "sealed" }, and is idempotent - a second call on an already-sealed enrollment answers the same way.

The certificate endpoint does not seal the enrollment; this does. Until the acknowledgement lands, /devices/enroll/cert re-issues, which is what makes delivery safe to retry when a device dies between receiving a certificate and storing it. Once sealed, the enrollment is terminal: a second delivery needs an explicit re-pair, so a leaked claim token cannot be redeemed twice.

Alerts and subscriptions

GET /alerts - the tenant’s recent alerts (newest first), bounded by ?limit=.

GET /alert-subscriptions - the calling user’s subscriptions.

POST /alert-subscriptions - body:

{ "channel": "email", "min_severity": "warn", "device_pk": null }

channel is email or telegram. min_severity is info, warn, or crit (default warn). device_pk is optional - omit it or send null to cover all of the user’s devices; a supplied id must belong to your tenant. Returns 201 { "status": "created" }.

DELETE /alert-subscriptions/{id} - removes one of your subscriptions. Idempotent; returns 204.


Thesada - AGPL-3.0-only (app) / GPL-3.0-only (firmware) / CC BY-NC-SA 4.0 (docs) - License

This site uses Just the Docs, a documentation theme for Jekyll.