Reliability & trust

iMessage API delivery status, webhook retries and latency, explained

ZeroSettle's iMessage API tells you when a message left the line (message.sent) or was recorded but not delivered (message.failed); it has no delivered or read event. A ZeroSettle send normally returns in 2–5 seconds (at most about 75 for a text and about 105 for a photo or voice message), an inbound text reaches your webhook about 1–2 seconds after the line receives it, and a failed webhook delivery is retried after 1 s, 5 s and 20 s before ZeroSettle records the failure.

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 in a server facility, that your code texts customers from and hears back on through a bearer token and signed webhooks.
  • Status you get: message.sent (the line handed the message to Apple) and message.failed (recorded but not delivered). No delivered or read status.
  • Speed: sends normally take 2–5 s; inbound reaches your webhook in about 1–2 s.
  • Webhook retries: a 10 s timeout, then 3 retries after 1 s, 5 s and 20 s. Anything missed can be re-read from GET /api/imessage/messages.
  • Price and promises: Pro is $49/month; Growth is from $599/mo. ZeroSettle reports 99.9% uptime on its homepage, which is a stat, not an SLA.

What does “sent” mean in ZeroSettle’s API?

In ZeroSettle’s API, “sent” means the line handed the message to Apple, and that is the last status the API reports. The send call itself blocks until that hand-off, then returns "status": "message_sent" with the stored message id.

Delivery status signals in ZeroSettle’s iMessage API (docs, checked October 12, 2026)
SignalWhere it appearsWhat it means
200, message_sentSend responseThe line handed the message to Apple.
message.sentWebhook, live stream“A message left the line (from your code, the console, or MCP).”
message.failedWebhook, live stream“A send was recorded but not delivered.”
status on the stored rowSend response (record.status), GET /api/imessage/messagessent or failed for your sends: every attempt is stored.
Delivered, readNowhereThe API has no such event or field.

Your own sends come back to your webhook as message.sent, so an auto-responder should react only to message.received or it will answer itself.

Why are there no read receipts in the API?

ZeroSettle’s API has no event or field for “delivered to the recipient” or “read”, so no workflow should depend on one. ZeroSettle’s iMessage API Terms (section 7) say why such signals are weak evidence either way:

Delivery of a message does not guarantee that it was read, and the absence of a receipt does not mean a message was not delivered.

ZeroSettle iMessage API Terms, section 7

The same section adds that outputs including “message status, delivery and read receipts, typing indicators” “may be delayed, incomplete, or inaccurate,” and section 2 states that “delivery of any particular message or feature is not guaranteed.” A 200 from the send call is therefore not proof the customer got the message. The dependable signal of attention is an answer: a reply arrives as message.received, and a tapback as reaction.received. Ask for one when it matters. The on-call alert pattern uses a tapback as the acknowledgment and escalates when none arrives.

How fast is a send?

A ZeroSettle send returns once the line has handed the message to Apple: “normally 2–5 seconds and at most about 75 for a text (about 105 for a voice message or photo, which ZeroSettle converts first).” Set your HTTP client timeout above those ceilings; the docs’ examples use 90 seconds for a text and 120 for a photo or voice message.

  • One send at a time per line. Concurrent sends “queue behind each other and surface as line_busy after ~12 s of waiting.” Retry after 2 seconds. If order matters, send from one worker per line.
  • Typing is ordered with sends. POST /api/imessage/typing (ttl_s 1–60, default 25) shares the send queue, so the bubble shows before the text, and your next send to that person clears it. It is not stored, not billed and never a new contact, and works only with someone you already have a conversation with (otherwise 409 no_conversation).

The first send, end to end, is in how to send an iMessage from code without a Mac.

How fast does a reply reach you?

A customer’s text reaches your ZeroSettle webhook about 1–2 seconds after the line receives it. In the docs’ words, “Inbound is pushed; nothing polls for it, so inbound never delays a send.”

Three ways to receive inbound messages from a ZeroSettle line (docs, checked October 12, 2026)
ChannelHowTiming and notes
WebhookSigned POST to the URL you register with PUT /api/imessage/webhookAbout 1–2 s; retried 3 times on failure
PollingGET /api/imessage/messages?after_id=…Inbound appears within about 1–2 s, plus your poll interval
Live stream (SSE)Token from GET /api/imessage/stream-token, then /api/imessage/streamEvents message, delivery, typing, conversation, optout, ai_credits; a : ping after 15 s of silence; the token lives 5 minutes; the console is built on it

For an auto-reply, the documented figures add up to about 3–7 seconds from the customer’s text reaching the line to your answer leaving it (1–2 s inbound plus a 2–5 s send), plus your own lookup or model call. When that work takes more than a couple of seconds, raise the typing bubble first.

