# NoticeFox: sending notifications (full reference) > NoticeFox delivers rich, animated notifications to a person's iPhone, Apple Watch and Vision Pro. A sender holds a send token and sends a notification with one HTTPS request. This file is everything a script or an AI agent needs to send one correctly; it needs no other page. ## What you need 1. **The base URL** of the NoticeFox service that issued the token: `https://api.noticefox.app`, unless the user's token comes from a test service. Read it from the environment variable `NOTICEFOX_URL`, or ask the user; never guess one. It has no trailing slash. It must be HTTPS. 2. **A send token**: `nfx_send_` followed by exactly 32 letters and digits. Read it from `NOTICEFOX_TOKEN`, or ask the user, who creates one in the NoticeFox app (or by linking the NoticeFox CLI). It is a secret: never print it, log it, put it in a commit, or paste it into a chat or a URL you share. A token works only with the service that issued it. If either is missing, stop and ask the user for it. Don't try to create an account or a token yourself: a new account has none of the person's devices, and making tokens needs the account key, which only the person's apps hold. ## Send a notification `POST {base URL}/v1/notifications`, with the token in the `Authorization` header and a JSON object body. ```bash curl "$NOTICEFOX_URL/v1/notifications" \ -H "Authorization: Bearer $NOTICEFOX_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"title": "Tests", "message": "All 812 tests passed"}' ``` Success is HTTP `202 Accepted` with this body: the message is stored and queued for delivery. ```json { "id": "ntf_01JABCDEFGHJKMNPQRSTVWXYZ0", "status": "accepted", "request_id": "req_8hT2kLm4Qw9Zx1Cv3Bn5" } ``` Only `message` is required. A richer send, with an animated scene and its parameters, a link and a time-sensitive level: ```json { "title": "Garage", "message": "Door opened", "scene": "garage", "scene_params": { "doorState": "open" }, "url": "https://ha.example.net/garage", "url_title": "View Garage", "level": "time-sensitive", "group": "home", "tag": "garage-door" } ``` ### Retries and `Idempotency-Key` Send an `Idempotency-Key` header (1–255 printable ASCII characters, unique per notification) with every notification you might retry: - **Retry with the same key and the byte-identical body.** Keep the exact body you sent the first time and send it again as it is. A retry that matches returns the original 202 with the same `id`, so it doesn't notify twice. - Don't "tidy up" a retry: adding a field, even one set to what you believe is its default (a `title` of `"Notice"`, say), can make it a different request, which is `409 idempotency_conflict`. - Keys are scoped to the sending token, and remembered for 24 hours. Don't retry a notification later than that. - If you can't tell whether a send went through (a timeout, a dropped connection), retry with the same key and body; never with a new key, which would notify twice if the first one was accepted. ```bash curl "$NOTICEFOX_URL/v1/notifications" \ -H "Authorization: Bearer $NOTICEFOX_TOKEN" \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: deploy-2026-10-19-1' \ -d '{"title": "Deploy", "message": "Production deploy finished", "scene": "rocket", "scene_params": {"color": "green", "countdown": 3}}' ``` ### In code The first request above in Python, standard library only: ```python import json, os, urllib.request request = urllib.request.Request( os.environ["NOTICEFOX_URL"] + "/v1/notifications", data=json.dumps({"title": "Tests", "message": "All 812 tests passed"}).encode(), headers={"Authorization": "Bearer " + os.environ["NOTICEFOX_TOKEN"], "Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(request) as response: print(response.status, json.load(response)["id"]) # 202 ntf_… ``` And in JavaScript (Node 18+ or a browser-free runtime with `fetch`): ```javascript const response = await fetch(`${process.env.NOTICEFOX_URL}/v1/notifications`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.NOTICEFOX_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Tests', message: 'All 812 tests passed' }), }); if (response.status !== 202) throw new Error(`NoticeFox: ${response.status} ${await response.text()}`); ``` ## Fields All fields are optional except `message`. Any field not listed here is rejected with `400 invalid_field`, except `version`, `type` and `attachments` in a body, which are ignored (Apprise sends them). Lengths are UTF-8 **bytes**. Text may contain tab and newline but no other control characters. | Field | Type | Limit | Default | Meaning | |---|---|---|---|---| | `message` | string | 1–4,000 bytes, **required** | — | The notification text. `body` and `text` are aliases; send only one of the three | | `title` | string | ≤ 250 bytes | none (the app shows the scene's name) | Bold first line | | `subtitle` | string | ≤ 250 bytes | — | Second line | | `scene` | string | a catalog scene id (below) | `notice` | The animation | | `scene_version` | integer | a version the catalog lists | the scene's current version | Pins the animation version; usually omit it | | `scene_params` | object | ≤ 20 keys, ≤ 2 KB, only the scene's parameters | — | The scene's parameters (below) | | `bell_color` | string | `gold`, `red`, `blue`, `green`, or `#RRGGBB` | `gold` | The fox's bell | | `url` | string | ≤ 2,048 bytes; `https://` with a DNS host name, no IP address, no user:password | — | A link the full-screen scene offers as its own button, showing the domain first; never opened automatically or by the tap itself | | `url_title` | string | 1–64 bytes; only together with `url` | — | The link button's title | | `image` | string | like `url` | — | An image to attach | | `level` | string or integer | `passive`, `active`, `time-sensitive`, or a Pushover priority `-2`…`2` | `active` | How it interrupts (table below) | | `group` | string | 1–64 bytes | — | Groups related notifications into one thread | | `tag` | string | 1–64 bytes of `A-Z a-z 0-9 . _ -` | — | A newer send with the same tag **replaces** the older notification on the devices the newer send reaches (use it for states such as "door open" → "door closed", with the same `target` each time) | | `sound` | string | `default` or `none` | `default` | Whether it plays a sound | | `ttl` | integer | 1–2,592,000 seconds (30 days) | 86,400 (24 h) | The latest time delivery may happen (below) | | `target` | string | 1–64 bytes, exact and case-sensitive | the token's default, or `auto` | Which devices get it (table below) | The whole request (path, query string, headers and body) is at most 16 KB. - **`ttl` is an upper deadline, not a retry duration.** For each device, the service hands the notification to Apple's push service (APNs) and, if that fails temporarily, tries again: at most **5 attempts** in all, waiting about 2, 4, 8 and 16 seconds between them, however long `ttl` is. Nothing is attempted after `ttl`. A long `ttl` doesn't make delivery more certain; check delivery status (below) to see how each device ended. The message stays in the app's history for 30 days either way. - **`tag` replacement** can only happen on the devices the newer send reaches. Send "door open" with `target: "auto"` and then "door closed" with the same tag and `target: "iphone"`, and the Watch still shows "door open". So send every update of one tag to the same `target`. A tag isn't private to your token: another sender of the same person using the same tag replaces your notification too, so pick specific tags (`garage-door`, not `status`). Without a tag, every send is a separate notification. - **`target`** picks devices by these exact, lowercase keywords, or by a device name: | `target` | Devices | |---|---| | `auto` (default) | Every active device included in `auto` (all of them, unless the person excluded one) | | `iphone` | Every iPhone (`platform` `ios` in `GET /v1/devices`) | | `watch` | Every Apple Watch (`platform` `watchos`) | | `vision` | Every Vision Pro (`platform` `visionos`) | | a device name | The one device with exactly that `name`, from `GET /v1/devices` | | a device id | That device (the person's apps know ids; a send token doesn't see them) | The `platform` values `ios`, `watchos` and `visionos` are **not** keywords: as a target they are taken as a device name and normally reach nothing. A target is checked only for its form, so one that matches no device (no Watch, an unknown or misspelled name, a name two devices share) is still `202`, but no device gets the notification. Use `auto` unless the user asks for specific devices; then use the keyword or the exact device name they give, or one from `GET /v1/devices` (below) if your token may read it. ### Levels and Pushover priorities | `level` input | Result | Behavior | |---|---|---| | `passive`, `-2`, `-1` | passive | iOS's passive interruption level: delivered quietly | | `active`, `0` (default) | active | A normal notification | | `time-sensitive`, `1`, `2` | time-sensitive | iOS's time-sensitive level; the person's Focus settings decide whether it breaks through. Use it only for things that need attention now | Integers may be JSON numbers or strings. There are no critical alerts: `2` is time-sensitive and doesn't repeat. ### Scenes | `scene` | `scene_params` | Good for | |---|---|---| | `notice` (default) | none | Anything (the fox rings its bell) | | `rocket` | `color`: `red`, `blue`, `green` or `white` (default `white`); `countdown`: integer 0–10 (default 3) | Launches, deploys, releases | | `fireworks` | `bursts`: integer 1–10 (default 3) | Successes and celebrations | | `garage` | `doorState`: `open`, `closed`, `opening` or `closing` (default `open`) | Doors, covers, gates | | `campfire` | none | Calm updates (the fox sits by a campfire) | Every parameter is optional (the app plays an omitted one with its default). A parameter the scene doesn't take, or a value outside its range, is `400 invalid_scene_param`; an unknown scene is `400 invalid_scene`. The live catalog, with each parameter's JSON Schema, is `GET /v1/scenes` (any credential). ## Responses and errors Every error has this shape; `field` names the offending field when there is one: ```json { "error": { "code": "invalid_field", "message": "ttl must be an integer from 1 to 2592000.", "field": "ttl" }, "request_id": "req_8hT2kLm4Qw9Zx1Cv3Bn5" } ``` Match on `error.code`, not on the message. Every response also carries `X-Request-Id`, the same `req_…` id; report it when something fails. | Status | Codes | What to do | |---|---|---| | 202 | — | Done. Keep `id` if you want to check delivery later | | 400 | `invalid_request` (body isn't a JSON object), `missing_field` (no `message`), `invalid_field` (unknown field or bad value), `conflicting_field` (a field given twice with different values, for example `message` and `text`), `invalid_scene`, `invalid_scene_param` | Fix the request as `field` says. Don't retry it unchanged | | 401 | `unauthorized` (unknown token, or wrong base URL), `token_revoked`, `token_expired`, `account_deleted` | Stop and tell the user: the token no longer works | | 403 | `forbidden` (not a send token), `scene_not_allowed` (the token is limited to certain scenes) | `forbidden`: ask the user for a send token. `scene_not_allowed`: ask the user which scenes the token allows; don't assume any scene, `notice` included, is allowed | | 409 | `idempotency_conflict` | This key was used with a different body. If you are retrying, resend the byte-identical original body with the same key. Use a new key only for a genuinely new notification, and only if you know the first one wasn't accepted | | 413 | `payload_too_large` | Shorten the message (≤ 4,000 bytes) | | 415 | `unsupported_media_type` | Send `Content-Type: application/json` | | 422 | `urls_not_allowed` | This token may not send `url` or `image` (agent tokens can't by default). Send again without `url`, `url_title` and `image` (`url_title` is invalid without `url`); put the link in `message` as plain text only if the user wants that | | 429 | `rate_limited` | Wait `Retry-After` seconds, then retry (see below) | | 429 | `quota_exceeded` | The month's quota is used up: don't retry, tell the user | | 500, 503 | `internal_error`, `service_unavailable` | Retry (see below) with the same `Idempotency-Key` | **Retry policy.** Retry only 429 `rate_limited`, 5xx, and failures with no HTTP status (a timeout or a dropped connection), always with the same body and `Idempotency-Key`. Make at most 3 retries. Before each, wait `Retry-After` when the response has it (always an integer number of seconds, never a date), otherwise 1, 2, then 4 seconds plus a little random jitter; give up if a wait would be over 60 seconds, and tell the user. Use a client timeout of about 30 seconds. **Responses without `error.code`.** Something in front of the service can answer for it: the API gateway may reject requests when the whole service is over its request rate (`429`, a body such as `{"message": "Too Many Requests"}`, no `Retry-After`), and a gateway or proxy may answer 502, 503 or 504 with a body of its own. So decide on the **HTTP status first**, and read `error.code` only when the body has one: treat a 429 without it like `rate_limited` and a 5xx without it like `service_unavailable`, with the backoff above. Example: a send without a message. ```bash # expect 400 missing_field curl "$NOTICEFOX_URL/v1/notifications" \ -H "Authorization: Bearer $NOTICEFOX_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"title": "No message"}' ``` ## Limits - 30 sends per minute per token, 60 per minute per account across all its tokens: `429 rate_limited` with `Retry-After`. - 10,000 sends per account per UTC month: `429 quota_exceeded`. One send counts once however many devices get it. - Every send response after the token is known carries `X-RateLimit-Limit` (monthly limit), `X-RateLimit-Remaining` (sends left this month) and `X-RateLimit-Reset` (epoch seconds when the month resets). - Don't send more than the person would want to read. Combine bursts into one notification, and use `tag` to replace a state instead of piling up notifications. ## Check delivery `GET {base URL}/v1/notifications/{id}/status`, with the same token, for a message that token sent. Keep the `id` from the 202 (here with `jq`; any JSON parser works): ```bash MESSAGE_ID=$(curl -s "$NOTICEFOX_URL/v1/notifications" \ -H "Authorization: Bearer $NOTICEFOX_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"message": "Backup finished"}' | jq -r .id) curl "$NOTICEFOX_URL/v1/notifications/$MESSAGE_ID/status" \ -H "Authorization: Bearer $NOTICEFOX_TOKEN" ``` ```json { "id": "ntf_01JABCDEFGHJKMNPQRSTVWXYZ0", "status": "sent_to_apns", "destinations": [{ "platform": "ios", "state": "accepted", "apns_status": 200, "updated_at": "2026-10-19T09:30:01.000Z" }], "handled": [] } ``` `status` is the furthest state observed: `accepted` (stored, and no device has it yet), `sent_to_apns` (APNs accepted it for at least one device), `opened` or `cleared` (someone opened or dismissed it). `dropped` means it was never sent and never will be, and `reason` says why: `token_revoked` or `token_expired` (the token was revoked or expired before delivery started), `expired` (its `ttl` ran out first) or `no_matching_device` (no active device matched `target`). NoticeFox never claims "displayed". A message another token sent is `404 not_found`, and so is any message when the token may not read status (see "Read access" below). Each entry of `destinations` is one device, and its `state` says where it is: | `state` | Meaning | |---|---| | `pending`, `sending` | Not finished: waiting for an attempt, or being sent | | `accepted` | APNs accepted it for this device (not proof that it was shown) | | `failed_permanent` | It won't be tried again on this device (APNs refused it, or the attempts ran out) | | `expired` | `ttl` ran out before it was sent | | `superseded` | A newer send with the same `tag` replaced it before it was sent | | `skipped` | Not sent to this device (for example, the device was removed or the token revoked) | **An empty `destinations` list doesn't mean the send failed.** It can mean delivery hasn't been routed to devices yet (normally seconds, but a queue delay can make it minutes), or that the `target` matched no device. Don't resend because of it: check again later. Status is information, not a trigger for retries: resend only if the user asks, and then as a new notification. ## Other calls a send token may make - `GET /v1/scenes`: the scene catalog. - `GET /v1/devices`: the names and platforms of the person's devices, for choosing a `target` (needs the `device_names` read scope, below): ```bash curl "$NOTICEFOX_URL/v1/devices" -H "Authorization: Bearer $NOTICEFOX_TOKEN" ``` A send token sees only each active device's `name` and `platform` (`ios`, `watchos` or `visionos`): ```json { "devices": [{ "name": "Bob's iPhone", "platform": "ios" }, { "name": "Bob's Apple Watch", "platform": "watchos" }] } ``` `target: "watch"` reaches every `watchos` device, and `target: "Bob's Apple Watch"` only that one (a name shared by two devices reaches neither). `target: "watchos"` reaches nothing. A send token can't read messages, manage devices or tokens, or create other credentials. ### Read access A token's reads beyond sending are two **read scopes**, both on by default, which the person can turn off for a token: - `own_status`: the status of messages this token sent. Without it, every status lookup is `404 not_found`, even for a message the token just sent. - `device_names`: `GET /v1/devices`. Without it, that is `403 forbidden`. A `404` from status or a `403` from devices, right after a successful send, means the scope is off, not that the send failed. Without these reads, send with `target: "auto"` or a target the user named, don't try to confirm delivery, and never resend to compensate. ## The simple endpoint (when headers are impossible) Some tools can only fetch a URL. `GET` or `POST {base URL}/v1/send/{token}` takes the token in the path, and fields from the query string, a form body, a JSON body, a raw text body (the whole body becomes `message`) and the headers `X-Title` and `X-Level`. ```bash curl "$NOTICEFOX_URL/v1/send/$NOTICEFOX_TOKEN?title=Backup&message=Done" ``` ```bash curl -d "Build finished" "$NOTICEFOX_URL/v1/send/$NOTICEFOX_TOKEN" ``` - The token goes into the path as it is: it is only letters, digits and `_`, so it needs no encoding. Query and form values do: encode spaces and `&`, `=`, `#`, `+` and `%` (for example `message=Build%20finished`, or let curl do it with `-G --data-urlencode "message=…"`). - Scene parameters in a query or form are `param.=`, such as `&scene=garage¶m.doorState=open`. - A form-typed body without `=` is the message exactly as sent (that is what `curl -d "text"` sends). To send a message that contains `=`, use `--data-urlencode "message=…"` or `Content-Type: text/plain`. - A `GET` without `message` is `400 missing_field`, so a link preview of a bare token URL never notifies. A URL that carries a `message` does send whenever anything fetches it, link previews included. - Sources merge by precedence (JSON body, form body, query, headers); the same field with different values in two places is `400 conflicting_field`. - **Prefer `POST /v1/notifications` with the header.** A URL that contains the token can leak through logs, shell history and link previews. Never show such a URL to anyone. ## Good notifications - Put the outcome in `title` or the first words of `message` ("Deploy failed", not "Update"). - Keep `message` short: lock screens and the Watch show only the start of it. - Use `time-sensitive` only when the person should act now; `passive` for FYIs. - Every notification shows "via ", so the person always knows which sender it came from. Don't impersonate other senders in the text. ## Checklist 1. `NOTICEFOX_URL` and `NOTICEFOX_TOKEN` are set (ask the user if not); the URL is HTTPS with no trailing slash. 2. `POST $NOTICEFOX_URL/v1/notifications`, `Authorization: Bearer $NOTICEFOX_TOKEN`, `Content-Type: application/json`. 3. The body is a JSON object with `message` (1–4,000 bytes) and only the fields listed above. 4. Add an `Idempotency-Key` if you might retry. 5. `202` is success. Decide on the HTTP status first. On 4xx (except 429 `rate_limited`), fix the request or tell the user; on 429 `rate_limited`, 5xx or no answer, retry at most 3 times with the byte-identical body and the same `Idempotency-Key`, waiting `Retry-After` seconds or 1, 2, 4 s. 6. Don't resend because status looks incomplete; when unsure, ask the user. 7. Never print or log the token.