Files
Alarm/API.md
T
2026-10-18 22:31:16 +01:00

27 KiB
Raw Blame History

Alarm — service API (v1, frozen)

The Alarm service is the server half of the Dcntrlized alert feature: one user (the admin) creates an alert and holds an admin token; everybody else joins by scanning the alert's subscribe token as a QR code; the admin can trigger and disarm the alert at will, and a trigger must fire live on every subscribed client.

  • Container / DNS name: alarm
  • One plaintext listener inside the container: :8080 (dev host port 8088)
  • Module: git.dcntrlized.org/Dcntrlized/Alarm
  • Image: 192.168.122.183:3000/dcntrlized/alarm:dev

Everything — the API, the auth proxy and the WebSocket — rides that single port. There is no second listener and no TLS on the service itself (an edge proxy terminates TLS).

Routes

Route Purpose
GET /_manifest service manifest consumed by Maestro
/auth/* shared-auth proxy (dcntrlized-auth), unchanged from every other service
/action/{actionName} the action API documented below
GET /ws/alarm the live alarm socket

/ws/alarm is deliberately registered on the outer mux, outside the logging/CORS chain: the connection is hijacked and the subscribe token travels in the query string, so it must never reach a request logger.

Envelope and authentication

/action/* uses the platform envelope:

{ "payload": { "...": "action specific" } }
  • POST, PUT and DELETE read the envelope from the body. Unknown fields inside payload are ignored (forward compatible); an unknown top-level envelope field is rejected by the dispatcher with a plain-text 400 (see Errors below).
  • GET carries no body: the same actions take their arguments from the query string, e.g. GET /action/getAlert?alertId=….
  • Every action that addresses an alert accepts alertId under the alias id, both as a query parameter and as a payload key: ?id=<uuid> and { "payload": { "id": "<uuid>" } } work exactly like alertId. Precedence is query alertId → query id → payload alertId → payload id; the first non-empty one wins.

Every alert action requires a verified access token in the X-Access-Token header (minted by dcntrlized-auth). A missing, malformed or expired token is always:

{ "error": { "code": "ERR_UNAUTHORIZED", "message": "…" } }

with HTTP 401. The service never trusts a user id, name or email from the payload — identity comes from the token only. The shared-auth proxies (login, register, refresh, delete, logout, me) are the exception: login/register mint the token, me/logout/refresh/delete forward whatever header the caller sent and let dcntrlized-auth decide.

Two further credentials exist, both in the payload (never in a header):

  • adminToken — the alert's admin secret. Minted once by createAlert, stored only as a SHA-256 hash server-side, and required by the destructive actions.
  • subscribeToken — the shareable secret rendered as the QR code. It is rotatable and is the only thing a joining client needs.

Errors are not uniform across the request pipeline. Clients must know where the boundary is.

Action-level errors — everything the action handlers themselves return — use the platform envelope:

{ "error": { "code": "ERR_…", "message": "human readable" } }

That covers every code in the table below (401/403/404/409/500) as well as the WebSocket rejections.

Dispatch-level errors are the exception. They are produced by the layer that routes /action/{actionName} by HTTP method, before any handler or error helper runs, and they are plain text (text/plain; charset=utf-8), never JSON. A client that calls JSON.parse on every error body throws on exactly these — check Content-Type first, and treat a non-JSON body as a generic failure:

Situation HTTP Body Example
unknown action name for this verb 501 Action not implemented POST /action/getAlerts (typo), GET /action/triggerAlert, any PUT /action/<name>
malformed request envelope 400 the JSON decoder's message body is not valid JSON, has an unknown top-level field (DisallowUnknownFields), or cannot be decoded into the envelope — e.g. json: unknown field "payloads"
unsupported HTTP verb 405 method not allowed PATCH /action/createAlert

Consequences worth internalising:

  • PUT is routed but implements nothing, so every PUT /action/<name> is 501 — including for actions that exist under another verb.
  • A known action sent with the wrong verb is also 501, not 405. 405 is reserved for a verb outside GET/POST/PUT/DELETE.
  • GET never decodes a body, so it can only produce the 501 row.
  • Only POST, PUT and DELETE can produce the plain-text 400.

Error codes

Code HTTP Meaning
ERR_VALIDATION 400 malformed payload, bad uuid, field too long, bad query parameter
ERR_UNAUTHORIZED 401 missing/invalid access token
ERR_ALERT_TOKEN_INVALID 403 the adminToken or subscribeToken does not match this alert
ERR_ALERT_NOT_ADMIN 403 a valid access token, but not the alert's admin, for an admin-only action
ERR_ALERT_NOT_FOUND 404 no such alert (also returned instead of 403 where hiding existence matters)
ERR_ALERT_NOT_SUBSCRIBED 404 caller is neither the admin nor a subscriber of this alert
ERR_ALERT_ALREADY_JOINED 409 unique-constraint race on (alertId, user, deviceId); the normal re-scan path is idempotent and returns 200
ERR_ALERT_STATE 409 the action is illegal in the alert's current state (e.g. disarming a non-triggered alert, deleting a live one)
ERR_INTERNAL 500 unexpected server/database failure

ERR_METHOD_NOT_ALLOWED (405) and ERR_UNAVAILABLE (503) also exist on the WebSocket endpoint — see below. These codes only ever appear in a JSON envelope; the dispatch-level 405 described above is plain text and carries no code.

Action summary

The dispatcher routes by HTTP method (GET, POST, PUT, DELETE); in every case the action name is the path segment after /action/.

POST accepts every alert action plus the shared-auth proxies. GET accepts the read-only alert subset plus me and logout — me is GET-only (POST /action/me is a 501). DELETE accepts the two removals. PUT implements nothing at all.

Alert actions

Action Verbs Admin token Access
createAlert POST — any authenticated user becomes the admin
getAlert GET, POST — admin or subscriber
joinAlert POST — any authenticated user holding the subscribe token
listSubscriptions GET, POST — alert admin only: the access-token user must be the alert's adminUserId; no admin token is used
removeSubscription DELETE, POST — admin (any row) or the subscriber (own row)
triggerAlert POST required admin
disarmAlert POST required admin
alarmStatus GET, POST — admin or subscriber
deleteAlert DELETE, POST required admin
qrImage GET, POST — admin or subscriber
rotateSubscribeToken POST required admin

Shared-auth actions (/auth/* proxies)

Thin proxies to dcntrlized-auth, kept on /action/ for the RN clients that send everything through one path. They take the same {payload} envelope; the payload is forwarded as the raw auth body and the auth response is relayed as-is, unwrapped (so these return the auth service's own body shape, not the alert shapes documented here).

Action Verbs Proxies Payload
me GET GET /auth/me — (access token header only)
logout GET, POST POST /auth/logout POST: { "refreshToken": "…" }; GET: bodyless no-op
login POST POST /auth/login { "username": "…", "password": "…" }
register POST POST /auth/register { "username": "…", "password": "…", "email": "…" }
refresh POST POST /auth/refresh { "refreshToken": "…" }
delete POST POST /auth/delete { "password": "…" }

Direct /auth/* routes exist for the same calls; the RN clients call those for login/register (raw body, no envelope). These /action/ variants are the envelope-wrapped form of the same operations.


createAlert

Creates an alert and makes the caller its admin.

POST /action/createAlert

{ "payload": {
    "name": "Fire drill",
    "message": "Leave the building now",
    "autoStopSeconds": 300
} }
Field Type Rules
name string required, 1–255 characters after trimming
message string optional, ≤ 2000 bytes, the default text pushed with a trigger
autoStopSeconds int optional, 0–86400; 0 (default) means "ring until disarmed"

Response 200:

{
  "alertId": "0f8fad5b-d9cb-469f-a165-70867728950e",
  "name": "Fire drill",
  "message": "Leave the building now",
  "status": "armed",
  "active": false,
  "adminToken": "…",          // shown ONCE, store it
  "subscribeToken": "…",
  "qrPayload": "https://alarm.dcntrlized.org/a/0f8f…e#…",
  "autoStopSeconds": 300,
  "tokenVersion": 1,
  "created": "2026-10-18T17:00:00Z"
}

adminToken is returned only here (and nowhere else, ever). The server keeps a hash. Losing it means losing admin rights over that alert.

Errors: ERR_VALIDATION, ERR_UNAUTHORIZED.

getAlert

Returns the alert, the caller's relationship to it, and the newest event.

GET /action/getAlert?alertId=<uuid> · POST { "payload": { "alertId": "…" } }

Response 200:

{
  "alertId": "…",
  "name": "Fire drill",
  "message": "Leave the building now",
  "status": "triggered",           // "armed" | "triggered" | "disarmed"
  "active": true,                  // derived, see alarmStatus
  "autoStopSeconds": 300,
  "tokenVersion": 1,
  "created": "2026-10-18T17:00:00Z",
  "modified": "2026-10-18T17:05:00Z",
  "triggeredAt": "2026-10-18T17:05:00Z",
  "qrPayload": "https://alarm.dcntrlized.org/a/…#…",
  "isAdmin": true,
  "subscriptionCount": 2,
  "lastEvent": {
    "eventId": "…", "action": "trigger", "message": "Leave the building now",
    "serverTime": "2026-10-18T17:05:00Z"
  }
}

qrPayload is present for the admin and for subscribers (the subscriber already holds that token). isAdmin tells the client which controls to render.

Optional fields are omitted, never sent as null (omitempty on the wire). That covers triggeredAt (absent until the first trigger — and kept after a disarm, so it always means "when it last rang"), disarmedAt (absent until a disarm) and lastEvent (absent while the alert has no events). Test for presence, not for null.

Errors: ERR_VALIDATION, ERR_ALERT_NOT_FOUND, ERR_ALERT_NOT_SUBSCRIBED, ERR_UNAUTHORIZED.

joinAlert

Subscribes the caller's device. This is what the QR scan calls.

POST /action/joinAlert

{ "payload": {
    "alertId": "0f8fad5b-…",
    "subscribeToken": "…",
    "deviceId": "android-8f2c…",
    "deviceLabel": "Pixel 7",
    "platform": "android"
} }
Field Type Rules
alertId uuid required (the scanner takes it from the QR payload)
subscribeToken string required, 16–64 url-safe characters, must match the alert
deviceId string required, 1–128 characters after trimming, no control characters — the client generates and persists a stable per-install id and sends it on every join
deviceLabel string optional, ≤ 128 chars
platform string optional, ≤ 32 chars (android, ios, …)

deviceId is part of the subscription's identity, so it is not optional: missing or empty is 400 ERR_VALIDATION (deviceId is required). Generate it once per install (a random UUID persisted in app storage is enough) and never rotate it — a new value makes the same user a separate subscription, which is exactly the "second device" behaviour. Reuse the same value as ?deviceId= on /ws/alarm so the right subscription's last_seen_at is refreshed.

Response 200 — the subscription view (see listSubscriptions).

Idempotent per (alertId, user, deviceId): re-scanning the same QR on the same device returns 200 with the same subscription id and creates no new row. The same user scanning on a second device creates a second subscription. ERR_ALERT_ALREADY_JOINED is only the 409 fallback for a concurrent duplicate insert race.

Errors: ERR_VALIDATION, ERR_ALERT_NOT_FOUND, ERR_ALERT_TOKEN_INVALID, ERR_ALERT_ALREADY_JOINED, ERR_UNAUTHORIZED.

listSubscriptions

GET /action/listSubscriptions?alertId=<uuid> · POST { "payload": { "alertId": "…" } }

Admin only (the access token must belong to the alert's admin — the admin token is not required for this read).

{ "items": [
  { "id": "…", "userId": "…", "deviceId": "android-8f2c…",
    "deviceLabel": "Pixel 7", "platform": "android",
    "enabled": true, "lastSeenAt": "2026-10-18T17:04:00Z",
    "created": "2026-10-18T16:00:00Z" }
] }

username is intentionally never populated: it lives in the auth service's schema and Alarm does not read another service's tables. The field is omitted rather than sent empty. lastSeenAt is refreshed whenever that subscription's socket connects or heartbeats.

Errors: ERR_VALIDATION, ERR_ALERT_NOT_FOUND, ERR_ALERT_NOT_ADMIN, ERR_UNAUTHORIZED.

removeSubscription

DELETE /action/removeSubscription · POST (same envelope)

{ "payload": { "alertId": "…", "subscriptionId": "…" } }

The admin may remove any subscription of the alert; a subscriber may remove its own. Response 200:

{ "alertId": "…", "subscriptionId": "…", "removed": true }

Errors: ERR_VALIDATION, ERR_ALERT_NOT_FOUND, ERR_ALERT_NOT_SUBSCRIBED, ERR_ALERT_NOT_ADMIN, ERR_UNAUTHORIZED.

triggerAlert

Fires the alarm live on every subscriber of every instance.

POST /action/triggerAlert

{ "payload": {
    "alertId": "0f8fad5b-…",
    "adminToken": "…",
    "message": "Optional per-trigger override"
} }

message is optional; when omitted the alert's stored message is used. The override is not persisted — it applies to this event only.

Response 200:

{
  "eventId": "…",
  "action": "trigger",
  "serverTime": "2026-10-18T17:05:00.123Z",
  "autoStopSeconds": 300,
  "delivered": 2
}
  • delivered is the number of live WebSocket clients on this instance that the frame was written to. It is not a delivery guarantee: clients that were not connected (or are on another instance) are reached by the reconnect catch-up and by the alarmStatus fallback instead.
  • Re-triggering an already-triggered alert is allowed and always produces a new eventId, restarting the auto-stop window. Clients must treat each eventId as a new alarm.
  • The alert state is stored before the fan-out, so a client that reconnects immediately after the call still sees the live event.

Errors: ERR_VALIDATION, ERR_ALERT_NOT_FOUND, ERR_ALERT_TOKEN_INVALID, ERR_ALERT_NOT_ADMIN, ERR_UNAUTHORIZED.

disarmAlert

POST /action/disarmAlert — same payload as triggerAlert (the message override applies to the disarm event the same way).

{ "payload": { "alertId": "…", "adminToken": "…", "message": "All clear" } }

Response 200 — same shape as triggerAlert with "action": "disarm":

{
  "eventId": "…",
  "action": "disarm",
  "serverTime": "2026-10-18T17:09:00.456Z",
  "autoStopSeconds": 300,
  "delivered": 2
}

autoStopSeconds echoes the alert's configured value (e.g. 300), not 0: disarming does not rewrite the stored column, and the same value rides the broadcast disarm frame. Treat it as informational — "that is how long the trigger would have rung" — never as a stop signal. Cancel the ring on action: "disarm" (or on active: false from alarmStatus). A client that waits for autoStopSeconds === 0 before stopping will keep ringing.

Disarming an alert that is not currently active is 409 ERR_ALERT_STATE.

Errors: as triggerAlert, plus ERR_ALERT_STATE.

alarmStatus

The cheap, indexed fallback a client polls when its socket is down, or on cold start when it may have been killed mid-alarm.

GET /action/alarmStatus?alertId=<uuid> · POST { "payload": { "alertId": "…" } }

{
  "active": true,
  "eventId": "…",
  "action": "trigger",
  "message": "Leave the building now",
  "serverTime": "2026-10-18T17:05:00.123Z",
  "autoStopSeconds": 300
}

active is derived, never a timer: it is true only when the newest event is a trigger, the stored status is triggered, and (when autoStopSeconds > 0) now < triggeredAt + autoStopSeconds. Nothing sweeps the database, so the answer stays correct across restarts and across instances. When there is no event yet the fields are empty and active is false.

serverTime is the event's timestamp (or the server's clock when there is no event) — use it to correct client clock skew rather than comparing with the device clock.

Errors: ERR_VALIDATION, ERR_ALERT_NOT_FOUND, ERR_ALERT_NOT_SUBSCRIBED, ERR_UNAUTHORIZED.

deleteAlert

DELETE /action/deleteAlert · POST (same envelope)

{ "payload": { "alertId": "…", "adminToken": "…" } }

Deletes the alert, its subscriptions and its event history (cascade). Response 200:

{ "alertId": "…", "deleted": true }

Refused with 409 ERR_ALERT_STATE while the alert is active — disarm first, so a ringing alarm cannot be deleted out from under its subscribers.

Errors: ERR_VALIDATION, ERR_ALERT_NOT_FOUND, ERR_ALERT_TOKEN_INVALID, ERR_ALERT_NOT_ADMIN, ERR_ALERT_STATE, ERR_UNAUTHORIZED.

qrImage

Renders the alert's subscribe QR as a PNG.

GET /action/qrImage?alertId=<uuid>&scale=8&quietZone=4 · POST { "payload": { "alertId": "…" } }

Query Default Range
scale 8 1–64 pixels per module
quietZone (alias margin) 4 4–32 modules

A quiet zone below 4 modules is rejected with 400 ERR_VALIDATION: the ISO/IEC 18004 minimum is 4 and smaller margins produce symbols real scanners refuse (measured: zbarimg returns "no symbol" at 0–3).

Response 200 is binary, not JSON: Content-Type: image/png, Cache-Control: no-store, X-Content-Type-Options: nosniff. The image encodes exactly qrPayload (below). The QR is byte-mode, EC level M, version chosen automatically from the payload length.

Keep the quiet zone at ≥ 4 modules; scanners reject codes without it.

Errors: ERR_VALIDATION, ERR_ALERT_NOT_FOUND, ERR_ALERT_NOT_SUBSCRIBED, ERR_UNAUTHORIZED, ERR_INTERNAL (encoder failure — never expected).

rotateSubscribeToken

Invalidates the current QR and mints a new subscribe token. Existing subscriptions keep working; only joining with the old token stops working. Use it when the QR leaked.

POST /action/rotateSubscribeToken

{ "payload": { "alertId": "…", "adminToken": "…" } }

Response 200:

{ "alertId": "…", "subscribeToken": "…",
  "qrPayload": "https://alarm.dcntrlized.org/a/…#…",
  "tokenVersion": 2 }

Errors: as triggerAlert.


QR payload format

The QR encodes exactly the qrPayload field:

https://alarm.dcntrlized.org/a/<alertId>#<subscribeToken>

An app-scheme deep link carries the same pair for in-app scanners:

dcntrlized-alarm://join?alert=<alertId>&token=<subscribeToken>

alertId is a UUID and subscribeToken is 16–64 url-safe characters (A–Z a–z 0–9 - _). The fragment is used because a URL fragment is never sent to a server by a browser — the token stays client-side until the app posts it to joinAlert.


Live protocol — GET /ws/alarm

ws://<host>:8088/ws/alarm?alertId=<uuid>&token=<adminToken|subscribeToken>&accessToken=<token>[&deviceId=<id>]

The socket is authenticated, not anonymous: the access token identifies the user and the second token proves the relationship to the alert.

Authentication

Credential Where Required
access token (who you are) X-Access-Token header or ?accessToken= / ?access_token= yes
alertId query yes, valid uuid
token query yes — the admin token or the subscribe token
deviceId query optional; the deviceId used at joinAlert — selects which subscription's last_seen_at is refreshed (without it the oldest subscription is used)
  • admin token match and the caller is the alert's admin → accepted as the admin connection (no subscription required);
  • subscribe token match and the caller has a subscription on that alert → accepted as a subscriber connection (?deviceId= selects the subscription, otherwise the oldest one is used);
  • anything else is rejected before the upgrade with the usual JSON error (401 ERR_UNAUTHORIZED, 403 ERR_ALERT_TOKEN_INVALID, 403 ERR_ALERT_NOT_ADMIN, 404 ERR_ALERT_NOT_SUBSCRIBED, 404 ERR_ALERT_NOT_FOUND, or 503 ERR_UNAVAILABLE during shutdown). A non-GET request gets 405 ERR_METHOD_NOT_ALLOWED.

A header is preferred for the access token (headers do not appear in access logs); the query form exists because browsers cannot set headers on a WebSocket handshake.

Server → client frames

alarm — the alarm event. Every subscribed client receives the same frame from one fan-out:

{
  "type": "alarm",
  "alertId": "0f8fad5b-…",
  "eventId": "…",
  "action": "trigger",              // "trigger" | "disarm"
  "message": "Leave the building now",
  "serverTime": "2026-10-18T17:05:00.123Z",
  "autoStopSeconds": 300
}

ack — the server's acknowledgement of a client ack:

{ "type": "ack", "eventId": "…" }

Client → server frames

ack — "I have this event and I am ringing":

{ "type": "ack", "eventId": "…" }

ping — application-level keepalive. The server answers with a WebSocket pong control frame (there is no JSON pong) and refreshes the subscription's last_seen_at.

{ "type": "ping" }

Unknown or malformed client frames are ignored, never fatal.

The one fatal exception is frame size. A client frame larger than 4096 bytes is a protocol violation: the server refuses it and closes the connection with the WebSocket close code 1009 (message too big). ack and ping are tiny, so this only bites a client that tries to send something else through the socket — don't. If you see an unexplained disconnect, this is a candidate; the same applies to sending non-JSON frames.

Keepalive and timeouts

  • The server sends a WebSocket ping every 20s; a client that sends nothing (no pong, no ping) for 60s is dropped.
  • Every write has a 10s deadline, so a dead peer can never block the hub.
  • Each connection has a buffered send queue. A client that cannot keep up is dropped (its socket closed) rather than allowed to slow the fan-out down for everyone else. Reconnect and the catch-up frame restores the state.

Reconnect catch-up

The service keeps the last event per alert in memory. On connect, if that event is still active (a trigger whose auto-stop window has not expired), the server sends it immediately — so a client that reconnects mid-alarm fires without polling, and a client that reconnects after the alarm is over is never re-alarmed.

Multiple instances

Fan-out is local: Broadcast writes to this instance's connections. Every trigger/disarm is also an INSERT into alarm.alarm_events, whose trigger issues pg_notify('alarm_events', …); each instance holds a LISTEN and re-broadcasts to its own clients. So a trigger raised on instance A fires on subscribers of instance B as well. Duplicate deliveries are suppressed by eventId, so the local fast path and the NOTIFY path cannot double-fire.

Minimal client

const ws = new WebSocket(
  `ws://alarm:8088/ws/alarm?alertId=${alertId}&token=${subscribeToken}` +
  `&accessToken=${accessToken}&deviceId=${deviceId}`);

ws.onmessage = (ev) => {
  const f = JSON.parse(ev.data);
  if (f.type === "alarm" && f.action === "trigger") {
    // autoStopSeconds: 0 = ring until a disarm arrives; >0 = self-stop after N
    ring(f.message, f.eventId, f.autoStopSeconds);       // max volume, wake screen
    ws.send(JSON.stringify({ type: "ack", eventId: f.eventId }));
  }
  if (f.type === "alarm" && f.action === "disarm") stopRinging(f.eventId);
};
setInterval(() => ws.readyState === 1 && ws.send('{"type":"ping"}'), 25000);

Polling fallback

When the socket cannot be established, poll alarmStatus (every 5–15s while the app is foregrounded) and treat active: true with a new eventId exactly like a live trigger frame, and active: false (or action: "disarm") as the stop signal. Always prefer the socket: it is the < 1s path, and polling only bounds the worst case.


GET /_manifest

{
  "name": "Alarm",
  "author": "Dcntrlized",
  "capabilities": ["database", "alarm.live"],
  "has_auth": true,
  "public_key": "",
  "icon": ""
}

Served verbatim, Content-Type: application/json. database declares the schema/migration capability Maestro provisions; has_auth tells Maestro to mount the shared-auth proxy.


Worked example

BASE=http://localhost:8088

# 1. access token (shared auth)
TOKEN=$(curl -s -X POST "$BASE/auth/login" \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"admin_password_123"}' | jq -r .accessToken)

# 2. create the alert (keep adminToken!)
curl -s -X POST "$BASE/action/createAlert" -H "X-Access-Token: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"payload":{"name":"Fire drill","message":"Leave the building","autoStopSeconds":300}}' | jq

# 3. a second user scans the QR payload and joins
curl -s -X POST "$BASE/action/joinAlert" -H "X-Access-Token: $BOB" \
  -H 'Content-Type: application/json' \
  -d '{"payload":{"alertId":"'$ALERT'","subscribeToken":"'$SUB'","deviceId":"pixel-7"}}' | jq

# 4. bob opens the socket (leave it running in another terminal)
websocat "ws://localhost:8088/ws/alarm?alertId=$ALERT&token=$SUB&accessToken=$BOB&deviceId=pixel-7"

# 5. trigger — bob's socket prints the alarm frame
curl -s -X POST "$BASE/action/triggerAlert" -H "X-Access-Token: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq

# 6. disarm
curl -s -X POST "$BASE/action/disarmAlert" -H "X-Access-Token: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"payload":{"alertId":"'$ALERT'","adminToken":"'$ADMIN'"}}' | jq

Storage

All state lives in the shared PostgreSQL database, schema alarm:

Table Contents
alarm.alerts one row per alert: name, message, admin user id, admin-token hash, subscribe token + version, status, timestamps, auto_stop_seconds
alarm.subscriptions (alert, user, device) unique, label/platform, enabled, last_seen_at
alarm.alarm_events append-only trigger/disarm history; its AFTER INSERT trigger issues the pg_notify that fans out to other instances

Migrations live in database/migrations/ and are applied by the compose migrate one-shot; they are idempotent and safe to re-apply.