What happens when your webhook is down?

ZeroSettle waits 10 seconds for a 2xx; on a non-2xx or a timeout it retries 3 more times, after 1 s, 5 s and 20 s, then records the failure. Redirects are not followed, so a URL that answers with a redirect fails the same way.

When each webhook attempt for one message starts, computed from the documented 10 s timeout and 1/5/20 s retry delays
AttemptYour server errors at onceEvery attempt times out
1 (first delivery)0 s0 s
2about 1 sabout 11 s
3about 6 sabout 26 s
4 (last)about 26 sabout 56 s
Failure recordedabout 26 sabout 66 s

Network time adds a little to each figure.

So a receiver that refuses connections for more than about 26 seconds, during a deploy for example, can miss the webhook for a message that arrived at the start of that gap. The message itself is still stored. GET /api/imessage/line shows last_delivery (ok, status_code, error, attempts, at), and the live stream sends a delivery event with the same fields. Recover by paging forward with after_id from the cursor your last backfill saved, skipping ids your webhook already handled, and run it every time your receiver starts. Don’t use the highest id your webhook saw as the cursor: events can arrive out of order, so a missed lower id would be skipped.

python
import os, requests

BASE = "https://portal.zerosettle.io"
TOKEN = os.environ["ZEROSETTLE_TOKEN"]

def backfill(cursor: int) -> int:
    while True:
        r = requests.get(f"{BASE}/api/imessage/messages",
                         params={"after_id": cursor, "limit": 500},
                         headers={"Authorization": f"Bearer {TOKEN}"}, timeout=15)
        r.raise_for_status()
        page = r.json()
        for m in page["messages"]:             # both directions, in id order
            handle_once(m)                     # yours: skip ids already handled
        if page["next_after_id"] == cursor:    # nothing new
            return cursor                      # save it for the next run
        cursor = page["next_after_id"]

Signature checks and a complete receiver are in how to receive iMessages with a webhook.

How do you handle duplicates and ordering?

Deduplicate ZeroSettle webhook events on the pair of message.id and event, because a retry carries the same pair. Sort by message.id when order matters, because events for one line are delivered concurrently and can arrive out of order under retries.

A slow handler causes duplicates too: a receiver that works for more than 10 seconds before answering counts as a timeout, and ZeroSettle sends the same event again. Answer 2xx first and queue the work. Delivery is per line, so if two systems need the same messages, fan out from your own receiver. Verifying the X-ZeroSettle-Signature header before any of this is covered in ZeroSettle’s API security explainer.

Which errors mean retry and which mean stop?

Retry ZeroSettle’s busy and temporary-outage errors after a short wait, hold a new-contact send until retry_after_s has passed, and don’t resend the rest unchanged: they fail again until something changes: your input, the subscription, the token, or the recipient (a reply, or START after a STOP). Every error has one shape, {"error": {"code", "message", …}, "detail"}: branch on error.code. Rate-limited responses carry a Retry-After header.

ZeroSettle send errors: retry or stop (docs, checked October 12, 2026)
HTTP and codeRetry?What to do
429 line_busyYesRetry after 2 s.
429 new_contact_limitLaterWait retry_after_s. On a multi-number account it comes back only when every number is full (or the line you named is).
503 (line temporarily unreachable or no answer in time)YesRetry with backoff.
403 recipient_opted_outNoThey opted out with a text containing STOP or a clear request like “take me off your list”. Only their START (or unstop) opts them back in.
403 awaiting_replyAfter a replyEvery plan: 2 unanswered messages to that contact. Wait for their reply.
403 attachment_before_replyAfter a replyA photo or voice message to someone who has never replied. Send text until they do.
422 not_on_imessageNoApple says the new contact isn’t on iMessage. Nothing was sent and no new-contact slot was used; reach them another way.
502 error (the iMessage check for a new contact didn’t complete)YesNothing was sent. Retry in a minute.
502 send_failedNoFix the input (for example, a bad handle).
402 payment_requiredNoThe subscription is not active. Subscribe in the console.
422 invalid_requestNoFix the fields listed in error.fields.
401 unauthorizedNoCheck the token. Minting another revokes nothing; revoking revokes all.

A send that fails on the line’s side (busy, temporarily unreachable, or unable to send) keeps a stored record with status failed and reaches your webhook as message.failed, so a send that succeeds on its second try leaves a failed row and then a sent row with a new id. A STOP or awaiting_reply refusal happens before anything is sent or stored. In code:

python
import os, time, requests

BASE = "https://portal.zerosettle.io"
TOKEN = os.environ["ZEROSETTLE_TOKEN"]

