# info.summary
Send a push notification to your own devices with a single HTTP call.

# info.description
Notify! turns any script, server, or automation into something that can reach your phone, iPad, Mac or browser.

Every device gets a personal webhook URL. Call it and the notification arrives. There are no accounts to create and nothing to host: the device registers itself and the URL it hands back is the credential.

Beyond one-off pushes the gateway also does **device groups** (one URL reaching every screen you own), **beacons** (monitoring in reverse: tell us when something *stops* happening, and we alert you if the ping does not arrive), and **Live Activities** on iOS.

## Hostnames

Both servers below are permanent. No Notify! hostname is ever retired, because the URLs this product issues end up in crontabs, Zaps and Home Assistant automations that nobody can reach back into and edit. The gateway answers with whichever host you called it on, so a URL keeps working exactly as issued.

## Authentication

Two credentials, and the distinction matters:

- **`Authorization: Bearer`** carries a shared application token. It ships inside every client, so treat it as a coarse public gate rather than a secret. It identifies the app, not you.
- **`token`** carries your per-device secret, returned when the device registers. This is the credential that actually protects your device, and it is what device-scoped endpoints check.

Webhook-style send endpoints accept the per-device token as a `?token=` query parameter instead, so they can be called from a plain `curl` or a browser address bar.

## Not in this API

Notify! also monitors things on its own: website change detection, and feeds (RSS, Atom and JSON Feed, discovered from a plain site address). Feeds are read by the app on the device; website checks run either on the device or against your own ChangeDetection.io server. Either way this gateway is not involved and holds nothing about them, so there are no feed or watch endpoints here.

One consequence is worth knowing before you read `GET /device/notifications`: alerts from feed monitoring are never in that history. The server writes no row for them, deliberately, and they stay on the device that fetched the feed. To put a feed through this API, poll the feed in your own script and call `POST /notify-json/{id}` with what you find.

# servers[].description
Original host. Permanent: URLs issued here keep working forever.

# tags[].description
Health, keys, and credential validation.

# tags[].description
Registering a device and managing its record.

# tags[].description
Delivering a notification. The endpoints most integrations use.

# tags[].description
Reading and clearing delivered notifications.

# tags[].description
Dead man's switch monitoring: alert when an expected ping stops arriving.

# tags[].description
iOS Lock Screen and Dynamic Island tiles.

# tags[].description
Delivering a notification to a single device.

# tags[].description
Endpoints third-party services connect through.

# tags[].description
Operator-only. Requires the admin token, not the app token.

# paths./health.get.summary
Liveness and database reachability probe

# paths./health.get.description
Runs a `SELECT 1` connectivity check against PostgreSQL and answers in **plain text, not JSON**. This is the only endpoint with no authentication of any kind: it is what the reverse proxy, the container health check and external uptime monitors call.

A `200 OK` means the gateway process is up *and* the database answered. A `503` means the process is up but the database check failed, which is the signal a load balancer should use to take the instance out of rotation.

# paths./health.get.responses.503.description
The database connectivity check failed. The HTTP process is still answering, so this is a dependency failure rather than a crash.

# paths./.get.description
The gateway is an API host, not a website. The root path is an unconditional `302` to `https://getnotifyapp.com/`, on every hostname the gateway answers on. Nothing about the request changes the destination, and no authentication is required.

Unlike the URL-building endpoints (`/link`, group creation, beacon ping URLs), which echo back whichever hostname the caller used, this redirect is a fixed literal.

# paths./webpush/public-key.get.summary
VAPID public key browsers subscribe with

# paths./webpush/public-key.get.description
Returns the server's VAPID application server key, the value a browser passes as `applicationServerKey` to `PushManager.subscribe()`.

The web app also bakes the same key in as a constant, because rotating it would invalidate every existing browser subscription and is therefore treated as a kill switch rather than routine maintenance. This endpoint exists as the authority a client can lazily compare its baked-in copy against to detect drift, not as a per-load dependency.

When web push is switched off on the server (feature flag off, or VAPID keys absent or unparseable) this answers `503` rather than a silent `null`, so a subscribe button can surface an honest error.

# paths./webpush/public-key.get.responses.200.description
Web push is enabled and the key is returned.

# paths./webpush/public-key.get.responses.401.description
Missing or invalid shared app bearer token.

# paths./webpush/public-key.get.responses.503.content.application/json.example.message
Web push is currently disabled on this server

# paths./register.post.summary
Register a new device, or update an existing one

# paths./register.post.description
Creates or updates the row that binds a device identity to a delivery address. Every client calls this: the iOS app on launch and whenever its APNs token rotates, the macOS Notify Listener, and the web app on every page open and on `pushsubscriptionchange`.

`Content-Type: application/json` is required. The gateway's JSON body parser only populates the request body for that content type, so a request sent without it arrives with an empty body and fails validation as though the fields were missing.

### Create versus update

One endpoint does both, keyed on `deviceId`. The response's `isUpdate` flag tells you which happened. On an update, omitted optional fields **keep** their stored value rather than being nulled (`token`, `deviceName`, `pushChannel`, the Live Activity fields and `registrationEpoch` all COALESCE). That is deliberate: the per-device token is embedded in every webhook URL the user has already deployed, and an older client that re-registers without the field must not wipe it.

### Two device ID namespaces

Browser devices use a reserved 16-character `WB` + 14 `[A-Z0-9]` format; every other client uses the legacy 8-character format. The two are fenced against each other in both directions, so an iOS build can never claim an ID in the web namespace and vice versa. A brand-new registration is also refused if the ID collides with an existing Live Activity ID.

### The channel is set once, never unset

`pushChannel` selects delivery: `poll` for the macOS listener, `webpush` for browsers, and omitted or unrecognized for APNs. Because it COALESCEs, a channel can be **set but never unset** by an omitted field. A web install is permanently a web device, the way a Mac is permanently poll. Switching platform means a new install, which generates a new identity on the client.

### Two fences on an existing device

**Replacement epoch (`409`).** The in-app Replace flow bumps `registrationEpoch` in the identity's iCloud record. A registration carrying an epoch *older* than the stored one comes from a phone the user has already replaced, and is refused. This is what makes "replacing will deactivate your old device" true at the server before the retired phone has even noticed. Requests with no epoch make no fencing claim and are always accepted, so older builds keep working.

**Ownership (`403`).** Device IDs are not secret (they are the path segment of every webhook URL), and the shared app bearer ships inside every copy of the app, so neither one proves ownership. Changing a *sensitive* field on an **existing** device therefore requires presenting that device's current per-device `token` in the body. The sensitive fields are the APNs delivery address (`deviceToken`), the per-device secret (`token`), the delivery channel (`pushChannel`), and the browser subscription endpoint. A first registration is trust-on-first-use, and an ordinary heartbeat that changes none of those fields is never gated. When the fence is in migration mode the refusal is logged instead of enforced.

### Hardware migration

When an update arrives with a changed `deviceToken` (the iCloud flow moving an identity to a new phone), both Live Activity tokens on file belong to the retired hardware, so the stored push-to-start token is cleared unless this request carries a fresh one, and the update tokens on the device's non-terminal tiles are nulled. Reconciliation re-pushes the stored tile content once the new phone reports fresh tokens.

### Rate limiting

General rate limiting for this API lives at the Cloudflare edge. This one endpoint additionally carries in-process abuse control, because it is reachable with only the public app bearer and it mints rows: a per-address backstop window counting every request, plus a tighter hourly window that counts only registrations which actually create a **new** browser device row. Re-registering an existing device never spends the hourly budget, so an ordinary user reloading the web app is not locked out.

# paths./register.post.requestBody.content.application/json.examples.iosLiveActivities.summary
iPhone reporting Live Activity capability

# paths./register.post.requestBody.content.application/json.examples.macListener.summary
macOS Notify Listener, poll-only (no APNs)

# paths./register.post.responses.200.description
Device created or updated. Check `isUpdate` to tell which.

# paths./register.post.responses.400.description
Validation failure. The `error` string names the specific problem: a device ID in the wrong format for the requested channel, a missing `deviceToken` or `platform`, a non-string value in one of the ownership-sensitive fields, a device ID that collides with an existing Live Activity ID, or (for `pushChannel: "webpush"`) a malformed subscription or one whose endpoint is not a recognized push service.

# paths./register.post.responses.400.content.application/json.examples.badWebId.value.error
Web device ID must match WB + 14 characters [A-Z0-9]

# paths./register.post.responses.400.content.application/json.examples.missingFields.value.error
Missing required fields: deviceToken, platform

# paths./register.post.responses.400.content.application/json.examples.nonStringField.summary
Ownership-sensitive field sent as a non-string

# paths./register.post.responses.400.content.application/json.examples.idCollision.summary
New device ID collides with a Live Activity ID

# paths./register.post.responses.400.content.application/json.examples.idCollision.value.error
Device ID unavailable, please regenerate

# paths./register.post.responses.400.content.application/json.examples.badSubscription.value.error
webPushSubscription.keys.auth must be a base64url 16-byte secret

# paths./register.post.responses.400.content.application/json.examples.disallowedEndpoint.summary
Subscription endpoint is not an allowlisted push service

# paths./register.post.responses.400.content.application/json.examples.disallowedEndpoint.value.error
endpoint host push.example.com is not a recognized push service

# paths./register.post.responses.401.description
Missing or invalid shared app bearer token.

# paths./register.post.responses.403.description
Ownership fence. This device already exists and the request changes a sensitive field (delivery token, per-device secret, push channel, or a stored web push endpoint) without presenting the device's current per-device `token` in the body.

# paths./register.post.responses.403.content.application/json.example.message
This device already exists. To change its delivery settings, the registration must present the device's current per-device token.

# paths./register.post.responses.409.description
Replacement-epoch fence. The `registrationEpoch` supplied is lower than the one stored, meaning this registration comes from a device the user has already replaced.

# paths./register.post.responses.409.content.application/json.example.message
This device identity was replaced by another device; registration refused

# paths./register.post.responses.429.description
Too many registrations from this client address. Either the short backstop window (which counts every request, including malformed ones) or the hourly window on browser registrations that create a new device row.

# paths./register.post.responses.429.content.application/json.examples.backstop.value.message
Too many registrations from this address. Try again in a minute.

# paths./register.post.responses.429.content.application/json.examples.webpushHourly.value.message
Too many browser registrations from this address. Try again in an hour.

# paths./register.post.responses.500.description
Database or internal error. The whole registration is one transaction, so a failure leaves nothing half-written.

# paths./register.post.responses.503.description
`pushChannel: "webpush"` was requested while web push is disabled on this server.

# paths./register.post.responses.503.content.application/json.example.message
Web push is currently disabled on this server

# paths./device.get.description
Returns the non-sensitive registration record for one device, including its lifetime notification count.

The APNs `device_token` is deliberately never returned. It is a delivery credential the client already has and never needs back, and returning it once made this endpoint a way to harvest other users' push tokens. `device_name` is not returned here either; `GET /link` is the endpoint that answers with a display name.

When the per-device `token` header is presented it is validated against the requested `deviceId` and the query is scoped to the device you proved you own. Presenting it is strongly recommended for every call: with per-device authorization enforced, a request without it is refused outright.

# paths./device.get.parameters[].description
The device to read. Must be the device the per-device `token` header belongs to.

# paths./device.get.responses.401.description
Missing or invalid shared app bearer token.

# paths./device.get.responses.403.description
Per-device authorization failed. Either no `token` header was sent while enforcement is on, no device ID was present to check the token against, or the token does not belong to the requested device. Note that a token which does not match is refused even when enforcement is off, so a broken client surfaces immediately.

# paths./device.get.responses.403.content.application/json.examples.mismatch.value.message
Per-device token does not match the requested device

# paths./device.delete.description
Permanently removes the device row. Foreign keys cascade, so the device's notification history, its web push subscription, its group memberships and its Live Activities go with it.

The call is idempotent: it answers `200` whether or not a row existed, and never `404`. That means it cannot be used to probe whether a device ID is registered.

As with `GET /device`, presenting the per-device `token` header scopes the delete to the device you proved you own, and is mandatory once per-device authorization is enforced.

# paths./device.delete.parameters[].description
The device to delete. Must be the device the per-device `token` header belongs to.

# paths./device.delete.responses.401.description
Missing or invalid shared app bearer token.

# paths./device.delete.responses.403.description
Per-device authorization failed. Same rules and codes as `GET /device`.

# paths./device.delete.responses.403.content.application/json.example.message
Per-device token does not match the requested device

# paths./link.get.summary
Validate an ID and token pair, and describe what it points at

# paths./link.get.description
The "test connection" endpoint for automation platforms (Zapier, Make, TRMNL, and hand-rolled integrations). Give it an ID and its matching token and it confirms the pair is valid, tells you whether the ID names a **device** or a **group**, returns a human-readable name, and hands back the notification URL to post to.

Auto-detection is by lookup, not by parsing the ID: the device table is tried first, then the group table. You do not have to know which kind you hold.

The credentials travel as query parameters, and no bearer token is involved: the ID and token pair *is* the authentication. The response never echoes a token back, and the returned `notification_url` deliberately has no `?token=` on it.

### The response is flat

Every field sits at the top level. There is no `device` or `group` sub-object. `type` tells you which shape you got, and a few fields are specific to one type: `platform`, `os_version` and `app_version` on a device; `member_count` and `created_at` on a group.

### About `name`

For a group this is the stored group name. For a device it is the stored device name: what the owner called it in the app, or what the client supplied on its behalf when the OS withholds the real one (`iPhone (iOS 27.0)`, `Chrome on macOS`, `Notify Listener (Mac)`). Only when that stored value is empty or whitespace, which is possible for devices registered before the field existed, does the response fall back to a synthesized `platform (os_version)` string such as `iOS (18.2)`. It is therefore always a non-empty string and can be displayed directly; Zapier uses it as the saved connection label and TRMNL as its screen title.

### Which hostname you get back

`notification_url` is built from the hostname the request arrived on, so an integration set up against one of the gateway's hostnames keeps using that hostname. An unrecognized `Host` falls back to a known-good origin rather than failing.

General rate limiting for this endpoint is enforced at the Cloudflare edge, which can return `429` before the request reaches the gateway.

# paths./link.get.parameters[].description
A device ID (8 characters for app devices, `WB` + 14 for browser devices) or a group ID (`GRP` + 5). Only a loose length sanity check of 8 to 20 characters is applied, so all three formats pass unchanged.

# paths./link.get.parameters[].description
The per-device token or the group token that matches `id`. Never returned in the response.

# paths./link.get.responses.200.description
The credentials are valid. Inspect `type` to know which set of optional fields is present.

# paths./link.get.responses.200.content.application/json.examples.webDevice.summary
A browser device, with a client-supplied name

# paths./link.get.responses.400.description
One of the query parameters is missing, or `id` is outside the accepted 8 to 20 character range.

# paths./link.get.responses.400.content.application/json.examples.missing.value.message
Both id and token query parameters are required

# paths./link.get.responses.404.description
No device and no group matches that ID and token pair. The same answer is given for a wrong token and for an ID that does not exist, so the endpoint cannot be used to enumerate IDs.

# paths./link.get.responses.404.content.application/json.example.message
No device or group found with the provided ID and token combination

# paths./link.get.responses.500.description
Database or internal error while validating.

# paths./link.get.responses.500.content.application/json.example.message
An error occurred while validating credentials

# paths./cleanup.post.summary
Manually trigger the inactive-device sweep (admin only)

# paths./cleanup.post.description
Deletes every device whose registration has not been refreshed in 60 days, and reports how many were examined and how many went. Cascading foreign keys take each deleted device's notification history, subscription, group memberships and Live Activities with it. Deletion is permanent.

The same sweep runs automatically once a day; this endpoint exists for on-demand maintenance. It takes no request body and is safe to run repeatedly.

**This endpoint does not accept the shared app bearer token.** It requires a separate administrative token in the `Authorization: Bearer` header. The app token that every other endpoint on this API accepts is refused here with `401`. If the server has no administrative token configured at all, the endpoint fails closed with `500` rather than running unprotected.

Despite what an older comment in the source suggests, this call prunes **devices only**. Pruning of notification history (individual and group, older than a year) happens only in the daily scheduled job, never on this manual call.

# paths./cleanup.post.responses.401.description
Missing or invalid administrative token. The ordinary app bearer token lands here.

# paths./cleanup.post.responses.401.content.application/json.example.message
Invalid admin authorization token. This endpoint requires ADMIN_TOKEN, not AUTH_TOKEN.

# paths./cleanup.post.responses.500.description
No administrative token is configured on the server (the endpoint fails closed rather than running unauthenticated), or the database sweep failed.

# paths./cleanup.post.responses.500.content.application/json.examples.notConfigured.value.message
ADMIN_TOKEN not configured. This endpoint requires admin authentication.

# paths./device/notifications.get.description
Returns the notifications recorded for one device, newest first.

The gateway keeps a delivery record for every push it attempts, whether or not the push succeeded, so this history includes failures with the reason they failed. Records are retained for one year and then removed by a nightly cleanup job.

**What `message` holds.** It is a stored *envelope*, not necessarily plain text. See the `NotificationRecord` schema for the exact rules; in short, a title-less send is stored as the bare body string, and anything richer is stored as a JSON object encoded into that same string field. Readers must accept both forms. `message` is always the full text as sent, never the shortened copy that went into the APNs alert (Apple caps a push payload at 4 KB, while a stored body may be up to 16 KB).

**Keeping a page small.** Because a single body can be 16 KB and this endpoint returns up to 500 of them, pass `preview` to get shortened bodies plus enough metadata to know there is more, then fetch one full record from `GET /device/notifications/{id}` when the reader actually opens it. Previewing is opt-in: without the parameter every body comes back whole, byte for byte.

**Authorization.** The shared app token is always required. The per-device `token` header is currently optional (migration mode): when it is sent and matches the device, the query is scoped to the proven device and the `deviceId` parameter is ignored. When the gateway is switched to strict per-device auth, the header becomes mandatory and a request without it is refused with `403`.

