Use cases

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/send to 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.

  1. 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.
  2. 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.
  3. Monitoring tools post to the relay. Grafana's webhook contact point sends a status of firing or resolved and a groupKey, plus a panel screenshot URL in each alert's imageURL when 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 sends event_alert and metric_alert webhooks.
  4. The relay calls POST /api/imessage/send for 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 in image_b64.
  5. A 👍 acknowledges. It arrives as reaction.received with reaction set to like and target_id pointing 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.
  6. 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 ✅.
  7. 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.

  1. Today 2:14 AM
  2. Ops Alerts: 🔴 api-prod p95 latency 2.4s (threshold 800ms) since 02:13👍
  3. Ops Alerts: Photo: api-prod p95 latency graph, last hour
  4. Ops Alerts: Acked by Devon at 02:15.
  5. Today 2:41 AM
  6. Ops Alerts: ✅ Resolved: api-prod latency back to 180ms.
Illustrative example. A fictional team. Blue bubbles are sent by the relay from the team's number; the 👍 is Devon's tapback, which reached the relay as 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.

python
# 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.

ZeroSettle limits that shape an alert relay (API docs, checked October 12, 2026)
LimitValueWhat it means for alerts
Sends per lineOne at a time; a send queued for about 12 s returns 429 line_busyGroup alerts; retry after 2 s
Send timeNormally 2–5 s for a text; at most about 75 s, or about 105 s for a photoSend the text before the graph
Photo size5 MB decoded; over 2048 px on the long edge is re-encoded to JPEGA panel PNG within that goes out as sent
Unanswered messages2 on every plan, then 403 awaiting_replyAsk engineers to tapback the ✅ too
Temporary outage503 when the line is unreachable or doesn't answer in timeRetry 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).

ZeroSettle plans for on-call alerts (zerosettle.io, checked October 12, 2026)
PlanPriceFits
Pro$49/month, no setup feeOne 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
GrowthFrom $599/moSeveral 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

  1. ZeroSettle iMessage API docs: "Send a message", "Attachments", "Webhooks, step by step" and "Errors, limits, and compliance". Checked October 12, 2026.
  2. ZeroSettle iMessage API Terms of Service, section 14 (disclaimer of warranties). Last modified September 28, 2026.
  3. ZeroSettle homepage: the "99.9% uptime" figure and plan prices. Checked October 12, 2026.
  4. Allow or silence notifications for a Focus on iPhone, iPhone User Guide, Apple Support. Checked October 8, 2026.
  5. Configure the webhook notifier for Alerting, Grafana documentation: payload fields and HMAC signature. Checked October 8, 2026.
  6. Use images in notifications, Grafana documentation: screenshots for webhooks need cloud storage. Checked October 8, 2026.
  7. Group alert notifications, Grafana documentation: default grouping by alertname and grafana_folder. Checked October 8, 2026.
  8. Webhooks, Datadog documentation. Checked October 8, 2026.
  9. Webhooks, Sentry documentation. Checked October 8, 2026.

Written by the ZeroSettle team. 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.

Product facts on this page come from the ZeroSettle iMessage API docs, the iMessage API Terms and zerosettle.io as of October 12, 2026. Outside facts come from the pages listed under Sources, checked October 8, 2026. This page is not legal advice.

Text your customers from your own iMessage number

ZeroSettle gives your business its own phone number on real iMessage, running on Apple hardware we operate for you in a server facility. Your code sends with one API call and hears every reply on a signed webhook.

Pro is $49 a month with a 30-day money-back guarantee. Growth, for several numbers working as one, is from $599/mo.