class RetryLater(Exception):
    """Re-queue the send; retry_after_s can be hours away."""

def send(to: str, text: str, tries: int = 4) -> dict:
    for attempt in range(tries):
        r = requests.post(f"{BASE}/api/imessage/send", json={"to": to, "text": text},
                          headers={"Authorization": f"Bearer {TOKEN}"}, timeout=90)
        if r.ok:
            return r.json()                    # message_sent: handed to Apple
        err = r.json()["error"]
        if err["code"] == "new_contact_limit":
            raise RetryLater(err["retry_after_s"])
        if err["code"] == "error":             # 502: iMessage check didn't finish; nothing sent
            raise RetryLater(60)
        if err["code"] not in ("line_busy", "phone_offline", "phone_timeout"):
            # 401, 402, 403, 422, 502: resending the same request fails again
            raise RuntimeError(f'{r.status_code} {err["code"]}: {err["message"]}')
        if attempt + 1 < tries:
            if err["code"] == "line_busy":
                time.sleep(int(r.headers.get("Retry-After", 2)))
            else:
                time.sleep(5 * 2 ** attempt)   # 5, 10, 20 s
    raise RetryLater(60)

How do other iMessage API providers retry webhooks?

Published webhook retry policies range from a single attempt to about three hours of retries, and ZeroSettle’s window is on the short side. Each figure below comes from that provider’s own docs.

Published webhook retry policies (each provider’s docs, checked October 8, 2026)
ProviderRetriesTimeout
ZeroSettle3, after 1 s, 5 s and 20 s, on a non-2xx or timeout10 s
SendblueUp to 3, on a 5xx or no response45 s
LinqAbout 10, with exponential backoff, for up to 30 minutes, on a 5xx, 429 or network error10 s
LoopMessageUp to 30: ten every 30 s, ten every 3 min, ten every 15 min15 s
Texting BlueFour attempts: immediately, then after 10 s, 60 s and 300 s, on a 400+ status or timeoutNot stated (asks for a 200 within 5 s)
Project BlueNone: “single-attempt today”Not stated (asks for a response within about 10 s)

ZeroSettle’s last retry starts about 26 seconds after a fast failure; Linq retries for 30 minutes, and LoopMessage’s retries span about three hours. On ZeroSettle, a longer outage is covered by the stored history and the after_id cursor, not by more retries. Uptime and spam flags are on the reliability and trust hub. To try it, read the API docs, compare plans, or get a number.

Frequently asked questions

Is the webhook called on a line the hosted AI agent manages?

No. On a line group managed by ZeroSettle’s hosted AI agent (in beta), the agent answers inbound texts and the webhook is not called. Your own sends from that group are refused with 403 managed_by_zerosettle.

Do failed sends count against the new-contact limit?

No. A new contact is a handle the line delivers its first message to, and ZeroSettle’s docs say failed sends never count.

Are typing events retried like message events?

No. Inbound typing.started and typing.stopped reach the webhook only if you opt in with "typing_events": true, and they are delivered once with no retries. The live stream always carries them.

How long does ZeroSettle keep messages for a backfill?

ZeroSettle’s docs say “messages are kept indefinitely for now,” so a backfill from GET /api/imessage/messages can reach back to the start of the line’s history. The exception is a conversation you deleted: that removes its stored rows.

Does the 99.9% uptime figure mean 99.9% of messages are delivered?

No. ZeroSettle reports 99.9% uptime as a homepage stat; it is not a delivery rate or an SLA. What 99.9% uptime means covers the difference.

Sources

  1. ZeroSettle iMessage API docs: “Send a message”, “Read messages”, “Typing indicators”, “AI agent”, “Webhooks, step by step”, “Live stream (SSE)” and “Errors, limits, and compliance”. Checked October 12, 2026.
  2. ZeroSettle iMessage API Terms, sections 2 and 7. Last modified September 28, 2026.
  3. ZeroSettle homepage: the “99.9% uptime” stat. Checked October 12, 2026.
  4. Webhooks, Sendblue docs, “Retry Policy”. Checked October 8, 2026.
  5. Webhooks guide, Linq docs. Checked October 8, 2026.
  6. Conversation API webhooks, LoopMessage docs. Checked October 8, 2026.
  7. Webhooks, Texting Blue docs. Checked October 8, 2026.
  8. Webhook retries, timeouts and delivery status, Project Blue support. Checked October 8, 2026.

Changelog

  • First published. Reviewed quarterly, and after any incident or change to these figures.

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 in a server facility, 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 and iMessage API Terms as of October 12, 2026; other providers’ figures come from their own docs, checked October 8, 2026.

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.