# paths./device/notifications.get.parameters[].description
The device whose history to read. Eight characters for an app device, or the sixteen-character `WB…` form for a web device. Ignored when the per-device `token` header proves a device: the proven id always wins, which is what stops one device reading another's history.

# paths./device/notifications.get.parameters[].description
How many records to return. Clamped to 1..500. A value that is not a number (including an empty value) falls back to the default rather than erroring.

# paths./device/notifications.get.parameters[].description
Opt in to shortened bodies. Each returned `message` is cut to at most this many characters of decoded body and gains `truncated` and `messageLength`. The value is clamped to 1..8000; anything that is not a positive integer (including `0` and non-numeric text) simply turns previewing off rather than erroring.

The shortening is structural, not a string slice: an envelope is decoded, its body shortened, and the envelope rebuilt, so a previewed `message` still parses and still carries its title and any `data` fields. The cut also never splits a surrogate pair, so the response cannot contain a lone surrogate that a strict decoder would reject.

# paths./device/notifications.get.responses.200.description
The device's notification history, newest first. A bare JSON array; an empty array when the device has no history. Entries carry `truncated` and `messageLength` only when `preview` was passed.

# paths./device/notifications.get.responses.200.content.application/json.examples.previewed.summary
With ?preview=40: long bodies are shortened and flagged

# paths./device/notifications.get.responses.400.description
No device could be determined: neither a proven device nor a `deviceId` parameter was present.

# paths./device/notifications.get.responses.401.description
The shared app token was missing or wrong.

# paths./device/notifications.get.responses.403.description
Per-device authorization failed. Returned when a `token` header was sent that does not match the requested device, and (under strict per-device auth) when the header or the device id is absent entirely. The `code` field distinguishes the cases.

# paths./device/notifications.get.responses.403.content.application/json.example.message
Per-device token does not match the requested device

# paths./device/notifications.get.responses.500.description
Database or internal error. In production the message is deliberately generic.

# paths./device/notifications.delete.description
Deletes every stored notification for one device and reports how many rows went. This affects the server-side history only; it does not recall notifications already delivered to the device, and it does not delete the device registration itself (use `DELETE /device` for that).

The delete is always scoped to a single device id, and to the proven device when the per-device `token` header is supplied, so it cannot be pointed at somebody else's history.

Clearing a history that is already empty is not an error: the call succeeds with `deleted: 0`.

# paths./device/notifications.delete.parameters[].description
The device whose history is cleared. Ignored in favour of the proven device when the per-device `token` header is supplied.

# paths./device/notifications.delete.responses.200.description
History cleared. `deleted` is the number of rows removed, and is `0` when there was nothing to remove.

# paths./device/notifications.delete.responses.400.description
No device could be determined: neither a proven device nor a `deviceId` parameter was present.

# paths./device/notifications.delete.responses.401.description
The shared app token was missing or wrong.

# paths./device/notifications.delete.responses.403.description
Per-device authorization failed. See `GET /device/notifications` for the cases behind each `code`.

# paths./device/notifications/{id}.get.description
Returns the complete record for a single notification. This is the companion to `?preview=` on the list endpoint: list shortened, read whole. A client shows a page of previews cheaply and calls this only for the one notification somebody actually opened.

`message` is always complete here, up to the full 16 KB a body may be, and is never accompanied by `truncated` or `messageLength`.

The lookup is scoped to the device, so an id belonging to another device is not readable. A notification that does not exist and a notification that belongs to somebody else return the identical `404`, deliberately, so the endpoint cannot be used to probe for valid ids.

# paths./device/notifications/{id}.get.parameters[].description
The notification's numeric id, as returned in the `id` field of the list response.

# paths./device/notifications/{id}.get.parameters[].description
The owning device. Ignored in favour of the proven device when the per-device `token` header is supplied.

# paths./device/notifications/{id}.get.responses.400.description
Either no device could be determined (no proven device and no `deviceId`), or the path id did not parse as a number.

# paths./device/notifications/{id}.get.responses.401.description
The shared app token was missing or wrong.

# paths./device/notifications/{id}.get.responses.403.description
Per-device authorization failed. See `GET /device/notifications` for the cases behind each `code`.

# paths./device/notifications/{id}.get.responses.403.content.application/json.example.message
Per-device token does not match the requested device

# paths./device/notifications/{id}.get.responses.404.description
No such notification for this device. The same answer is given for an id that never existed, an id that has been deleted, and an id belonging to another device.

# paths./device/notifications/{id}.delete.description
Removes a single notification from a device's stored history. Only the owning device's records can be deleted: the delete is scoped by device id, and by the proven device when the per-device `token` header is supplied.

The call is not idempotent in its status code. Deleting a notification that is already gone returns `404`, not `200`, so a client retrying a delete it already completed should treat `404` as success.

Note that unlike `GET /device/notifications/{id}`, this endpoint does not pre-validate the path id. A non-numeric id reaches the database and surfaces as a `500` rather than a `400`.

# paths./device/notifications/{id}.delete.parameters[].description
The owning device. Ignored in favour of the proven device when the per-device `token` header is supplied.

# paths./device/notifications/{id}.delete.responses.400.description
No device could be determined: neither a proven device nor a `deviceId` parameter was present.

# paths./device/notifications/{id}.delete.responses.401.description
The shared app token was missing or wrong.

# paths./device/notifications/{id}.delete.responses.403.description
Per-device authorization failed. See `GET /device/notifications` for the cases behind each `code`.

# paths./device/notifications/{id}.delete.responses.403.content.application/json.example.message
Per-device token does not match the requested device

# paths./device/notifications/{id}.delete.responses.404.description
No such notification for this device: it never existed, was already deleted, or belongs to another device. As with the read, the three cases are indistinguishable on purpose.

# paths./device/notifications/{id}.delete.responses.500.description
Database or internal error. Also what a non-numeric path id produces, because this endpoint does not validate the id before querying.

# paths./group/{groupId}/notifications.get.description
Returns the broadcasts sent to a group, newest first, with per-send delivery counts.

A group history record is one *broadcast*, not one delivery: a single message fanned out to five devices is one row here, carrying `success_count` and `failure_count` for that fan-out. The individual per-device attempts are recorded separately and appear in each member's own `GET /device/notifications` history with `source: "group"`.

Unlike the device history, `success` and `error_message` are not stored columns. They are derived for you from the counts, so a client can render group and device history through the same code path. `success` is true only when at least one device was reached and none failed; `error_message` is a generated sentence naming the failure count, or `null`.

Also unlike the device history, there is no `apns_id` (a fan-out has many) and no `preview` parameter. Bodies come back whole, and a group body may be up to 16 KB, so keep `limit` modest if the group sends long messages.

Records are retained for one year.

**Authorization.** The shared app token is always required. When the caller also proves a device with the per-device `token` header, that device must be a member of the group or the request is refused with `403`. Under strict per-device auth the header is always present, so the membership check always runs.

# paths./group/{groupId}/notifications.get.parameters[].description
The group's id, as returned when the group was created.

# paths./group/{groupId}/notifications.get.parameters[].description
How many records to return. Capped at 200, which is also the default. Beware two rough edges the device endpoint does not share: a value that is not a number, or is `0`, falls back to 200 rather than erroring, and a negative value is passed through to the database and surfaces as a `500`. Send a positive integer.

# paths./group/{groupId}/notifications.get.responses.200.description
The group's broadcast history, newest first. A bare JSON array; an empty array when the group has never been sent to.

# paths./group/{groupId}/notifications.get.responses.401.description
The shared app token was missing or wrong.

# paths./group/{groupId}/notifications.get.responses.403.description
The caller proved a device with the per-device `token` header, but that device is not a member of this group. Also returned by the per-device auth layer itself when a supplied `token` does not match its device.

# paths./group/{groupId}/notifications.get.responses.404.description
No group exists with that id. Checked before the membership gate, so a non-member asking about a real group gets `403` while anyone asking about a nonexistent group gets `404`.

# paths./group/{groupId}/notifications.get.responses.404.content.application/json.example.message
No notification group exists with ID: GRP12345

# paths./group/{groupId}/notifications.get.responses.500.description
Database or internal error. Also what a negative `limit` produces.

# paths./group/{groupId}/notifications.get.responses.500.content.application/json.example.message
An internal server error occurred while retrieving notifications

# paths./notify/{deviceId}.description
The plain-URL webhook. GET and POST are the same handler, and **both read every parameter from the query string**. A POST request body is ignored entirely: there is no form or JSON parsing on this route, so posting `body=...` as a form field returns `400 Missing body parameter`. If you need a real request body, use `POST /notify-json/{id}` or `POST /send/{deviceId}`.

Because the credential travels in the URL, treat the whole webhook URL as a secret. Anyone holding it can notify the device.

**Double decoding.** Express URL-decodes the query string once, then the handler calls `decodeURIComponent` on `body` a second time, falling back to the raw string if that throws. A literal percent sign therefore survives (`body=Disk%2095%25%20full` arrives as `Disk 95% full`), but text that itself looks like percent-encoding is decoded twice: send `%2520` if you want a literal `%20` in the message.

**Length.** Node rejects a request line longer than about 16 KB with a bodyless `431` before this code runs, and URL-encoding inflates newlines and non-ASCII roughly threefold, so this endpoint tops out near 5 KB of prose in practice. The 16 KB body limit is only fully reachable through `/notify-json`.

# paths./notify/{deviceId}.parameters[].description
Target device. Legacy iOS and macOS devices use an 8-character id; web push devices use the 16-character `WB` + 14 form. Both are accepted here.

# paths./notify/{deviceId}.parameters[].description
The per-device secret, matched against `devices.token` for this `deviceId`. Checked before anything else, so a wrong token returns 403 even when the request is otherwise malformed. This is the same credential the `deviceToken` header scheme carries on `/send`, presented in the query string instead.

# paths./notify/{deviceId}.parameters[].description
The notification text, URL-encoded. Must be non-empty after decoding. Capped at 16384 bytes of UTF-8 (a `400` names the actual size and the limit if you go over). The full text is stored and readable in the app's History; only the alert shown on the Lock Screen is shortened to fit Apple's 4 KB push ceiling.

# paths./notify/{deviceId}.parameters[].description
Notification title. Capped at 2048 bytes of UTF-8, of which only the first 200 bytes appear in the alert itself. The whole title is stored with the record.

# paths./notify/{deviceId}.parameters[].description
HTTPS URL to a sender avatar shown beside the notification and kept with the history record. Must start with `https://` (an `http://` URL is a `400`, not a silent downgrade). Capped at 1024 bytes measured on the wire, because it competes with your message for Apple's 4 KB budget. A URL with no image extension is resolved through a favicon service on the device.

# paths./notify/{deviceId}.parameters[].description
HTTPS URL to a hero image rendered inside the expanded notification. Same rules as `iconUrl`: HTTPS only, 1024 wire bytes. Independent of `iconUrl`, so you can send both.

# paths./notify/{deviceId}.parameters[].description
Thread identifier. Notifications sharing a `groupType` are stacked together on the device, because the APNs thread id becomes `{deviceId}-{groupType}`. Omit it and the thread id is just the device id. Capped at 1024 wire bytes.

# paths./notify/{deviceId}.parameters[].description
Raises delivery to the Time Sensitive interruption level, which breaks through Do Not Disturb and Focus (the user can still overrule it per app in iOS Settings). Only the exact strings `true` and `1` enable it. Any other value, including `yes`, leaves it off, and nothing is ever rejected for this parameter.

# paths./notify/{deviceId}.get.responses.200.description
Handed off for delivery. A `success: true` here means the gateway accepted and dispatched the notification, not that the phone has displayed it. Poll-only devices (the macOS Notify Listener) also return 200: the record is written and they collect it on their next poll.

# paths./notify/{deviceId}.post.summary
Send a notification to one device (POST)

# paths./notify/{deviceId}.post.description
Byte-for-byte the same handler as the GET form, offered only because some webhook products cannot be configured to issue a GET. **Parameters still come from the query string.** The request body is not read at all.

```bash
curl -X POST "https://push.getnotifyapp.com/notify/ABC12345?token=YOUR_DEVICE_TOKEN&body=Build%20passed&title=CI&groupType=github-actions"
```

# paths./send/{deviceId}.post.summary
Send a raw-text notification to one device

# paths./send/{deviceId}.post.description
The minimal endpoint: the entire request body is the notification text, with no encoding, no JSON, and no query parameters. Built for shell scripts and automation tools that can pipe text but struggle with URL-encoding.

**Authentication is the `token` HTTP header**, not a query parameter and not the shared Bearer token.

**This endpoint is deliberately plain.** It sends no title, no icon, no image, no thread id, and no Time Sensitive flag, because it builds a minimal APNs payload (`aps.alert.body` plus `aps.sound`) rather than the webhook-shaped payload the other three endpoints build. If you need any of those fields, use `POST /notify-json/{id}`.

**Body handling.** The body is read as UTF-8 text and trimmed. `Content-Type` is not inspected, so `text/plain` is conventional rather than required. The raw stream is hard-capped at 65536 bytes; the gateway stops reading, answers `413`, and closes the connection. Independently, the notification text itself is capped at 16384 bytes with a `400` that names the limit.

```bash
curl -X POST https://push.getnotifyapp.com/send/ABC12345 \
  -H "token: YOUR_DEVICE_TOKEN" \
  -H "Content-Type: text/plain" \
  --data-binary "Nightly backup finished in 4m12s"
```

# paths./send/{deviceId}.post.parameters[].description
Target device id. Must be 8 to 32 alphanumeric characters for the raw-body reader to engage, which covers both the 8-character legacy ids and the 16-character `WB` web ids.

# paths./send/{deviceId}.post.requestBody.description
The notification text, sent verbatim. Leading and trailing whitespace is trimmed; an empty result is a `400`.

# paths./send/{deviceId}.post.responses.400.description
Empty text, text over the 16384-byte limit, or a push that Apple refused.

# paths./send/{deviceId}.post.responses.400.content.application/json.examples.bodyTooLong.value.message
Notification body is 20480 bytes; the limit is 16384 bytes (about 16 KB). Send less text, or link to the full content.

# paths./send/{deviceId}.post.responses.403.description
The `token` header is missing, or does not match this device.

# paths./send/{deviceId}.post.responses.404.description
Device not found. Rare in practice: the token check already proved the device existed a moment earlier, so this only fires if the row is deleted in between.

# paths./send/{deviceId}.post.responses.413.description
The raw body exceeded 65536 bytes. The gateway stops reading, returns this, and closes the connection with `Connection: close`.

# paths./send/{deviceId}.post.responses.413.content.application/json.example.message
Notification body exceeds the 65536-byte limit

# paths./notify-json/{id}.post.summary
Send a notification to a device or a group (JSON)

# paths./notify-json/{id}.post.description
The endpoint to build new integrations against. It takes a JSON body, so nothing needs URL-encoding, newlines and emoji survive intact, and you get the full 16384-byte message allowance that the URL-based endpoints cannot reach.

**One endpoint, two targets.** The target type is inferred from the id: anything starting with `GRP` is treated as a group and fanned out to its members, everything else is treated as a single device. The response tells you which happened via its `type` field, and the two shapes differ, so branch on `type` rather than assuming.

**`Content-Type: application/json` is mandatory.** Without it you get `415`, not a parse error.

**Title default.** Unlike `/notify`, a missing, non-string, or blank `title` is replaced with `"Notify!"` rather than left off. There is no way to send a genuinely title-less notification through this endpoint.

```bash
curl -X POST "https://push.getnotifyapp.com/notify-json/ABC12345?token=YOUR_DEVICE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Deploy to production succeeded in 3m 42s",
    "title": "GitHub Actions",
    "iconUrl": "https://example.com/favicon.png",
    "groupType": "github-production",
    "timeSensitive": false
  }'
```

Same call against a group, changing only the id and the token:

```bash
curl -X POST "https://push.getnotifyapp.com/notify-json/GRP45678?token=YOUR_GROUP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text":"Security scan clean","title":"Nightly scan"}'
```

# paths./notify-json/{id}.post.parameters[].description
A device id or a group id. Ids beginning with `GRP` are routed to the group fan-out; everything else is treated as a device id.

# paths./notify-json/{id}.post.parameters[].description
The device secret or the group secret, matching whichever kind of id you passed. It stays in the query string here even though the payload is a JSON body, for consistency with the other webhook endpoints.

# paths./notify-json/{id}.post.responses.200.description
Handed off for delivery. The shape depends on whether the id resolved to a device or a group; check `type`.

# paths./notify-json/{id}.post.responses.200.content.application/json.examples.group.value.results[].error
Web push subscription retired or missing; browser must reopen the web app

# paths./notify-json/{id}.post.responses.400.description
The body was not a JSON object, `text` was missing or not a string, a field exceeded its limit, an icon or image URL was not HTTPS, or Apple refused the push for a device target.

# paths./notify-json/{id}.post.responses.400.content.application/json.examples.invalidJson.value.details
Unexpected token } in JSON at position 42

# paths./notify-json/{id}.post.responses.400.content.application/json.examples.insecureIcon.value.message
Field "iconUrl" must be an HTTPS URL for security reasons

# paths./notify-json/{id}.post.responses.400.content.application/json.examples.extraFieldTooLong.value.message
Field "imageUrl" is 2048 bytes on the wire; the limit is 1024 bytes. It travels inside the notification, where it competes with your message for Apple's 4 KB budget.

# paths./notify-json/{id}.post.responses.400.content.application/json.examples.apnsRefused.summary
Apple refused the push (device target only)

# paths./notify-json/{id}.post.responses.403.description
The `token` query parameter is missing, or does not match the device or group named by `id`. A wrong token and a non-existent id are answered identically, so this endpoint never confirms whether an id exists.

# paths./notify-json/{id}.post.responses.403.content.application/json.examples.device.value.message
Invalid token for this device or device not found

