Sending alerts
Every header, every body field and its exact rules, how severity and category behave, and what each field in the response means.
An alert is one POST to /notify with a JSON body. This page is the full contract for that
request. To attach images, see Images; for checklists, see
Checklists.
POST https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notifyHeaders
Link to this section| Header | Required | Example | Purpose |
|---|---|---|---|
Authorization |
Yes | Bearer zona_live_... |
Authenticates one source. Use the Bearer scheme exactly. |
Idempotency-Key |
Yes | backup-20260726-020000 |
Names one logical event so retries cannot create duplicates. See Idempotency and retries. |
Content-Type |
Yes | application/json |
Use multipart/form-data only when sending images, and let your HTTP library write the boundary. |
Header names are case-insensitive. Only POST is accepted; any other method returns
405 METHOD_NOT_ALLOWED.
Body fields
Link to this section{
"title": "Build complete",
"body": "The release build finished successfully.",
"category": "build",
"severity": "high",
"todo": ["Tag the release", "Post the notes"],
"data": {
"buildId": "2026.07.26.14",
"branch": "main",
"durationSeconds": 482
}
}| Field | Type | Required | Rules |
|---|---|---|---|
title |
string | Yes | 1 to 120 characters after surrounding whitespace is trimmed. |
body |
string | Yes | 1 to 2,000 characters after surrounding whitespace is trimmed. |
category |
string or null |
No | 1 to 80 characters when present. Omitted, null or "" means no category. |
severity |
string or null |
No | low, medium, high or critical, case-insensitive. Omitted, null or "" means no severity ring. |
todo |
array or null |
No | Up to 20 checklist entries. Rules in Checklists. |
data |
object | No | Defaults to {}. At most 4,096 UTF-8 bytes once serialized. Arrays and plain values are rejected. |
The whole JSON body is limited to 16 KiB; a larger body returns 413 PAYLOAD_TOO_LARGE. A field
that breaks a rule returns 400 INVALID_PAYLOAD.
Push size
Link to this sectionZona also caps the push it generates at a conservative 3,800 UTF-8 bytes. Text that uses several
bytes per character, such as Chinese or emoji, can therefore return 400 INVALID_PAYLOAD before it
reaches the character limits above. If that happens, shorten the title and body.
Severity
Link to this sectionSeverity is optional presentation. It changes how the alert looks, not how it is delivered: it does not affect delivery priority, sound, rate limits or which source the alert belongs to.
| Value | In Zona | Notification accent |
|---|---|---|
omitted, null or "" |
No ring | Zona green |
low |
Green ring around the source’s avatar | Green |
medium |
Yellow ring | Yellow |
high |
Orange ring | Orange |
critical |
Red ring | Red |
The ring shows on the inbox row, on the alert’s details, which also name the level, and on the banner Zona shows while it is open. It is thicker for more urgent levels, so the level reads without relying on colour. While the alert is unread the ring pulses, faster for more urgent levels; once you have read it, or with Reduce Motion on, the ring stays still. Its colours are the system green, yellow, orange and red for the active theme’s light or dark appearance.
On iPhone, iOS controls the icon in a system notification, so a lock-screen or system banner does not show the severity colour. It appears once you are in Zona.
The operator can temporarily switch off critical alerts. While that switch is off, a critical
request returns 403 CRITICAL_SEVERITY_DISABLED. Only send a lower severity instead if it still
describes the event honestly.
Severity is part of the payload for idempotency: resending a key with only the severity changed
returns 409 IDEMPOTENCY_CONFLICT.
Category
Link to this sectioncategory is a free-form label such as backup, build or deploy. It groups and describes
alerts; it does not choose a sound. Each source’s sound is set in the app.
The data object
Link to this sectiondata carries context for your own tooling, such as a build ID, a branch or a job name. Zona
stores it with the inbox item. It does not change routing, source identity, sound, severity or push
behaviour.
- Use namespaced, non-sensitive keys.
- Keep large logs and files elsewhere, and link to them.
- Never put tokens, passwords or secrets in
data. Like the title and body, it can reach the phone.
Examples
Link to this sectioncurl --request POST \
"https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify" \
--header "Authorization: Bearer $ZONA_SOURCE_TOKEN" \
--header "Idempotency-Key: build-20260726-14" \
--header "Content-Type: application/json" \
--data '{
"title": "Build complete",
"body": "The release build finished successfully.",
"category": "build",
"severity": "high",
"data": {"buildId": "2026.07.26.14", "branch": "main"}
}'$headers = @{
Authorization = "Bearer $env:ZONA_SOURCE_TOKEN"
'Idempotency-Key' = 'deploy-' + [guid]::NewGuid().ToString()
}
$payload = @{
title = 'Deployment complete'
body = 'Version 1.2.0 is online.'
category = 'deploy'
severity = 'medium'
data = @{ version = '1.2.0'; environment = 'production' }
} | ConvertTo-Json -Depth 5
Invoke-RestMethod `
-Method Post `
-Uri 'https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify' `
-Headers $headers `
-ContentType 'application/json; charset=utf-8' `
-Body ([System.Text.Encoding]::UTF8.GetBytes($payload)) `
-TimeoutSec 10Sending the body as UTF-8 bytes keeps non-ASCII text intact on Windows PowerShell 5.1.
import os
import uuid
import requests
response = requests.post(
"https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify",
headers={
"Authorization": f"Bearer {os.environ['ZONA_SOURCE_TOKEN']}",
"Idempotency-Key": f"export-{uuid.uuid4()}",
},
json={
"title": "Job complete",
"body": "The data export is ready.",
"category": "export",
"data": {"jobId": "export-1842", "rows": 12500},
},
timeout=10,
)
response.raise_for_status()
print(response.json()["notificationId"])const response = await fetch('https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.ZONA_SOURCE_TOKEN}`,
'idempotency-key': `health-${crypto.randomUUID()}`,
'content-type': 'application/json',
},
body: JSON.stringify({
title: 'Service recovered',
body: 'The API health check is passing again.',
category: 'health',
severity: 'low',
data: { service: 'orders-api', status: 'healthy' },
}),
signal: AbortSignal.timeout(10_000),
});
const result = await response.json();
if (!response.ok) throw new Error(`${response.status}: ${result.error}`);
console.log(result.notificationId);These examples mint a fresh key each run, which is fine for a one-off send. A job that retries should create its key once per event and reuse it; see Idempotency and retries.
Response
Link to this sectionA newly accepted alert returns 202. An identical replay of an earlier request returns 200 with
the original record.
| Field | Meaning |
|---|---|
notificationId |
UUID of the inbox record. Log it if you want to correlate later. |
sourceId |
The source’s permanent ID, derived from the token. |
sourceName |
The source name stored with this alert. |
acceptedAt |
When the inbox record was created, in UTC. A replay returns the original time. |
idempotentReplay |
true when the response returns an existing record instead of a new one. |
attachmentAccepted |
true when every image sent is stored. false when no image was sent. |
attachmentError |
UPLOAD_FAILED when images could not be stored after the alert was accepted; otherwise null. |
pushQueued |
Delivery jobs created for eligible phones. Present on a new alert, omitted on a replay. |
pushAttempted |
Kept for older clients. Equals pushQueued on a new alert and 0 on a replay. It does not mean a push has been sent yet. |
pushAccepted |
Kept for older clients. Always 0 from /notify, because push results arrive later. |
Errors use a small envelope, {"error": "INVALID_TOKEN"}. Every code is listed in
Errors and limits.
Settings a sender cannot change
Link to this sectionThese live in the app and are deliberately out of reach of a request:
| Setting | Effect |
|---|---|
| Source name | Shown in the push and saved with each alert as it was at the time. |
| Source sound | Picks the notification sound for that source. |
| Push notifications | When off, alerts are still stored; only the phone push is skipped. |
| Notification sound | A global switch; when off, every source is silent. |
| Message previews | When off, the push and Zona’s in-app banner show generic text, and the full alert stays in the inbox. |
| In-app banners | While Zona is open, shows a new alert as a banner at the top of the screen. On by default and saved on each phone. |