How it works

How to send an iMessage from code without a Mac

To send an iMessage from code without a Mac, call a hosted iMessage API from any server. With ZeroSettle that is one HTTPS POST to https://portal.zerosettle.io/api/imessage/send with a bearer token, and the call returns once the line has handed the message to Apple, normally in 2–5 seconds. ZeroSettle runs the number on Apple hardware it operates in a server facility, so your code can run on Linux, Windows or a serverless function. Replies reach a signed webhook about 1–2 seconds after they reach your number.

Key takeaways

  • What you need: any machine that can make an HTTPS request, and a ZeroSettle API token. You need no Mac, no Messages app and no SDK.
  • Send: POST /api/imessage/send with to and text. The call returns once the line has handed the message to Apple, normally in 2–5 seconds.
  • Receive: a signed webhook gets each reply about 1–2 seconds after it reaches the line. Without a public URL, poll GET /api/imessage/messages instead.
  • Price: Pro is $49/month for one number. Growth is from $599/mo.
  • Main limit: on Pro, a number can send the first message to 5 new contacts a day. People who text you first are never limited, and messages with them are unlimited.
  • What it does not do: reach phones without iMessage (there is no SMS fallback), or send to group chats.

Why do most iMessage scripts need a Mac?

Most do-it-yourself iMessage scripts need a Mac because they drive the Messages app. They send through its AppleScript interface and read replies from its local database, ~/Library/Messages/chat.db, so they only work on a Mac signed in to Messages. Open-source projects such as imsg and BlueBubbles also run on that Mac; they put a command line or a server API in front of it.

What each way of sending an iMessage from code needs (each project's own docs, checked October 8, 2026)
ApproachWhere it runsHow your code reaches it
Your own AppleScriptA Mac signed in to Messages, with Full Disk Access (to read chat.db) and Automation permission for MessagesScripts on that Mac
imsg (used by OpenClaw)A Mac on macOS 14 or newer, signed in to Messages, with the same two permissionsJSON-RPC over stdio, or through an SSH wrapper from another machine
BlueBubbles ServerA Mac, or a macOS VM that "is meant to be done on Apple Hardware"A REST API with the server password in the URL, plus webhooks
ZeroSettleNothing of yoursA REST API with a bearer token, plus signed webhooks

The AppleScript row lists the permissions the imsg README gives for the same method. Sources: the imsg README, the OpenClaw iMessage setup page, the BlueBubbles FAQ and the BlueBubbles REST API guide.

With any of these, a Mac becomes part of production. OpenClaw supports iMessage "through imsg only", and "Messages must be signed in on the Mac running imsg." The BlueBubbles FAQ tells you to keep macOS awake and to restart automatically after a power failure.

A hosted iMessage API takes the Mac out of the picture. 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. In the words of ZeroSettle's homepage: "We run the Apple hardware and set up the number for you. You get an API token and a console; nothing to install or host." More in the How it works explainers.

Step 1: get a number and an API token

You sign up for a ZeroSettle number in the browser, then copy the API token from the console into an environment variable. Pro costs $49 a month for one number; the plans on zerosettle.io and ZeroSettle pricing cover Growth.

  1. Press Get started at portal.zerosettle.io/imessage and sign in with your phone number using a one-time SMS code. A number is set aside for you while you finish.
  2. Answer one question: what the number is for (sign in with iMessage, branded messages, or an AI agent). Answering claims the number.
  3. Set a brand name and photo, and text yourself a test message. You can skip the test and change the name and photo later.
  4. Subscribe.

Until the subscription is active, the API answers 402 to every call. A number that isn't paid for within 30 minutes is released, and if every number is taken you can join the waitlist. You can mint more than one token, but in the docs' words "minting again revokes nothing, revoking revokes all": revoking one token revokes every token on the account.

bash
export BASE=https://portal.zerosettle.io
export ZEROSETTLE_TOKEN='…'   # from the console; never commit it
curl -s -H "Authorization: Bearer $ZEROSETTLE_TOKEN" $BASE/api/imessage/line

A working token returns your line's state. The handle field is your number.

Step 2: send a message

To send, POST JSON with to and text to /api/imessage/send, with an Authorization: Bearer header (the send reference in the docs has every field). The call blocks until the line has handed the message to Apple, which ZeroSettle's docs put at "normally 2–5 seconds and at most about 75 for a text (about 105 for a voice message or photo, which ZeroSettle converts first)."

bash
curl -s -X POST -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"to":"+18055559876","text":"Hi from the line"}' $BASE/api/imessage/send
python
# send.py: pip install requests
import os, requests

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

