Send alerts
Every channel has one or more send URLs. To send an alert, POST a JSON body to a send URL; devices that cannot build JSON can send plain text, a form, or a query string. Copy the full URL from the app; it looks like this:
https://<api-host>/v1/send/<token>The token in the URL is the only credential. Anyone who has the URL can send to the channel, so keep it out of public repositories and logs. A send URL can only send: it cannot read alerts or change the channel.
A minimal request
Section titled “A minimal request”curl -X POST "$SEND_URL" \ -H 'Content-Type: application/json' \ -d '{"alert": {"title": "Build failed", "body": "3 tests failed on main"}}'A successful request returns 201 Created. The alert is stored before the response is sent, and delivery to devices happens in the background.
From scripts and cron jobs, the command-line tool sends the same request and adds retries, encryption, and alerts for failed commands.
Request fields
Section titled “Request fields”| Field | Type | Description |
|---|---|---|
alert.title |
string | Required. The alert title. |
alert.body |
string | Optional details shown under the title. |
level |
string | urgent, high, normal, or silent. Defaults to normal. See Alert levels and reminders. |
require_ack |
boolean | Keep the alert pending until someone acknowledges it. See Acknowledgement and receipts. |
receipt |
boolean | Return a receipt you can poll, even if acknowledgement is not required. |
callback |
boolean | Call the channel’s acknowledgement callback when the alert is acknowledged. |
emergency |
object | Reminder timing for urgent alerts: retry_seconds and expire_seconds. |
url |
string | A link to open from the alert. |
event.type |
string | What happened, for example build.failed. |
event.subject |
string | What it happened to, for example repo:payments:main. |
event.fingerprint |
string | A stable identity for the underlying event, used to recognize repeats. Defaults to the subject and type. |
dedupe |
object | How repeats are handled; see Repeated events. |
thread_key |
string | Groups related alerts. Shown in the alert’s details. |
payload |
object | Any JSON you want to keep with the alert. It is stored and returned by the API, not displayed. |
expires_at |
string | An RFC 3339 time after which the alert may be deleted. Defaults to 21 days after it was sent, and may be at most 90 days after it was sent. |
idempotency_key |
string | Retrying a request with the same key returns the original alert instead of creating a second one. You can also send it as an Idempotency-Key header. |
Channels that require end-to-end encryption take an encrypted envelope instead of alert; see Encrypted alerts.
Devices and simple requests
Section titled “Devices and simple requests”Devices that cannot build JSON, such as routers, cameras, NAS boxes, PLCs, and microcontrollers, can send to the same URL in simpler ways. These requests use the flat fields below. Everything else, such as emergency, callback, dedupe, and payload, needs JSON.
| Field | Header | Same as |
|---|---|---|
title |
Title |
alert.title |
body |
— | alert.body |
level |
Level |
level |
url |
Url |
url |
thread_key |
Thread-Key |
thread_key |
idempotency_key |
Idempotency-Key |
idempotency_key |
event_type |
Event-Type |
event.type |
event_subject |
Event-Subject |
event.subject |
event_fingerprint |
Event-Fingerprint |
event.fingerprint |
Plain text. POST or PUT the text with Content-Type: text/plain, or with no Content-Type. The text is the alert body; put the other fields in headers or the query string.
curl -X POST "$SEND_URL" \ -H 'Content-Type: text/plain' \ -H 'Title: Door opened' -H 'Level: high' \ --data-binary 'Garage, 03:12'Without a title, the first line of the body becomes the title, so a device can send one line of text and nothing else.
Form. Send application/x-www-form-urlencoded fields, as an HTML form does:
curl -X POST "$SEND_URL" --data 'title=UPS+on+battery&body=Load+42%25&level=urgent'Query string. Any of these requests can also carry fields in the query string, for example POST $SEND_URL?title=Door+opened&level=high.
The rules:
- Each field may appear once. The same field in two places, such as the query string and a header, is rejected with
invalid_target, and so is an unknown query or form field.fieldnames it. - A body that starts with
{is always read as JSON, whatever itsContent-Type. A form body without any=is read as plain text, socurl -d 'text'works. - Text is UTF-8. Declare GBK text with
Content-Type: text/plain; charset=gbk;gb2312andgb18030work too. - JSON requests ignore the headers above, except
Idempotency-Key. - The limits and rate limits are the same as for JSON. Channels that require end-to-end encryption reject these requests with
encryption_required.
Sending with GET
Section titled “Sending with GET”Some devices can only open a URL. To use one, allow GET for its send URL in the app: open the send URL’s actions and turn on Allow GET requests. Then put the fields in the query string:
https://<api-host>/v1/send/<token>?title=Motion&body=Front+door&level=highGET is off by default because anything that opens the URL sends an alert, including a chat app that previews a pasted link. While it is off, GET requests get 405 method_not_allowed. HEAD requests never send. Devices tend to retry blindly, so add an idempotency_key or an event_type to keep a retry from creating a second alert.
Connection requirements
Section titled “Connection requirements”- Send URLs work only over HTTPS, with TLS 1.2 or 1.3 and SNI. A plain
http://request gets403 https_requiredrather than a redirect, because most devices cannot follow one. The token has then crossed the network unencrypted, so regenerate the URL if that network is not trusted. - A device that cannot use TLS at all can send through a local gateway that can, such as Home Assistant or Node-RED.
201means the alert is stored; the response body can be ignored. Retry afterRetry-Afteron429and after a pause on5xx. Do not retry other4xxerrors; they will fail again.
The response
Section titled “The response”{ "message": { "id": "msg_4f1c…", "receipt_id": "rcp_9a02…", "level": "urgent", "require_ack": true, "status": "open", "dedupe_count": 1 }, "decision": { "reason": "new_message" }}The response includes the stored alert as message, abridged here. receipt_id is present when the alert requires acknowledgement or you asked for a receipt. decision.reason says what happened to the request:
| Reason | Meaning |
|---|---|
new_message |
A new alert was created. |
duplicate_updated |
A repeat updated an open alert; see below. |
duplicate_recorded |
A repeat was counted without changing the alert. |
duplicate_dropped |
A repeat was ignored. |
idempotent_replay |
The idempotency_key was already used; the original alert is returned. |
decision.effective_policy shows the repeat handling that applied, with the same field names as the request’s dedupe object.
Repeated events
Section titled “Repeated events”A job that fails every minute should not fill your inbox. When a request has an event and an open alert with the same fingerprint was last seen within the window, the request updates that alert instead of creating a new one: the title, body, link, and level are replaced, and the occurrence count goes up. The app shows it as, for example, “3 occurrences”. Repeats do not notify again unless the alert requires acknowledgement.
Requests without any event field are never treated as repeats.
| Field | Default | Description |
|---|---|---|
dedupe.mode |
coalesce |
none always creates a new alert. |
dedupe.window_seconds |
600 |
How long after the last repeat a new one still matches the open alert. |
dedupe.accept_policy |
update_visible |
update_visible updates the alert; update_and_notify also notifies again; record_only counts the repeat without changing the alert; drop ignores it. |
dedupe.renotify_after_seconds |
0 |
When a repeat may notify again, the minimum time since the last notification. |
Once an alert is acknowledged or resolved, the next request with its fingerprint starts a new alert.
Limits
Section titled “Limits”Lengths count characters, so a Chinese character, a Latin letter, and an emoji each count as one. Leading and trailing whitespace is ignored.
| Field | Limit |
|---|---|
| Request body | 64 KB |
alert.title |
100 characters, one line |
alert.body |
1,000 characters; line breaks and tabs are allowed |
alert.title + alert.body |
1,000 characters together |
url |
512 bytes; an absolute http or https URL with non-ASCII characters percent-encoded |
thread_key, idempotency_key |
128 characters |
event.type |
64 characters |
event.subject, event.fingerprint |
200 characters |
payload |
8 KB of JSON, nested at most 8 levels |
expires_at |
At most 90 days after the alert is sent; a later time is rejected with invalid_target |
A request over a limit is rejected with alert_too_long; nothing is shortened silently. Long bodies are shortened in the push notification itself, but the full text is always in the inbox. All limits are collected in Limits.
Encrypted alerts have one content limit instead of these: at most 4,096 bytes of encrypted content, and no character limits. See Size limit.
Rate limits
Section titled “Rate limits”- Each send URL accepts 60 requests per minute by default. Over the limit, requests get
429 limit_exceededwith aRetry-Afterheader, and do not count as sent. - If a send URL keeps sending far past its limit — at least 100 rejected requests in a minute, or ten times its limit if that is higher — it is suspended and answers
401 invalid_send_url. A runaway script cannot be stopped from the server side otherwise, and a leaked URL is never silently reactivated. Fix the sender, then regenerate the URL in the app. - Each network address can make 600 requests per minute across all endpoints. Over that, requests get
429 rate_limited.
Validation errors are checked first and do not count against the send URL’s limit.
Errors
Section titled “Errors”Errors have a JSON body with a stable code:
{ "error": { "code": "alert_too_long", "message": "…", "field": "alert.title", "limit": 100, "actual": 128 } }| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json |
The body is not valid JSON or not valid UTF-8. |
| 400 | invalid_request |
A simple request’s text, form, query string, or header is not valid UTF-8 or URL encoding. |
| 401 | invalid_send_url |
The send URL is wrong, revoked, suspended, or its channel was deleted. |
| 403 | https_required |
The request used http://. Use https://, and regenerate the URL if the network is not trusted. |
| 405 | method_not_allowed |
GET on a send URL without Allow GET requests, or HEAD. |
| 413 | request_too_large |
The request body is larger than 64 KB. |
| 415 | unsupported_media_type |
The Content-Type or charset is not one the send API reads. |
| 422 | invalid_target |
alert.title is missing, a field has an invalid value or forbidden characters, a simple field is unknown or given twice, or the request is otherwise invalid; field names the field. |
| 422 | alert_too_long |
A field is over its limit. The error includes field, limit, and actual. |
| 422 | invalid_alert_level |
level is not one of the four levels. |
| 422 | invalid_emergency |
The emergency timing is out of range. |
| 422 | invalid_callback |
callback was requested on an alert that cannot be acknowledged. |
| 422 | callback_not_configured |
callback was requested, but the channel has no enabled callback. |
| 422 | encryption_required |
The channel accepts only end-to-end encrypted alerts. |
| 422 | invalid_encrypted_message |
The encrypted envelope is incomplete or malformed. |
| 429 | limit_exceeded |
The send URL’s rate limit was reached. Retry after the number of seconds in Retry-After. |
| 429 | rate_limited |
Too many requests from your address. Retry after the number of seconds in Retry-After. |
Every error code across the API is listed in Errors.