Idempotency and retries
Pick one stable Idempotency-Key per event, retry only what is safe to retry, and honour Retry-After, so a flaky network never sends the same alert twice.
Networks drop responses. A request can time out on your side after Zona has already stored the
alert. The Idempotency-Key header is what lets you retry that request without a second alert
appearing on your phone.
The key
Link to this sectionIdempotency-Key is required on every request. It must be 8 to 128 characters and match:
^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$A missing or malformed key returns 400 INVALID_IDEMPOTENCY_KEY.
A key names one logical event, not one HTTP request. Good keys come from the event itself:
| Event | Key |
|---|---|
| The 02:00 backup on 26 July 2026 | backup-20260726-020000 |
| A CI run and its attempt number | ci-8841920731-1 |
| A health check going down | health-orders-api-down- followed by a UUID created when the state changed |
Create the key once, when the event happens, and keep it until you have a definite answer. A key created fresh inside the retry loop defeats the purpose: each attempt looks like a new event.
What Zona does with a key
Link to this sectionKeys are scoped to the source that sends them. The same key from two different sources is two independent events.
| Request | Result |
|---|---|
| First request with a key | 202. A new inbox record is stored and delivery jobs are queued for eligible phones. |
| Same source, same key, same content | 200. The original record comes back with idempotentReplay: true. Nothing new is queued. |
| Same source, same key, different content | 409 IDEMPOTENCY_CONFLICT. Nothing new is stored. |
| Different source, same key | A separate event, handled as a first request. |
“Same content” means the same title, body, category, severity, data, todo and image
set. Changing any of them, including only the severity or the order of the images, is a conflict.
A replay returns the original notificationId and acceptedAt. It omits pushQueued, reports
pushAttempted: 0, does not retry a failed image upload, does not resend the push, and does not
update the key’s last-used time.
When to retry
Link to this section| Outcome | Retry? | How |
|---|---|---|
| Network error or timeout | Yes | Same key, same content. The alert may already be stored; the replay tells you. |
429 RATE_LIMITED, 429 ACCOUNT_RATE_LIMITED |
Yes | Wait at least the Retry-After seconds, then retry with the same key. |
503 SERVICE_UNAVAILABLE |
Yes | Wait at least the Retry-After seconds, then retry with the same key. |
500 INTERNAL_ERROR and other 5xx |
Yes | Exponential backoff with jitter, same key, same content. |
409 IDEMPOTENCY_CONFLICT |
No | Send the original content, or use a new key if this really is a new event. |
Any other 4xx |
No | Fix the request or the token first. Retrying unchanged gives the same answer. |
Use a short client timeout, around 10 seconds for JSON and a little longer for images, so a stuck connection turns into a retry instead of a hung job.
A retry helper
Link to this sectionBoth helpers take the key from the caller, so every attempt reuses it, and both treat 200 and
202 as success.
import os
import random
import time
import requests
ENDPOINT = "https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify"
def send_alert(payload: dict, event_id: str, attempts: int = 5) -> dict:
headers = {
"Authorization": f"Bearer {os.environ['ZONA_SOURCE_TOKEN']}",
"Idempotency-Key": event_id,
}
for attempt in range(attempts):
wait = min(2 ** attempt, 30) + random.uniform(0, 1)
try:
response = requests.post(ENDPOINT, headers=headers, json=payload, timeout=10)
except (requests.ConnectionError, requests.Timeout):
response = None
if response is not None:
if response.status_code in (200, 202):
return response.json()
if response.status_code != 429 and response.status_code < 500:
raise RuntimeError(f"Zona rejected the alert: {response.status_code} {response.text}")
retry_after = response.headers.get("Retry-After", "")
if retry_after.isdigit():
wait = max(wait, int(retry_after))
if attempt < attempts - 1:
time.sleep(wait)
raise RuntimeError("Zona did not confirm the alert; retry later with the same key")
send_alert(
{"title": "Backup complete", "body": "The nightly backup finished successfully."},
event_id="backup-20260726-020000",
)const ENDPOINT = 'https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function sendAlert(payload, eventId, attempts = 5) {
const body = JSON.stringify(payload); // serialise once, so every attempt is identical
for (let attempt = 0; attempt < attempts; attempt++) {
let wait = Math.min(2 ** attempt, 30) * 1000 + Math.random() * 1000;
try {
const response = await fetch(ENDPOINT, {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.ZONA_SOURCE_TOKEN}`,
'idempotency-key': eventId,
'content-type': 'application/json',
},
body,
signal: AbortSignal.timeout(10_000),
});
if (response.status === 200 || response.status === 202) return await response.json();
if (response.status !== 429 && response.status < 500) {
throw Object.assign(new Error(`Zona rejected the alert: ${response.status} ${await response.text()}`), { fatal: true });
}
const retryAfter = Number(response.headers.get('retry-after'));
if (retryAfter > 0) wait = Math.max(wait, retryAfter * 1000);
} catch (error) {
if (error.fatal) throw error; // network errors and timeouts fall through to a retry
}
if (attempt < attempts - 1) await sleep(wait);
}
throw new Error('Zona did not confirm the alert; retry later with the same key');
}
await sendAlert(
{ title: 'Backup complete', body: 'The nightly backup finished successfully.' },
'backup-20260726-020000',
);With curl, --retry covers the same ground for a single command: it retries timeouts and HTTP
408, 429, 500, 502, 503 and 504, resends the same headers and body each time, and waits
for Retry-After when the server sends it.
curl --retry 4 --max-time 10 --request POST \
"https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify" \
--header "Authorization: Bearer $ZONA_SOURCE_TOKEN" \
--header "Idempotency-Key: backup-20260726-020000" \
--header "Content-Type: application/json" \
--data '{"title": "Backup complete", "body": "The nightly backup finished successfully."}'If you are unsure whether an alert landed
Link to this sectionSend it again with the same key and the same content. A 200 with idempotentReplay: true means
it was already stored; a 202 means this attempt stored it. Either way there is exactly one alert.