Skip to content
imessageapi

iMessage API for Node.js

Every hosted provider is an HTTP call away, so Node needs no special SDK to send an iMessage. What it does need is the wrapper, the webhook handler and the retry logic that turn a working curl into something you can leave running.

9 min readUpdated August 24, 2026Language

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.

lib/messaging.ts — your interface, not theirs
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.

Exponential backoff with jitter
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.

A webhook handler that survives production
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.

app/api/webhooks/imessage/route.ts
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.

Paced bulk send
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.

AppleScript from Node, safely
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.