# paths./notify-json/{id}.post.responses.403.content.application/json.examples.group.value.message
Invalid token for this group or group not found

# paths./notify-json/{id}.post.responses.404.description
The group exists and the token was right, but it has no members at all. A group whose members are all poll-only devices returns 200 instead, since the broadcast is recorded for them to collect.

# paths./notify-json/{id}.post.responses.415.description
`Content-Type` was not `application/json`. Checked before the body is looked at.

# paths./notify-json/{id}.post.responses.415.content.application/json.example.hint
Add header: -H "Content-Type: application/json" to your request

# paths./notify-json/{id}.post.responses.500.description
Unexpected server error. Note this endpoint's 500 body carries both `error` and `message`, unlike the other send endpoints.

# paths./notify-group/{groupId}.description
The group twin of `/notify/{deviceId}`. GET and POST are the same handler and **both read every parameter from the query string**; a POST request body is ignored.

**Partial failure is still a 200.** The gateway attempts every push-capable member, then returns a per-member breakdown. One dead member does not fail the call, so check `failureCount` and `results` rather than relying on the status code.

**Who receives it.** Members whose push channel is `poll` (the macOS Notify Listener) are excluded from the fan-out by design and collect the broadcast on their next poll. If a group consists entirely of poll members, the call still returns 200 with `results: []` and `successCount` equal to the member count. Only a group with no members at all returns 404.

**Delivery is per member and per channel.** iOS members go out over APNs, browser members over their own web push service, and one member's failure never affects another's.

# paths./notify-group/{groupId}.parameters[].description
Target group. Group ids are the literal prefix `GRP` followed by 5 characters from `A-Z0-9`.

# paths./notify-group/{groupId}.parameters[].description
The group secret, matched against `notification_groups.group_token`. This is the group's own credential, not any member's device token. Checked before anything else, so a wrong token returns 403 even when the request is otherwise malformed.

# paths./notify-group/{groupId}.parameters[].description
The notification text, URL-encoded. Must be non-empty after decoding, capped at 16384 bytes of UTF-8. Decoded twice, exactly as on `/notify/{deviceId}`, so a literal percent sign survives but text that looks like percent-encoding is decoded again.

# paths./notify-group/{groupId}.parameters[].description
Notification title. Capped at 2048 bytes; the first 200 bytes appear in the alert. Unlike `/notify-json`, omitting it sends no title at all rather than defaulting to "Notify!".

# paths./notify-group/{groupId}.parameters[].description
HTTPS URL to a sender avatar, shown on the notification and kept with each member's history record. HTTPS only, 1024 wire bytes.

# paths./notify-group/{groupId}.parameters[].description
HTTPS URL to a hero image rendered inside the expanded notification. HTTPS only, 1024 wire bytes.

# paths./notify-group/{groupId}.parameters[].description
Thread identifier. The APNs thread id becomes `{groupId}-{groupType}`, so distinct categories from the same group stack separately on each member's device. Capped at 1024 wire bytes.

# paths./notify-group/{groupId}.parameters[].description
Raises delivery to the Time Sensitive interruption level for every member. Only the exact strings `true` and `1` enable it; any other value leaves it off and is never rejected.

# paths./notify-group/{groupId}.get.summary
Send a notification to every member of a group (GET)

# paths./notify-group/{groupId}.post.summary
Send a notification to every member of a group (POST)

# paths./notify-group/{groupId}.post.description
The same handler as the GET form, for webhook products that can only issue a POST. **Parameters still come from the query string**; the request body is not read.

```bash
curl -X POST "https://push.getnotifyapp.com/notify-group/GRP45678?token=YOUR_GROUP_TOKEN&body=All%20tests%20passed&title=CI&groupType=test-results"
```

# paths./create-group.post.description
Creates a new notification group and makes the calling device its owner.

The response carries the newly minted `groupId` and `groupToken`. The token is the group's bearer secret: anyone holding it can join the group and can post to `/notify-group/{groupId}`, so treat it like a password. It is 15 characters drawn from a 62-symbol **mixed case** alphabet (`A-Za-z0-9`) using a CSPRNG, giving roughly 89 bits of entropy.

**Do not normalize the case of a group token.** Tokens are compared exactly. Uppercasing one corrupts it: only about 0.03% of valid tokens happen to contain no lowercase letter, so a client that uppercases will reject essentially every real token.

### Authentication
Two independent layers run in front of this handler, each with its own deployment flag:

* `optionalGroupAuthMiddleware` checks the shared app bearer (`bearerAuth`). When the gateway runs with `REQUIRE_GROUP_AUTH=true` the header is mandatory; otherwise a request with no `Authorization` header is still accepted for backward compatibility, but a header that is *present and wrong* is always rejected with 401.
* `groupDeviceAuthMiddleware` proves that the caller really is `deviceId`, by matching the `token` header against that device's per-device token (`deviceToken`). When the gateway runs with `REQUIRE_GROUP_DEVICE_AUTH=true` a missing or mismatched proof is a 403; otherwise the failure is logged and the request proceeds.

New integrations should always send both `Authorization: Bearer …` and the `token` header, because both flags are intended to end up enforced.

# paths./create-group.post.responses.200.description
Group created. The creator is now its owner.

# paths./create-group.post.responses.400.description
`deviceId` or `groupName` was missing from the body.

# paths./create-group.post.responses.400.content.application/json.example.error
Missing required fields: deviceId, groupName

# paths./create-group.post.responses.401.description
The shared app bearer token was missing (strict mode only) or wrong.

# paths./create-group.post.responses.401.content.application/json.examples.missing.value.message
Authorization header required for group operations

# paths./create-group.post.responses.401.content.application/json.examples.invalid.summary
Header present but wrong (rejected in either mode)

# paths./create-group.post.responses.403.description
Per-device proof failed while `REQUIRE_GROUP_DEVICE_AUTH=true`: the `token` header was absent, or it did not belong to `deviceId`.

# paths./create-group.post.responses.403.content.application/json.example.message
Group operations require the acting device to prove ownership with its per-device token

# paths./create-group.post.responses.404.description
No device is registered under that `deviceId`.

# paths./create-group.post.responses.500.description
Database or server error. The transaction is rolled back, so no partial group is left behind.

# paths./join-group.post.description
Adds a device to an existing group, identified by the group's secret token rather than by its id. The joining device always becomes a `member`, never an `owner`.

The `groupToken` must be sent **exactly as issued**. It matches `^[A-Za-z0-9]{15}$` and is mixed case; trimming surrounding whitespace is fine, changing case is not. A client that uppercased the token rejected roughly 99.97% of valid tokens as invalid, so this is worth getting right.

A device may belong to many groups, but cannot join the same group twice (that is a 400, not an idempotent no-op).

Authentication is the same two-layer arrangement as `POST /create-group`: the shared app bearer, plus the acting device's own `token` header proving it is `deviceId`.

# paths./join-group.post.responses.200.description
The device is now a member of the group.

# paths./join-group.post.responses.400.description
A required field was missing, or the device is already a member of this group.

# paths./join-group.post.responses.400.content.application/json.examples.missingFields.value.error
Missing required fields: deviceId, groupToken

# paths./join-group.post.responses.400.content.application/json.examples.alreadyMember.value.error
Device is already a member of this group

# paths./join-group.post.responses.401.description
The shared app bearer token was missing (strict mode only) or wrong.

# paths./join-group.post.responses.403.content.application/json.example.message
Per-device token does not match the acting device

# paths./join-group.post.responses.404.description
The device is not registered, or no group has that token. The two cases are reported separately.

# paths./join-group.post.responses.404.content.application/json.examples.badGroupToken.summary
No group matches that token (check for case mangling)

# paths./leave-group.post.description
Removes the acting device from a group.

Owners may leave too, and the gateway handles the consequences automatically inside one transaction:

* **Owner leaves, other members remain**: ownership transfers to the first remaining member (the first row returned for that group, which is not a defined ordering, so do not rely on *which* member inherits it).
* **Owner leaves and was the last member**: the group row itself is deleted, cascading to members and group notification history.
* **A plain member leaves**: only that membership row goes.

No notification is sent to the other members.

Authentication is the same two-layer arrangement as `POST /create-group`.

# paths./leave-group.post.responses.200.description
The device left the group. In practice `message` is always `Successfully left group`, including in the case where the group was deleted because the departing owner was its last member.

# paths./leave-group.post.responses.400.description
`deviceId` or `groupId` was missing from the body.

# paths./leave-group.post.responses.400.content.application/json.example.error
Missing required fields: deviceId, groupId

# paths./leave-group.post.responses.401.description
The shared app bearer token was missing (strict mode only) or wrong.

# paths./leave-group.post.responses.403.content.application/json.example.message
Per-device token does not match the acting device

# paths./leave-group.post.responses.404.description
That device is not a member of that group. A group id that does not exist at all produces the same response, so this never confirms whether a group exists.

# paths./leave-group.post.responses.500.description
Database or server error. The transaction is rolled back, so a half-completed ownership transfer is not possible.

# paths./groups/{deviceId}.get.description
Returns every group the device is a member of, newest membership first (`ORDER BY joined_at DESC`), as a bare JSON array. An empty array means the device is in no groups; there is no 404 for an unknown device id.

**This response contains each group's secret `groupToken` and a ready-to-use `notificationUrl` with the token embedded.** Treat the whole payload as sensitive.

