How it works

How to receive iMessages with a webhook: events, signing and retries

ZeroSettle delivers every inbound iMessage to one HTTPS webhook you register, about 1–2 seconds after the message reaches the line, as a JSON POST signed with HMAC-SHA256 in the X-ZeroSettle-Signature header. Your receiver verifies the signature on the raw body, dedupes on message.id plus event, and answers 2xx within 10 seconds; otherwise ZeroSettle retries after 1 s, 5 s and 20 s. 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.

Key takeaways

  • What you get: one HTTPS URL per account that ZeroSettle POSTs every message event to, inbound and outbound; an inbound text reaches it about 1–2 s after the line receives it.
  • Signing: X-ZeroSettle-Signature: t=<unix>,v1=<hex>, an HMAC-SHA256 of "<t>." plus the raw body, with a 300-second tolerance. The secret is shown once.
  • Retries: no 2xx within 10 s means 3 more attempts, after 1 s, 5 s and 20 s. Retries repeat message.id and event, so dedupe on that pair.
  • Price: inbound messages are unlimited on both plans. Pro is $49/month; Growth is from $599/mo.
  • What it does not report: no event says a message was delivered to the recipient or read, and lines managed by the hosted AI agent do not call your webhook.

How do you register a webhook?

You register a ZeroSettle webhook with one call, PUT /api/imessage/webhook, and the response carries the signing secret, shown only that once. From a connected AI coding agent the same step is the MCP tool set_webhook.

bash
export BASE=https://portal.zerosettle.io   # ZEROSETTLE_TOKEN is already in your environment
curl -s -X PUT -H "Authorization: Bearer $ZEROSETTLE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"url":"https://hooks.example.com/webhook"}' $BASE/api/imessage/webhook
# {"url": "https://hooks.example.com/webhook", "secret": "whsec_…", "typing_events": false,
#  "usage": "Verify X-ZeroSettle-Signature with this secret. Shown once."}
  • URL rules: https:// only, up to 2048 characters. Plain http:// is accepted only for localhost or 127.0.0.1; anything else is 422 invalid_request.
  • The secret rotates whenever you register again, same URL or not, and last_delivery resets. Keep it in an environment variable, never in source.
  • Typing events are opt-in: add "typing_events": true to the same body.
  • Removing it: DELETE /api/imessage/webhook.

Sending is covered in how to send an iMessage from your server's code.

Which events will you receive?

ZeroSettle's webhook receives every message event on the line in both directions: inbound texts, your own sends, failures, tapbacks and, if you opted in, typing. Each POST carries one event, named in the body and in the X-ZeroSettle-Event header.

ZeroSettle webhook events (API docs, checked October 12, 2026)
EventWhen it firesdirection / status
message.receivedSomeone texted the line: a text, photo or voice message.in / received
message.sentA message left the line, from your code, the console or MCP.out / sent
message.failedA send was recorded but not delivered.out / failed
reaction.receivedThey tapbacked one of your messages; message.target_id names it.in / received
reaction.sent / reaction.failedA tapback left, or failed to leave, the line.out / sent or failed
typing.started / typing.stoppedOpt-in: the other party started or stopped typing. No message object.—

The body is compact JSON with sorted keys, and message is the same object GET /api/imessage/messages returns. On an account with several numbers it also carries line and line_id.

http
POST https://hooks.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":"+18055550123","id":44,"status":"received","text":"hey!","ts_ms":1757400012000}}

The echo rule

ZeroSettle's docs: “React only to message.received in an auto-responder. Your own sends come back to you as message.sent events; replying to those would loop.”

  1. Today 3:14 PM
  2. Customer: Do you ship to Canada?
  3. Business: Yes, 5–7 business days. Want me to hold your size?❤️
  4. Customer is typing
Illustrative example. Each bubble is one webhook event: the question is message.received, the reply a message.sent echo, the heart reaction.received (its target_id is the reply), and the typing bubble typing.started, if typing_events is on.

How do you verify the signature?

Every ZeroSettle webhook carries X-ZeroSettle-Signature: t=<unix>,v1=<hex>, where v1 is an HMAC-SHA256 of the string "<t>." followed by the raw request body, keyed with your webhook secret. Recompute it over the exact bytes you received, before any JSON parsing, using the whole secret (whsec_ prefix included) as the key; compare in constant time; and reject any t more than 300 seconds from now. This is the Python verifier from ZeroSettle's docs, unchanged; the Node one is in the receiver below.

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)

