Good news first: there is no Node-specific obstacle here. Every hosted iMessage provider exposes REST, so fetch is the whole integration. The interesting work is everything around the send.
Before you pick a library, pick a provider
Node support is not a differentiator — every vendor has it. Pricing, outbound limits and webhook quality are. Start at who sells an iMessage API and what it costs, then come back here.
The wrapper, written once
Write this file on day one. It is the only place in your codebase that knows which vendor you use, which means a migration is a single-file change rather than a search-and-replace across your app.
const API = process.env.IMESSAGE_API_URL!;const KEY = process.env.IMESSAGE_API_KEY!; export type Channel = "imessage" | "sms" | "rcs";export type SendResult = { id: string; channel: Channel }; export class SendError extends Error { constructor( message: string, readonly status: number, readonly retryable: boolean, ) { super(message); }} export async function send(to: string, text: string): Promise<SendResult> { const res = await fetch(`${API}/v1/messages`, { method: "POST", headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to, text }), // Without this a hung provider hangs your request handler with it. signal: AbortSignal.timeout(10_000), }); if (!res.ok) { // 429 and 5xx are worth retrying. A 4xx means your call is wrong and // retrying it will simply be wrong again, more expensively. const retryable = res.status === 429 || res.status >= 500; throw new SendError(await res.text(), res.status, retryable); } const data = await res.json(); return { id: data.id, channel: data.channel ?? "imessage" };}Retries that respect the provider
Providers rate-limit deliberately — aggressive sending is what gets Apple accounts flagged, so a 429 is the vendor protecting your line as much as their infrastructure. Back off properly and honour Retry-After when it is present.
import { send, SendError } from "./messaging"; export async function sendWithRetry(to: string, text: string, attempts = 4) { for (let attempt = 0; attempt < attempts; attempt++) { try { return await send(to, text); } catch (err) { const last = attempt === attempts - 1; if (last || !(err instanceof SendError) || !err.retryable) throw err; // Jitter matters: without it, a burst of failures retries in lockstep // and you re-create the spike that caused the 429. const base = 2 ** attempt * 500; await new Promise((r) => setTimeout(r, base + Math.random() * base)); } } throw new Error("unreachable");}Receiving replies
Inbound is where integrations actually break. Providers deliver at least once, which means duplicates are not an edge case — they are a Tuesday. Two rules cover almost all of it: acknowledge before you process, and deduplicate on the provider's message id.
import express from "express";import crypto from "node:crypto"; const app = express(); // Capture the raw body — signature verification must run against the exact// bytes received, before JSON parsing normalises anything.app.use(express.json({ verify: (req, _res, buf) => ((req as any).rawBody = buf) })); app.post("/webhooks/imessage", async (req, res) => { const sig = req.header("x-signature") ?? ""; const expected = crypto .createHmac("sha256", process.env.IMESSAGE_WEBHOOK_SECRET!) .update((req as any).rawBody) .digest("hex"); const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.sendStatus(401); } // Acknowledge first. Anything slow here becomes a provider retry, which // becomes a duplicate reply to your customer. res.sendStatus(200); const { message_id, from, text } = req.body; if (await alreadyHandled(message_id)) return; await markHandled(message_id); await handleInbound(from, text);});On serverless, do not fire-and-forget after responding
The pattern above assumes a long-lived server. On Vercel Functions, AWS Lambda or Cloudflare Workers, work started after the response can be killed mid-flight. Push the job onto a queue inside the handler and process it separately — or use waitUntil where your platform provides it.
Next.js specifically
If you are on the App Router, a Route Handler is the natural webhook endpoint. Read the raw body with request.text() so signature verification sees the original bytes.
import crypto from "node:crypto";import { after } from "next/server"; export async function POST(request: Request) { const raw = await request.text(); const sig = request.headers.get("x-signature") ?? ""; const expected = crypto .createHmac("sha256", process.env.IMESSAGE_WEBHOOK_SECRET!) .update(raw) .digest("hex"); const a = Buffer.from(sig); const b = Buffer.from(expected); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return new Response("bad signature", { status: 401 }); } const event = JSON.parse(raw); // `after` keeps the function alive past the response, so the reply is not // killed halfway through when the request finishes. after(async () => { if (await alreadyHandled(event.message_id)) return; await markHandled(event.message_id); await handleInbound(event.from, event.text); }); return Response.json({ ok: true });}Sending in bulk without getting flagged
The instinct from SMS — fire the whole list at once and let the provider queue it — is actively harmful here. A line that sends 500 identical messages in a minute looks exactly like the thing Apple bans accounts for. Pace it, and vary it.
import { sendWithRetry } from "./retry"; export async function sendCampaign( recipients: { phone: string; firstName: string }[], template: (name: string) => string, { perMinute = 30 } = {},) { const gap = 60_000 / perMinute; const results = []; for (const [i, r] of recipients.entries()) { try { // Personalised text is both better marketing and a weaker spam signal // than the same string sent hundreds of times. results.push(await sendWithRetry(r.phone, template(r.firstName))); } catch (err) { // One bad number must not end the campaign. results.push({ error: String(err), phone: r.phone }); } if (i < recipients.length - 1) await new Promise((res) => setTimeout(res, gap)); } return results;}Why the pacing matters in detail is in do iMessage providers use phone farms, and the scheduling side is in scheduling and queuing.
Self-hosted from Node
If you are running a bridge on your own Mac rather than paying a provider, the shape is the same — the API is just on your network. And if the Node process runs on the Mac itself, you can skip the bridge entirely and drive AppleScript.
import { execFile } from "node:child_process";import { promisify } from "node:util"; const run = promisify(execFile); export async function sendLocal(phone: string, text: string) { // Pass values as argv. Interpolating user text into the script body is an // injection bug — an apostrophe alone will break it. const script = ` on run argv tell application "Messages" set svc to 1st account whose service type = iMessage send (item 2 of argv) to participant (item 1 of argv) of svc end tell end run`; await run("osascript", ["-e", script, phone, text]);}For the Python equivalents see iMessage API for Python; to go end to end from scratch, your first iMessage from code.
Common questions
- What is the best iMessage API for Node.js?
- Any hosted provider works from Node, since they are all REST or gRPC. Photon ships first-class TypeScript tooling and a Vercel Chat SDK adapter; Blooio publishes a Node SDK alongside Python, Go and Java. Choose on pricing and features rather than on Node support, which is universal.
- Is there an npm package for sending iMessages?
- There are vendor SDKs on npm, and open-source clients for self-hosted bridges. There is no official Apple package, because Apple publishes no API.