Now launching iMessage API!Learn more

Products

Ships to

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:

  1. Get an API key.
  2. Receive an event when someone writes to your line.
  3. 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, signing in at flow.engineer gives you a free trial on Flow’s shared iMessage line for 25 people, and there are 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/v0/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/v0/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/v0/conversations/conv_01JB8ZC3K5M7P9R1T3V5X7Z9B1/basic \
  -H "Authorization: Bearer $FLOW_MESSAGING_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"text": "Hello from my agent"}'

TypeScript:

// npm install @flow-engineer/messaging
// node --env-file=.env agent.ts   (Node 22.18+; older Node: npx tsx --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 /v0/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 /v0/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 in a basic message’s media, one file per message with an optional caption, or as a voice note. iMessage takes 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. Flow converts nothing, so a buttons message to an iMessage conversation is refused with 422 unsupported_content and nothing is sent: list the options in the text instead, and read the person’s answer as text.

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. That fallback is included on every plan. Ask any provider what your app sees when a message changes channel, and store the real channel. Customers text the line first, so you only reply to someone who is already on iMessage. Writing first comes with the $40 a month Outbound add-on on a dedicated line, and the conversations it starts ramp from 2 a day to 40 a day over 21 days; from any other line, a start is refused with 403 permission.

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 not recently), or typing or a read receipt was requested more than 5 minutes after your line’s last message to them Do not retry. Wait for the person to write, then reply. For typing, send your reply first, or ignore it.
permission The key may not do this, for example starting a conversation from a line that only replies Reply into an existing conversation instead, or add Outbound (writing first) 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 does not take this kind or field, for example buttons on iMessage. Nothing was sent Send a kind the channel takes, such as plain text, with a new idempotency key. The hint says what.
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 the text as JSON to /v0/conversations/{id}/basic with a bearer key, 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.

Start hereiMessage API: a developer’s guideThe overview of how iMessage APIs work, and what to ask a provider. Developer guideiMessage API without a MacWhy a hosted iMessage API removes the Mac, what the DIY relay costs you, and how to choose.Developer guideiMessage MCP serverTwo things people mean by it: a messaging MCP for your coding agent, and your product as an MCP server.Developer guideiMessage API for Node.js and TypeScriptInstall the SDK, stream inbound messages, verify signed webhooks in Express and reply safely.Developer guideiMessage API for PythonStream inbound events, verify signed webhooks in FastAPI and reply with idempotency keys.Developer guideiMessage API for GoPoll events, reply and verify signed webhooks with the standard library, no SDK needed.Developer guideiMessage webhooks: signing, retries and orderingWhat a reliable webhook does, and how Flow, Sendblue and Linq differ on retries and replay.

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