Recipes
Copy-ready integrations for GitHub Actions, cron, Windows Task Scheduler, a Python script and a long-running Node.js service.
Each recipe is complete: it reads the token from a secret, creates one idempotency key per event, and retries safely. Create a separate source for each one so its alerts are easy to tell apart in the inbox.
GitHub Actions: alert when a workflow fails
Link to this sectionAdd the token as a repository secret named ZONA_SOURCE_TOKEN (Settings, Secrets and
variables, Actions). Then add this step as the last step of any job you want to watch.
- name: Alert Zona on failure
if: failure()
env:
ZONA_SOURCE_TOKEN: ${{ secrets.ZONA_SOURCE_TOKEN }}
WORKFLOW: ${{ github.workflow }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# One key per run attempt: a re-run that fails again is a new alert.
EVENT_ID: gha-${{ github.run_id }}-${{ github.run_attempt }}
run: |
payload=$(jq -n \
--arg title "$WORKFLOW failed" \
--arg body "$REPO on $REF failed. $RUN_URL" \
--arg repo "$REPO" --arg ref "$REF" --arg run "$RUN_URL" \
'{title: $title[0:120], body: $body, category: "ci", severity: "high",
data: {repo: $repo, ref: $ref, runUrl: $run}}')
curl --fail-with-body --silent --show-error \
--retry 4 --max-time 10 \
--request POST "https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify" \
--header "Authorization: Bearer $ZONA_SOURCE_TOKEN" \
--header "Idempotency-Key: $EVENT_ID" \
--header "Content-Type: application/json" \
--data "$payload"The workflow values pass through env and jq rather than being pasted into the script, so a
branch name with quotes in it cannot break the JSON or the shell. jq and curl are already on
GitHub-hosted Ubuntu runners.
cron and curl: a nightly job on Linux or macOS
Link to this sectionKeep the token in a file only your user can read. Create it in a text editor, so the token never passes through your shell history:
ZONA_SOURCE_TOKEN='zona_live_YOUR_SOURCE_TOKEN'Then run chmod 600 ~/.config/zona/env, and let a small wrapper run the job and report the result.
#!/bin/sh
set -u
. "$HOME/.config/zona/env"
# One key per run, created before the job starts, reused by every retry below.
event_id="backup-$(date -u +%Y%m%d-%H%M%S)"
if /usr/local/bin/run-backup; then
title="Backup complete"; severity="low"; status=0
else
status=$?
title="Backup failed"; severity="high"
fi
curl --silent --show-error --retry 4 --max-time 10 \
--request POST "https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify" \
--header "Authorization: Bearer $ZONA_SOURCE_TOKEN" \
--header "Idempotency-Key: $event_id" \
--header "Content-Type: application/json" \
--data "{\"title\": \"$title\", \"body\": \"Nightly backup run $event_id exited with status $status.\", \"category\": \"backup\", \"severity\": \"$severity\"}"# crontab -e: every night at 02:00
0 2 * * * $HOME/bin/nightly-backup >> $HOME/nightly-backup.log 2>&1Replace /usr/local/bin/run-backup with your own command, and make the wrapper executable with
chmod +x ~/bin/nightly-backup. The JSON is built from fixed strings and numbers only; if you add
free text such as a hostname or an error message, build the body with jq as in the GitHub
Actions recipe.
Windows Task Scheduler and PowerShell
Link to this sectionThis script works in Windows PowerShell 5.1 and PowerShell 7. It sends the body as UTF-8, retries
network errors, 429 and 5xx with the same key, and waits for Retry-After.
param(
[Parameter(Mandatory = $true)][string]$Title,
[Parameter(Mandatory = $true)][string]$Body,
[string]$Category = 'task',
[ValidateSet('low', 'medium', 'high', 'critical')][string]$Severity = 'medium',
[string]$IdempotencyKey = ('task-' + [guid]::NewGuid().ToString())
)
$endpoint = 'https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify'
if ([string]::IsNullOrWhiteSpace($env:ZONA_SOURCE_TOKEN)) { throw 'Set ZONA_SOURCE_TOKEN first.' }
$headers = @{ Authorization = "Bearer $env:ZONA_SOURCE_TOKEN"; 'Idempotency-Key' = $IdempotencyKey }
$json = @{ title = $Title; body = $Body; category = $Category; severity = $Severity } | ConvertTo-Json
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
for ($attempt = 0; $attempt -lt 5; $attempt++) {
try {
return Invoke-RestMethod -Method Post -Uri $endpoint -Headers $headers `
-ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 10
} catch {
$response = $_.Exception.Response
$status = if ($response) { [int]$response.StatusCode } else { 0 } # 0 = network error or timeout
if ($status -ne 0 -and $status -ne 429 -and $status -lt 500) { throw }
$wait = [Math]::Min([Math]::Pow(2, $attempt), 30) + (Get-Random -Minimum 0.0 -Maximum 1.0)
$retryAfter = $null
if ($response) {
try { $retryAfter = [int]$response.Headers['Retry-After'] } catch { } # Windows PowerShell 5.1
if (-not $retryAfter) { try { $retryAfter = [int]$response.Headers.RetryAfter.Delta.TotalSeconds } catch { } } # PowerShell 7
}
if ($retryAfter -gt $wait) { $wait = $retryAfter }
if ($attempt -lt 4) { Start-Sleep -Seconds ([Math]::Ceiling($wait)) }
}
}
throw "Zona did not confirm the alert. Retry later with -IdempotencyKey $IdempotencyKey"Store the token as a user environment variable through System Properties, Environment Variables, so it never lands in your command history. Then register a daily task from PowerShell:
$arguments = '-NoProfile -NonInteractive -ExecutionPolicy RemoteSigned -File "C:\Scripts\Send-ZonaAlert.ps1" ' +
'-Title "Nightly check finished" -Body "The scheduled task ran on this PC." -Severity low'
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments
$trigger = New-ScheduledTaskTrigger -Daily -At 2am
Register-ScheduledTask -TaskName 'Zona nightly alert' -Action $action -Trigger $triggerTo report on another task, call the script from that task’s last step with the result in -Title
and -Body, and pass a key that names the run, such as -IdempotencyKey "backup-$(Get-Date -Format yyyyMMdd)".
Python: a reusable sender
Link to this sectionA single-file helper for scripts and cron jobs. It needs the requests package.
"""Send a Zona alert with safe retries: python zona_alert.py "Title" "Body" [event-id]"""
import os
import random
import sys
import time
import uuid
import requests
ENDPOINT = "https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify"
def send_alert(title, body, *, event_id, category=None, severity=None, data=None, attempts=5):
payload = {"title": title, "body": body}
if category:
payload["category"] = category
if severity:
payload["severity"] = severity
if data:
payload["data"] = data
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(f"Zona did not confirm the alert; retry later with event id {event_id}")
if __name__ == "__main__":
if len(sys.argv) < 3:
sys.exit(__doc__)
event = sys.argv[3] if len(sys.argv) > 3 else f"script-{uuid.uuid4()}"
result = send_alert(sys.argv[1], sys.argv[2], event_id=event, category="script")
print(result["notificationId"], "(replay)" if result["idempotentReplay"] else "")Import it from other scripts and pass an event ID that you can reproduce, such as
send_alert("Export ready", "12,500 rows written.", event_id=f"export-{job_id}", severity="low").
Node.js: alerts from a long-running service
Link to this sectionA health monitor that alerts when a dependency goes down and again when it recovers. It sends one alert per change of state, not one per check, which keeps it well inside the rate limits.
// Node.js 20 or later. Run with: ZONA_SOURCE_TOKEN=... node health-monitor.mjs
const ENDPOINT = 'https://gerncrjtrdjtjvybvseb.supabase.co/functions/v1/notify';
const TARGET = 'http://localhost:8080/healthz';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function sendAlert(payload, eventId, attempts = 5) {
const body = JSON.stringify(payload);
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) {
console.error(`Zona rejected ${eventId}: ${response.status} ${await response.text()}`);
return null;
}
const retryAfter = Number(response.headers.get('retry-after'));
if (retryAfter > 0) wait = Math.max(wait, retryAfter * 1000);
} catch {
// Network error or timeout: retry with the same key.
}
if (attempt < attempts - 1) await sleep(wait);
}
console.error(`Zona did not confirm ${eventId}`);
return null;
}
async function isHealthy() {
try {
const response = await fetch(TARGET, { signal: AbortSignal.timeout(5_000) });
return response.ok;
} catch {
return false;
}
}
let healthy = true;
for (;;) {
const now = await isHealthy();
if (now !== healthy) {
healthy = now;
// The key is created once per state change and reused by every retry inside sendAlert.
const eventId = `health-orders-api-${now ? 'up' : 'down'}-${crypto.randomUUID()}`;
await sendAlert(
now
? { title: 'orders-api recovered', body: 'The health check is passing again.', category: 'health', severity: 'low' }
: { title: 'orders-api is down', body: `${TARGET} is failing its health check.`, category: 'health', severity: 'critical' },
eventId,
);
}
await sleep(30_000);
}If the operator has critical alerts switched off, the down alert comes back as
403 CRITICAL_SEVERITY_DISABLED, and this monitor logs it rather than retrying. See
Errors and limits for every code worth handling in a service.