Guides

Send an iMessage From Code: Python, Node.js and curl

To send an iMessage from code, POST JSON with to and text to https://portal.zerosettle.io/api/imessage/send with a bearer token; the same call works from Python requests, Node.js fetch or curl. ZeroSettle Pro is $49/month for a dedicated iMessage number that can message first, and replies reach your webhook, signed with HMAC-SHA256, about 1–2 seconds after they reach your number.

Key takeaways

  • Send: POST /api/imessage/send with Authorization: Bearer <token> and {"to": "…", "text": "…"}. The call returns once the line has handed the message to Apple, normally in 2–5 seconds, so set a 120-second client timeout (a photo or voice message can take about 105 seconds).
  • Receive: register one URL with PUT /api/imessage/webhook. Each event carries X-ZeroSettle-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of "<t>." plus the raw body; reject a t more than 300 seconds from now.
  • Errors: branch on error.code. Retry only line_busy and the two 503 codes; never retry awaiting_reply, attachment_before_reply, not_on_imessage or recipient_opted_out; wait retry_after_s on new_contact_limit; stop on 402.
  • Price: $49/month flat, no per-message fees on API texts, for one dedicated number. Video sends use credits.
  • Main limit: on Pro a number can message 5 new contacts a day. People who text first, and ongoing conversations, are never limited, up to 10,000 messages a day per number.

What do you need before you send?

You need a ZeroSettle number with an active subscription and an API token from the console; nothing else to install. How to send an iMessage from code without a Mac walks through signup and explains why no Mac is involved. This page is its code-first companion: the same API in curl, Python and Node.js, with webhook verification and error handling you can paste.

  1. Press Get started at portal.zerosettle.io/imessage, sign in with your phone number, answer one question about what the number is for, set a brand name and photo, and subscribe. Until the subscription is active, every call returns 402.
  2. Copy the API token from the console into an environment variable. Never commit it.
  3. Check the token with GET /api/imessage/line. The handle field is your number, and usage.new_contacts shows how many of today's new contacts you have used.
bash
export BASE=https://portal.zerosettle.io
export ZEROSETTLE_TOKEN='pt_mcp_…'   # from the console
curl -s -H "Authorization: Bearer $ZEROSETTLE_TOKEN" $BASE/api/imessage/line

Text only people who gave you their number for this purpose. ZeroSettle enforces STOP for you; the TCPA consent guide covers the rest.

How do you send an iMessage with curl?

With curl, a send is one POST with a bearer token and a JSON body ("Send a message" in the docs). to takes a phone number or an email address registered with iMessage, up to 128 characters; a number without a country code is treated as a US number. text is 1–4000 characters.

bash
curl -s -w '\nHTTP %{http_code}\n' -X POST \
  -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"to":"+18055559876","text":"Hi Sam, your order is ready for pickup. Reply here with any questions."}' \
  $BASE/api/imessage/send

A 200 looks like this. id is the stored message id, and the same record reaches your webhook as message.sent:

json
{"ok": true, "status": "message_sent", "message": "…", "id": 43,
 "record": {"id": 43, "direction": "out", "handle": "+18055559876", "text": "Hi Sam, …",
            "status": "sent", "guid": null, "ts_ms": 1757400000000}}

A photo goes through the same endpoint with image_b64 and image_format, and text becomes its caption. Photos, videos, voice messages and polls go only to contacts who have replied at least once (see 403 attachment_before_reply).

bash
# photo.jpg up to 5 MB; the body goes in a file because base64 is too long for an argument
printf '{"to":"+18055559876","image_format":"jpg","text":"Here it is","image_b64":"%s"}' \
  "$(base64 < photo.jpg | tr -d '\n')" > body.json
curl -s -X POST -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H 'Content-Type: application/json' \
  --data-binary @body.json $BASE/api/imessage/send

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

How do you send an iMessage from Python?

In Python, use requests.post with the bearer header, a JSON body and a timeout above the docs' maximum wait (about 75 seconds for a text, about 105 for a photo or voice message). This module turns every non-2xx answer into an exception that carries the API's error object, so the caller can branch on error.code.

