On-call alerts over iMessage that engineers acknowledge with a tapback
ZeroSettle lets a small engineering or IT team send monitoring alerts from Grafana, Datadog or Sentry to the on-call engineer as iMessages, through a small relay you run, optionally with the alert's graph attached as a photo. A 👍 tapback on the alert reaches your webhook as reaction.received, and the relay marks the alert acknowledged; if nobody reacts within five minutes, the relay texts the next person on the rotation. Don't make ZeroSettle your only pager: its homepage shows a 99.9% uptime figure, but ZeroSettle publishes no SLA, so the relay should fall back to a second channel whenever a send fails.
Key takeaways
- What it is: ZeroSettle is an iMessage API: a real phone number on real iMessage, running on Apple hardware we operate for you, that your code texts customers from and hears back on through a bearer token and signed webhooks. For on-call alerts, the people your code texts are your own engineers.
- The workflow: monitoring tool, then your relay, then
POST /api/imessage/sendto whoever is on call. In the relay below, 👍 acknowledges, ‼️ escalates at once, and five minutes of silence escalates to the next engineer. - Main limit: a line sends one message at a time, and a send queued for about 12 seconds comes back as
line_busy, so group alerts before they reach ZeroSettle. Engineers who texted the line first never count as new contacts. - Price: Pro is $49/month for one number; Growth is from $599/mo for several numbers working as one. Messaging is unlimited on both.
- What it does not do: no paging SLA, no SMS fallback for Android, no read receipts in the API, and no on-call schedules. Your relay holds the rotation.
Who uses iMessage for on-call alerts?
iMessage on-call alerts fit small engineering, IT and operations teams, and agencies running client infrastructure, where a handful of people share one rotation and all of them use iMessage. The problem is the gap between an alert firing and a person seeing it: alert emails get filtered, Slack is muted at night, and a full paging platform is overkill for a five-person team.
Acknowledging is a tapback in Messages, with no paging app to install and no login. The ask-then-move-on pattern is the same one used for offering open shifts one person at a time; other jobs are in the index of iMessage use cases.
How iMessage on-call alerts work, step by step
Your monitoring tools post to a relay you run; the relay texts whoever is on call through ZeroSettle and watches ZeroSettle's signed webhook for a tapback. The rotation, the timers and the escalation live in the relay.
- Each engineer texts the line once when they join the rotation. Alerts are then an ongoing conversation: no new-contact cost, runbook links in an alert are tappable, and iOS doesn't file the alerts under unknown senders.
- Each engineer saves the number as a contact with Emergency Bypass (steps in the FAQ), so a Focus doesn't silence it. Set the line's name and photo, for example "Ops Alerts", with
PUT /api/imessage/profile; ZeroSettle shares them automatically with the line's first message to each engineer. - Monitoring tools post to the relay. Grafana's webhook contact point sends a
statusoffiringorresolvedand agroupKey, plus a panel screenshot URL in each alert'simageURLwhen Grafana is set up to capture screenshots and upload them to cloud storage. Datadog calls the relay from any monitor whose message includes@webhook-<NAME>, and Sentry sendsevent_alertandmetric_alertwebhooks. - The relay calls
POST /api/imessage/sendfor whoever is on call: the alert text first, which normally reaches Apple in 2–5 seconds, then the graph, if there is one, as a photo inimage_b64. - A 👍 acknowledges. It arrives as
reaction.receivedwithreactionset tolikeandtarget_idpointing at the alert, or at the graph, since tapbacks work on photos too. The relay texts "Acked by …" to everyone it paged. A ‼️ (emphasize) escalates at once. - Five minutes without a reaction sends the alert to the next engineer, and after the last one, to the second channel. When the monitor resolves, everyone paged gets a ✅.
- A failed send goes to a second channel. When a send still fails after the relay's retries, the relay pages through another channel at once. The failed attempt also reaches the webhook as
message.failed; how ZeroSettle reports delivery covers each event.
Example conversation: a latency alert at 2 AM
Devon, the on-call engineer, acknowledges a 2 AM latency alert with one 👍, and the relay tells everyone it paged who has it. This is the thread between the team's alert number, named "Ops Alerts", and Devon.
- Today 2:14 AM
- Ops Alerts: 🔴 api-prod p95 latency 2.4s (threshold 800ms) since 02:13👍
- Ops Alerts: Photo: api-prod p95 latency graph, last hour
- Ops Alerts: Acked by Devon at 02:15.
- Today 2:41 AM
- Ops Alerts: ✅ Resolved: api-prod latency back to 180ms.
reaction.received and stopped the escalation timer.Setup: an alert relay with acknowledgement and escalation
The relay is one small web service with two routes: one Grafana posts alerts to, and one that receives ZeroSettle's signed webhook. A Datadog or Sentry route only has to build the same alert record from its own payload. The signature check and send call follow the webhook steps and send reference in ZeroSettle's docs.
# oncall_relay.py: run with uvicorn oncall_relay:app --port 8100 (one worker: state is in memory)
import asyncio, base64, hashlib, hmac, json, os, time
import httpx
from fastapi import FastAPI, Request, Response
app = FastAPI()
ZS = "https://portal.zerosettle.io"
TOKEN, SECRET = os.environ["ZEROSETTLE_TOKEN"], os.environ["ZEROSETTLE_WEBHOOK_SECRET"]
# ONCALL_ROTATION="Devon:+15555550101,Priya:+15555550102", in paging order
ROTATION = [p.split(":", 1) for p in os.environ["ONCALL_ROTATION"].split(",")]
NAMES = {handle: name for name, handle in ROTATION}
RETRY = ("line_busy", "phone_offline", "phone_timeout", "no_answer")
alerts, by_msg, seen, tasks = {}, {}, set(), set() # use your database in production
def bg(coro) -> None: # keep a reference so the task isn't garbage-collected
t = asyncio.create_task(coro); tasks.add(t); t.add_done_callback(tasks.discard)
def verify(header: str, raw: bytes, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = int(parts["t"]), parts["v1"]
except Exception:
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(SECRET.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
async def second_channel(text: str) -> None:
... # your other pager: a call, email or chat
async def send(to: str, **body) -> dict | None:
async with httpx.AsyncClient(timeout=120) as c:
for wait in (2, 5, 15, None): # three retries, then give up
try:
r = await c.post(f"{ZS}/api/imessage/send", json={"to": to, **body},
headers={"Authorization": f"Bearer {TOKEN}"})
if r.status_code == 200:
return r.json()
code = r.json()["error"]["code"]
except Exception:
code = "no_answer" # network error or a reply that isn't JSON
if wait is None or code not in RETRY:
return None # recipient_opted_out, awaiting_reply: don't retry
await asyncio.sleep(wait)
async def send_graph(key: str, handle: str) -> None:
try:
async with httpx.AsyncClient(timeout=30) as c:
img = await c.get(alerts[key]["image_url"]); img.raise_for_status()
shot = await send(handle, image_b64=base64.b64encode(img.content).decode(), image_format="png")
if shot:
by_msg[shot["id"]] = key # a 👍 on the graph counts too
except Exception:
pass # the text already went out
async def page(key: str) -> None:
a = alerts.get(key)
if a is None or a["acked"]:
return
if a["step"] >= len(ROTATION):
return await second_channel(a["text"]) # the whole rotation stayed silent
step, handle = a["step"], ROTATION[a["step"]][1]
sent = await send(handle, text=a["text"])
if sent is None:
return await second_channel(a["text"])
by_msg[sent["id"]] = key; a["paged"].append(handle)
if a["image_url"]:
bg(send_graph(key, handle))
await asyncio.sleep(300) # five minutes to react
if alerts.get(key) is a and not a["acked"] and a["step"] == step:
a["step"] += 1
await page(key)
def say(alert: dict) -> str:
return alert["annotations"].get("summary") or alert["labels"]["alertname"]
@app.post("/alerts/grafana")
async def grafana(req: Request):
n = await req.json() # check Grafana's HMAC signature here too
key, firing = n["groupKey"], [x for x in n["alerts"] if x["status"] == "firing"]
if n["status"] == "resolved" and key in alerts:
for handle in set(alerts.pop(key)["paged"]):
bg(send(handle, text=f"✅ Resolved: {say(n['alerts'][0])}"))
elif firing and key not in alerts: # one page per group, not per repeat
more = f" (+{len(firing) - 1} more)" if len(firing) > 1 else ""
alerts[key] = {"text": f"🔴 {say(firing[0])}{more}", "image_url": firing[0].get("imageURL"),
"step": 0, "acked": False, "paged": []}
bg(page(key))
return {"ok": True}
@app.post("/zerosettle")
async def zerosettle(req: Request):
raw = await req.body()
if not verify(req.headers.get("X-ZeroSettle-Signature", ""), raw):
return Response(status_code=401)
ev = json.loads(raw); m = ev.get("message") or {}
if ev["event"] != "reaction.received" or m["id"] in seen:
return {"ok": True} # only tapbacks matter here; a retry repeats the id
seen.add(m["id"])
key = by_msg.get(m.get("target_id"))
a = alerts.get(key)
if a is None or a["acked"]:
return {"ok": True}
if m["reaction"] == "like": # 👍 acknowledges
a["acked"] = True
who = NAMES.get(m["handle"], m["handle"])
for handle in set(a["paged"]):
bg(send(handle, text=f"Acked by {who} at {time.strftime('%H:%M')}."))
elif m["reaction"] == "emphasize": # ‼️ escalates now
a["step"] += 1
bg(page(key))
return {"ok": True}
Register the /zerosettle route with PUT /api/imessage/webhook and keep the signing secret it returns, which ZeroSettle shows only once. Protect the Grafana route too: the webhook contact point's HMAC Signature setting signs each request with HMAC-SHA256 in an X-Grafana-Alerting-Signature header, so anyone else posting to the relay can't page your team. A team that prefers no code can receive the same webhook in Zapier, n8n or Make, but the five-minute timer still has to live somewhere.
Limits and consent for on-call alerts
On-call alerts never touch ZeroSettle's new-contact limit, because every engineer texted the line first. The limit that shapes an alert relay is throughput: a line sends one message at a time.
| Limit | Value | What it means for alerts |
|---|---|---|
| Sends per line | One at a time; a send queued for about 12 s returns 429 line_busy | Group alerts; retry after 2 s |
| Send time | Normally 2–5 s for a text; at most about 75 s, or about 105 s for a photo | Send the text before the graph |
| Photo size | 5 MB decoded; over 2048 px on the long edge is re-encoded to JPEG | A panel PNG within that goes out as sent |
| Unanswered messages | 2 on every plan, then 403 awaiting_reply | Ask engineers to tapback the ✅ too |
| Temporary outage | 503 when the line is unreachable or doesn't answer in time | Retry with backoff, then the second channel |
Source: ZeroSettle iMessage API docs, "Send a message", "Attachments" and "Errors, limits, and compliance".
The limit math for an alert storm
Say a bad deploy trips 12 alerts at once. As 12 texts and 12 graphs, that is 24 sends through one line; the texts alone take 24 to 60 seconds at 2–5 seconds each, and fired in parallel, any send waiting more than about 12 seconds comes back as line_busy. Grafana's default notification policy groups alerts by alert rule (the alertname and grafana_folder labels), so 12 instances of one rule arrive as one notification with one groupKey, and the engineer gets one text ending "(+11 more)" and one graph. Twelve different rules are twelve groups unless the policy groups by a label they share, such as service.
Why the relay needs one extra tapback
On every plan, ZeroSettle refuses a third message to a contact who has sent nothing back since the last two; messages within 5 minutes of the first count as one, so an alert and its graph are one. In the example, Devon's 👍 resets the count, "Acked by Devon" is one and the ✅ is two, so the next night's alert would get 403 awaiting_reply until Devon sends anything.
Consent, and the word STOP
The rotation is your own staff, who opted in by texting the line; keep it current and remove people when they leave. Tell every engineer that a text with the word STOP in it, even "stop paging me, on it", or a clear request like "remove me", opts them out of every number on your account: later alerts return 403 recipient_opted_out until they send START as the whole message. Silence a noisy alert in the monitoring tool, never by texting the line. How STOP and opt-outs work has the details.
When ZeroSettle is not a fit for on-call alerts
ZeroSettle is the wrong primary pager in these cases:
- You need a contractual paging SLA. ZeroSettle publishes none, and its terms say ZeroSettle "does not warrant that the services will meet your requirements or that their operation will be uninterrupted or error-free". See what 99.9% uptime means for an iMessage API.
- Large rotations with complex schedules, such as follow-the-sun handoffs, overrides and several escalation tiers. A paging platform manages schedules; this relay walks a flat list.
- Engineers on Android. ZeroSettle has no SMS fallback, so an engineer without iMessage gets nothing.
- Alert storms of many messages per second. A line sends one message at a time, at 2–5 seconds per text.
How much do iMessage on-call alerts cost?
On-call alerts cost the plan price: messaging on ZeroSettle is unlimited in both directions (counted, not capped).
| Plan | Price | Fits |
|---|---|---|
| Pro | $49/month, no setup fee | One alert number to start, for one team, self-serve; unlimited messages with people who message you first; its 5 new contacts a day go unused when engineers text first |
| Growth | From $599/mo | Several numbers working as one under one API key and webhook, each line group with its own name and photo, such as an agency with an alert number per client; set up by ZeroSettle after a demo |
Both plans are billed monthly with a 30-day money-back guarantee on the subscription, and a relay that answers its own webhook uses no AI credits. Compare ZeroSettle's plans or get a number.
Frequently asked questions
Will an alert break through Do Not Disturb or a Focus?
It can, if each engineer turns on Emergency Bypass for the alert number: in Contacts, open the saved number, tap Edit, tap Text Tone, then turn on Emergency Bypass. Apple says sounds and vibrations from that contact then come through even when the iPhone or notifications are silenced.
Does ZeroSettle tell me an engineer has read the alert?
No. message.sent means the message left the line, and no API event reports that it reached the engineer's iPhone or was read. The tapback is the proof a person saw it, which is why the relay escalates on silence.
What happens if ZeroSettle can't send an alert?
The send call returns an error with a code to branch on, such as a 503 when the line is temporarily unreachable; the relay retries with backoff, then pages through your second channel. The failed attempt is stored as failed and reaches your webhook as message.failed, so GET /api/imessage/messages shows exactly what the relay did.
Can engineers outside the US be on the rotation?
Yes, if they use iMessage. Store each number with its country code (for example +44); a number without one is treated as a US number.
Sources
- ZeroSettle iMessage API docs: "Send a message", "Attachments", "Webhooks, step by step" and "Errors, limits, and compliance". Checked October 12, 2026.
- ZeroSettle iMessage API Terms of Service, section 14 (disclaimer of warranties). Last modified September 28, 2026.
- ZeroSettle homepage: the "99.9% uptime" figure and plan prices. Checked October 12, 2026.
- Allow or silence notifications for a Focus on iPhone, iPhone User Guide, Apple Support. Checked October 8, 2026.
- Configure the webhook notifier for Alerting, Grafana documentation: payload fields and HMAC signature. Checked October 8, 2026.
- Use images in notifications, Grafana documentation: screenshots for webhooks need cloud storage. Checked October 8, 2026.
- Group alert notifications, Grafana documentation: default grouping by
alertnameandgrafana_folder. Checked October 8, 2026. - Webhooks, Datadog documentation. Checked October 8, 2026.
- Webhooks, Sentry documentation. Checked October 8, 2026.