def send_text(to: str, text: str) -> requests.Response:
    return requests.post(f"{BASE}/api/imessage/send",
                         headers={"Authorization": f"Bearer {TOKEN}"},
                         json={"to": to, "text": text}, timeout=90)

if __name__ == "__main__":
    r = send_text("+18055559876", "Hi from the line")
    print(r.status_code, r.json())
javascript
// send.mjs: Node 18+, no packages
const r = await fetch("https://portal.zerosettle.io/api/imessage/send", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.ZEROSETTLE_TOKEN}`, "Content-Type": "application/json" },
  body: JSON.stringify({ to: "+18055559876", text: "Hi from the line" }),
  signal: AbortSignal.timeout(90_000),
});
console.log(r.status, await r.json());

Set your client's timeout above those maximums; the docs use 90 seconds for text and 120 for media. A serverless function with a shorter time limit should pass sends to a worker. A 200 response carries "status": "message_sent", which means the message left the line, not that anyone read it (see iMessage delivery status and retries).

Fields of POST /api/imessage/send (ZeroSettle API docs, checked October 8, 2026)
FieldWhat it takes
toA phone number or an email address registered with iMessage, up to 128 characters. Include a country code (+, 00 or 011); without one, the US is assumed.
text1–4000 characters after trimming whitespace. With a photo, the text becomes its caption.
image_b64 / audio_b64A base64 photo (up to 5 MB decoded) or voice message (up to 6 MB decoded), never both in one call. A voice message goes without text.

A link sent to someone who has never texted you shows as plain text until they reply or save your number, and the response adds warning.code: "link_not_tappable" (see why first-message links aren't tappable).

Step 3: receive replies

Register one HTTPS URL with PUT /api/imessage/webhook, and ZeroSettle POSTs every message event on your number to it, signed, "about 1–2 s from the line receiving a text to your webhook." If you have no public server, you can poll for new messages instead, as shown at the end of this step.

  1. Today 10:12 AM
  2. Script: Build 1.8.2 passed every check. Reply SHIP to deploy it, or HOLD to wait.
  3. Jordan: SHIP
  4. Script: 1.8.2 is live in production.👍
Illustrative example. A deploy script on a Linux server sends the blue bubbles. Jordan's "SHIP" reaches its webhook as message.received, and the thumbs-up as reaction.received.
bash
curl -s -X PUT -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/zerosettle/webhook"}' $BASE/api/imessage/webhook

The secret in the response is shown only once, and registering again rotates it, so store it right away as ZEROSETTLE_WEBHOOK_SECRET. Each POST carries X-ZeroSettle-Signature: t=<unix>,v1=<hex>, where v1 is an HMAC-SHA256 of "<t>." plus the raw body, keyed with your secret. This is the Python verifier from the docs, which also have a Node version:

python
import hmac, hashlib, time

def verify(secret: str, header: str, raw_body: 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_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Verify the raw bytes before you parse any JSON, return a 2xx within 10 seconds, and act only on message.received. Your own sends come back as message.sent. Failed deliveries are retried after 1, 5 and 20 seconds with the same message.id and event, so dedupe on that pair (more in how to receive iMessages with a webhook and the webhook steps in the docs).

To poll instead, call GET /api/imessage/messages?after_id=… every few seconds with the last next_after_id, and handle messages whose direction is "in". Inbound messages appear there within about 1–2 seconds, though the docs prefer a webhook because it is pushed and signed.

Which errors should your code handle?

Every ZeroSettle error has the same shape, {"error": {"code", "message", …}, "detail"}. Branch on error.code, and honor the Retry-After header on rate-limited responses. The table covers a send; the docs' errors, limits and compliance reference lists every code.

Errors a ZeroSettle send can return (API docs, checked October 8, 2026)
HTTPerror.codeMeaningWhat to do
401unauthorizedThe token is missing, wrong or revoked.Fix the token; don't retry.
402payment_requiredThe subscription isn't active.Stop and subscribe.
422invalid_requestInvalid input. error.fields lists each problem.Fix the input.
403recipient_opted_outThe recipient texted STOP. Nothing was sent.Never retry.
403awaiting_replyThis contact hasn't replied to your last 2 messages (every plan). Nothing was sent.Wait for their reply.
403attachment_before_replyA photo or voice message to someone who hasn't replied yet. Nothing was sent.Send text first; attach after they reply.
422not_on_imessageA first message to a handle that isn't on iMessage. Nothing was sent and no new-contact slot was used.Reach them another way.
429new_contact_limitToo many new contacts in the window.Wait error.retry_after_s seconds.
429line_busyThe send waited about 12 s behind other sends.Retry after 2 s.
502send_failedThe line couldn't send it, for example to a bad handle. error.message says why.Fix the input.
503Two codes (see the retry code below)The line is temporarily unreachable or didn't answer in time.Retry with backoff.
python
import time
from send import send_text  # the Step 2 function

def send_with_retries(to: str, text: str, attempts: int = 5) -> dict:
    for i in range(attempts):
        r = send_text(to, text)
        body = r.json()
        if r.ok:
            return body
        err = body["error"]
        if err["code"] == "line_busy":
            delay = 2               # 429: retry after 2 s
        elif err["code"] in ("phone_offline", "phone_timeout"):
            delay = 5 * 2 ** i      # 503: back off 5, 10, 20, 40 s
        else:  # queue new_contact_limit for retry_after_s; never retry the rest
            raise RuntimeError(f"{r.status_code} {err['code']}: {err['message']}")
        if i < attempts - 1:
            time.sleep(delay)
    raise RuntimeError("gave up after retries")

What limits apply?

On ZeroSettle's Pro plan, one number can start conversations with 5 new contacts a day (3 AM to 3 AM Eastern); Growth allows 50, with at most 3 in any rolling 15 minutes. Anyone who texted the line first is never limited, and neither is an ongoing conversation. Each line sends one message at a time.

ZeroSettle sending limits per number (API docs, checked October 8, 2026)
LimitProGrowth
New contacts per day550
New contacts per rolling 15 minutes—3
Unanswered messages to one contact2 until they reply2 until they reply
Photos and voice messagesOnly after the contact has repliedOnly after the contact has replied
Sends at a timeOneOne

A new contact is "a handle the line delivers a text, voice message, or photo to first; reactions, typing, profile shares, and failed sends never count."

  • Send one at a time. On one number, sending through a thread pool or Promise.all isn't faster, because sends queue on the line and each normally takes 2–5 seconds. A send that waits more than about 12 seconds in the queue returns 429 line_busy.
  • Expect a 429 at the 6th new contact. A Pro script that texts 8 new numbers sends 5, then gets 429 new_contact_limit with retry_after_s. The last 3 wait until 3 AM Eastern.

The limits keep the number "out of Apple's spam filters", and the docs call them "a floor, not a licence": text only people who gave you their number for this purpose. Consent rules vary by country and use; this isn't legal advice.

Can a coding agent set it up for you?

Yes. Paste ZeroSettle's one-line prompt into any coding agent, or add the ZeroSettle MCP connector. The connector signs you up, mints your server's API key, and registers and tests your webhook.

prompt
implement the ZeroSettle iMessage API according to https://portal.zerosettle.io/docs.md
bash
claude mcp add --transport http zerosettle https://portal.zerosettle.io/mcp

That adds it to Claude Code. Claude on the web, desktop and mobile uses Customize → Connectors → Add custom connector, and Cursor and Grok are covered in how to connect Claude Code or Cursor to an iMessage number. The connector is a setup tool, not a way to message customers: the code it builds owns the conversation.

When a Mac-based setup is the better choice

As of October 2026, per each project's public docs, a Mac bridge such as BlueBubbles is the better choice for personal messaging with no software cost, for group chats and edits, or for texting from a number you already use.

  • No software cost. The BlueBubbles FAQ says "We don't charge anything for the use of our applications or code," though you provide the Mac that runs it. ZeroSettle starts at $49 a month.
  • Group chats, edits and unsending. ZeroSettle is one-to-one only, and group chats are on its roadmap. BlueBubbles can create group chats (macOS 11 and later) and lists editing and unsending messages, but both need its Private API, which requires you to "disable MacOS extra security measures, called System Integrity Protection (SIP)".
  • Your existing number. A Mac bridge sends as the account signed in to Messages on that Mac. A ZeroSettle number is new, and under the iMessage API Terms it remains ZeroSettle's property and is reclaimed once the subscription ends.

If your code already runs on Linux servers, CI or serverless functions, a Mac bridge adds a machine you have to keep signed in, awake and online. Self-hosting iMessage vs a managed API compares the two on cost, upkeep and reliability. To try the hosted route, get a number or read the ZeroSettle API docs.

Frequently asked questions

What happens if the recipient doesn't have iMessage?

The message can't reach them. Apple describes iMessage as messaging between iPhones and other Apple devices, and ZeroSettle has no SMS or email fallback (SMS fallback is on the roadmap). ZeroSettle checks every first message to a new contact with Apple: if the handle isn't on iMessage, the send returns 422 not_on_imessage, nothing is sent and no new-contact slot is used.

Can I schedule a message for later?

Not through ZeroSettle, which sends only when your code calls it. Use a cron job or a task queue to call POST /api/imessage/send at the right time.

Can more than one script or server use the same number?

Yes. Each system can have its own token (though revoking one revokes them all), and their sends share the line's one-at-a-time queue. Webhooks go to one registered URL, so if two systems need the replies, have your receiver pass them on.

Do I have to keep a server running?

Not to send: any script or CI job that can make an HTTPS call can send. To receive, you need a webhook URL or a poller. Without either, the hosted AI agent (Beta) can answer texts, using one AI credit per text it sends (Pro includes 1,000 a month); while it manages a number, your own API sends from it are refused.

Sources

  1. ZeroSettle iMessage API docs: "Your iMessage number", "Authentication", "Send a message", "Attachments", "Read messages", "AI agent", "Webhooks, step by step", "Connect your AI agent" and "Errors, limits, and compliance". Checked October 8, 2026.
  2. ZeroSettle homepage, FAQ "Do I need my own iPhone or Mac?" and plans. Checked October 8, 2026.
  3. iMessage API Terms, ZeroSettle, §6 "Phone Numbers" (last modified September 28, 2026). Checked October 8, 2026.
  4. BlueBubbles FAQ, BlueBubbles. Checked October 8, 2026.
  5. Private API installation, BlueBubbles docs. Checked October 8, 2026.
  6. REST API and webhooks, BlueBubbles docs. Checked October 8, 2026.
  7. iMessage channel and iMessage setup, OpenClaw docs. Checked October 8, 2026.
  8. imsg README, GitHub. Checked October 8, 2026.
  9. What is the difference between iMessage, RCS, and SMS/MMS?, Apple Support. 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 zerosettle.io homepage and the iMessage API Terms as of October 8, 2026; limits were updated October 12, 2026. Facts about other projects come from their own docs, checked the same day. Nothing here is 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.