python
# zerosettle.py: pip install requests
import os
import requests

BASE = os.environ.get("ZEROSETTLE_BASE_URL", "https://portal.zerosettle.io")
TOKEN = os.environ["ZEROSETTLE_TOKEN"]


class SendError(Exception):
    def __init__(self, status: int, error: dict):
        super().__init__(f"{status} {error.get('code')}: {error.get('message')}")
        self.status = status
        self.code = error.get("code")
        self.error = error


def send(to: str, **fields) -> dict:
    """POST /api/imessage/send. fields: text, image_b64 + image_format, reply_to, ..."""
    r = requests.post(f"{BASE}/api/imessage/send",
                      headers={"Authorization": f"Bearer {TOKEN}"},
                      json={"to": to, **fields}, timeout=120)
    try:
        body = r.json()
    except ValueError:  # a gateway page, not JSON: the send may still have gone
        raise SendError(r.status_code, {"code": "no_json", "message": r.text[:200]})
    if r.ok:
        return body
    raise SendError(r.status_code, body.get("error", {}))


if __name__ == "__main__":
    print(send("+18055559876", text="Hi from Python"))

If the call times out or the answer isn't JSON, the message may still have been sent. Check GET /api/imessage/messages before you send it again.

How do you send an iMessage from Node.js?

In Node.js 18 or later, the built-in fetch is enough: no packages. AbortSignal.timeout(120_000) gives the call the same 120-second ceiling, and a non-2xx answer throws an Error carrying the API's error object.

javascript
// zerosettle.mjs: Node 18+, no packages
const BASE = process.env.ZEROSETTLE_BASE_URL ?? "https://portal.zerosettle.io";
const TOKEN = process.env.ZEROSETTLE_TOKEN;

export async function send(to, fields) {
  const r = await fetch(`${BASE}/api/imessage/send`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ to, ...fields }),
    signal: AbortSignal.timeout(120_000),
  });
  const body = await r.json().catch(() => null); // null: not JSON, the send may still have gone
  if (r.ok && body) return body;
  const error = body?.error ?? { code: "no_json", message: `HTTP ${r.status}` };
  throw Object.assign(new Error(`${r.status} ${error.code}: ${error.message}`), { status: r.status, error });
}

// node -e 'import("./zerosettle.mjs").then(m => m.send("+18055559876", { text: "Hi from Node" })).then(console.log)'

How do you receive replies and verify the signature?

Register one HTTPS URL with PUT /api/imessage/webhook, store the secret it returns, and check every POST's X-ZeroSettle-Signature header against the raw request body before you parse it ("Webhooks, step by step" in the docs).

bash
curl -s -X PUT -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/webhook"}' $BASE/api/imessage/webhook
# {"url": "https://example.com/webhook", "secret": "whsec_…", "typing_events": false,
#  "instant_typing": true, "usage": "Verify X-ZeroSettle-Signature with this secret. Shown once."}

The secret is shown once, and registering again, even with the same URL, rotates it. Save it as ZEROSETTLE_WEBHOOK_SECRET. A delivery looks like this; the body is compact JSON with sorted keys:

http
POST https://example.com/webhook
Content-Type: application/json
User-Agent: ZeroSettle-iMessage/1
X-ZeroSettle-Event: message.received
X-ZeroSettle-Signature: t=1757400012,v1=3f0c…(64 hex chars)

{"event":"message.received","message":{"direction":"in","guid":"5A6F…","handle":"+18055559876","id":44,"status":"received","text":"hey!","ts_ms":1757400012000}}

The rules for a receiver, from the docs:

  • Signature: HMAC-SHA256 with your secret over "<t>." plus the raw body bytes, compared in constant time. Reject a t more than 300 seconds from now.
  • Answer fast: return any 2xx within 10 seconds and do slow work afterwards. A non-2xx or a timeout is retried 3 more times, after 1, 5 and 20 seconds.
  • Dedupe: a retry carries the same message.id and event, so dedupe on that pair.
  • No loops: act only on message.received. Your own sends come back as message.sent; other events include message.failed, reaction.received and poll.voted.

