Give your AI agent an iMessage number (bring your own model)
ZeroSettle gives an AI assistant its own iMessage number: someone texts it, your webhook fires, you call your own model with your own keys, and the answer goes back through POST /api/imessage/send, so the model, prompt and keys never touch ZeroSettle. Inbound texts are unlimited, anyone who texts the number first is never limited, and photos and voice messages arrive with files you can pass to vision and speech models. To skip the server, use ZeroSettle's hosted AI agent (Beta) instead. Pro is $49 a month for one number.
Key takeaways
- What it is: an iMessage number for your assistant. ZeroSettle carries the messages; your server runs the model.
- Who it fits: AI startups and developers building assistants, companions, tutors or concierge services on Claude, GPT or open-weight models, for users on iPhone.
- Your keys stay yours: the model, prompt, provider account and API keys never touch ZeroSettle, and a line your own server answers uses no ZeroSettle AI credits.
- Price: Pro is $49/month for one number. Growth, for several numbers working as one, is from $599/mo.
- Main limit: replies to people who text first are never limited. A first text the agent sends to someone who never texted is a new contact: 5 a day per number on Pro, 50 on Growth.
- What it does not do: group chats, SMS fallback for Android, or sending video and files.
Who uses iMessage for AI agents?
iMessage for AI agents fits assistants, companions, tutors and concierge services that people should reach by texting a number instead of installing an app. 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.
The usual alternative to an app, US carrier SMS from a 10-digit number, needs A2P 10DLC brand and campaign registration (Twilio says registering lowers filtering) and shows green bubbles, while Apple lists typing indicators and Tapbacks among iMessage's features. For an assistant only you will text, see build a personal AI assistant you text on iMessage.
How iMessage for AI agents works, step by step
An AI agent on a ZeroSettle number is a webhook receiver that acknowledges each text at once, plus a worker per person that answers it.
- Register your receiver with
PUT /api/imessage/webhookand store the signing secret (shown once). - On
message.received, verifyX-ZeroSettle-Signature, dedupe onmessage.idandevent, queue the message per handle and return200at once. The webhook times out after 10 seconds and is retried, so a slow model call in the handler can answer twice (webhook guide). - The worker waits a few seconds for the rest of a burst (or, with
"typing_events": true, for theirtyping.stopped), so “hey”, “quick q” and “can I change my delivery day” get one answer. It handles STOP before any model call. - It calls
POST /api/imessage/typing(ttl_sup to 60, re-sent about every 20 seconds) and rebuilds the thread fromGET /api/imessage/messages?handle=…: inbound rows are user turns, outbound rows assistant turns. - It calls your model with your key and sends one
POST /api/imessage/sendper bubble, in order; the first send clears the typing bubble. - It ignores each send's
message.sentecho, or it would answer itself.
To scaffold this from Claude Code or Cursor, run the ZeroSettle MCP connector's build-agent prompt (MCP guide). To skip the server, ZeroSettle's hosted AI agent (Beta) answers from a business context you write.
What your model receives from photos and voice messages
ZeroSettle converts inbound media first, so most files go straight to a model. Fetch each from its url with your bearer token.
| The user sends | Your webhook gets | Pass it to |
|---|---|---|
| A photo | JPEG for HEIC or oversized images; JPEG, PNG or GIF within 4 MB and 4032 px as sent; a Live Photo's still | A vision model; Claude takes JPEG, PNG, GIF and WebP, not HEIC |
| A voice message | An .m4a (AAC) file with duration_ms | A speech model; OpenAI's transcription takes m4a up to 25 MB |
| Several photos | Only the first | Ask for the rest one at a time |
| A video or document | A message row with no file | A reply saying the agent can't open it |
Example conversation: an AI travel assistant
A ZeroSettle agent texts like a person: a typing bubble, short bubbles, and answers about photos.
- Today 8:14 PM
- Customer: can you plan me 3 days in Lisbon in March? I love food and hate crowds
- Pathfinch is typing
- Pathfinch: Love it. Day 1: Alfama early, before the tour groups, then lunch at a tasca in Graça.
- Pathfinch: Want me to do days 2 and 3 the same way?
- Customer: Photo: a restaurant storefront
- Customer: is this place any good?
- Pathfinch: It’s a local favorite. Go right at 12:30 when it opens, no reservations ❤️
Setup: the worker pattern for bursts, typing and STOP
This Python version adapts the AI agent section of the ZeroSettle docs with Claude as the model; another provider changes only the model call and its message format. Register the receiver, then put the returned secret in ZEROSETTLE_WEBHOOK_SECRET:
curl -s -X PUT -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H 'Content-Type: application/json' \
-d '{"url":"https://agent.example.com/webhook"}' https://portal.zerosettle.io/api/imessage/webhook
# agent.py: run with uvicorn agent:app --port 8100
import asyncio, base64, hashlib, hmac, json, os, re, time
import anthropic, httpx
from fastapi import FastAPI, Request, Response
BASE = "https://portal.zerosettle.io"
TOKEN, SECRET = os.environ["ZEROSETTLE_TOKEN"], os.environ["ZEROSETTLE_WEBHOOK_SECRET"]
SYSTEM = "You are Pathfinch, an AI travel planner. Plain text; one to three short bubbles split by blank lines."
app, llm = FastAPI(), anthropic.AsyncAnthropic() # reads ANTHROPIC_API_KEY
zs = httpx.AsyncClient(base_url=BASE, headers={"Authorization": f"Bearer {TOKEN}"}, timeout=90)
seen, queues, muted = set(), {}, set() # keep seen and muted in your database
VISION = ("image/jpeg", "image/png", "image/gif", "image/webp")
def verify(header: str, raw: bytes) -> bool:
try:
p = dict(x.split("=", 1) for x in header.split(",")); t = int(p["t"])
except Exception:
return False
if abs(time.time() - t) > 300:
return False
exp = hmac.new(SECRET.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(exp, p["v1"])
@app.post("/webhook")
async def webhook(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)
if ev["event"] != "message.received" or (ev["message"]["id"], ev["event"]) in seen:
return {"ok": True} # message.sent echoes and retries
m = ev["message"]; seen.add((m["id"], ev["event"]))
if m["handle"] not in queues:
queues[m["handle"]] = asyncio.Queue(); asyncio.create_task(worker(m["handle"]))
queues[m["handle"]].put_nowait(m)
return {"ok": True}
async def worker(handle: str):
while True:
burst = [await queues[handle].get()]
try: # wait for 3 s of quiet
while True: burst.append(await asyncio.wait_for(queues[handle].get(), timeout=3))
except asyncio.TimeoutError:
pass
try: await reply(handle, burst)
except Exception as e: print("reply failed", handle, repr(e))
async def reply(handle: str, burst: list[dict]):
burst.sort(key=lambda m: m["id"]) # retries can arrive out of order
for m in burst: # STOP before any model call
t = (m.get("text") or "").strip().lower()
if re.search(r"\bstop\b", t): muted.add(handle)
elif t in ("start", "unstop"): muted.discard(handle)
if handle in muted:
return
typing = asyncio.create_task(keep_typing(handle))
try:
r = await llm.beta.messages.create(
model="claude-opus-5-5", max_tokens=16000, output_config={"effort": "low"},
betas=["server-side-fallback-2026-07-01"], extra_body={"fallbacks": "default"},
system=[{"type": "text", "text": SYSTEM, "cache_control": {"type": "ephemeral"}}],
messages=await thread(handle, burst[0]["id"], burst[-1]["id"]))
finally:
typing.cancel()
text = "".join(b.text for b in r.content if b.type == "text").strip()
if r.stop_reason == "refusal" or not text:
await zs.post("/api/imessage/typing", json={"to": handle, "state": "stop"}); return
for bubble in [b.strip() for b in text.split("\n\n") if b.strip()][:3]:
await send(handle, bubble)
async def keep_typing(handle: str):
while True:
await zs.post("/api/imessage/typing", json={"to": handle, "state": "start", "ttl_s": 60})
await asyncio.sleep(20)
async def thread(handle: str, first: int, last: int) -> list[dict]:
rows = (await zs.get("/api/imessage/messages", params={
"handle": handle, "before_id": last + 1, "limit": 40})).json()["messages"]
out = [{"role": "user", "content": [{"type": "text", "text": "(conversation so far)"}]}]
for r in rows:
if r["status"] == "failed" or r["kind"] == "reaction":
continue
role = "user" if r["direction"] == "in" else "assistant"
text = "[voice message]" if r["kind"] == "audio" else r["text"] or "[video or file]"
blocks = [{"type": "text", "text": text}]
if (r["kind"] == "image" and r.get("image") and role == "user" and r["id"] >= first
and r["image"]["content_type"] in VISION):
img = (await zs.get(r["image"]["url"])).content
blocks.insert(0, {"type": "image", "source": {"type": "base64",
"media_type": r["image"]["content_type"], "data": base64.b64encode(img).decode()}})
if out[-1]["role"] == role: out[-1]["content"] += blocks
else: out.append({"role": role, "content": blocks})
return out
async def send(handle: str, text: str):
for attempt in range(4):
r = await zs.post("/api/imessage/send", json={"to": handle, "text": text[:4000]})
if r.status_code == 200: return
err = r.json().get("error", {})
if r.status_code not in (429, 503): raise RuntimeError(f"send failed: {err.get('code')}")
await asyncio.sleep(err.get("retry_after_s") or 2 * (attempt + 1))
raise RuntimeError(f"send failed after retries: {err.get('code')}")
fallbacks retries a declined request on another Claude model. Voice messages reach the model as “[voice message]”; to let it hear one, transcribe the file at audio.url first. To test, text the line from your own phone: the “…” should appear 3–5 seconds after your last text, then the reply. If nothing appears, check last_delivery in GET /api/imessage/line.
Limits and consent for AI agents
Replies to people who text the agent first are never capped, and links in them are tappable. Limits apply when the agent texts someone first, which needs their opt-in, and, on every plan, after two unanswered messages.
| Limit | Pro | Growth |
|---|---|---|
| Replies to people who texted first | Unlimited | Unlimited |
| New contacts per number per day (3 AM to 3 AM Eastern) | 5 | 50 |
| New contacts per number, rolling 15 minutes | — | 3 |
| Unanswered messages to one contact | 2, then 403 awaiting_reply | 2, then 403 awaiting_reply |
A new contact is a handle the line delivers a text, voice message or photo to first.
The limit math for an AI agent
- A daily check-in to 50 opted-in users who never texted: on one Pro number the first texts take ten days (5 a day); then each is an ongoing conversation. A link in a first text stays plain text until they reply or save the number.
- The same check-in on one Growth number: all 50 first texts go out in about four hours (3 per 15 minutes). On any plan, a third check-in in a row with no answer returns
403 awaiting_replyuntil they send anything, a tapback included. - Many people at once: a line sends one message at a time, normally in 2–5 seconds, so a three-bubble answer holds it for about 6–15 seconds; a send that waits about 12 seconds gets
429 line_busy.
Disclosure, opt-in and STOP
ZeroSettle's terms require “transparent disclosure of AI interaction in systems like chatbots, unless it is contextually evident”: say it's an AI in the name you share or in its first reply. Text first only people who opted in, and send nothing promotional before 8 am or after 9 pm in their time zone. This isn't legal advice.
A text with the word STOP in it, or a clear request like “take me off your list”, opts the sender out of every number on your account; sends, typing and tapbacks to them return 403 recipient_opted_out. “Can we stop in Sintra on day 2?” counts too, so tell users that texting START turns the assistant back on (how STOP works).
When ZeroSettle is not a fit for AI agents
ZeroSettle is the wrong transport for an AI agent when:
- You need group chats. Conversations are one to one; group chats are on the roadmap.
- Your users are on Android. There is no SMS fallback, and an
sms:link opens their SMS app, so keep email or web chat. WhatsApp is no easy swap: as of October 2026, Meta's published terms bar AI providers from its business platform when AI is the primary functionality, as Meta decides, except in some countries (channels compared). - The agent must send video or files. It sends text, photos (5 MB) and voice messages (6 MB).
- You want proactive outreach at scale. New contacts are capped per number per day, and the terms prohibit high-volume unsolicited messaging.
- You need read status. For your sends the API reports
message.sent(handed to Apple) ormessage.failed, never delivered or read.
How much does iMessage for AI agents cost?
A bring-your-own-model agent costs the ZeroSettle plan plus your model provider's bill; ZeroSettle doesn't charge per message, and AI credits apply only to the hosted agent.
| Plan | Price | What it includes |
|---|---|---|
| Pro | $49/month | One number, 5 new contacts a day, unlimited messages with people who message you first, no setup fee, 30-day money-back guarantee; 1,000 AI credits a month |
| Growth | From $599/mo | 3+ numbers working as one, dedicated support, set up after a demo; 5,000 AI credits a month |
| AI credit packs | 1,000 for $50 or 5,000 for $200 | Hosted agent only; never expire |
See the plans on zerosettle.io, read the ZeroSettle docs, or get a number. More workflows are in 50 ways businesses use iMessage.
Frequently asked questions
Can I switch between my own model and the hosted agent?
Yes, per line group. Under Managed by AI Agent, inbound texts no longer reach your webhook and your sends return 403 managed_by_zerosettle; setting managed_by back to "api" hands the conversations back to your server.
Can the agent reply with a photo or a voice message?
Yes. POST /api/imessage/send takes image_b64 (up to 5 MB, optional caption) or audio_b64 (up to 6 MB, no caption), and text-to-speech output works as-is.
Can the agent answer with a tapback instead of a text?
Yes. Call POST /api/imessage/reactions with the message_id of their text and love, like, dislike, laugh, emphasize or question. The docs suggest giving the model a react tool, so “thanks!” gets a ❤️, not a paragraph.
Sources
- ZeroSettle iMessage API docs: “AI agent”, “Send a message”, “Typing indicators”, “Attachments”, “Read messages”, “Webhooks, step by step”, “Connect your AI agent” and “Errors, limits, and compliance”. Checked October 12, 2026.
- ZeroSettle iMessage API Terms of Service, section 4 (AI disclosure) and section 13 (prohibited uses). Last modified September 28, 2026.
- Meta Terms for WhatsApp Business Platform, section 4.7 (AI Providers), Meta. Last modified September 23, 2026; checked October 8, 2026.
- Vision, Claude API docs, Anthropic: supported image formats. Checked October 8, 2026.
- File transcription, OpenAI API docs: supported input formats and file size. Checked October 8, 2026.
- Programmable Messaging and A2P 10DLC, Twilio. Checked October 8, 2026.
- What is the difference between iMessage, RCS, and SMS/MMS?, Apple Support. Checked October 8, 2026.