### Authentication
The shared app bearer (`bearerAuth`) is always required here, unlike the create/join/leave routes. On top of that, `deviceAuthMiddleware` resolves the acting device: if a `token` header is present it is validated against the device id in the path and, when it matches, the *proven* device id is what gets queried (so a caller cannot list someone else's groups by editing the path). When `REQUIRE_DEVICE_AUTH=true` the `token` header is mandatory; otherwise a request without it falls back to the path parameter for backward compatibility, while a `token` header that is present and wrong is rejected with 403 in either mode.

# paths./groups/{deviceId}.get.parameters[].description
The device whose memberships to list. Ignored in favour of the device proven by the `token` header when one is supplied.

# paths./groups/{deviceId}.get.responses.200.description
The device's group memberships. Bare array, not wrapped in an envelope.

# paths./groups/{deviceId}.get.responses.401.description
The shared app bearer token was missing or wrong.

# paths./groups/{deviceId}.get.responses.403.description
Per-device authorization failed: the `token` header did not belong to `deviceId`, or it was absent while `REQUIRE_DEVICE_AUTH=true`.

# paths./groups/{deviceId}.get.responses.403.content.application/json.examples.mismatch.summary
Token does not match the requested device

# paths./groups/{deviceId}.get.responses.403.content.application/json.examples.mismatch.value.message
Per-device token does not match the requested device

# paths./group/{groupId}.get.summary
Get one group, its members, and its recent sends

# paths./group/{groupId}.get.description
Returns the group record, the full member roster (owner first, then members by join time), and the ten most recent group notifications.

**This response contains the group's secret token and every member's device id.** The token appears under the snake_case key `group_token`, not `groupToken`. That inconsistency is deliberate and load-bearing: the iOS `GroupDetails` model decodes that exact key, so it cannot be renamed.

### Authentication and the membership gate
The shared app bearer is always required. Beyond that, `deviceAuthMiddleware` may establish a proven device from the `token` header, and **the membership check only runs when a device was proven**:

* `token` header sent and valid: the proven device must be a member of this group, or the request is refused with 403.
* No `token` header and `REQUIRE_DEVICE_AUTH=false`: no membership check runs, preserving behavior for older clients.
* `REQUIRE_DEVICE_AUTH=true`: a proven device always exists, so the membership check always applies.

Send the `token` header. It is the only path that will keep working once the flag is enforced.

Note that the middleware resolves the acting device from `requestingDeviceId` or `deviceId` in the query string, in that order. This route has no `deviceId` path parameter of its own, so pass `?requestingDeviceId=…` alongside the `token` header if you want the proof to apply.

# paths./group/{groupId}.get.parameters[].description
The device making the request. Used together with the `token` header to prove who the caller is, which in turn enables the membership check. Optional today, mandatory once `REQUIRE_DEVICE_AUTH` is enforced.

# paths./group/{groupId}.get.responses.401.description
The shared app bearer token was missing or wrong.

# paths./group/{groupId}.get.responses.403.description
The caller proved a device (via the `token` header) that is not a member of this group, or per-device authorization itself failed.

# paths./group/{groupId}.get.responses.403.content.application/json.examples.badDeviceToken.summary
token header did not match the requesting device

# paths./group/{groupId}.get.responses.403.content.application/json.examples.badDeviceToken.value.message
Per-device token does not match the requested device

# paths./group/{groupId}.delete.description
Permanently deletes a group. The delete cascades: every membership row and the group's notification history go with it. There is no undo and no confirmation step.

Only the group's **owner** may do this. A regular member gets 403, and so does a caller naming a group that does not exist, because the ownership lookup simply finds no row. This route therefore never returns 404, and never reveals whether a given group id is real.

### Identifying the owner: send both the token header and requestingDeviceId
This is the fiddly part of the route.

* `deviceAuthMiddleware` looks for the acting device in `?requestingDeviceId=` first, then `?deviceId=`, and validates it against the `token` header. If that succeeds, the proven device is used for the ownership check.
* If no device was proven (no `token` header, migration mode), the handler falls back to reading **`?deviceId=`** only. A request carrying `?requestingDeviceId=` alone and no `token` header fails with 400 `Missing deviceId parameter`.

The safe call is to send the `token` header together with `?requestingDeviceId=`. Sending `?deviceId=` as well is harmless and keeps the request working in either mode.

# paths./group/{groupId}.delete.parameters[].description
The owner making the request. Read by the per-device authorization layer and validated against the `token` header. Preferred over `deviceId` when both are present.

# paths./group/{groupId}.delete.parameters[].description
The owner making the request. This is the parameter the handler itself falls back to when no device was proven via the `token` header, so include it unless you are certain the token header will validate.

# paths./group/{groupId}.delete.responses.200.description
Group deleted, along with all memberships and its notification history.

# paths./group/{groupId}.delete.responses.400.description
No acting device could be determined: neither a proven device nor a `deviceId` query parameter was supplied.

# paths./group/{groupId}.delete.responses.401.description
The shared app bearer token was missing or wrong.

# paths./group/{groupId}.delete.responses.403.description
The acting device is not the owner of that group. Returned identically when the group does not exist, or when per-device authorization failed.

# paths./group/{groupId}.delete.responses.403.content.application/json.examples.badDeviceToken.summary
token header did not match the requesting device

# paths./group/{groupId}.delete.responses.403.content.application/json.examples.badDeviceToken.value.message
Per-device token does not match the requested device

# paths./group/{groupId}/members.post.summary
Add a member to a group by device id and device token (owner only)

# paths./group/{groupId}/members.post.description
Adds a device to a group directly, bypassing the normal "share the group token and let them join" flow. Built for controlled onboarding: an IT admin adding staff devices, a parent adding a child's device.

Because it puts a device into a group without that device asking, it requires proof of the **target** device as well as of the owner. The caller must supply the target's per-device token in the body.

### Three different secrets, do not mix them up
The body's `deviceToken` is the **15-character app authentication token** stored in `devices.token` and shown in the app under Settings, Device. It is not the 64-character hexadecimal APNs push token, and it is not the shared app bearer. (Until an October 2025 fix this handler compared against the APNs token, which made every add-member attempt fail with `Invalid device token`.)

### Authorization
The shared app bearer is always required. The owner identity is `requestingDeviceId`, preferring the device proven by the `token` header when one validates, so the check cannot be spoofed by naming the real owner in the body. The caller must hold the `owner` role in this group; a non-member and a plain member get different 403 messages.

The insert runs in a transaction and the new member always gets the `member` role.

# paths./group/{groupId}/members.post.responses.200.description
The device was added to the group with the `member` role.

# paths./group/{groupId}/members.post.responses.200.content.application/json.example.message
Device NEW12345 added to group successfully

# paths./group/{groupId}/members.post.responses.400.description
A required field was missing, or the target device is already in this group.

# paths./group/{groupId}/members.post.responses.400.content.application/json.examples.missingDeviceToken.value.message
You must provide the Device Token to verify device ownership

# paths./group/{groupId}/members.post.responses.400.content.application/json.examples.missingRequestingDeviceId.summary
No acting device (and none proven from the token header)

# paths./group/{groupId}/members.post.responses.400.content.application/json.examples.missingRequestingDeviceId.value.message
You must provide the device ID making this request

# paths./group/{groupId}/members.post.responses.400.content.application/json.examples.alreadyMember.value.message
Device NEW12345 is already a member of this group

# paths./group/{groupId}/members.post.responses.401.description
The shared app bearer token was missing or wrong.

# paths./group/{groupId}/members.post.responses.403.description
The caller is not in this group, is in it but not as owner, or supplied a `deviceToken` that does not match the target device.

# paths./group/{groupId}/members.post.responses.403.content.application/json.examples.notAMember.summary
Caller is not in this group (also returned for a group that does not exist)

# paths./group/{groupId}/members.post.responses.403.content.application/json.examples.notAMember.value.message
Your device is not a member of this group

# paths./group/{groupId}/members.post.responses.403.content.application/json.examples.notOwner.value.message
You must be the group owner to add members

# paths./group/{groupId}/members.post.responses.403.content.application/json.examples.badTargetToken.summary
deviceToken does not match the target device's 15-character app token

# paths./group/{groupId}/members.post.responses.403.content.application/json.examples.badTargetToken.value.message
The device token provided does not match the device

# paths./group/{groupId}/members.post.responses.404.description
The target `deviceId` is not registered with the gateway.

# paths./group/{groupId}/members.post.responses.404.content.application/json.example.message
Device NEW12345 does not exist in the system

# paths./group/{groupId}/members.post.responses.500.description
Database or server error. The insert is rolled back.

# paths./group/{groupId}/members/{deviceId}.delete.summary
Remove a member from a group (owner only)

# paths./group/{groupId}/members/{deviceId}.delete.description
Removes one device from a group. Only the group's owner may do this.

Unlike `POST /group/{groupId}/members`, no proof of the *target* device is needed. The owner's authority is enough, which is the point: this is how a lost or compromised device gets cut off.

**An owner cannot remove themselves.** That returns 400 and points you at `DELETE /group/{groupId}` instead, so a group is never left without an owner. (An owner who wants out without destroying the group should use `POST /leave-group`, which transfers ownership to a remaining member.)

### Identifying the owner
`requestingDeviceId` is the owner and travels in the query string. The path's `deviceId` is the **member being removed**, so the per-device authorization layer deliberately prefers `?requestingDeviceId=` over it when deciding what the `token` header should be checked against. Send the `token` header along with `?requestingDeviceId=`; when a device is proven that way, the proven id wins and the owner check cannot be spoofed.

The removal runs in a transaction. Member counts are computed on demand, so nothing else needs updating.

# paths./group/{groupId}/members/{deviceId}.delete.parameters[].description
The member being removed. Must not equal `requestingDeviceId`.

# paths./group/{groupId}/members/{deviceId}.delete.parameters[].description
The owner making the request. Required unless a device is proven via the `token` header, in which case the proven id is used instead.

# paths./group/{groupId}/members/{deviceId}.delete.responses.200.description
The device is no longer a member and will stop receiving this group's notifications.

# paths./group/{groupId}/members/{deviceId}.delete.responses.200.content.application/json.example.message
Device XYZ67890 removed from group successfully

# paths./group/{groupId}/members/{deviceId}.delete.responses.400.description
No acting device was supplied, or the owner tried to remove themselves.

# paths./group/{groupId}/members/{deviceId}.delete.responses.400.content.application/json.examples.missingRequestingDeviceId.summary
No acting device (and none proven from the token header)

# paths./group/{groupId}/members/{deviceId}.delete.responses.400.content.application/json.examples.missingRequestingDeviceId.value.message
You must provide the device ID making this request

# paths./group/{groupId}/members/{deviceId}.delete.responses.400.content.application/json.examples.selfRemoval.value.message
Group owners cannot remove themselves. Delete the group instead.

# paths./group/{groupId}/members/{deviceId}.delete.responses.401.description
The shared app bearer token was missing or wrong.

# paths./group/{groupId}/members/{deviceId}.delete.responses.403.description
The caller is not in this group, or is in it but not as owner.

# paths./group/{groupId}/members/{deviceId}.delete.responses.403.content.application/json.examples.notAMember.summary
Caller is not in this group (also returned for a group that does not exist)

# paths./group/{groupId}/members/{deviceId}.delete.responses.403.content.application/json.examples.notAMember.value.message
Your device is not a member of this group

# paths./group/{groupId}/members/{deviceId}.delete.responses.403.content.application/json.examples.notOwner.value.message
You must be the group owner to remove members

# paths./group/{groupId}/members/{deviceId}.delete.responses.404.description
The device named in the path is not a member of this group.

# paths./group/{groupId}/members/{deviceId}.delete.responses.404.content.application/json.example.message
Device XYZ67890 is not a member of this group

# paths./group/{groupId}/members/{deviceId}.delete.responses.500.description
Database or server error. The removal is rolled back.

# paths./checks.post.description
Creates a beacon: a heartbeat watcher that alerts when an expected ping *stops* arriving.

Omit `targetGroupId` for a device-targeted beacon, owned by the acting device. Supply `targetGroupId` for a group-targeted beacon, which alerts every member of that group (including poll-channel macOS listeners); only a device with the `owner` role in that group may create one.

This is the one response that is guaranteed to include `pingToken` and `pingUrl`, because the caller is provably a manager at this moment. The `pingUrl` is what the user pastes into a crontab.

`pingUrl` is built from the host the request arrived on, so a request to `push.getnotifyapp.com` gets a `push.getnotifyapp.com` ping URL and a request to `notifypush.pingie.com` gets a `notifypush.pingie.com` one. An unrecognized `Host` falls back to `https://notifypush.pingie.com`. Both hosts are permanent and either URL keeps working forever.

A newly created beacon starts at status `new`: nothing is expected of it and no alert can fire until the first real ping arms the schedule.

`Content-Type: application/json` is required. The gateway's JSON body parser only populates the request body for that content type, so any other type (or none) surfaces as `400 Missing deviceId`.

# paths./checks.post.requestBody.content.application/json.examples.groupBeacon.summary
Production cron, alerting a whole group, Time Sensitive

# paths./checks.post.requestBody.content.application/json.examples.minimal.summary
Minimum body, defaults applied server side

# paths./checks.post.responses.200.description
Beacon created. Returns the full beacon including its ping token and ping URL.

# paths./checks.post.responses.400.description
Missing `deviceId`, a field failed validation, or the per-device / per-group beacon cap is already reached. The `message` names the offending field.

# paths./checks.post.responses.400.content.application/json.examples.missingDeviceId.summary
No deviceId (also what a wrong Content-Type looks like)

# paths./checks.post.responses.400.content.application/json.examples.badPeriod.value.message
Field "periodMinutes" must be an integer between 1 and 43200

# paths./checks.post.responses.400.content.application/json.examples.badReminder.value.message
Field "reminderMinutes" must be 0 (off) or an integer between 5 and 10080

# paths./checks.post.responses.400.content.application/json.examples.capReached.value.message
This device already has the maximum of 10 checks

# paths./checks.post.responses.401.description
Missing or invalid shared app bearer token.

# paths./checks.post.responses.403.description
Per-device token did not authorize the acting device, or a group member who is not the group owner tried to create a group-targeted beacon.

# paths./checks.post.responses.403.content.application/json.examples.notGroupOwner.value.message
Only group owners can create checks for a group

# paths./checks.post.responses.403.content.application/json.examples.deviceAuth.value.message
Per-device token does not match the requested device

# paths./checks.post.responses.404.description
The acting device is not registered, or `targetGroupId` does not exist / the acting device is not a member of it. Non-membership and non-existence are deliberately indistinguishable.

# paths./checks.post.responses.500.description
Database error, or (vanishingly rare) the server could not allocate a unique beacon id after ten attempts.

# paths./checks.get.description
Returns the beacons this device owns, plus every beacon targeting a group the device belongs to. Newest first, by `createdAt` descending.

Visibility is broader than management on purpose: a regular group member can be paged by a group beacon, so they must be able to see what can wake them. Those entries come back with `canManage: false` and, critically, with `pingToken` and `pingUrl` omitted. The ping token is a bearer secret whose holder can spoof "still alive" pings and mask a real outage.

The response also reports the server's current beacon caps, so a client can render "3 of 10 slots used" without hardcoding a number that operations can change with an environment variable.

# paths./checks.get.parameters[].description
The device asking. Also the value the per-device `token` header is checked against.

# paths./checks.get.responses.200.description
The visible beacons and the server's current caps.

# paths./checks.get.responses.401.description
Missing or invalid shared app bearer token.

# paths./checks.get.responses.403.description
The per-device token did not authorize the requested device.

# paths./checks.get.responses.403.content.application/json.example.message
Per-device token does not match the requested device

# paths./checks/{checkId}.parameters[].description
The beacon id, `CHK` plus five uppercase alphanumerics.

# paths./checks/{checkId}.get.description
Returns a single beacon using the same visibility rules and the same secret-hiding rules as the list.

A beacon that does not exist and a beacon the caller cannot see both return `404`, so the API never confirms an id's existence to an outsider.

Note this is the only beacon response that is the bare object: the create and update responses wrap it with `success: true`.

# paths./checks/{checkId}.get.parameters[].description
The device asking. Also the value the per-device `token` header is checked against.

# paths./checks/{checkId}.get.responses.200.description
The beacon. `pingToken` and `pingUrl` are present only when `canManage` is true.

# paths./checks/{checkId}.get.responses.401.description
Missing or invalid shared app bearer token.

# paths./checks/{checkId}.get.responses.403.description
The per-device token did not authorize the requested device.

# paths./checks/{checkId}.get.responses.403.content.application/json.example.message
Per-device token does not match the requested device

# paths./checks/{checkId}.get.responses.404.description
No such beacon, or it exists but is not visible to this device. The two cases are deliberately identical.

# paths./checks/{checkId}.put.description
Partial update. Every field is optional and an omitted field is left alone. Manager only: the owning device for a device-targeted beacon, the group owner for a group-targeted one.

**Pause** sets `status` to `paused` from any state. Pings that arrive while paused are still recorded (`lastPingAt` and `pingCount` move), the status simply stays `paused` and no alert can fire.

**Resume** sets `status` to `new`, not `up`. Resuming to `up` would mean inventing a `lastPingAt` of now, a heartbeat the watched system never actually sent, and scheduling a down alert from a fabricated timestamp. `new` means "silent until the next real ping arms the schedule", the same as at creation. Because pings during a pause are recorded, a still-healthy cron job re-arms the beacon within one period of resuming.

Shrinking `periodMinutes` on an armed beacon can make it instantly overdue, in which case the down alert fires at the next minute tick. That is intended: the user has just declared the old cadence unacceptable.

`Content-Type: application/json` is required.

# paths./checks/{checkId}.put.responses.200.description
The updated beacon. The caller is a manager by definition here, so `pingToken` and `pingUrl` are always included.

# paths./checks/{checkId}.put.responses.400.description
Missing `deviceId`, an unrecognized `action`, or a field failed validation.

# paths./checks/{checkId}.put.responses.400.content.application/json.examples.badAction.value.message
Field "action" must be "pause" or "resume"

# paths./checks/{checkId}.put.responses.400.content.application/json.examples.badName.value.message
Field "name" must be at most 100 characters

# paths./checks/{checkId}.put.responses.401.description
Missing or invalid shared app bearer token.

# paths./checks/{checkId}.put.responses.403.description
The per-device token did not authorize the acting device, or the beacon is visible to this device but not manageable by it (a group member who is not the group owner).

# paths./checks/{checkId}.put.responses.404.description
No such beacon, or it is not visible to this device.

# paths./checks/{checkId}.delete.description
Manager only, and permanent. The ping URL stops working the moment this returns, and a later ping to it gets the same anonymous `404` as any unknown id.

Alert history is deliberately kept. Notification rows this beacon produced remain in the user's history and are pruned only by the normal one-year cleanup: they are a record of what happened, not part of the beacon's configuration.

# paths./checks/{checkId}.delete.parameters[].description
The device asking. Also the value the per-device `token` header is checked against.

# paths./checks/{checkId}.delete.responses.401.description
Missing or invalid shared app bearer token.

# paths./checks/{checkId}.delete.responses.403.description
The per-device token did not authorize the acting device, or the beacon is visible to this device but not manageable by it.

# paths./checks/{checkId}.delete.responses.404.description
No such beacon, or it is not visible to this device.

# paths./ping/{checkId}/{pingToken}.parameters[].description
The beacon id, `CHK` plus five uppercase alphanumerics. Anything not matching this shape is rejected with `404` before any database work happens.

# paths./ping/{checkId}/{pingToken}.parameters[].description
The beacon's 15-character ping token. This is the credential: treat the whole URL as a secret, because whoever holds it can mark the job alive.

# paths./ping/{checkId}/{pingToken}.get.description
The heartbeat, and the endpoint people actually paste into a crontab. Call it at least once every `periodMinutes`; if `periodMinutes + graceMinutes` elapse with no ping, the gateway's minute sweep flips the beacon to `down` and pushes an alert to its target.

**Authentication is the URL itself.** No headers are involved: the 15-character token in the path is the credential (about 89 bits from a CSPRNG), the same bearer-in-URL model the `/notify` webhooks use. That is what makes this callable from a shell one-liner, a router, or an IoT device that cannot set headers.

**What a ping does.** `new`, `up`, and `down` all become `up`, `lastPingAt` moves to now, and `pingCount` increments. A `paused` beacon stays `paused` but still records the ping, so a still-healthy job re-arms the schedule cleanly when the user resumes it. A ping that arrives while the beacon is `down` additionally queues a recovery alert, dispatched *after* this response has been sent so the calling script never waits on Apple's push service.

**Method and body tolerance.** GET, POST, and HEAD behave identically (Express serves HEAD from this GET route). On POST the body and `Content-Type` are ignored entirely, with one caveat: a body *declared* as `application/json` must be valid JSON under 100 KB or the gateway's global body parser rejects it before this handler runs. An empty POST or a plain GET sidesteps that.

**No existence oracle.** An unknown beacon id and a wrong token return byte-identical `404` responses, so the endpoint leaks nothing to a brute-force attempt.

There is deliberately no companion `/fail` endpoint. Down detection is purely timed, so to test an alert you simply stop pinging.

Cron example:

    */30 * * * * curl -fsS --retry 3 "https://push.getnotifyapp.com/ping/CHK7Q2ZK/YOUR_PING_TOKEN" > /dev/null

Put the curl at the *end* of a job so a crashed job never pings.

# paths./ping/{checkId}/{pingToken}.get.responses.404.description
Unknown beacon id, or a token that does not match it. The two are indistinguishable by design, as is a request whose id or token fails the shape check.

# paths./ping/{checkId}/{pingToken}.get.responses.500.description
Database error. The ping was not recorded; retry.

# paths./ping/{checkId}/{pingToken}.post.description
Identical in every respect to `GET /ping/{checkId}/{pingToken}`, provided for tools and webhook senders that can only emit POST.

The request body and `Content-Type` are ignored. The one caveat: a body declared as `application/json` still passes through the gateway's global JSON parser first, so it must be syntactically valid and under 100 KB. Sending no body at all avoids the question.

# paths./ping/{checkId}/{pingToken}.post.responses.404.description
Unknown beacon id, or a token that does not match it. Indistinguishable by design.

# paths./ping/{checkId}/{pingToken}.post.responses.500.description
Database error. The ping was not recorded; retry.

# paths./live-activity/{id}.post.description
**A start requires `title`.** Everything else is optional on every call. See `LiveActivityStart` for the start contract; an update may send any subset of the content fields.

Drives an iOS Live Activity: the Lock Screen and Dynamic Island tile that updates in place while a job runs, instead of a stack of separate notifications.

**One route, two dialects.** The handler tells a start from an update by LOOKING THE ID UP, not by its shape (an 8 character device id can legitimately begin with `LA`):

* `{id}` is a **device id**: the call is an upsert against that device's single live tile. The first call carrying content starts a tile, every later call updates it in place, `end=1` finishes it, and `new=1` deliberately starts an additional one. Because a device id and its token never change, one static URL can sit in a service's configuration forever, exactly like a `/notify` webhook. A retried call can never duplicate a tile.
* `{id}` is an **activity id** (`LA` plus 6 characters, returned by a start): the call always addresses that exact tile. This is the precise dialect for scripts running several tiles at once.

**Two content dialects.** Fields arrive either as a JSON body or as query parameters. A non-empty JSON body wins entirely, so the two can never half merge into something nobody sent. Only the JSON dialect can send an explicit `null` to clear a field; in the query dialect the `#` of a tint may simply be omitted (`tint=7C3AED`).

**Timing is always relative.** Callers never send a timestamp. Send `endsIn` (plain seconds from now) and the gateway computes the absolute finish against its own clock, which removes time zones, date formats, and client clock skew from the problem. iOS then ticks the countdown locally, so a time driven tile costs exactly one request for its whole life. A value driven tile costs one request per meaningful `progress` change.

**Updates are a JSON merge patch.** An absent field is left alone, a value replaces, and an explicit `null` deletes the key. `{"endsIn": null}` therefore turns a countdown tile back into a plain progress tile.

**iPhone and iPad only.** A poll only device (the macOS listener) and a web push browser have no Lock Screen tile, so a start against either is refused with `400` rather than accepted into the void.

**Pacing.** The gateway sends at most one content push per tile per 10 seconds, coalescing on the trailing edge: every state is stored, and the newest one is what gets pushed when the window opens. No update is ever lost, only merged. Routine updates go out at APNs priority 5; starts, ends, and token arrival reconciliation use priority 10.

**Not recorded in history.** Live Activity traffic is deliberately not written to server side notification history, so a printer pushing percent every few minutes cannot bury real alerts. The activity row itself is the record and is queryable with `GET` while it lives, plus one day after it ends.

# paths./live-activity/{id}.post.parameters[].description
A device id (upsert dialect) or an activity id (precise dialect). Activity ids are `LA` plus 6 characters from `[A-Z0-9]` and are allocated so they can collide with neither an existing activity nor an existing device.

# paths./live-activity/{id}.post.parameters[].description
The device's own per device token, the same credential a `/notify` webhook URL carries. For an activity scoped call the token must belong to the tile's OWNING device. A wrong token, an unknown id, and someone else's id all return the identical `403`, so the API never confirms to a stranger that an id exists.

# paths./live-activity/{id}.post.parameters[].description
Device dialect only. `1` or `true` forces a fresh tile even when one is already live, and is also the deliberate override for the sticky dismissal `410`. Multi tile is always explicit.

# paths./live-activity/{id}.post.parameters[].description
`1` or `true` ends the tile instead of starting or updating it, with the same semantics as `DELETE`. May also be sent as `{"end": true}` in a JSON body.

# paths./live-activity/{id}.post.parameters[].description
`text` makes a successful START answer `text/plain` containing ONLY the new activity id, so a shell script can capture it with `ACTIVITY_ID=$(curl -s ...)` and no parsing. Ignored on updates and ends, which always answer JSON.

# paths./live-activity/{id}.post.parameters[].description
URL only dialect. Tile title, at most 120 characters. Required to start a tile.

# paths./live-activity/{id}.post.parameters[].description
URL only dialect. Second line, at most 300 characters.

# paths./live-activity/{id}.post.parameters[].description
URL only dialect. SF Symbol name for the tile icon, at most 64 characters.

# paths./live-activity/{id}.post.parameters[].description
URL only dialect. Accent color as `#RRGGBB` or `#AARRGGBB`. The leading `#` is optional, which is what makes this usable in a URL without escaping.

# paths./live-activity/{id}.post.parameters[].description
URL only dialect. Progress bar value from 0 to 100. Values outside the range are clamped rather than rejected, so float math producing 100.4 gets a full bar instead of an error. A value that is not numeric is rejected with a named `400`.

# paths./live-activity/{id}.post.parameters[].description
URL only dialect. Countdown length in SECONDS from now, 1 to 86400. The gateway computes the absolute finish itself. The 24 hour cap doubles as the milliseconds trap: 45 minutes sent as 2700000 lands here with a message naming the actual mistake.

# paths./live-activity/{id}.post.parameters[].description
URL only dialect. Static trailing text shown when there is no timer, at most 40 characters.

# paths./live-activity/{id}.post.parameters[].description
URL only dialect. Free form phase word, at most 40 characters. This is the caller's own label and is unrelated to the activity `state` reported by `GET`.

# paths./live-activity/{id}.post.parameters[].description
URL only dialect, honored on an end. Seconds the finished tile lingers on the Lock Screen, 0 to 14400. Default 0 removes it immediately.

# paths./live-activity/{id}.post.requestBody.description
The JSON dialect. When the body is a non-empty object it wins entirely and every content query parameter is ignored. Omit the body to use the URL only dialect above.

# paths./live-activity/{id}.post.requestBody.content.application/json.examples.clearCountdown.summary
Drop the countdown, keep the progress bar

# paths./live-activity/{id}.post.requestBody.content.application/json.examples.end.summary
Finish with a final state that lingers 5 minutes

# paths./live-activity/{id}.post.responses.200.description
The call succeeded. The body depends on which branch ran: a start returns the new `activityId` and Apple's 8 hour ceiling, an update echoes the id, an end reports the final `state`, and an end against a device that has no live tile succeeds with a message (idempotent by design, so an end of job hook never fails for having already worked).

# paths./live-activity/{id}.post.responses.400.description
Validation failed and the message names the field, or the device already holds the maximum of 5 live tiles, or the combined content does not serialize under 2048 bytes, or the target device cannot show Live Activities at all (a poll only macOS listener, or a web push browser). The size check happens before the database write and before the APNs send, because Apple's own payload rejection would fail silently.

# paths./live-activity/{id}.post.responses.400.content.application/json.examples.missingTitle.value.message
Field "title" is required and must be a non-empty string

# paths./live-activity/{id}.post.responses.400.content.application/json.examples.notAniPhone.value.message
Live Activities require an iOS device; this device is registered as a web browser

# paths./live-activity/{id}.post.responses.400.content.application/json.examples.secondsNotMillis.value.message
Field "endsIn" looks like milliseconds; send seconds (max 86400)

# paths./live-activity/{id}.post.responses.403.description
The uniform script facing rejection. A missing token, a wrong token, an unknown id, and another user's id all return this identical body, so the endpoint cannot be used as an existence oracle over the 8 character id space.

# paths./live-activity/{id}.post.responses.409.description
The request is valid but cannot be honored as sent. Either the device cannot spawn a tile yet (no start credential on file, so the app has to be opened once, or the user has turned Live Activities off for Notify), or the device URL is ambiguous because more than one tile is live, in which case the body lists the ids so a script can pick one.

# paths./live-activity/{id}.post.responses.409.content.application/json.examples.noStartCredential.summary
App never opened on a Live Activity capable build

# paths./live-activity/{id}.post.responses.409.content.application/json.examples.noStartCredential.value.message
This device has no Live Activity start token yet. Open the Notify app once on the device (with a build that supports Live Activities), then try again.

# paths./live-activity/{id}.post.responses.409.content.application/json.examples.turnedOff.summary
User disabled Live Activities for Notify

# paths./live-activity/{id}.post.responses.409.content.application/json.examples.turnedOff.value.message
Live Activities are turned off for Notify on this device (Settings > Notify > Live Activities)

# paths./live-activity/{id}.post.responses.409.content.application/json.examples.ambiguous.summary
Several tiles live, so the device URL is ambiguous

# paths./live-activity/{id}.post.responses.409.content.application/json.examples.ambiguous.value.message
This device has 2 live activities, so the device URL is ambiguous. Address one by its activityId, or pass new=1 to start another.

# paths./live-activity/{id}.post.responses.410.description
Dismissal is sticky, in both dialects. If the user swiped the tile away, updates to that activity id are refused so nobody holding the device token can play Lock Screen whack a mole, and the device URL refuses the same way for as long as the dismissed job would still have been running (its `endsIn` window, or 2 hours for a progress only tile). An already ended tile answers the same way, with the reason named. `new=1` is the deliberate override; after the job's window the device URL starts fresh on its own.

# paths./live-activity/{id}.post.responses.410.content.application/json.examples.dismissed.value.message
This Live Activity was dismissed by the user. Start a new one for new content.

# paths./live-activity/{id}.post.responses.410.content.application/json.examples.ended.value.message
This Live Activity has ended (overdue). Start a new one for new content.

# paths./live-activity/{id}.post.responses.410.content.application/json.examples.stickyDevice.value.message
The user dismissed this tile while its job was still running. Pass new=1 to start a fresh one on purpose.

# paths./live-activity/{id}.post.responses.429.description
Starts only. Apple silently throttles push to start per app per device: after enough starts that never produce a tile, iOS discards them with no error anywhere while APNs keeps answering 200. The classic trigger is an app update or reinstall rotating the device's start credential. Rather than burn the device's spawn budget, the gateway refuses with the honest story and an escalating quiet time (30 minutes after 2 confirmed losses, 3 hours after 4, 6 hours after 6). The counter resets the moment any tile actually appears, and it fades on its own by one step per fully quiet day, so a device is never stuck at the top of the ladder. Opening the Notify app is the everyday remedy, but only while a pending tile is still there to re-drive: check `openingTheAppMayHelp` rather than repeating the advice blindly. Updates and ends are never gated here.

# paths./live-activity/{id}.post.responses.429.headers.Retry-After.description
Seconds to wait before the next start attempt.

# paths./live-activity/{id}.post.responses.429.content.application/json.examples.appCanHelp.summary
A pending tile exists, so a token rotation would retry it

# paths./live-activity/{id}.post.responses.429.content.application/json.examples.appCanHelp.value.message
Apple is silently ignoring Live Activity starts for this device right now: 2 in a row were accepted but no tile ever appeared. This usually means the app was updated or reinstalled and the server held a stale start token. Open the Notify app once on the device. If iOS hands it a different start token, the pending tile is retried straight away; if the token comes back unchanged, only the quiet time clears this. Either way, wait about 18 minute(s) before starting again.

# paths./live-activity/{id}.post.responses.429.content.application/json.examples.onlyWaitingHelps.summary
Nothing left to re-drive, so only the quiet time clears it

# paths./live-activity/{id}.post.responses.429.content.application/json.examples.onlyWaitingHelps.value.message
Apple is silently ignoring Live Activity starts for this device right now: 6 in a row were accepted but no tile ever appeared. This usually means the app was updated or reinstalled and the server held a stale start token. Opening the app will not shortcut this: there is no pending tile left to retry. Please wait about 214 minute(s) before starting again.

# paths./live-activity/{id}.post.responses.500.description
Unexpected server error. Nothing about the tile can be assumed; re-read with `GET`.

# paths./live-activity/{id}.post.responses.502.description
Starts only. The tile was not started, OR delivery is unknown. Check `deliveryState`: `not-delivered` removed the row and cannot duplicate a tile, `unknown` kept the row and returns its `activityId` to poll. On a `not-delivered`, retry only if `retryAfterSeconds` is present, and wait that long. Retrying an `unknown` with `new=1` can produce a second tile.

# paths./live-activity/{id}.post.responses.502.content.application/json.examples.rateLimited.summary
Apple is pacing this device (429); wait and retry

# paths./live-activity/{id}.post.responses.502.content.application/json.examples.rateLimited.value.message
Apple is rate limiting Live Activity starts for this device and nothing was started. Wait about 60 second(s), then start again.

# paths./live-activity/{id}.post.responses.502.content.application/json.examples.appleSideError.summary
Apple's own side failed (5xx); wait considerably longer

# paths./live-activity/{id}.post.responses.502.content.application/json.examples.appleSideError.value.message
Apple could not process the Live Activity start and nothing was started. This is Apple's side, not the request: wait about 15 minute(s), then start again.

# paths./live-activity/{id}.post.responses.502.content.application/json.examples.permanentlyRejected.summary
A verdict no wait will change; note the absent retryAfterSeconds

# paths./live-activity/{id}.post.responses.502.content.application/json.examples.permanentlyRejected.value.message
Apple rejected the Live Activity start and nothing was started. Sending the same request again will fail the same way; check the device still supports Live Activities and that the content is within limits.

# paths./live-activity/{id}.post.responses.502.content.application/json.examples.deadStartToken.summary
The device's start credential died; a fresh one is the remedy

# paths./live-activity/{id}.post.responses.502.content.application/json.examples.deadStartToken.value.message
The device's Live Activity start token is no longer valid and nothing was started. Open the Notify app once on the device to report a fresh one, then start again.

# paths./live-activity/{id}.post.responses.502.content.application/json.examples.unknown.summary
Apple never answered; a tile may still appear

# paths./live-activity/{id}.post.responses.502.content.application/json.examples.unknown.value.message
Apple could not be reached and delivery is unknown. The tile may still appear; check its status before starting another.

# paths./live-activity/{id}.post.responses.502.headers.Retry-After.description
Seconds to wait before starting again. Sent ONLY alongside `retryAfterSeconds`, that is only when a wait is the remedy. Absent on a permanent rejection and on `deliveryState: "unknown"`.

# paths./live-activity/{id}.post.responses.503.description
The server side Live Activity kill switch is off, so starts and updates are refused. Ending a tile and reading status are deliberately NOT gated: when the feature is disabled, removing tiles is exactly the operation that must keep working.

# paths./live-activity/{id}.post.responses.503.content.application/json.example.message
Live Activities are temporarily disabled on this server

# paths./live-activity/{id}.get.summary
Read a Live Activity, list a device's live tiles, or drive one from a bare URL

# paths./live-activity/{id}.get.description
A BARE `GET` reads:

* with an **activity id**, the full status of that one tile, including `endReason` for a tile that has already finished. Status stays readable for one day after a tile ends, then the row is cleaned up.
* with a **device id**, every tile currently live on that device. This is the crash recovery path: a script that lost its activity id lists its device and reattaches, and the iOS app's launch reconciliation reads the same list.

A `GET` **carrying content parameters ACTS instead of reading**, with exactly the semantics of `POST`: `?title=...&endsIn=2700` starts, `?progress=94` updates, `&end=1` ends. That is the house `/notify` idiom, where a GET with parameters IS the webhook, and it is what lets a Live Activity be driven from anything that can fetch a URL. Adding no parameters keeps the old read behavior, so nothing existing changed when this was added.

Reading is never gated by the server side kill switch. Acting is, on the same terms as `POST`.

# paths./live-activity/{id}.get.parameters[].description
An activity id for one tile's status, or a device id for the list of that device's live tiles (and for the acting dialect, the device upsert).

# paths./live-activity/{id}.get.parameters[].description
The device's own per device token. For an activity id it must be the token of the OWNING device. Wrong token and unknown id share one identical `403`.

# paths./live-activity/{id}.get.parameters[].description
`1` or `true` ends the tile, identical to `DELETE`. Optional final content fields and `keepFor` may ride along.

# paths./live-activity/{id}.get.parameters[].description
Device dialect only, acting mode. `1` or `true` starts an additional tile instead of updating the live one.

# paths./live-activity/{id}.get.parameters[].description
`text` makes a successful START answer `text/plain` containing only the new activity id. Has no effect on a read.

# paths./live-activity/{id}.get.parameters[].description
Acting mode. Tile title, at most 120 characters. Required to start.

# paths./live-activity/{id}.get.parameters[].description
Acting mode. Second line, at most 300 characters.

# paths./live-activity/{id}.get.parameters[].description
Acting mode. SF Symbol name, at most 64 characters.

# paths./live-activity/{id}.get.parameters[].description
Acting mode. Accent color, `#RRGGBB` or `#AARRGGBB`, leading `#` optional.

# paths./live-activity/{id}.get.parameters[].description
Acting mode. Progress bar value 0 to 100, clamped rather than rejected when it strays outside.

# paths./live-activity/{id}.get.parameters[].description
Acting mode. Countdown length in seconds from now, 1 to 86400.

# paths./live-activity/{id}.get.parameters[].description
Acting mode. Static trailing text when there is no timer, at most 40 characters.

# paths./live-activity/{id}.get.parameters[].description
Acting mode. Free form phase word, at most 40 characters.

# paths./live-activity/{id}.get.parameters[].description
Acting mode, honored on an end. Seconds the finished tile lingers, 0 to 14400. Default 0 removes it immediately.

# paths./live-activity/{id}.get.responses.200.description
A status object (activity id), a list (device id), or, when content parameters were present, the same success body the equivalent `POST` would have returned.

# paths./live-activity/{id}.get.responses.400.description
Acting mode only: validation failed, the per device tile cap is full, the content will not serialize under 2048 bytes, or the device is a poll only listener or a web push browser.

# paths./live-activity/{id}.get.responses.403.description
Missing token, wrong token, unknown id, or someone else's id. One identical body for all four.

# paths./live-activity/{id}.get.responses.409.description
Acting mode only: the device has no start credential yet, has Live Activities turned off, or has several tiles live so the device URL is ambiguous.

# paths./live-activity/{id}.get.responses.410.description
Acting mode only: the tile was dismissed or has already ended, so it cannot be updated. Start a new one, or pass `new=1`.

# paths./live-activity/{id}.get.responses.429.description
Acting mode, starts only: Apple is silently ignoring push to start for this device. See the `POST` description.

# paths./live-activity/{id}.get.responses.429.headers.Retry-After.description
Seconds to wait before the next start attempt.

# paths./live-activity/{id}.get.responses.502.description
Acting mode, starts only. Same two outcomes as the `POST`: read `deliveryState` before retrying.

# paths./live-activity/{id}.get.responses.503.description
Acting mode only: the server side kill switch is off, so starts and updates are refused. Reading and ending keep working.

# paths./live-activity/{id}.delete.description
Ends a tile and, by default, removes it from the Lock Screen at once. This matters: without an explicit end, iOS leaves a finished tile visible for up to FOUR HOURS, so a script that stops calling leaves a frozen "91%" on the user's Lock Screen. `keepFor` buys a deliberate lingering window instead.

`{id}` may be an activity id (end that exact tile) or a device id (end the device's single live tile, mirroring the upsert semantics: no live tile is an idempotent success, several live tiles return the teaching `409`).

**Idempotent.** Ending an already finished tile succeeds and reports how it actually finished, so an end of job hook never fails for having already worked.

**Optional final content.** Any of the update fields may be sent to set the last state the tile shows while it lingers, for example `{"progress": 100, "status": "done", "keepFor": 300}`.

**Never gated by the kill switch.** When Live Activities are disabled server side, ending existing tiles is exactly the operation that must keep working.

The URL only equivalent is `GET /live-activity/{id}?token=...&end=1`, which lands in the same code, so the two ways to end a tile cannot drift apart.

# paths./live-activity/{id}.delete.parameters[].description
An activity id, or a device id to end that device's single live tile.

# paths./live-activity/{id}.delete.parameters[].description
The device's own per device token; for an activity id, the OWNING device's token.

# paths./live-activity/{id}.delete.parameters[].description
URL only dialect. Seconds the finished tile lingers on the Lock Screen, 0 to 14400 (Apple's own 4 hour ceiling). Default 0 removes it immediately.

# paths./live-activity/{id}.delete.parameters[].description
URL only dialect. Final progress value, 0 to 100.

# paths./live-activity/{id}.delete.parameters[].description
URL only dialect. Final phase word, at most 40 characters.

# paths./live-activity/{id}.delete.parameters[].description
URL only dialect. Final title, at most 120 characters.

# paths./live-activity/{id}.delete.parameters[].description
URL only dialect. Final second line, at most 300 characters.

# paths./live-activity/{id}.delete.parameters[].description
URL only dialect. Final SF Symbol name, at most 64 characters.

# paths./live-activity/{id}.delete.parameters[].description
URL only dialect. Final accent color, leading `#` optional.

# paths./live-activity/{id}.delete.parameters[].description
URL only dialect. Final trailing text, at most 40 characters.

# paths./live-activity/{id}.delete.requestBody.description
Optional final content plus `keepFor`. A non-empty JSON body wins entirely over the query parameters above.

# paths./live-activity/{id}.delete.responses.200.description
The tile is finished. `state` reports how it actually ended, which for an already terminal tile may be `dismissed` rather than `ended`. A device scoped end with nothing live succeeds with a message instead.

# paths./live-activity/{id}.delete.responses.400.description
A final content field failed validation, `keepFor` is out of range, or the final merged content will not serialize under 2048 bytes.

# paths./live-activity/{id}.delete.responses.400.content.application/json.example.message
Field "keepFor" must be an integer number of seconds between 0 and 14400

# paths./live-activity/{id}.delete.responses.403.description
Missing token, wrong token, unknown id, or someone else's id. One identical body for all four.

# paths./live-activity/{id}.delete.responses.409.description
Device scoped end while several tiles are live: the device URL is ambiguous, so the body lists the ids and the caller ends one by its activity id.

# paths./live-activity/{activityId}/token.put.summary
Report a tile's per activity push token (app internal)

# paths./live-activity/{activityId}/token.put.description
**Called by the Notify iOS app, not by scripts.** Documented for completeness and for anyone building their own client.

When ActivityKit spawns a tile it hands the app a per activity push token, and the app reports it here. That arrival is the whole reconciliation model: it flips the activity from `starting` to `active` and immediately pushes whatever content the row holds NOW. A late token, a rotated token, and an update that raced the start all heal at this one line, which is why the write API never has to answer "not ready, retry".

Reporting a token also proves that this device's push to start path is open, so it resets the unanswered start counter that drives the `429` backoff on the write endpoint.

**Ghost handling.** If the server already wrote the activity off (the never born sweep gave up at 15 minutes) but a store and forward delivery spawned the tile anyway, the reported token is the one chance to remove it: the gateway sends an immediate end push and answers with the terminal state rather than resurrecting the row.

Auth here is strict and unconditional, unlike the script facing routes: the shared app bearer, the per device secret in a `token` header, AND the `deviceId` in the body, verified as a pair and then verified to own the activity. These routes are new, so they inherit none of the legacy device auth leniency. An unknown activity and an activity owned by someone else both return `404`.

# paths./live-activity/{activityId}/token.put.responses.200.description
The token was recorded. `state` is `active` in the normal case, or the terminal state when the server had already written the activity off and just sent an immediate end push to remove the ghost tile.

# paths./live-activity/{activityId}/token.put.responses.400.description
`pushToken` is missing, not a string, empty, or longer than 200 characters.

# paths./live-activity/{activityId}/token.put.responses.401.description
The shared app bearer is missing or wrong.

# paths./live-activity/{activityId}/token.put.responses.403.description
The `deviceId` in the body and the `token` header are missing, or they do not match a registered device.

# paths./live-activity/{activityId}/token.put.responses.404.description
No such activity, or the activity belongs to a different device. The two are deliberately indistinguishable.

# paths./live-activity/{activityId}/token.delete.summary
Report that a tile is gone (app internal)

# paths./live-activity/{activityId}/token.delete.description
**Called by the Notify iOS app, not by scripts.** The app reports that a tile no longer exists on the device.

`reason: "dismissed"` means the user swiped it away, which is STICKY: later updates to that activity id return `410`, and the device URL refuses the same way for the rest of the dismissed job's window, so a looping script cannot resurrect a tile the user deliberately removed. Any other value (including an absent one) records `ended`, which covers Apple's 8 hour system ceiling and the app confirming an end the server asked for.

Either report proves the tile existed, which is as strong an all clear for the push to start budget as a token report, so this also resets the unanswered start counter behind the `429` backoff.

Both branches stamp the end time, because row cleanup keys on it and dismissal is the most common terminal path there is. The row stays readable through `GET` for one day, then it is deleted.

Same strict auth as the `PUT`: shared bearer, per device `token` header, and `deviceId` in the body.

# paths./live-activity/{activityId}/token.delete.parameters[].description
The activity that is gone from the device.

# paths./live-activity/{activityId}/token.delete.responses.200.description
The activity is now terminal. `state` echoes the recorded fate.

# paths./live-activity/{activityId}/token.delete.responses.401.description
The shared app bearer is missing or wrong.

# paths./live-activity/{activityId}/token.delete.responses.403.description
The `deviceId` in the body and the `token` header are missing, or they do not match a registered device.

# paths./live-activity/{activityId}/token.delete.responses.404.description
No such activity, or the activity belongs to a different device.

# components.securitySchemes.bearerAuth.description
The shared application token. Embedded in every Notify! client by design, so it is a coarse gate rather than a secret; the per-device token is what protects a device.

# components.securitySchemes.deviceToken.description
The per-device secret returned at registration. Required by device-scoped endpoints. Send-style endpoints accept it as a `?token=` query parameter instead.

# components.schemas.MetaError.description
The gateway's standard error envelope. `error` is always present and is a short label or, on some validation paths, the complete human-readable reason. `message` carries the longer explanation when there is one, and `code` appears on the machine-readable refusals (authorization fences and the `/link` failures).

# components.schemas.MetaError.properties.error.description
Short error label, or the full validation message on field-validation failures.

# components.schemas.MetaError.properties.message.description
Human-readable explanation, when the endpoint provides one.

# components.schemas.MetaError.properties.details.description
Parser detail, present only on malformed-JSON rejections.

# components.schemas.MetaVapidPublicKey.properties.publicKey.description
The VAPID application server key: a base64url-encoded, uncompressed 65-byte P-256 public key, suitable for passing straight to `PushManager.subscribe({ applicationServerKey })`.

# components.schemas.MetaWebPushSubscription.description
The browser's `PushSubscription.toJSON()` output, forwarded verbatim. Validated for shape, then checked against the allowlist of real push-service hostnames and resolved to confirm it points at a public address, so an invalid or hostile endpoint is refused at registration time rather than silently never delivering.

# components.schemas.MetaWebPushSubscription.properties.endpoint.description
The push service URL. Must be `https`, on port 443, carry no credentials, use a hostname rather than an IP literal, and belong to a recognized push service (Google FCM, Mozilla, Apple Web Push, Microsoft WNS, Opera).

# components.schemas.MetaWebPushSubscription.properties.expirationTime.description
Almost always `null`. A non-negative safe integer is stored; anything else is discarded rather than failing the registration.

# components.schemas.MetaWebPushSubscription.properties.keys.properties.p256dh.description
The client's public key: base64url, decoding to a 65-byte uncompressed P-256 point (first byte `0x04`).

# components.schemas.MetaWebPushSubscription.properties.keys.properties.auth.description
The authentication secret: base64url, decoding to exactly 16 bytes.

# components.schemas.MetaRegisterRequest.description
On an update, every optional field that is omitted keeps its stored value. Sending `null` or leaving a field out are the same thing: neither clears anything.

# components.schemas.MetaRegisterRequest.properties.deviceId.description
The device identity. Two namespaces, fenced against each other. A browser (`pushChannel: "webpush"`) must use `WB` followed by 14 `[A-Z0-9]` characters, 16 in total. Every other client must use the legacy format, which the server enforces as exactly 8 characters. A brand-new registration is refused if the ID already names a Live Activity.

# components.schemas.MetaRegisterRequest.properties.deviceToken.description
The delivery address. For APNs devices this is Apple's 64-character hex device token. Channels that do not use APNs send a fixed placeholder instead: `poll-no-push` for the macOS listener, `webpush-no-apns` for browsers, whose real delivery address lives in `webPushSubscription`. Changing this on an existing device requires presenting the current per-device `token`.

# components.schemas.MetaRegisterRequest.properties.platform.description
Client platform. In practice `iOS`, `macos-listener`, or `web`. Stored as sent.

# components.schemas.MetaRegisterRequest.properties.appVersion.description
Client version. On a create, an omitted value is stored as the literal string `unknown`.

# components.schemas.MetaRegisterRequest.properties.osVersion.description
Operating system version. On a create, an omitted value is stored as the literal string `unknown`.

# components.schemas.MetaRegisterRequest.properties.token.description
The per-device secret. This is the credential that actually protects the device: it authorises webhook sends, per-device reads and deletes, and it is the proof the registration ownership fence asks for. Omitting it on an update keeps the stored value, which is what stops an older client from wiping a token that is already embedded in the user's deployed webhook URLs. Once a device has one, it cannot be changed through this endpoint.

# components.schemas.MetaRegisterRequest.properties.deviceName.description
Display name for the device. Either what the owner typed, or what the client supplied on its behalf when the OS withholds the real name (`iPhone (iOS 27.0)`, `Chrome on macOS`, `Notify Listener (Mac)`). This is the value `GET /link` returns as `name`. Omitting it on an update keeps the stored name.

# components.schemas.MetaRegisterRequest.properties.pushChannel.description
Delivery channel. `poll` marks a poll-only device (the macOS Notify Listener). `webpush` marks a browser and requires `webPushSubscription`. Omitted, null, or any unrecognized value means APNs delivery, and any unrecognized value is stored as null rather than rejected. A channel can be **set but never unset**: an omitted field keeps whatever is stored, so a browser stays a browser and a Mac stays poll for the life of the identity. Changing between two channels on an existing device requires the per-device `token`.

# components.schemas.MetaRegisterRequest.properties.webPushSubscription.description
Required when `pushChannel` is `webpush`, ignored otherwise. Stored as an upsert keyed on the device, so rotation is an in-place replace, and a fresh subscription clears any soft-prune stamp: reopening the web app is itself the recovery gesture. Changing a *stored* endpoint requires the per-device `token`; the first subscription on a device is trust-on-first-use.

# components.schemas.MetaRegisterRequest.properties.liveActivityPushToStartToken.description
Apple's push-to-start token, as the app last observed it. Omitted or empty means keep the stored value, except when this registration also changes `deviceToken` (a hardware migration), where the stored token is cleared because it belongs to the retired phone. Reporting a token different from the one on file re-drives any Live Activity start that was still waiting on the old one.

# components.schemas.MetaRegisterRequest.properties.liveActivitiesEnabled.description
Whether the user permits Live Activities, as the app last observed. Must be a real boolean. Omitted means keep the stored value; an explicit `false` is stored, because that is exactly what lets the Live Activity start endpoint answer honestly instead of pushing into the void.

# components.schemas.MetaRegisterRequest.properties.frequentPushesEnabled.description
Whether frequent Live Activity updates are permitted. Must be a real boolean. Omitted means keep the stored value.

# components.schemas.MetaRegisterRequest.properties.registrationEpoch.description
The identity's replacement generation, bumped by each in-app Replace. A registration whose epoch is lower than the stored one is refused with `409`. Values that are not positive integers, and an omitted field, are treated as making no claim and are always accepted, so builds that predate the field keep working.

# components.schemas.MetaRegisterRequest.properties.liveActivityPtsObservedAt.description
OPTIONAL. Epoch MILLISECONDS at which the client observed the accompanying `liveActivityPushToStartToken`, stable across the client's retries. Same purpose as `observedAt` on the per-tile token report: it fences a delayed APNs 410 from an earlier validity period against the credential currently on file. Ignored unless `liveActivityPushToStartToken` is present; omit it and the gateway uses its receipt time, exactly as older clients already behave.

# components.schemas.MetaRegisterResponse.properties.deviceId.description
The device ID that was registered or updated, echoed back.

# components.schemas.MetaRegisterResponse.properties.isUpdate.description
`false` when a new device row was created, `true` when an existing one was updated.

# components.schemas.MetaDevice.description
A device's registration record, in camelCase. The APNs delivery token is deliberately absent, and so is the device name.

# components.schemas.MetaDevice.properties.appVersion.description
The literal string `unknown` for a device that registered without reporting one.

# components.schemas.MetaDevice.properties.osVersion.description
The literal string `unknown` for a device that registered without reporting one.

# components.schemas.MetaDevice.properties.lastUpdated.description
When the device last registered. This is the clock the 60-day inactive-device sweep reads.

# components.schemas.MetaDevice.properties.notificationCount.description
Lifetime count of notifications delivered to this device.

# components.schemas.MetaLinkDeviceResult.description
Returned by `GET /link` when the ID and token named a device. All fields are flat, not nested.

# components.schemas.MetaLinkDeviceResult.properties.name.description
Display name. The stored device name when there is one, otherwise a synthesized `platform (os_version)` fallback such as `iOS (18.2)`. Never empty, so it can be displayed directly.

# components.schemas.MetaLinkDeviceResult.properties.notification_url.description
Where to post notifications for this device. Built from the hostname the request arrived on. Carries no token: append your own `?token=` when sending.

# components.schemas.MetaLinkDeviceResult.properties.last_active.description
The device's last registration timestamp.

# components.schemas.MetaLinkGroupResult.description
Returned by `GET /link` when the ID and token named a group. All fields are flat, not nested.

# components.schemas.MetaLinkGroupResult.properties.notification_url.description
Where to post notifications for this group. Built from the hostname the request arrived on. Carries no token: append your own `?token=` when sending.

# components.schemas.MetaLinkGroupResult.properties.last_active.description
The most recent registration timestamp across the group's members, falling back to the group's creation time when it has no members.

# components.schemas.MetaLinkGroupResult.properties.member_count.description
How many distinct devices are in the group.

# components.schemas.MetaLinkError.description
`GET /link` uses its own error envelope, with `success: false` and a stable `code`.

# components.schemas.MetaLinkError.properties.message.example
No device or group found with the provided ID and token combination

# components.schemas.MetaCleanupResult.properties.devicesChecked.description
Total devices in the table before the sweep ran.

# components.schemas.MetaCleanupResult.properties.cutoffDate.description
The inactivity cutoff used: 60 days before the sweep. Devices whose last registration predates this were deleted.

# components.schemas.NotificationRecord.description
One delivery attempt to one device. Written whether the push succeeded or failed, and kept for one year.

Field names are snake_case here, matching the storage layer; the device endpoints elsewhere in this API use camelCase. That inconsistency is historical and is preserved rather than fixed, because existing clients parse these names.

# components.schemas.NotificationRecord.properties.id.description
The record's id, unique across all devices. Use it with `GET` and `DELETE /device/notifications/{id}`.

# components.schemas.NotificationRecord.properties.device_id.description
The device this notification was sent to.

# components.schemas.NotificationRecord.properties.message.description
The stored envelope: the full content as sent, in one of two forms.

**Bare string.** A send with no title, no sender icon and no extra data is stored as the body text exactly as given. This is also the shape of every record written before the envelope format existed.

**JSON object, encoded into this string.** Anything richer is stored as `{"title": string|null, "body": string, "data"?: object}`, where `data` may carry `iconUrl` and any other fields the send attached. See `NotificationEnvelope`.

A reader must accept both. The rule is: trim leading whitespace, and if the value starts with `{`, try to parse it as a JSON object; if that parse succeeds, read `title`, `body` and `data` from it; on any failure treat the whole value as the body. Never assume the envelope, and never assume plain text.

One subtlety the gateway handles for you: a body that would itself be *misread* as an envelope (because it happens to be JSON with a `title`, `body` or `data.iconUrl` key) is always wrapped, so decoding a stored value can never invent a title or an icon the sender did not send.

This is always the complete text, never the shortened copy that went into the APNs alert. A body may be up to 16 KB (16,384 bytes of UTF-8) and a title up to 2 KB, well beyond Apple's 4 KB push ceiling, which is why the alert is truncated while the record is not.

# components.schemas.NotificationRecord.properties.sent_at.description
When the delivery was attempted. Records are returned newest first.

# components.schemas.NotificationRecord.properties.success.description
Whether the push provider accepted the notification. Accepted is not the same as seen by a human: it means APNs (or the web push service) took the message, not that the device displayed it.

# components.schemas.NotificationRecord.properties.apns_id.description
The notification's unique id, which is also the value the push itself carried as `nid`. That is what lets a client match a notification it received on the device to its row in this history. Null for records written before ids were minted, and for deliveries that never reached the provider.

# components.schemas.NotificationRecord.properties.error_message.description
Why the delivery failed, or null when it succeeded. Usually the provider's own JSON response serialized as a string (for example `{"reason":"BadDeviceToken"}`), so treat it as opaque diagnostic text rather than a stable machine-readable code.

# components.schemas.NotificationRecord.properties.source.description
Which path produced this notification. Useful for filtering a history view or attributing traffic.

- `api`: sent via `POST /send/{deviceId}`
- `notify`: sent via `/notify/{id}`
- `notify-json`: sent via `POST /notify-json/{id}` to a device
- `group`: this device's share of a group fan-out
- `healthcheck`: raised by a check (the dead man's switch), including the group-targeted variety
- `admin`: sent from the admin panel

Treat the list as open: new senders add new values, and older records may carry values not listed here.

# components.schemas.NotificationListItem.description
A `NotificationRecord` as returned by the list endpoint. The two preview fields are present only when the request passed `?preview=`; without it the object is byte-identical to a plain record.

# components.schemas.NotificationListItem.allOf[].properties.truncated.description
True when `message` holds only the beginning of the body and the rest must be fetched from `GET /device/notifications/{id}`. False when the body already fitted inside the requested preview length, in which case `message` is the stored value untouched.

# components.schemas.NotificationListItem.allOf[].properties.messageLength.description
The length of the *full decoded body*, in UTF-16 code units, regardless of how much of it `message` carries. Compare against your preview length to show a "more" affordance. Note this measures the body only: it excludes the title and any envelope punctuation, and it is a character count, not the byte count that the 16 KB send limit is measured in.

# components.schemas.NotificationEnvelope.description
The decoded form of a `message` field that was stored as JSON. This object is never transmitted as JSON in its own right: it arrives encoded inside the `message` string, and this schema exists to document what you get after parsing one.

History originally stored only the body, which silently threw the sender's title away: a push titled "Deploy failed" came back from the server as a generic "Push Notification". Wrapping the content in this small object fixed that without a schema change, and because both official clients fall back to plain text when a value does not parse, old records and new records coexist indefinitely.

# components.schemas.NotificationEnvelope.properties.title.description
The title as sent, or null when the send had none. Up to 2 KB of UTF-8, although only roughly the first 200 bytes ever appear in the notification itself.

# components.schemas.NotificationEnvelope.properties.body.description
The full body as sent, up to 16 KB of UTF-8.

# components.schemas.NotificationEnvelope.properties.data.description
Extra fields carried with the notification. Present only when the send attached any. Preserved whole through previewing, so a field this documentation does not name still survives in a shortened list row.

# components.schemas.NotificationEnvelope.properties.data.properties.iconUrl.description
The sender's avatar, shown beside the alert. Up to 1 KB on the wire.

# components.schemas.NotificationGroupRecord.description
One broadcast to a group, with the delivery counts for that fan-out. Kept for one year.

`success` and `error_message` are not stored: they are computed from the counts on every read, so that a client can render group history and device history with the same code.

# components.schemas.NotificationGroupRecord.properties.id.description
The broadcast's id. Group history has no read-one or delete endpoint, so this is for display and de-duplication rather than addressing.

# components.schemas.NotificationGroupRecord.properties.message.description
The stored envelope, in exactly the same two forms as a device record's `message`: either the bare body string, or a JSON object encoded into this string carrying `title`, `body` and optional `data`. See `NotificationRecord.message` for the parsing rule and `NotificationEnvelope` for the decoded shape.

Always complete. There is no preview parameter on group history, so a 16 KB body arrives whole.

# components.schemas.NotificationGroupRecord.properties.sent_at.description
When the broadcast was sent. Records are returned newest first.

# components.schemas.NotificationGroupRecord.properties.success.description
Derived, not stored: true only when `failure_count` is zero *and* `success_count` is greater than zero. A broadcast to a group with no members is therefore false, not true.

# components.schemas.NotificationGroupRecord.properties.error_message.description
Derived, not stored: a generated sentence of the form `Failed to deliver to N device(s)` when `failure_count` is above zero, otherwise null. It never names which devices failed, and it is not a provider error code. To find out why a particular device missed a broadcast, look at that device's own history for the matching `source: "group"` record, which carries the real `error_message`.

# components.schemas.NotificationGroupRecord.properties.source.description
Which path produced the broadcast.

- `group-api`: sent via `/notify-group/{groupId}`
- `notify-json`: sent via `POST /notify-json/{id}` where the id was a group
- `healthcheck`: raised by a group-targeted check
- `group`: the fallback reported for older records that were stored with no source at all

Treat the list as open.

# components.schemas.NotificationGroupRecord.properties.success_count.description
How many devices the push provider accepted the broadcast for. When a group has members but the gateway short-circuits delivery (for example an all-poll-channel group, where members collect the message on their next poll), this is the member count and `failure_count` is zero.

# components.schemas.NotificationGroupRecord.properties.failure_count.description
How many devices the broadcast could not be delivered to.

# components.schemas.NotificationDeleteResult.properties.success.description
Always true. A delete that removed nothing returns `404` instead of this body.

# components.schemas.NotificationDeleteAllResult.properties.deleted.description
How many records were removed. Zero when the history was already empty, which is a success, not an error.

# components.schemas.NotificationError.description
The gateway's error shape for these endpoints. `error` is always present and is the short label; `message` is an optional human-readable elaboration. In production, `500` bodies are deliberately generic so that internal detail is not leaked to callers.

# components.schemas.NotificationError.properties.message.description
Human-readable detail, when the endpoint provides any.

# components.schemas.NotificationError.properties.message.example
No notification group exists with ID: GRP12345

# components.schemas.NotificationAuthError.description
The `403` shape from the per-device authorization layer and the group membership gate. Identical to `NotificationError` plus a stable `code` on the per-device cases, which is the field to branch on rather than the prose in `message`.

# components.schemas.NotificationAuthError.properties.message.example
Per-device token does not match the requested device

# components.schemas.NotificationAuthError.properties.code.description
Stable reason code. Absent on the group membership refusal, which returns only `error` and `message`.

- `DEVICE_AUTH_REQUIRED`: strict mode, and no `token` header was sent
- `DEVICE_ID_REQUIRED`: a `token` header was sent but the request carried no device id to check it against
- `DEVICE_AUTH_INVALID`: the `token` does not belong to the requested device

# components.schemas.SendDeviceAccepted.description
Returned by `/notify/{deviceId}` and `/send/{deviceId}` when the notification was accepted and dispatched. There is no `apnsId` in this response; the Apple message id is recorded server-side only.

# components.schemas.SendJsonDeviceAccepted.description
The `/notify-json/{id}` response when the id resolved to a single device.

# components.schemas.SendJsonDeviceAccepted.properties.type.description
Tells you the id was treated as a device rather than a group.

# components.schemas.SendGroupMemberResult.description
One member's outcome inside a group fan-out.

# components.schemas.SendGroupMemberResult.properties.success.description
Whether this member's push was dispatched without error.

# components.schemas.SendGroupMemberResult.properties.error.description
Present only when `success` is false. A human-readable reason, for example an Apple rejection or a retired browser subscription. Members that Apple reports as permanently gone are pruned automatically, so a repeated failure for the same device usually resolves itself.

# components.schemas.SendGroupMemberResult.properties.error.example
Device APNs token retired; awaiting re-registration

# components.schemas.SendGroupAccepted.description
Returned by `/notify-group/{groupId}`. Always a 200 when the group token was valid and the group has members, even if every individual push failed. `deviceCount` counts only push-capable members: poll-only members are excluded from the fan-out and receive the broadcast on their next poll.

# components.schemas.SendGroupAccepted.properties.success.description
The group send was processed. It does not mean every member succeeded.

# components.schemas.SendGroupAccepted.properties.deviceCount.description
Members attempted. For an all-poll group this is the total member count instead.

# components.schemas.SendGroupAccepted.properties.results.description
Per-member outcomes, one entry per attempted member. Empty for an all-poll group.

# components.schemas.SendJsonGroupAccepted.description
The `/notify-json/{id}` response when the id started with `GRP`. Identical to `SendGroupAccepted` plus a `type` discriminator.

# components.schemas.SendJsonRequest.description
The JSON body accepted by `POST /notify-json/{id}`. Field names are case sensitive: `iconUrl`, not `iconURL` or `IconUrl`. Unknown fields are ignored.

# components.schemas.SendJsonRequest.properties.text.description
The notification message. Must be a non-empty string and at most 16384 bytes of UTF-8. Newlines and emoji are fine. The full text is stored and readable in the app's History; the alert on the Lock Screen is shortened to fit Apple's 4 KB push.

# components.schemas.SendJsonRequest.properties.text.example
Deploy to production succeeded in 3m 42s

# components.schemas.SendJsonRequest.properties.title.description
Notification title, at most 2048 bytes of UTF-8, of which the first 200 bytes appear in the alert. A missing, non-string, or blank value is replaced with "Notify!".

# components.schemas.SendJsonRequest.properties.iconUrl.description
HTTPS URL to a sender avatar shown beside the notification and kept with the history record. Must be a string beginning `https://` and at most 1024 bytes on the wire. PNG is preferred, and roughly 60 by 60 points (120 by 120 pixels) is the sweet spot. A URL with no image extension is resolved through a favicon service on the device.

# components.schemas.SendJsonRequest.properties.imageUrl.description
HTTPS URL to a hero image rendered inside the expanded notification (JPEG, PNG, or GIF, up to about 10 MB on the device side). Same string, HTTPS, and 1024-wire-byte rules as `iconUrl`, and independent of it.

# components.schemas.SendJsonRequest.properties.groupType.description
Thread identifier. The APNs thread id becomes `{id}-{groupType}`, so notifications sharing a `groupType` stack together on the device. At most 1024 bytes on the wire.

# components.schemas.SendJsonRequest.properties.timeSensitive.description
Raises delivery to the Time Sensitive interruption level, breaking through Do Not Disturb and Focus. Accepts the boolean `true`, or the strings `"true"` or `"1"` for templating tools that stringify everything. Anything else, including `"yes"`, leaves it off, and nothing is rejected for this field.

# components.schemas.SendError.description
The generic error envelope used across the send endpoints. Some failures return only `error`; most return `error` plus a `message` explaining what to change.

# components.schemas.SendError.properties.error.description
Short machine-ish label, for example `Forbidden`, `Bad Request`, or `Payload Too Large`.

# components.schemas.SendError.properties.hint.description
Present on the 415 from `/notify-json`, suggesting the fix.

# components.schemas.SendError.properties.details.description
Present when a JSON body failed to parse: the parser's own message.

# components.schemas.SendValidationError.description
The `400` returned by `/notify-json/{id}` when the JSON body is missing a required field. Carries the field lists so an integration can self-describe.

# components.schemas.SendApnsFailure.description
Returned with HTTP 400 when a single-device push was refused by Apple or could not be attempted. Note the status is 400 even when Apple's own status was something else: `apnsStatus` carries Apple's code. The attempt is still recorded in the device's history as a failure, and a device Apple reports as permanently gone (410 Unregistered) is pruned automatically so it stops being sent to until it re-registers.

# components.schemas.SendApnsFailure.properties.apnsStatus.description
Apple's HTTP status, or 500 when the send failed before Apple was reached. Local failures use a string marker instead of a number: `DEAD_TOKEN` when the device's APNs token was retired by an earlier 410, and `DEAD_SUBSCRIPTION` when a browser's web push subscription is gone.

# components.schemas.SendApnsFailure.properties.apnsError.description
Apple's parsed response body when there was one, otherwise the local error message.

# components.schemas.GroupId.description
A group identifier: the literal prefix `GRP` followed by 5 UPPERCASE alphanumerics, 8 characters in total. Generated with a CSPRNG and stored as `VARCHAR(8)`. Unlike the group token, group ids contain no lowercase letters.

# components.schemas.GroupToken.description
A group's secret bearer token: exactly 15 characters from the **mixed case** alphabet `A-Za-z0-9`, drawn from a CSPRNG (about 89 bits of entropy) and stored as `VARCHAR(15)`.

Case is significant and the comparison is exact. Never uppercase, lowercase, or otherwise normalize a group token: only about 0.03% of valid tokens happen to be all-uppercase, so a client that uppercases will reject nearly every real token as invalid. Trimming surrounding whitespace is the only safe cleanup.

Holding this token is enough to join the group and to send notifications to it, so handle it like a password and never log it.

# components.schemas.GroupDeviceId.description
A registered device identifier. Apple and Android clients use 8 alphanumerics (`ABC12345`); web push clients use a longer `WB`-prefixed form. Stored as `VARCHAR(32)`, so treat it as an opaque string of up to 32 characters rather than assuming a fixed length.

# components.schemas.GroupRole.description
A device's role within a group. Owners can add and remove members, delete the group, and manage group beacons; members can only receive, leave, and read.

# components.schemas.GroupNotificationUrl.description
A ready-to-paste webhook URL that pages every member of the group. The group token is embedded as the `token` query parameter, so the URL is itself a secret.

The host is chosen per request: a caller who reached the gateway on `push.getnotifyapp.com` is handed a getnotifyapp URL, and a caller on `notifypush.pingie.com` is handed a pingie one. Both hostnames are permanent, so a URL minted under either keeps working.

# components.schemas.GroupError.description
The error shape used across the group routes. `error` is always present. `message` adds a human-readable explanation on the authorization middlewares and on the member management routes, and is absent on the terser create/join/leave/delete errors. `code` appears only on middleware rejections and is the stable value to branch on.

In production the 500 `message`/`error` text is always the fixed string `An internal error occurred`; underlying database errors are never leaked to callers.

# components.schemas.GroupError.properties.message.description
Human-readable explanation, when the route provides one.

# components.schemas.GroupError.properties.code.description
Machine-readable code, present only on authorization middleware rejections.

# components.schemas.GroupActionSuccess.description
The minimal success envelope returned by `POST /leave-group` and `DELETE /group/{groupId}`.

# components.schemas.GroupCreateRequest.properties.deviceId.description
The device creating the group. It must already be registered, and it becomes the group's owner. This is also the device that the `token` header is checked against.

# components.schemas.GroupCreateRequest.properties.groupName.description
Human-readable name for the group. Free text; the handler does not trim or length-limit it, and the column is `TEXT`. Keep it short enough to display on a phone.

# components.schemas.GroupCreateResponse.description
The only time the gateway volunteers a brand new group token. Store it, or read it back later from `GET /groups/{deviceId}` or `GET /group/{groupId}`.

# components.schemas.GroupJoinRequest.properties.deviceId.description
The device joining the group. Must already be registered.

# components.schemas.GroupJoinRequest.properties.groupToken.description
The group's secret token, exactly as issued. Case-sensitive: do not uppercase it.

# components.schemas.GroupJoinResponse.properties.groupName.description
The joined group's name, so the client can display it without a second call.

# components.schemas.GroupLeaveRequest.properties.deviceId.description
The device leaving the group. This is the device that the `token` header is checked against.

# components.schemas.GroupMembershipSummary.description
One row of `GET /groups/{deviceId}`: a group the device belongs to, plus that device's role in it. Contains the group's secret token.

# components.schemas.GroupMembershipSummary.properties.joinedAt.description
When this device joined the group. The array is sorted by this field, newest first.

# components.schemas.GroupMembershipSummary.properties.memberCount.description
Current number of devices in the group, counted at request time.

# components.schemas.GroupMemberDetail.description
One member row from `GET /group/{groupId}`. These keys are snake_case because they are returned as the database produces them, unlike the camelCase envelope around them. Several device fields can be `null` for devices registered before those columns existed.

# components.schemas.GroupMemberDetail.properties.platform.description
Client platform recorded at registration, for example `ios`, `macos`, or `web`.

# components.schemas.GroupMemberDetail.properties.app_version.description
Client version recorded at registration.

# components.schemas.GroupMemberDetail.properties.os_version.description
Operating system version recorded at registration.

# components.schemas.GroupMemberDetail.properties.device_name.description
User-visible device name. `null` for devices registered before this field existed.

# components.schemas.GroupMemberDetail.properties.last_updated.description
When the device record was last refreshed, which is a reasonable proxy for last seen.

# components.schemas.GroupNotificationRecord.description
One historical group send, returned as the raw `group_notifications` row.

# components.schemas.GroupNotificationRecord.properties.message.description
The stored message envelope. For notifications carrying a title, an icon, or other structure this is a JSON string rather than plain text, so parse defensively.

# components.schemas.GroupNotificationRecord.properties.failure_count.description
Devices that failed. A non-zero value usually means stale push tokens.

# components.schemas.GroupNotificationRecord.properties.source.description
Which entry point produced the send, for example `notify-group`, `notify-json`, or `api`.

# components.schemas.GroupDetail.description
The full view of one group. Note the mixed casing: the envelope is camelCase except for `group_token`, which is snake_case because the iOS `GroupDetails` model decodes that exact key. Do not assume it will ever be renamed to `groupToken`.

# components.schemas.GroupDetail.properties.group_token.description
The group's secret token. Deliberately snake_case here, unlike the `groupToken` key used by `POST /create-group` and `GET /groups/{deviceId}`.

# components.schemas.GroupDetail.properties.createdBy.description
The device that originally created the group. May be `null` if that device has since been deleted, because the foreign key is `ON DELETE SET NULL`. This is a historical record and is not necessarily the current owner: ownership transfers when an owner leaves.

# components.schemas.GroupDetail.properties.members.description
Every member of the group, ordered owner first (role descending) then by join time.

# components.schemas.GroupDetail.properties.recentNotifications.description
The ten most recent sends to this group, newest first. For a longer history use `GET /group/{groupId}/notifications`.

# components.schemas.GroupAddMemberRequest.properties.deviceId.description
The device being ADDED to the group. Must already be registered with the gateway.

# components.schemas.GroupAddMemberRequest.properties.deviceToken.description
The target device's own 15-character app authentication token (`devices.token`, shown in the app under Settings, Device). Supplying it is how the caller proves they are entitled to enrol that device.

This is NOT the 64-character hexadecimal APNs push token, and NOT the shared app bearer token. Sending the wrong one produces a 403 `Invalid device token`.

# components.schemas.GroupAddMemberRequest.properties.requestingDeviceId.description
The OWNER making the request. Ignored in favour of the device proven by the `token` header when one validates, so it cannot be used to impersonate the real owner.

# components.schemas.GroupAddMemberResponse.properties.message.example
Device NEW12345 added to group successfully

# components.schemas.GroupRemoveMemberResponse.properties.message.example
Device XYZ67890 removed from group successfully

# components.schemas.BeaconId.description
A beacon identifier: the literal `CHK` followed by five characters drawn from A-Z and 0-9. Generated with a CSPRNG, because the id seeds a URL that carries a bearer token.

# components.schemas.BeaconPingToken.description
A beacon's 15-character ping token, mixed-case alphanumeric, from a CSPRNG (roughly 89 bits of entropy). It is a bearer credential: anyone holding it can send heartbeats for the beacon and therefore mask a genuine outage. The gateway only ever discloses it to a caller who can manage the beacon.

# components.schemas.BeaconDeviceId.description
A registered device identifier. Apple devices use an 8-character id; web push registrations use `WB` followed by 14 characters from A-Z and 0-9.

# components.schemas.BeaconStatus.description
Where a beacon sits in its lifecycle.

- `new` - created, or just resumed. Nothing is expected yet and no alert can fire; the first real ping arms the schedule.
- `up` - a ping arrived within the expected window.
- `down` - no ping for `periodMinutes + graceMinutes`. Detected by a sweep that runs every minute. Down alerts repeat every `reminderMinutes` until a ping recovers the beacon.
- `paused` - alerts silenced by the user. Pings are still recorded, so resuming re-arms cleanly.

The usual cycle is `new` to `up` to `down` and back to `up`.

# components.schemas.BeaconTarget.description
Who gets paged when this beacon goes down. Exactly one of the two forms, enforced by a database constraint: a beacon targets a single device or a single group, never both.

# components.schemas.BeaconTarget.oneOf[].description
Every member of the group is paged, macOS Notify Listener installations included.

# components.schemas.BeaconTarget.oneOf[].properties.groupName.description
The group's display name at the time of the read. Null when the reading query did not join the group row.

# components.schemas.Beacon.description
A beacon, the dead man's switch resource. Called a "check" throughout the wire format for historical reasons: the user-facing name is Beacons, but the resource path, the JSON keys, and the push payload keys all kept the original `check` naming.

# components.schemas.Beacon.properties.name.description
Human label, shown in the app and used as the alert title. Stored trimmed.

# components.schemas.Beacon.properties.periodMinutes.description
How often a ping is expected, in minutes. One minute to thirty days.

# components.schemas.Beacon.properties.graceMinutes.description
Slack added to the period before the beacon is considered down, in minutes. `0` means alert the moment the period lapses.

# components.schemas.Beacon.properties.reminderMinutes.description
How often to re-page while the beacon stays down. `0` turns reminders off entirely (one down alert, then silence). Any other value must be between 5 and 10080 (seven days); the five-minute floor exists so a typo cannot produce an every-minute re-page.

# components.schemas.Beacon.properties.timeSensitive.description
When true, Down and reminder alerts are delivered at Apple's Time Sensitive interruption level so they break through Do Not Disturb and Focus. The Up recovery alert is never elevated. This is a request, not a guarantee: the user can still overrule it per app in iOS Settings.

# components.schemas.Beacon.properties.lastPingAt.description
When the most recent ping arrived. Null until the first ping. Updated even while paused.

# components.schemas.Beacon.properties.lastAlertAt.description
When a down or reminder alert was last delivered successfully. Null when none has been, and cleared again on every fresh up-to-down transition. This is what drives the reminder cadence.

# components.schemas.Beacon.properties.pingCount.description
Total pings ever received, including those received while paused.

# components.schemas.Beacon.properties.createdAt.description
When the beacon was created. The list is sorted by this, newest first.

# components.schemas.Beacon.properties.canManage.description
Whether the reading device may update, pause, or delete this beacon. True for the owning device of a device-targeted beacon and for the group owner of a group-targeted one. A regular group member reads group beacons with `canManage: false`.

# components.schemas.Beacon.properties.pingToken.description
Present only when `canManage` is true. Omitted entirely otherwise, because it is a bearer secret.

# components.schemas.Beacon.properties.pingUrl.description
The ready-to-paste heartbeat URL, present only when `canManage` is true. Built from the host the request arrived on, so calling the API on `push.getnotifyapp.com` yields a `push.getnotifyapp.com` URL and calling it on `notifypush.pingie.com` yields a `notifypush.pingie.com` one; an unrecognized Host falls back to `https://notifypush.pingie.com`. Both hostnames are permanent, and a URL already deployed in a crontab keeps working regardless of which one it uses.

# components.schemas.BeaconLimits.description
The server's current beacon caps, enforced when a beacon is created. They are operational configuration rather than product constants, so clients should read them here instead of hardcoding a number. Beacons that already exist above a lowered cap keep working; only new creates are refused.

# components.schemas.BeaconLimits.properties.perDevice.description
Maximum device-targeted beacons one device may own. Defaults to 10.

# components.schemas.BeaconLimits.properties.perGroup.description
Maximum beacons that may target one group. Defaults to 10.

# components.schemas.BeaconListResponse.properties.checks.description
Beacons the device owns plus beacons targeting any group it belongs to, newest first.

# components.schemas.BeaconCreateRequest.properties.deviceId.description
The device creating the beacon. It must be registered. For a device-targeted beacon it also becomes the owner and the alert target. Also the value the per-device `token` header is validated against.

# components.schemas.BeaconCreateRequest.properties.name.description
Human label, 1 to 100 characters after trimming. Used as the alert title.

# components.schemas.BeaconCreateRequest.properties.periodMinutes.description
How often a ping is expected, in minutes. Must be a whole number from 1 to 43200 (thirty days).

# components.schemas.BeaconCreateRequest.properties.graceMinutes.description
Extra slack before the beacon is declared down. 0 to 43200. Defaults to 5, chosen for the common cron case where a job starts a little late.

# components.schemas.BeaconCreateRequest.properties.reminderMinutes.description
Repeat interval for down alerts. `0` disables reminders; otherwise 5 to 10080. Defaults to 1440, a daily nag while down.

# components.schemas.BeaconCreateRequest.properties.timeSensitive.description
Deliver Down and reminder alerts at the Time Sensitive interruption level so they break through Focus. Defaults to false: breaking through a user's Focus is a deliberate choice, and an older client that omits the field must never start doing it silently.

# components.schemas.BeaconCreateRequest.properties.targetGroupId.description
Target a group instead of the creating device. The creating device must hold the `owner` role in that group; a plain member gets `403`. Omit for a device-targeted beacon.

# components.schemas.BeaconUpdateRequest.description
Partial update. Only `deviceId` is required; every other field is optional and anything omitted is left unchanged. Same numeric bounds as creation.

# components.schemas.BeaconUpdateRequest.properties.deviceId.description
The device making the change. Must be able to manage the beacon, and is the value the per-device `token` header is validated against.

# components.schemas.BeaconUpdateRequest.properties.name.description
New label, 1 to 100 characters after trimming.

# components.schemas.BeaconUpdateRequest.properties.periodMinutes.description
New expected ping interval, 1 to 43200. Shrinking this on an armed beacon can make it immediately overdue, in which case the down alert fires at the next minute tick.

# components.schemas.BeaconUpdateRequest.properties.reminderMinutes.description
New reminder interval. `0` disables reminders; otherwise 5 to 10080.

# components.schemas.BeaconUpdateRequest.properties.timeSensitive.description
Turn Time Sensitive delivery on or off for Down and reminder alerts. Omitting the field leaves the current setting alone; any value present is coerced to a strict boolean.

# components.schemas.BeaconUpdateRequest.properties.action.description
Change the run state. `pause` silences alerts from any state while still recording pings. `resume` returns the beacon to `new`, not `up`: the gateway will not fabricate a heartbeat, so it stays quiet until a real ping re-arms it.

# components.schemas.BeaconMutationResponse.description
The create and update responses: the full beacon, flattened alongside a `success` flag. The caller is provably a manager in both cases, so `pingToken` and `pingUrl` are always present.

# components.schemas.BeaconError.description
The gateway's JSON error shape. `error` is the short class of failure, `message` the human explanation when there is one. In production, `500` bodies always read "An internal error occurred" rather than echoing database detail.

# components.schemas.BeaconError.properties.message.example
Field "periodMinutes" must be an integer between 1 and 43200

# components.schemas.BeaconError.properties.code.description
Machine-readable sub-code, present on per-device authorization failures.

# components.schemas.LiveActivityContent.description
The caller settable fields of a Live Activity tile. Every field is optional on an update and merges JSON merge patch style: absent leaves the current value alone, a value replaces it, and an explicit `null` deletes it. `title` is the one field required to START a tile, because a tile needs an identity to spawn. The same fields may be sent as query parameters, which cannot express `null`.

# components.schemas.LiveActivityContent.properties.title.description
Tile title, the tile's identity. Required on a start; must be non-empty. Cannot contain NUL.

# components.schemas.LiveActivityContent.properties.body.description
Second line of the tile. Optional. On a START, the gateway always emits an alert body to Apple: if you omit this field it repeats the title. Apple requires both alert attributes to be present on a start, and a start missing one is accepted with a 200 and then never produces a tile, so this is filled in for you rather than left to fail silently.

# components.schemas.LiveActivityContent.properties.tint.description
Accent color as `#RRGGBB` or `#AARRGGBB`. The leading `#` is optional so the value survives a URL untouched.

# components.schemas.LiveActivityContent.properties.progress.description
Value driven progress bar, 0 to 100. Out of range numbers are clamped rather than rejected, so a script computing 100.4 percent gets a full bar. `null` removes the bar. Non numeric values are rejected with a named `400`.

# components.schemas.LiveActivityContent.properties.endsIn.description
Countdown length in SECONDS from now. The gateway computes the absolute finish against its own clock and stores it as `endsAt`; the device then ticks locally, so a countdown tile costs one request for its whole life. Sending `endsIn` again recomputes the finish while keeping the original start, so the time driven bar does not visually reset. `null` clears the countdown, turning the tile back into a plain progress tile. The 86400 cap is also the milliseconds trap.

# components.schemas.LiveActivityContent.properties.trailing.description
Static trailing text shown where a timer would otherwise be.

# components.schemas.LiveActivityContent.properties.status.description
Free form phase word chosen by the caller. Unrelated to the activity lifecycle `state` reported by `GET`.

# components.schemas.LiveActivityWrite.allOf[].description
Control flags that ride alongside the content fields. They are stripped before storage, so they can never leak into the tile's content.

# components.schemas.LiveActivityWrite.allOf[].properties.end.description
`true` ends the tile instead of starting or updating it, identical to `DELETE` or `?end=1`.

# components.schemas.LiveActivityWrite.allOf[].properties.new.description
Device dialect only. `true` starts an additional tile instead of updating the live one, and is the deliberate override for the sticky dismissal `410`.

# components.schemas.LiveActivityWrite.allOf[].properties.keepFor.description
Honored on an end. Seconds the finished tile lingers on the Lock Screen, up to Apple's own 4 hour ceiling. Default 0 removes it immediately.

# components.schemas.LiveActivityEndRequest.allOf[].properties.keepFor.description
Seconds the finished tile lingers on the Lock Screen, 0 to 14400. Default 0 removes it immediately; without an explicit end iOS would leave a dead tile visible for up to four hours.

# components.schemas.LiveActivityContentState.description
The stored content of a tile, echoed back by `GET`. Same field names and shapes the caller sends, plus three the server owns. Absolute times are epoch SECONDS, deliberately: the format is pinned to what the iOS widget decodes.

# components.schemas.LiveActivityContentState.properties.progress.description
0 to 100, as the caller sends it. The APNs payload carries 0 to 1; the conversion happens at send time and is not visible here.

# components.schemas.LiveActivityContentState.properties.startsAt.description
Epoch seconds. Stamped once when a countdown first appears and kept across later `endsIn` refreshes, so the time driven bar keeps its original base. Server owned; not caller writable.

# components.schemas.LiveActivityContentState.properties.endsAt.description
Epoch seconds, computed as now plus `endsIn` against the gateway's clock. Server owned; not caller writable.

# components.schemas.LiveActivityContentState.properties.lastUpdated.description
Epoch seconds of the last accepted write. Always stamped by the server, never by the caller: it is the freshness anchor the widget uses to dim itself and show "updated Xm ago" when updates stop arriving.

# components.schemas.LiveActivityStatus.description
The full state of one tile. Readable while the tile lives and for one day after it ends, after which the row is deleted.

# components.schemas.LiveActivityStatus.properties.state.description
`starting` means the start push was sent but the tile has not confirmed itself yet, `active` means the device reported its per tile token, `dismissed` means the user swiped it away (sticky), and `ended` covers every other terminal path.

# components.schemas.LiveActivityStatus.properties.endReason.description
Why a terminal tile went terminal, so a script author debugging a vanished tile gets an answer instead of a bare state. `script` means you ended it. `dismissed` means the user swiped it away. `never-started` means the tile never appeared within 15 minutes (Live Activities off, device full, or Apple dropped the push, all of which return HTTP 200). `overdue` means a countdown passed its finish by 30 minutes with no end call. `abandoned` means a progress only tile went 2 hours without an update, because a frozen 91 percent that stopped being true is worse than no tile. `expired` means the row was still marked live 9 hours after starting, past Apple's own 8 hour ceiling. `dead-token` means the device's push-to-start token died before the tile could be born, so the row ended without one. A dead per-tile UPDATE token is deliberately not terminal: iOS rotates that token while the tile is alive, so the gateway just detaches it and the tile resumes when the device re-reports (updates in that window answer 200 with `pushed: false`). `null` while the tile is still live.

# components.schemas.LiveActivityStatus.properties.endsAt.description
The computed absolute finish, echoed in ISO 8601 so a caller can check its own `endsIn` math. `null` when the tile has no countdown.

# components.schemas.LiveActivityStatus.properties.expiresAt.description
Apple's hard ceiling: every Live Activity ends 8 hours after it starts no matter what `endsIn` said. Echoed here so nobody discovers it at hour eight. A job that runs longer simply starts a fresh tile.

# components.schemas.LiveActivityList.description
Every tile currently LIVE on a device, newest first. Terminal tiles are omitted here even though they remain readable by their own id for a day.

# components.schemas.LiveActivityStartResponse.description
A tile was created. Keep `activityId`: it is the precise handle for every later update, and the only handle that works when several tiles are live.

# components.schemas.LiveActivityUpdateResponse.description
The new content was stored. Note that this confirms STORAGE, not delivery: the gateway stores the desired state first and pushes second, so a tile whose per activity token has not arrived yet gets the content the moment it does. `pushed` reports which of those two situations this call landed in.

# components.schemas.LiveActivityUpdateResponse.properties.pushed.description
Whether a push toward the device was actually requested. `false` means the content was stored but no reachable tile token is on file right now (typically iOS rotated the token and the device has not re-reported yet); the update delivers when it does, which the app triggers on every launch. Added Aug 2026; absent from older gateway builds.

# components.schemas.LiveActivityUpdateResponse.properties.message.description
Present only when `pushed` is false: a plain-language note that the content is stored and will deliver when the device reconnects.

# components.schemas.LiveActivityEndResponse.properties.state.description
How the tile actually finished. `dismissed` appears when the user had already swiped it away before your end call arrived.

# components.schemas.LiveActivityNothingToEndResponse.description
A device scoped end with no live tile. Deliberately a success: a script's end of job hook must never fail just because the tile already finished some other way.

# components.schemas.LiveActivityError.description
The standard error shape for these routes: a short machine readable `error` plus a `message` written to be shown to a person.

# components.schemas.LiveActivityError.properties.message.example
Field "endsIn" must be a positive integer number of seconds

# components.schemas.LiveActivityAmbiguous.description
The device URL is only an unambiguous handle while at most one tile is live. More than one is not a mistake the caller made, so the response teaches the way out and lists the ids (the caller already authenticated as their owner).

# components.schemas.LiveActivityBackoff.description
The push to start backoff response. Accompanied by a `Retry-After` header carrying the same number of seconds. Read `openingTheAppMayHelp` before telling anyone to open the app: when it is false, the only remedy is the wait, and the counter also fades on its own by one step per fully quiet day.

# components.schemas.LiveActivityBackoff.properties.unansweredStarts.description
How many consecutive starts were accepted by Apple but never produced a tile. The ladder is 30 minutes after 2, 3 hours after 4, and 6 hours after 6. The counter resets the moment any tile actually appears.

# components.schemas.LiveActivityBackoff.properties.openingTheAppMayHelp.description
Whether the usual advice can apply at all right now. Note the MAY. Opening the Notify app helps only by re-driving a start that is still pending, and a pending row is necessary but NOT sufficient: the re-drive fires only when ActivityKit hands the app DIFFERENT start token bytes, because acting on an unchanged token risks a second tile nothing can address. So `true` means there is something to retry if the token rotates, and `false` means there is not and only the quiet time will clear this. Optional: absent from older gateway builds.

# components.schemas.LiveActivityNotFound.description
Returned by the app internal token routes for both an unknown activity and an activity owned by a different device. The two are deliberately indistinguishable.

# components.schemas.LiveActivityServerError.description
An unexpected failure. The message is sanitized before it leaves the gateway.

# components.schemas.LiveActivityTokenReport.description
The iOS app reporting the per activity push token ActivityKit handed it.

# components.schemas.LiveActivityTokenReport.properties.deviceId.description
The device that owns the activity. Verified as a pair with the `token` header, then verified to own the activity in the path.

# components.schemas.LiveActivityTokenReport.properties.pushToken.description
The per activity APNs push token. Distinct from the device's notification token and from its push to start token.

# components.schemas.LiveActivityTokenReport.properties.observedAt.description
OPTIONAL. Epoch MILLISECONDS at which the client observed this token from ActivityKit, held stable across the client's own retries. The gateway records it as the token's report time and compares it against the invalidation timestamp in an APNs 410, so a delayed 410 about a PREVIOUS validity period cannot clear a freshly reported token (iOS can reissue a token with identical bytes, so the text alone is not a generation). Omit it and the gateway falls back to its receipt time, which is the behavior of every client built before this field existed.

# components.schemas.LiveActivityTokenReportResponse.properties.state.description
`active` in the normal case. A terminal value means the server had already written this activity off and has just sent an immediate end push to remove the orphaned tile from the device.

# components.schemas.LiveActivityTokenClear.description
The iOS app reporting that a tile no longer exists on the device.

# components.schemas.LiveActivityTokenClear.properties.reason.description
`dismissed` records a user swipe, which is sticky: later updates to this activity return `410`. Any other value, including an absent one, records `ended`.

# components.schemas.LiveActivityStart.description
The subset of `LiveActivityWrite` that a START must satisfy. Sent to a device id that has no live tile yet, or to any id with `new=1`.

`title` is REQUIRED here and only here. An update carries whatever fields are changing and may legitimately omit it, which is why the shared content schema cannot mark it required. Omitting it on a start answers `400` with `Field "title" is required and must be a non-empty string`.

# components.schemas.LiveActivityStartFailure.description
Starts only. A start that failed has two possible outcomes and they call for opposite reactions, so the response says which one happened.

`deliveryState: "not-delivered"` means Apple answered and refused the request (or the gateway refused it before sending). No tile exists, the half created row was removed rather than left holding one of the 5 slots, and starting again cannot produce a duplicate. Whether starting again is USEFUL is a separate question: read `retryAfterSeconds`. When it is present, wait that long and retry. When it is absent, the same request will fail the same way and only a change to the request or to the device will help.

`deliveryState: "unknown"` means Apple never answered (a timeout or a dropped connection). The request may already have been accepted, so a tile may be appearing right now. The row is KEPT and its `activityId` is returned: poll it with `GET /live-activity/{activityId}` before deciding. Starting again with `new=1` here can leave the user with two tiles. If no tile appears, the row ends itself as never started within 15 minutes.

All three added fields are additive. A response without them predates this distinction and should be read as the `not-delivered` case with no retry guidance, which is what the gateway did for every failure before it existed.

# components.schemas.LiveActivityStartFailure.properties.deliveryState.description
Whether a tile can possibly exist. Optional: absent from older gateway builds.

# components.schemas.LiveActivityStartFailure.properties.activityId.description
Present only when `deliveryState` is `unknown`: the retained row, so the caller can poll for a tile instead of guessing. Optional.

# components.schemas.LiveActivityStartFailure.properties.retryAfterSeconds.description
Present ONLY when waiting is actually the remedy, so its absence is itself the signal that retrying unchanged is pointless. Apple's own `Retry-After` wins when it sends one; otherwise a rate limit (429) reports about 60 seconds and an Apple-side error (5xx) about 900, because retrying into Apple's own outage a few seconds later is what keeps it going. A permanent rejection (400, 403, 410, 413) and any refusal the gateway made before sending carry no value and no `Retry-After` header. Optional.