Python receiver

The verify function is the docs' Python verifier, unchanged.

python
# receiver.py: pip install fastapi uvicorn; run: uvicorn receiver:app --port 8100
import hashlib, hmac, json, os, time
from fastapi import FastAPI, Request, Response

SECRET = os.environ["ZEROSETTLE_WEBHOOK_SECRET"]
app = FastAPI()
seen: set[tuple[int, str]] = set()  # use your database in production


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)


@app.post("/webhook")
async def webhook(request: Request):
    raw = await request.body()  # the raw bytes, before any JSON parsing
    if not verify(SECRET, request.headers.get("X-ZeroSettle-Signature", ""), raw):
        return Response(status_code=401)
    payload = json.loads(raw)
    if payload["event"] != "message.received":
        return Response(status_code=200)  # message.sent echoes your own sends
    msg = payload["message"]
    key = (msg["id"], payload["event"])
    if key in seen:
        return Response(status_code=200)  # a retry of one already handled
    seen.add(key)
    print(msg["handle"], msg.get("text"))  # hand real work to a queue
    return Response(status_code=200)

Node.js receiver

The verify function is the docs' Node verifier, written as an ES module. The server uses only node:http so the body stays as raw bytes; with Express, mount the route with express.raw({ type: "application/json" }) for the same reason.

javascript
// receiver.mjs: Node 18+, no packages; run: node receiver.mjs
import http from "node:http";
import crypto from "node:crypto";

const SECRET = process.env.ZEROSETTLE_WEBHOOK_SECRET;
const seen = new Set(); // use your database in production

function verify(secret, header, rawBody, tolerance = 300) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || "");
  if (!m) return false;
  const t = Number(m[1]);
  if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(`${t}.`).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}

http.createServer((req, res) => {
  if (req.method !== "POST" || req.url !== "/webhook") return res.writeHead(404).end();
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const raw = Buffer.concat(chunks);
    if (!verify(SECRET, req.headers["x-zerosettle-signature"], raw)) return res.writeHead(401).end();
    const payload = JSON.parse(raw.toString("utf8"));
    res.writeHead(200).end(); // answer first, work after
    if (payload.event !== "message.received") return; // message.sent echoes your own sends
    const key = `${payload.message.id}:${payload.event}`;
    if (seen.has(key)) return; // a retry of one already handled
    seen.add(key);
    console.log(payload.message.handle, payload.message.text);
  });
}).listen(8100);

For local development, expose port 8100 with cloudflared tunnel --url http://localhost:8100 or ngrok http 8100 and register the https:// URL it prints. GET /api/imessage/line shows the last delivery attempt in last_delivery. How to receive iMessages with a webhook covers every event, and delivery status and retries explains what message.sent does and doesn't promise.

How should your code handle 402, 403, 422 and 429?

