Quick links

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.

On this page

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.

Send an Idempotency-Key with every request your sender can set it on. It must be 8 to 128 characters and match:

^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$

A malformed key returns 400 INVALID_IDEMPOTENCY_KEY. A request without the header is not refused: Zona derives a key for it (see Senders that cannot set a key).

A key names one logical event, not one HTTP request. Good keys come from the event itself:

Table, scrolls horizontally when narrow
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 section

Keys are scoped to the source that sends them. The same key from two different sources is two independent events.

Table, scrolls horizontally when narrow
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.

Senders that cannot set a key

Link to this section

Uptime monitors, home-automation hubs and most generic webhooks send fixed headers, so they have no per-event value to put in Idempotency-Key. Leave the header out and Zona derives a key from the source, the content (title, body, category, severity, data, todo and images, with JSON key order ignored) and the five-minute window the request arrived in. The response says so with idempotencyKeyDerived: true.

Table, scrolls horizontally when narrow
Request without the header Result
The same alert again inside the same five-minute window 200 with idempotentReplay: true; one alert.
The same alert in a later window 202; a new alert. A monitor that repeats “still down” keeps alerting.
Any difference in content 202; a new alert.

The windows are fixed five-minute slots, not a rolling five minutes, so a retry that straddles a slot boundary can arrive twice. Send your own key whenever the sender can: it is exact, and it is the only way to retry safely across a long outage. Only an absent or empty header is derived; a header that is present but malformed is still refused.

Table, scrolls horizontally when narrow
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, 429 DAILY_LIMIT_REACHED Yes Wait at least the Retry-After seconds, then retry with the same key. After the daily cap that is until 00:00 UTC, and a sooner retry is refused.
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 section

Both 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",
)

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 section

Send 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.