Python's hmac.compare_digest is designed to prevent timing analysis. Node's crypto.timingSafeEqual throws when its inputs differ in length, so the docs' Node verifier accepts only a 64-character hex v1. HMAC itself is defined in RFC 2104. Token handling and what data ZeroSettle keeps are covered in Is ZeroSettle's iMessage API secure?

How do retries and duplicates work?

ZeroSettle waits up to 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 gives up on that event and records the failure. Redirects are not followed, so register the final URL, not one that answers with a 301 or 302.

When each ZeroSettle webhook attempt starts, computed from the documented delays
AttemptWait after the previous failureReceiver refuses at onceReceiver times out every time
1—0 s0 s
21 s~1 s~11 s
35 s~6 s~26 s
420 s~26 s~56 s
Failure recorded—~26 s~66 s

The delays and the 10-second timeout are from ZeroSettle's docs; the start times are arithmetic from them, ignoring network time.

  • Dedupe on the pair. Retries carry the same message.id and event; store the pair under a unique key.
  • Answer first, work after. Return 2xx once the signature checks and queue anything slow, such as a model call. A handler that runs past 10 seconds is a timeout, and the event comes again.
  • Order by message.id. Events for one line are delivered concurrently and may arrive out of order under retries: a retried message 44 can land after 45.
  • Fan out from your receiver. If your app and a relay such as iMessage support routed to Slack both need the messages, forward them yourself.

What message.sent and message.failed promise, and how fast sends are, is in iMessage delivery status, webhook retries and latency explained.

What does a complete receiver look like?

A working ZeroSettle receiver reads raw bytes, verifies, answers 200, ignores everything but message.received, dedupes, then does the work. This Node version uses the docs' verifier unchanged and Express's express.raw(), which hands the route the body as a Buffer.

javascript
// receiver.js: npm install express, then node receiver.js
const crypto = require("crypto");
const express = require("express");

const BASE = "https://portal.zerosettle.io";
const TOKEN = process.env.ZEROSETTLE_TOKEN;
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]));
}

const app = express();
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  if (!Buffer.isBuffer(req.body) || !verify(SECRET, req.get("X-ZeroSettle-Signature"), req.body)) {
    return res.sendStatus(401);
  }
  const { event, message } = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200);                       // answer first, work after
  if (req.get("X-ZeroSettle-Test")) return;  // test_webhook: id 0, nothing to do
  if (event !== "message.received") return; // your own sends echo back as message.sent
  const key = `${message.id}:${event}`;
  if (seen.has(key)) return;                 // a retry of an event already handled
  seen.add(key);
  handle(message).catch(console.error);
});

async function handle(message) {
  const media = message.image || message.audio; // an inbound photo or voice message
  if (media) {
    const r = await fetch(BASE + media.url, { headers: { Authorization: `Bearer ${TOKEN}` } });
    const file = Buffer.from(await r.arrayBuffer()); // store it with the message
  }
  console.log(message.id, message.handle, message.text);
}

app.listen(8100);

As written, this receiver handles a valid event once, skips a retry of it, and answers 401 to a stale timestamp, a wrong secret, a key with the whsec_ prefix stripped or a re-serialized body. Test it against your own signed requests before you rely on it.

How do you fetch photos and voice messages?

Inbound photos and voice messages arrive in message.received with an image or audio object whose relative url you fetch with the same bearer token; the webhook body never carries the file.

bash
curl -H "Authorization: Bearer $ZEROSETTLE_TOKEN" $BASE/api/imessage/messages/45/image -o photo.jpg
curl -H "Authorization: Bearer $ZEROSETTLE_TOKEN" $BASE/api/imessage/messages/46/audio -o voice.m4a
  • Photos: a HEIC (what iPhones shoot by default) becomes a JPEG, and the message arrives as kind: "image". A Live Photo arrives as its still, and the caption is text.
  • Voice messages: converted to AAC in an .m4a (audio/mp4), with duration_ms filled in.
  • Several photos in one message: only the first is delivered.
  • Video and files: a message row with its text and no file.
  • image or audio is null when the file never finished downloading to the line.

How do you test without a real text?

ZeroSettle's MCP tool test_webhook POSTs one signed, synthetic message.received to your registered URL, with message id 0, "test": true and the header X-ZeroSettle-Test: 1, from your own number. It reports your status code, latency and any error, and is not retried or recorded as a delivery.

  1. Register the URL (set_webhook or PUT /api/imessage/webhook) and store the secret.
  2. Run test_webhook until it reports a 2xx. With the receiver above, a 401 means the signature check failed.
  3. Send a text to your own number with POST /api/imessage/send and confirm the message.sent echo reaches your receiver.
  4. Reply from your own iPhone and confirm the message.received.
  5. Check last_delivery in GET /api/imessage/line: ok, status_code, error (for example HTTP 500) and attempts for the newest delivery.