Branch on error.code, not only on the HTTP status: several codes share a status and need opposite handling. Every error on /api/imessage/* has the same shape ("Errors, limits, and compliance" in the docs):

json
{"error": {"code": "new_contact_limit",
           "message": "This line can message at most 3 new contacts per 15m (3 already in this window). Existing conversations are not limited. Try again in 412 s.",
           "limit": 3, "window": "15m", "used": 3, "retry_after_s": 412},
 "detail": "This line can message at most 3 new contacts per 15m …"}

That example is a Growth number's 15-minute window; on Pro the window is "day" and the message ends with how to get more new contacts a day. Show error.message to a human, and branch on the code:

Send errors to handle in code (ZeroSettle API docs, checked October 25, 2026)
HTTPerror.codeWhat happenedWhat your code should do
402payment_requiredThe number's subscription is not active.Stop sending and tell a human to subscribe in the console.
402ai_credits_exhaustedA video send with fewer than 10 credits left. Nothing was sent.Buy credits at error.buy_url or wait for error.resets_at; text still works.
403awaiting_replyYou already sent this contact 2 messages and they have never replied. Messages within 5 minutes of each other count as one. Nothing was sent or stored.Don't retry. Wait for their first reply; after it, the limit is gone for that contact.
403attachment_before_replyA photo, video, voice message or poll to a contact who has never sent your number anything. error.kind says which. Nothing was sent or stored.Send text until they reply, then the attachment.
403recipient_opted_outThey texted STOP. Nothing was sent or stored.Never retry. Only their START opts them back in.
422not_on_imessageA first message to someone Apple says isn't on iMessage. Nothing was sent or stored, and no new-contact slot was used.Don't retry. Reach them another way; there is no SMS fallback.
422invalid_requestA bad to, text or other field. error.fields lists each one.Fix the input.
429new_contact_limitToday's new contacts are used up (5 a day per number on Pro).Queue the send for error.retry_after_s seconds. Existing conversations still work.
429line_busyThe send waited about 12 seconds behind other sends on the same number.Retry after 2 seconds.
503phone_offline / phone_timeoutThe line was briefly unreachable or didn't answer in time.Retry with backoff.

The docs list more codes, mostly for videos, polls, replies and effects on phones that need a software update. A 401 unauthorized means a missing, wrong or revoked token.

Error handling in Python

This wraps send from the Python section. It retries only what is safe to retry and returns what happened, so your queue or CRM can record it.

python
import time
from zerosettle import SendError, send


def deliver(to: str, **fields) -> dict:
    for attempt in range(5):
        try:
            return {"outcome": "sent", "id": send(to, **fields)["id"]}
        except SendError as e:
            if e.code == "line_busy":                              # 429
                time.sleep(2)
            elif e.code in ("phone_offline", "phone_timeout"):     # 503: 5 s, doubling
                time.sleep(5 * 2 ** attempt)
            elif e.code == "new_contact_limit":                    # 429: try again later
                return {"outcome": "retry_later", "after_s": e.error["retry_after_s"]}
            elif e.code == "awaiting_reply":                       # 403: 2 unanswered already
                return {"outcome": "wait_for_reply"}
            elif e.code == "attachment_before_reply":              # 403: text until they reply
                return {"outcome": "send_text_first", "kind": e.error.get("kind")}
            elif e.code in ("not_on_imessage", "recipient_opted_out"):  # 422 / 403
                return {"outcome": "do_not_contact", "code": e.code}
            else:                         # 402, 401, invalid_request, no_json: stop, tell a human
                raise
    return {"outcome": "gave_up"}


print(deliver("+18055559876", text="Hi Sam, your order is ready for pickup."))

Error handling in Node.js

The same logic around send from the Node.js section.

javascript
// deliver.mjs
import { send } from "./zerosettle.mjs";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function deliver(to, fields) {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      return { outcome: "sent", id: (await send(to, fields)).id };
    } catch (e) {
      const code = e.error?.code; // undefined for a timeout: the send may still have gone
      if (code === "line_busy") await sleep(2_000);                                       // 429
      else if (code === "phone_offline" || code === "phone_timeout") await sleep(5_000 * 2 ** attempt); // 503
      else if (code === "new_contact_limit") return { outcome: "retry_later", afterS: e.error.retry_after_s };
      else if (code === "awaiting_reply") return { outcome: "wait_for_reply" };            // 403
      else if (code === "attachment_before_reply") return { outcome: "send_text_first", kind: e.error.kind };
      else if (code === "not_on_imessage" || code === "recipient_opted_out") return { outcome: "do_not_contact", code };
      else throw e; // 402, 401, invalid_request, no_json, timeouts: stop, tell a human
    }
  }
  return { outcome: "gave_up" };
}

Two habits keep those errors rare. Send in order from one worker per number: a number sends one message at a time, so Promise.all or a thread pool only produces line_busy. And ask for a reply in the first message ("Reply YES and I'll send the link"), because awaiting_reply and attachment_before_reply both clear once the contact answers.

What does sending from code cost, and what are the limits?

ZeroSettle Pro is $49/month for a dedicated iMessage number that can message first. No managed iMessage API we checked publishes a lower price for a dedicated number (checked October 25, 2026). It is $49/month flat, no per-message fees on API texts; only video sends (10 credits per recipient) and texts written by the hosted AI agent use credits, and Pro includes 500 a month.

Pro opens 5 new contacts a day per number, fewer than the 30–50 most $99+ plans allow; people who text first are never limited. Each number handles up to 10,000 messages a day with known contacts, and at most 2 unanswered messages go to any contact until they reply. Growth is $599/month for 3 numbers that work as one, plus $199 per extra number, with 50 new contacts a day per number; you sign up from a Growth link (book a demo to get one). Both plans have a 30-day money-back guarantee, no setup fee, and are month to month.

What the price doesn't include: an SMS fallback (phones without iMessage get 422 not_on_imessage). For other providers' prices, see the most affordable iMessage API options, with sources and iMessage API pricing compared; the ZeroSettle iMessage API page has the full feature list. To let a coding agent wire all of this up instead, see how to connect Claude Code or Cursor with MCP.

How we checked

We wrote this guide on October 25, 2026 from ZeroSettle's API reference as published that day (portal.zerosettle.io/docs.md).

  • Endpoints, headers, JSON fields, the signature format, retry timings and error codes are copied from the reference. Both verify functions are the docs' own verifiers; the Node one is converted from require to import.
  • We syntax-checked every Python and JavaScript sample on this page. We did not send live messages for this post.
  • Prices and limits come from the "Your iMessage number" and "Errors, limits, and compliance" pages. The price comparison sentence rests on the competitor pricing pages we checked on October 25, 2026, listed in our pricing roundup.

When the API changes, we update the samples and log it in the changelog.

Frequently asked questions

Do I need an SDK to send an iMessage from Python or Node.js?

No. Apple publishes no API for sending iMessages from a phone number, and ZeroSettle's API is plain HTTPS and JSON with a bearer token. The sending examples on this page use only the requests library in Python and, in Node.js 18 and later, the built-in fetch. The webhook receivers use FastAPI in Python and only the built-in http and crypto modules in Node.js.

Can I receive iMessage replies without a public URL?

Yes. Poll GET /api/imessage/messages?after_id=… every few seconds, pass the previous response's next_after_id each time, and handle messages whose direction is "in". Inbound messages appear there within about 1–2 seconds. A webhook is still better: it is pushed and signed.

Can I send several iMessages in parallel from one number?

You can make the calls, but they won't go out faster. A number sends one message at a time, so concurrent sends queue behind each other, and a send that waits about 12 seconds returns 429 line_busy, to retry after 2 seconds. Send in order from one worker per number.

What does it cost to send iMessages from code with ZeroSettle?

ZeroSettle Pro is $49/month for a dedicated iMessage number that can message first, with no per-message fees on API texts. Video sends use 10 credits per recipient. Growth is $599/month for 3 numbers plus $199 per extra number. Both have a 30-day money-back guarantee and no setup fee.

Sources

  1. Send a message, ZeroSettle iMessage API docs. Checked October 25, 2026.
  2. Webhooks, step by step, ZeroSettle iMessage API docs. Checked October 25, 2026.
  3. Errors, limits, and compliance, ZeroSettle iMessage API docs. Checked October 25, 2026.
  4. Your iMessage number (pricing), Authentication and Read messages, ZeroSettle iMessage API docs. Checked October 25, 2026.
  5. Full API reference as Markdown, ZeroSettle. Checked October 25, 2026.
  6. Messages, Apple Developer Documentation. Checked October 25, 2026.
  7. hmac: Keyed-Hashing for Message Authentication, Python documentation. Checked October 25, 2026.
  8. Crypto, Node.js documentation (createHmac, timingSafeEqual). Checked October 25, 2026.
  9. Requests: HTTP for Humans, Requests documentation. Checked October 25, 2026.

Changelog

  • First published.

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.

Drafted with AI assistance from ZeroSettle's API docs as of October 25, 2026. Phone numbers in the examples are placeholders.

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.