How to send iMessage with Node.js
Node cannot talk to Apple’s delivery layer directly, on any platform. What it can do is drive a provider that operates that layer — and the difference between a script that works once and a service that keeps working is almost entirely about retries, idempotency, and inbound events.
What Node can and cannot do
There is no npm package that sends an iMessage. Apple publishes no general-purpose send API, so nothing installed from a registry can reach the delivery layer on its own. Packages that appear to do it either shell out to AppleScript on a local Mac or wrap a third-party provider’s HTTP API.
The AppleScript path deserves a moment. It works, on a Mac, for the account signed into Messages on that Mac, driven by a process with the right permissions. It is fine for a personal automation and unsuitable as a service: it needs a machine that stays awake, it has no delivery events, no idempotency, no team access, and no recovery story. Everything below assumes the other path — an HTTP API in front of infrastructure somebody else operates.
Keep the call server-side
The API key is a bearer credential that can send from your line. It belongs in a server environment variable, never in a Next.js client component, a browser bundle, a mobile app, or anything shipped to a device you do not control. A key in a client bundle is a key that will eventually send messages you did not write.
In a Next.js application that means a route handler, a server action, or a background job — never a module that can be imported into the client. Read the key from the environment at call time and let the process fail loudly at startup if it is missing, rather than at 2am on the first send.
The request shape
A send is one POST. Miss Blue’s takes the number to send from, the recipient, and the text, with a bearer credential in the header. Set an explicit timeout — Node’s fetch has none by default, and a request with no deadline is a worker that eventually stops doing anything else.
Return the provider’s message identifier to your caller and store it. It is the key you will need to reconcile delivery events, to look up what happened to a specific send, and to answer the support question that starts with “did she ever get the message?”
// lib/messages.ts — server only
const BASE = "https://api.missblue.dev";
export async function sendMessage({
numberId,
recipient,
text,
idempotencyKey,
}: {
numberId: string;
recipient: string;
text: string;
idempotencyKey: string;
}) {
const response = await fetch(`${BASE}/v1/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MISS_BLUE_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ number_id: numberId, recipient, text }),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
throw new SendError(response.status, await response.text());
}
return response.json() as Promise<{ id: string; status: string }>;
}Validate before you spend a send
Check the recipient format, the text length, and the suppression list in your own code before the request leaves. A rejected request costs a round trip; a successfully delivered message to someone who opted out costs considerably more.
Normalize phone numbers to a single canonical form at the boundary and store them that way. Two representations of the same number become two conversations, two consent records, and eventually two messages to one person about the same thing.
- Normalize the recipient to one canonical format
- Check the suppression list on every send path, not just campaigns
- Bound the text length before the provider does
- Reject empty sends early — a message needs text or attachments
- Confirm the number ID belongs to the project you think it does
Retries without duplicate messages
Node’s failure modes make duplicates easy. A request times out, a worker retries, and the original send had already succeeded — the customer gets the message twice and your log shows one send. An idempotency key makes the retry safe: derive it from the thing you are sending, not from a fresh random value on each attempt.
Generate the key once, at the point where you decide a message should exist, and reuse it across every retry of that decision. A key generated inside the retry loop defeats the entire mechanism.
import { setTimeout as sleep } from "node:timers/promises";
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
export async function sendWithRetry(input: SendInput, attempts = 4) {
// Derived once, outside the loop — a fresh key per attempt is a duplicate.
const idempotencyKey = input.idempotencyKey;
for (let attempt = 1; ; attempt++) {
try {
return await sendMessage({ ...input, idempotencyKey });
} catch (error) {
const retryable =
error instanceof SendError ? RETRYABLE.has(error.status) : isTransient(error);
if (!retryable || attempt >= attempts) throw error;
// Exponential backoff with jitter, so a provider blip does not
// become a synchronized stampede from every worker at once.
const base = 250 * 2 ** (attempt - 1);
await sleep(base + Math.random() * base);
}
}
}Map HTTP outcomes into errors your application understands
A 4xx and a 5xx mean different things to your workflow. A 401 is a deployment problem and should page someone. A 422 is a bad recipient and should mark the contact, not retry forever. A 429 is pacing and should slow the queue. A 503 is transient and should back off.
Collapse all of them into a generic “send failed” and you lose the ability to act. Define a small error type that carries the status and the provider’s body, and branch on it at the call site.
- 401 or 403 — credential or scope problem, alert immediately, do not retry
- 404 — wrong number ID or message ID, a code or config bug
- 422 — invalid recipient or payload, mark and stop retrying
- 429 — slow the queue, respect any retry hint
- 5xx — retry with backoff, then dead-letter
- Timeout — retry with the same idempotency key, never a new one
Receiving replies and delivery events
Sending is the easy half. A conversation exists because messages come back, and those arrive as webhooks — a POST to an endpoint you expose, at a time you do not control, sometimes more than once for the same event.
Verify the signature before you trust the body, deduplicate on the event identifier, and acknowledge quickly. Miss Blue signs with the same scheme Stripe uses — a Miss-Blue-Signature header carrying a timestamp and a hex HMAC-SHA256 over the timestamp joined to the raw body — so the timestamp is covered by the signature and a captured delivery cannot be replayed at you later. Do the real work on a queue. A webhook handler that opens a database transaction, calls a model, and posts to Slack before responding is a handler that will time out and be retried, producing exactly the duplicates you were trying to avoid.
// app/api/webhooks/missblue/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export async function POST(request: Request) {
const raw = await request.text(); // raw body, before JSON.parse
const header = request.headers.get("Miss-Blue-Signature") ?? "";
// `t=<unix>,v1=<hex hmac>` over `"{t}.{body}"` — the same scheme Stripe
// uses, so the timestamp is inside the signed string and a captured
// delivery cannot be replayed at you later.
const parts = Object.fromEntries(
header.split(",").map((pair) => pair.split("=", 2) as [string, string]),
);
const timestamp = Number(parts.t);
const provided = parts.v1 ?? "";
if (!Number.isFinite(timestamp)) return new Response("bad signature", { status: 401 });
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
return new Response("stale signature", { status: 401 });
}
const expected = createHmac("sha256", process.env.MISS_BLUE_WEBHOOK_SECRET!)
.update(`${timestamp}.${raw}`)
.digest("hex");
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(raw);
// Idempotent by event id: a redelivery must be a no-op, not a second reply.
if (await alreadyProcessed(event.id)) return new Response(null, { status: 204 });
await enqueue(event); // acknowledge fast, work later
return new Response(null, { status: 204 });
}Ordering is not guaranteed
Webhook deliveries can arrive out of order, and a naive handler that applies the latest payload wins will happily overwrite a delivered status with a stale queued one. Store the provider’s timestamp or sequence and ignore updates older than what you already have.
The same applies to two replies arriving within a second of each other. Order by the event’s own ordering field when rendering a thread, not by the time your server happened to process it.
Where the send belongs in a Node app
Do not send from inside a request handler that a user is waiting on. Enqueue the intent, return, and let a worker perform the send with retries. This keeps request latency independent of provider latency and gives retries somewhere to live.
In a serverless deployment this matters more, not less: a function that ends when the response is returned will kill an in-flight retry loop. Use a queue, a durable workflow, or a background job runner rather than a promise you forgot to await.
- Enqueue the intent in the request path
- Send from a worker with retries and backoff
- Never fire-and-forget a floating promise in a serverless function
- Persist the message ID before you consider the send done
- Reconcile with delivery events rather than assuming success
Test without messaging real customers
Point the base URL at a stub in tests and assert on the request you would have sent: the number ID, the normalized recipient, the body, the idempotency key, and the timeout. That catches the majority of integration bugs without a single real send.
For end-to-end checks, use a sandbox or shared testing number and a short list of internal recipients. Keep a hard guard in code that refuses to send to anything outside that list unless an explicit environment flag is set — the guard costs nothing and prevents the single worst incident in this category.
Protect the credentials and the conversation data
Keep API keys and webhook secrets out of the repository and out of logs. Redact message bodies from error reporting; a stack trace with a customer’s text in it will be replicated into every observability system you use and will outlive the conversation.
Scope credentials narrowly where the provider allows it, rotate them on a schedule, and make revocation something you have actually tested rather than something you assume works.
A staged rollout
Ship it in the order that surfaces problems while they are cheap. Each stage exercises a failure mode that the previous stage could not.
By the last stage you have a service rather than a script, and the difference will be obvious the first time a provider has a bad ten minutes.
- One send to your own number from a local script
- The same send from a deployed worker, with timeouts and structured errors
- Idempotent retries, verified by forcing a timeout
- Webhook receipt with signature verification and deduplication
- Delivery reconciliation against stored message IDs
- Queue-backed sending with backoff and a dead-letter path
- Suppression, consent checks, and a human takeover route
Quick answers
Is there an npm package to send iMessage?+
Nothing that reaches Apple directly. Packages either shell out to AppleScript on a local Mac — fine for personal automation, unsuitable as a service — or wrap a third-party provider’s HTTP API. Apple publishes no general-purpose send API.
Can I send iMessage from Node on Linux or in a container?+
Only through a provider. The Apple side has to run on Apple software; Node’s job is to make an authenticated HTTP call and to handle the events that come back.
How do I avoid sending duplicate messages?+
Generate one idempotency key at the moment you decide a message should exist and reuse it across every retry of that decision. A key generated inside the retry loop produces exactly the duplicates it was meant to prevent.
Should I send from a Next.js route handler?+
Enqueue there and send from a worker. A serverless function that returns its response will kill an in-flight retry loop, and users should not wait on provider latency.