Developer guideiMessage API9 min read
Send iMessage programmatically: curl, TypeScript, Python
How to send and receive iMessage from code with Flow. Get a test key, receive a signed webhook, reply once with an idempotency key, and handle retries.

The short answer
Use a third-party iMessage API. You get a key, receive an event when someone writes, and POST a reply. The part that takes care is not the send call. It is verifying webhooks, ignoring duplicates and never sending the same reply twice.
Building on the iMessage API? Build on iMessage with Flow: one agent for iMessage, WhatsApp and Telegram. New to it? Read the iMessage API guide.
The short answer
You cannot call Apple directly, so you call a provider. The flow is the same everywhere:
- Get an API key.
- Receive an event when someone writes to your line.
- POST a reply into the same conversation.
The sections below show the Flow send loop in curl, TypeScript and Python. For what an iMessage API is and why Apple has none, start with the iMessage API guide.
Get a key and a first message
One thing to know first. Flow’s self-serve test key works on a Telegram sandbox bot. For iMessage, Flow has a free shared line for up to 25 people a month and dedicated lines. The handler you write against the sandbox is the same one that answers on iMessage, so you can build and test now.
Create a test key with no account. From your project folder:
npx @flow-engineer/messaging init
That writes FLOW_MESSAGING_KEY to .env and prints a sandbox link. Open the link on your phone and tap Start. Or use plain HTTP:
curl -X POST https://api.flow.engineer/v1/sandbox/keys
A test key allows one contact and 50 outbound messages and expires after seven days. Sign in with a Flow account to keep the app and get a larger allowance.
Now read an inbound message and reply to it. This is curl:
# Read the newest inbound messages (oldest first).
curl -G https://api.flow.engineer/v1/events \
-H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
--data-urlencode "type=message.received"
# Reply into the conversation named in the event.
curl https://api.flow.engineer/v1/conversations/conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1/messages \
-H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"content": {"type": "text", "text": "Hello from my agent"}}'
TypeScript:
// npm install @flow-engineer/messaging
// node --env-file=.env agent.ts
import { FlowMessaging, contentText } from "@flow-engineer/messaging";
const flow = new FlowMessaging();
for await (const event of flow.events.stream({ types: ["message.received"] })) {
const said = contentText(event.data.message.content);
await event.conversation.reply("You said: " + said, { idempotencyKey: event.id });
}
Python:
# pip install flow-messaging
from flow_messaging import Flow, content_text
flow = Flow()
for event in flow.events.stream(types=["message.received"]):
said = content_text(event.data.message.content)
flow.messages.send(
event.conversation.id,
f"You said: {said}",
idempotency_key=event.id,
)
A send is queued first. Watch message.sent, message.delivered or message.failed to learn what happened. These samples come from Flow’s quickstart.
Receive replies with a signed webhook
A stream is fine for scripts. In production you register an HTTPS endpoint with POST /v1/webhook_endpoints, keep the whsec_... secret it returns, and verify every delivery. Flow sends the signature in a Flow-Signature header as t=<unix>,v1=<hex HMAC-SHA256 of "t.<t>.<raw body>">.
This is the verification from Flow’s docs, in a Next.js route:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(body: string, header: string, secret: string): boolean {
const parts = header.split(",").map((p) => p.trim().split("="));
const t = parts.find(([k]) => k === "t")?.[1];
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const want = createHmac("sha256", secret).update(`t.${t}.${body}`).digest();
return parts.some(([k, v = ""]) => k === "v1" && /^[0-9a-f]{64}$/.test(v) && timingSafeEqual(want, Buffer.from(v, "hex")));
}
export async function POST(req: Request) {
const body = await req.text(); // the raw body: a re-serialized one will not verify
if (!verify(body, req.headers.get("Flow-Signature") ?? "", process.env.FLOW_MESSAGING_WEBHOOK_SECRET!)) {
return new Response(null, { status: 400 });
}
const event = JSON.parse(body);
if (event.type !== "message.received") return Response.json({});
return Response.json({ reply: `You said: ${event.data.message.content.text ?? ""}` });
}
Three rules matter more than the code:
- Use the raw body. If a framework parses and re-serializes it, the signature will not match.
- Deduplicate on the event
id. Deliveries are at least once. Flow retries for up to three days, so a slow endpoint can see the same event again. - Answer fast, work later. Return quickly and do slow model calls in a queue, then send the reply with the event ID as the idempotency key.
If your server is down, nothing is lost: the same events are in a log you can read with GET /v1/events?after=evt_... to catch up. The log keeps events for 30 days by default.
Send media
On Flow, upload the file first. The files endpoint checks the real file type from its first bytes, scans documents for malware, and returns a file_id you send as media content. iMessage supports images, video, audio and documents, and voice notes from mp3, wav, m4a, caf or aac files. Text can be up to 9,999 characters.
Two details that surprise people. Flow cuts reply bubbles at about 400 characters on iMessage, at a sentence end, because people read iMessage as short lines. And iMessage has no buttons. If you send buttons content with fallback: "auto", Flow sends numbered text and still gives you a button_reply with the button’s id when the person answers “2”.
Handle a recipient without iMessage
On Flow, when a customer cannot get iMessage, the same conversation continues over SMS or RCS from the same number, so your handler does not change. Ask any provider what your app sees when a message changes channel, and store the real channel. By default customers text the line first, so you only reply to someone who is already on iMessage. A line with the outbound add-on can write first, within warm-up limits, and a start without it is refused.
Handle errors and retries
A good send path is mostly error handling. Flow answers refusals with a typed error that carries a type, a hint and a doc_url, so you can branch on the type instead of parsing text. The ones you will meet on iMessage:
| Error | Meaning | What to do |
|---|---|---|
outside_window |
The contact has not written to this line, or typing was requested more than five minutes after their last message | Do not retry. Wait for the person to write, or use a line with the outbound add-on. For typing, ignore it. |
permission |
The key may not do this, for example starting a conversation on a line without the outbound add-on | Reply into an existing conversation instead, or add outbound to a dedicated line. |
rate_limited |
Too many requests, or sends faster than the sender’s pacing | Wait retry_after seconds, then retry with the same idempotency key. |
sender_throttled |
Abuse signals tripped, so the sender may not start conversations for a while | Replies into existing conversations still go. |
unsupported_content |
The channel cannot show this content and no fallback was set | Set fallback: "auto", or send plain text. |
idempotency_conflict |
The key was used for a different request, or the first request is still running | Use a new key for a new request. |
Retry only what clears by itself. rate_limited and sender_throttled carry retry_after. outside_window does not clear until the person writes again.
Idempotency keys are kept for 24 hours. A repeat returns the first answer with an Idempotent-Replayed: true header, so a retry never sends twice. Answers worth retrying (429, 500, 502, 503 and 504) are not kept, so the retry runs again. One case the key cannot cover is a channel that takes a send and never answers. Flow then reports channel_timeout_unknown_outcome, and you should check the conversation before you send again.
The DIY route: a Mac and a relay
You can also run a Mac that stays signed in to Messages and relay requests to it. That can work for a personal project. For a product you then own the hardware, the Apple ID, the uptime and every Apple change. A hosted provider takes that on, which is most of what you pay for.
Frequently asked questions
Can I send iMessage with curl?
Yes. With Flow you POST to /v1/conversations/{id}/messages with a bearer key and a JSON body, or use the TypeScript and Python SDKs.
Is there an iMessage API for Python?
Flow publishes flow-messaging on PyPI. The underlying API is HTTP, so you can also use requests or httpx.
Is there an iMessage API for Node?
Flow publishes @flow-engineer/messaging on npm. Use fetch if you prefer no SDK.
How do I avoid sending a message twice?
Send an Idempotency-Key header with each POST. Flow keeps keys for 24 hours and returns the first answer for a repeat. Use the inbound event's ID as the key when you reply to it.
How do I verify an iMessage webhook?
Check the signature header against the raw request body with your endpoint's secret. Flow signs with HMAC-SHA256 in a Flow-Signature header and rejects timestamps more than five minutes old in its example.
Can I test iMessage without a phone?
On Flow, test keys can create simulated contacts that exist only inside Flow, so you can test your agent's logic with no phone or channel. You still need a real line and a real phone to see the final iMessage.
Keep reading
All posts: the Flow blog. The Flow docs have the reference.
From reading to building
Your agent, on iMessage and everywhere else.
Flow gives your agent an iMessage line, and the same code answers on WhatsApp and Telegram and works as an MCP server and CLI. See how it works on iMessage.
Build on iMessage with Flow