Send alerts
Every channel has one or more send URLs. To send an alert, POST a JSON body to a send URL. 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.
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. |
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.
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 |
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.
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. |
| 401 | invalid_send_url |
The send URL is wrong, revoked, suspended, or its channel was deleted. |
| 413 | request_too_large |
The request body is larger than 64 KB. |
| 422 | invalid_target |
alert.title is missing, a field has an invalid value or forbidden characters, 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.