Every iMessage provider ships the same quickstart: a POST, an API key, twelve lines. It works, you see a blue bubble, and you conclude the integration is done. It is not — what follows is the part that decides whether it still works in March.
This guide assumes Node 20 or later, so fetch, AbortSignal.timeout and crypto.timingSafeEqual are all built in. No dependencies beyond a web framework for the inbound half.
Do you need an SDK?
Almost certainly not. These are small REST APIs — a send endpoint, a status endpoint, a webhook. A hand-written client is forty lines, has no supply chain, and does not go stale when the vendor stops publishing releases. Search results for "iMessage Node.js library" mostly return either provider-published wrappers around one endpoint, or abandoned bridges to a local Mac.
The exception is a provider publishing a genuinely maintained SDK with typed responses and webhook helpers. Check the npm publish date before you depend on it — in this category, a package last released two years ago is normal and is a warning. There is no community-maintained Node library that has become the trusted default here, and that is fine: fetch is the library.
A client worth keeping
const BASE_URL = "https://api.sendblue.co/api";
export class ImessageError extends Error {
constructor(
message: string,
readonly status: number,
readonly retryable: boolean,
) {
super(message);
this.name = "ImessageError";
}
}
export type SendResult = {
messageHandle: string;
status: string;
};
export async function sendMessage(input: {
to: string;
body: string;
statusCallback?: string;
idempotencyKey?: string;
}): Promise<SendResult> {
const response = await fetch(BASE_URL + "/send-message", {
method: "POST",
headers: {
"sb-api-key-id": process.env.IMESSAGE_KEY_ID!,
"sb-api-secret-key": process.env.IMESSAGE_SECRET!,
"Content-Type": "application/json",
// Not every provider honours this. Send it anyway: the ones that do
// will collapse a duplicate retry, and the ones that do not ignore it.
...(input.idempotencyKey
? { "Idempotency-Key": input.idempotencyKey }
: {}),
},
body: JSON.stringify({
number: input.to,
content: input.body,
status_callback: input.statusCallback,
}),
// Without this, a hung provider hangs your request handler with it.
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
const detail = await response.text();
throw new ImessageError(
"Send failed (" + response.status + "): " + detail.slice(0, 300),
response.status,
response.status === 429 || response.status >= 500,
);
}
const data = (await response.json()) as {
message_handle: string;
status: string;
};
return { messageHandle: data.message_handle, status: data.status };
}imessage.ts — timeouts, typed errors, and a distinction between retryable and permanent failures. Field names follow Sendblue's documented shape; swap them for your provider's.
The `retryable` flag is the whole point
A 422 because the number is not on iMessage will fail identically on every retry — retrying it wastes your rate limit and, on some plans, your money. A 429 or a 503 will very likely succeed in four seconds. Treating those two cases the same is the single most common defect in these integrations.
Retrying without making things worse
export async function sendWithRetry(
input: Parameters<typeof sendMessage>[0],
attempts = 4,
): Promise<SendResult> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await sendMessage(input);
} catch (error) {
lastError = error;
const retryable =
error instanceof ImessageError ? error.retryable : true;
if (!retryable || attempt === attempts - 1) throw error;
const backoff = 2 ** attempt * 500;
const jitter = Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, backoff + jitter));
}
}
throw lastError;
}Exponential backoff with jitter. The jitter matters: without it, a hundred failed sends retry in lockstep and hit the provider as one spike.
Receiving replies
Outbound is the easy half. The moment a customer replies, you need an endpoint that is publicly reachable, fast, and suspicious of what it receives.
import express from "express";
import crypto from "node:crypto";
const app = express();
function verifySignature(raw: Buffer, header: string | undefined): boolean {
if (!header) return false;
const expected = crypto
.createHmac("sha256", process.env.IMESSAGE_WEBHOOK_SECRET!)
.update(raw)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
// Length check first: timingSafeEqual throws on a length mismatch.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
"/webhooks/imessage",
express.raw({ type: "application/json" }),
async (req, res) => {
if (!verifySignature(req.body, req.get("x-signature"))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// Acknowledge first, work second. Providers retry on a slow response,
// and a retry storm caused by your own database is a bad afternoon.
res.status(200).end();
await enqueue(event).catch((error) => {
console.error("imessage webhook enqueue failed", error);
});
},
);Express. Note the raw body parser — you cannot verify a signature over a re-serialized object, because key order and whitespace will not match.
Three rules for that handler, in order of how often they are broken: verify before you parse, respond before you work, and make the work idempotent because the same event will arrive twice. The full webhook guide covers the rest.
The send queue
This is the part that separates an integration that lasts from one that gets the line flagged in week three. A phone line is not an SMS gateway — it is one number, behaving like a person, and bursting two hundred messages through it in ninety seconds does not look like a person.
type Job = { to: string; body: string };
export async function drain(
jobs: Job[],
opts: { perMinute: number; dailyCap: number },
) {
const spacingMs = 60_000 / opts.perMinute;
const sent: string[] = [];
for (const job of jobs.slice(0, opts.dailyCap)) {
const startedAt = Date.now();
try {
const result = await sendWithRetry({
to: job.to,
body: job.body,
idempotencyKey: dayKey(job.to, job.body),
});
sent.push(result.messageHandle);
} catch (error) {
// One bad recipient must not stop the run.
console.error("send failed", job.to, error);
}
const elapsed = Date.now() - startedAt;
if (elapsed < spacingMs) {
await new Promise((r) => setTimeout(r, spacingMs - elapsed));
}
}
return sent;
}A minimal paced sender. In production this belongs behind a real queue — BullMQ, SQS, pg-boss — but the pacing logic is the same.
Sensible starting values for a new line are slow enough to feel wrong: single-digit messages per minute, and a daily cap well under a hundred for the first fortnight. The warm-up schedule and the reasoning behind it are here.
Environment and secrets
IMESSAGE_KEY_ID=
IMESSAGE_SECRET=
IMESSAGE_WEBHOOK_SECRET=
IMESSAGE_FROM_NUMBER=+15550001111.env.example — commit this, never the real one. On Vercel these are project environment variables; the webhook secret in particular must not reach the browser.
Never prefix these with NEXT_PUBLIC_
In a Next.js app it is a two-character mistake that ships your sending credentials to every visitor. Anyone who reads your bundle can then send messages from your number, at your cost, in your business's name. Keep every one of these server-side and call the provider from a route handler or server action.
Testing
- Use the sandbox as your CI target. Four of the five providers we cover give you one free. Point your test suite at it and assert on real responses rather than mocks that drift from the API.
- Fixture your webhooks. Capture one real payload of each event type and replay them at your handler. It is the only cheap way to test the paths that only fire in production.
- Test the 429 branch. Force it with a stub. The retry path is the code you least want to discover is broken during a launch.
- Assert on the fallback. Send to a number you know is on Android and check what your provider actually did — silently dropped, fell back to SMS, or returned an error you are not handling.
Which provider for a Node codebase
| If you want | Look at |
|---|---|
| Public docs you can read before signing up | Sendblue — documented request shapes and a free sandbox |
| An OpenAPI spec to generate a typed client from | Blooio — publishes a specification |
| Per-message pricing rather than per line | LoopMessage — lower entry cost for low volume |
| A shared inbox for the humans as well as an API | Miss Blue |
All four are on the pricing comparison, and the wider list including the vendors we have not reviewed in depth is in the directory.