The live stream reports every attempt, retries included, as a delivery event. Connector setup is in Connect Claude Code or Cursor to an iMessage number.

What if you can't expose a public URL?

Without a public HTTPS endpoint you can tunnel a local port, poll ZeroSettle's messages endpoint, or read its live stream.

  • Tunnel: cloudflared tunnel --url http://localhost:8100 (no account needed) or ngrok http 8100, the two tunnels ZeroSettle's docs suggest. A Cloudflare Quick Tunnel gets a new trycloudflare.com address each time you start one and is meant for testing and development; each new address means registering again, which rotates the secret.
  • Poll: GET /api/imessage/messages?after_id=… pages forward through every message in both directions (pass back next_after_id). Inbound shows up within about 1–2 seconds; the docs' recipe polls every 3 seconds.
  • Live stream (SSE): mint a token with GET /api/imessage/stream-token (it lives 5 minutes) and open the /api/imessage/stream URL it returns for message, delivery, typing, conversation, optout and ai_credits events, with a : ping after 15 s of silence. The console is built on it.

ZeroSettle's docs prefer the webhook: pushed, signed, no wasted requests. With no server at all, iMessage automations with Zapier, n8n or Make covers receiving these events in those tools.

When is the webhook not called?

ZeroSettle skips the webhook for line groups the hosted AI agent manages: there the agent answers inbound texts instead of your server. A few other events never reach the webhook either.

  • Lines Managed by AI Agent (Beta). Inbound texts go to the agent, and your own sends there are refused with 403 managed_by_zerosettle; managed_by: "api" restores the webhook. See the AI agent docs.
  • Typing, unless you opted in. Even then, typing events are delivered once, with a 5 s timeout, no retries and no entry in last_delivery.
  • Stream-only events. conversation.deleted (after DELETE /api/imessage/conversations/{handle}), optout on each STOP and START, and ai_credits go to the live stream only.
  • Delivered or read status. No event reports that a message reached the recipient or was read; message.sent means it left the line.

What does receiving cost?

Receiving is part of every ZeroSettle plan: inbound messages are unlimited on both, and a line your own server answers uses no AI credits. Pro is $49/month; Growth, several numbers sharing one webhook, is from $599/mo. See plans and pricing, the webhook steps in the docs, or get a number. More explainers are in how ZeroSettle works.

Frequently asked questions

Can I register more than one webhook?

No. An account has one webhook URL, and on an account with several numbers every number shares it. Each event then carries line and line_id, so your receiver knows which number a message came in on.

What happens after the last retry fails?

ZeroSettle records the failure as the line's last_delivery (until a newer delivery replaces it) and does not try that event again. Every message stays stored, so backfill: page GET /api/imessage/messages?after_id= from just before the failed message_id and skip any id your dedupe store already holds.

Why does my signature check fail?

Check four things: that you hash the raw bytes (a JSON middleware that parses first leaves you hashing re-serialized JSON), that your clock is within 300 seconds, that the key is the whole secret with its whsec_ prefix, and that it is the secret from your latest registration: every registration, including the one a new tunnel URL forces, rotates it.

Does a STOP reach my webhook?

Yes. A contact's STOP arrives as message.received like any other text, and so does anything they send afterwards. ZeroSettle then refuses sends to them from every number on your account with 403 recipient_opted_out.

Sources

  1. ZeroSettle iMessage API docs: "Webhooks, step by step", "Attachments", "Read messages", "Live stream (SSE)", "AI agent", "MCP" and "Errors, limits, and compliance". Checked October 8, 2026.
  2. RFC 2104: HMAC: Keyed-Hashing for Message Authentication, H. Krawczyk, M. Bellare and R. Canetti, IETF, February 1997. Checked October 8, 2026.
  3. hmac: Keyed-Hashing for Message Authentication, Python documentation (compare_digest). Checked October 8, 2026.
  4. Crypto, Node.js documentation (crypto.createHmac, crypto.timingSafeEqual). Checked October 8, 2026.
  5. express(), Express documentation (express.raw). Checked October 8, 2026.
  6. Quick Tunnels, Cloudflare 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 and the zerosettle.io homepage as of October 12, 2026. Outside facts come from the pages listed under Sources, 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.