Apple has no public iMessage API, so no official npm package sends iMessages from a server. Blueping is an independent iMessage API, not affiliated with Apple: your Node.js code sends a JSON request over HTTPS, and the text arrives in Messages on the other person's iPhone, iPad or Mac.
This guide covers the whole loop in plain JavaScript: an API key, a first message with fetch, a join link, safe retries, and replies through a signed Express webhook or by polling. You need Node.js 18 or later and an iPhone.
Get an API key by texting a code
Open https://blueping.pages.dev/start to get a 6-character code, then text it from your iPhone to the Blueping line. The page shows your API key, which starts with bp_live_. You never create a password or enter a credit card, and there is no sales call.
That text also made your iPhone the contact me, so you can test on yourself first. To sign in later, text LOGIN to the same line and it texts back a one-time link.
Every example reads the key from an environment variable:
export BLUEPING_API_KEY="bp_live_your_key_here"If a key ever leaks, POST /v1/keys replaces it and returns the new one.
Send your first iMessage from Node.js
Node.js 18 and later ship fetch as a global, so sending a message takes no dependencies. Save this as send.mjs (the .mjs extension allows top-level await):
// send.mjs
const res = await fetch("https://blueping.pages.dev/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BLUEPING_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ to: "me", text: "Hello from Node.js" }),
});
console.log(res.status, await res.json());Run node send.mjs. The message lands on your iPhone, and the API answers 202 with a body like this:
{
"id": "msg_3HkT9cVb2LwQ8sXe5RmN",
"status": "sent",
"to": { "id": "ct_Q4nV7wXk2Rb9sL1mTe8P", "handle": "+14155550132" },
"created_at": "2026-11-04T19:42:09.000Z"
}Keep to.id, the contact id. It works as to in later requests, and webhook events use it to tell you who wrote. The text field takes up to 2,000 characters.
Run the script a few more times and you get a 429 with the code awaiting_reply. The free line allows at most 5 messages in a row before the person replies, and that includes you. Reply from your iPhone and the count starts over.
Invite people with your join link
Your app can text only you and the people who opted in. Each account has a join code and a join link, https://blueping.pages.dev/j/CODE. A person texts your code to the Blueping line once, and from then on your app can text them.
Set the app name people see (1 to 40 characters) with PATCH /v1/me, then get your invite details from POST /v1/invites:
// invite.mjs
const headers = {
Authorization: `Bearer ${process.env.BLUEPING_API_KEY}`,
"Content-Type": "application/json",
};
// The app name people see, 1 to 40 characters
const renamed = await fetch("https://blueping.pages.dev/v1/me", {
method: "PATCH",
headers,
body: JSON.stringify({ name: "Deploy Alerts" }),
});
console.log(renamed.status, await renamed.json());
const res = await fetch("https://blueping.pages.dev/v1/invites", { method: "POST", headers });
const { code, url, sms_url, line } = await res.json();
console.log({ code, url, sms_url, line });url is the join link, code is what people text, and line is the address they text it to. sms_url opens Messages with the code filled in.
The line is shared, and each person talks to one app at a time: texting another app's code switches them, MENU lists their apps, LEAVE leaves the current one, HELP explains, and STOP unsubscribes them from every app on the line. Blueping answers these keywords itself. Texting a code again after STOP is a fresh opt-in.
Send an iMessage to a contact
Once someone has joined, to also accepts their contact id (ct_...), their phone number in E.164 format such as +14155550132, or their email address. Put any of these in place of "me" in send.mjs. To see who joined, list your contacts:
// contacts.mjs
const res = await fetch("https://blueping.pages.dev/v1/contacts", {
headers: { Authorization: `Bearer ${process.env.BLUEPING_API_KEY}` },
});
const { data } = await res.json();
// id, handle, active, opted_out, remaining_before_reply, joined_at, last_message_at
console.table(data);Each row shows whether the person is active (talking to your app right now) or opted_out, and remaining_before_reply, the number of messages you can still send before they answer.
Retry safely with an Idempotency-Key
Networks like to fail at the worst moment: after Blueping sent your text, but before your code saw the answer. A blind retry texts the person twice. Send an Idempotency-Key header instead and reuse it when you retry the same message. Blueping keeps each key for 24 hours, and a repeat gets the first response back, marked by the header idempotent-replayed: true, without a second send.
This helper retries only when no answer arrived, and it turns API errors into a BluepingError with the error code:
// send-text.mjs
import { randomUUID } from "node:crypto";
export class BluepingError extends Error {
constructor(status, error) {
super(`${error.code}: ${error.message}`);
this.status = status;
this.code = error.code;
this.details = error; // join_url, resets_at or remaining, depending on the code
}
}
export async function sendText(to, text, idempotencyKey = randomUUID()) {
let res;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
res = await fetch("https://blueping.pages.dev/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BLUEPING_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ to, text }),
signal: AbortSignal.timeout(30_000),
});
break;
} catch (err) {
// No answer (timeout or dropped connection): retry with the same key
if (attempt === 3) throw err;
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
}
}
const body = await res.json();
if (!res.ok) throw new BluepingError(res.status, body.error);
return body; // { id, status, to, created_at }
}sendText makes one key per call. When a job queue runs your code, pass a key built from the job, so a job that runs twice within 24 hours still sends once:
// notify.mjs
import { sendText } from "./send-text.mjs";
const message = await sendText("me", "Deploy 4821 is live.", "deploy-4821-live");
console.log(message.id);A replay returns the first answer even when it was an error, such as daily_limit_reached. To try again later after an error, use a new key.
Receive iMessage replies with an Express webhook
When someone texts your app, Blueping POSTs a message.received event to your webhook. It also sends contact.joined, contact.left (with a reason of left or switched) and contact.opted_out as people come and go. A reply looks like this:
{
"id": "evt_8Lq2mP4xW0aTn7Ks1BvQ",
"type": "message.received",
"created_at": "2026-11-04T19:44:31.000Z",
"data": {
"message": {
"id": "msg_9PzR4fYc1KwM6tDq3VxB",
"from": { "id": "ct_Q4nV7wXk2Rb9sL1mTe8P", "handle": "+14155550132" },
"text": "Looks good, ship it.",
"created_at": "2026-11-04T19:44:31.000Z"
}
}
}Install Express, then save the server as webhook.mjs:
npm install express// webhook.mjs
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.BLUEPING_WEBHOOK_SECRET;
if (!secret) throw new Error("Set BLUEPING_WEBHOOK_SECRET first");
function isFromBlueping(rawBody, header = "") {
const pairs = header.split(",").map((part) => part.trim().split("="));
const { t, v1 } = Object.fromEntries(pairs);
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300; // within 5 minutes
if (!fresh || !v1 || !Buffer.isBuffer(rawBody)) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(v1, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
const app = express();
app.post("/blueping", express.raw({ type: "application/json" }), (req, res) => {
if (!isFromBlueping(req.body, req.get("Blueping-Signature"))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // answer first: Blueping expects a 2xx within 10 seconds
switch (event.type) {
case "message.received": {
const { from, text } = event.data.message;
console.log(`${from.handle} (${from.id}): ${text}`);
break;
}
case "contact.joined":
console.log(`Joined: ${event.data.contact.handle}`);
break;
case "contact.left":
console.log(`Left (${event.data.reason}): ${event.data.contact.handle}`);
break;
case "contact.opted_out":
console.log(`Opted out: ${event.data.contact.handle}`);
break;
}
});
app.listen(3000, () => console.log("Listening on http://localhost:3000/blueping"));express.raw() keeps req.body as the exact bytes Blueping signed. The Blueping-Signature header looks like t=1793811600,v1=..., where t is a Unix timestamp in seconds and v1 is the hex HMAC-SHA256 of t, a period and the raw body, keyed with your whole whsec_... secret. The handler recomputes it and compares with crypto.timingSafeEqual, which runs in constant time and throws on buffers of different lengths, hence the length check. The five-minute window on t stops a captured request from being replayed later.
If your app calls app.use(express.json()), register it after this route. When the JSON parser runs first, req.body is no longer the raw bytes and every signature check fails.
Blueping waits up to 10 seconds for a 2xx, so the handler answers first; slow work belongs in a queue. Failed deliveries are retried after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours, so an event can arrive twice if your answer was late or lost: skip event ids you already handled.
The webhook URL must start with https://. For local work, cloudflared tunnel --url http://localhost:3000 prints a temporary public address. Register it with PUT /v1/webhook, which returns your signing secret:
// register-webhook.mjs
const res = await fetch("https://blueping.pages.dev/v1/webhook", {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.BLUEPING_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ url: process.argv[2] }),
});
console.log(await res.json()); // { url, secret: "whsec_..." }# Terminal 1: a temporary public https address for port 3000
cloudflared tunnel --url http://localhost:3000
# Terminal 2: register that address, then start the server with the secret
node register-webhook.mjs https://your-tunnel.trycloudflare.com/blueping
export BLUEPING_WEBHOOK_SECRET="whsec_..."
node webhook.mjsReply to a test message from your iPhone and it shows up in the terminal. To answer, call sendText(from.id, ...): their reply has already reset the five-in-a-row count.
When someone refuses in their own words, such as "please stop texting me", report it with POST /v1/contacts/{id}/opt-out. Like STOP, it unsubscribes them from every app on the line.
Handle the errors the API returns
Every error has the same shape, {"error": {"code": "...", "message": "..."}}, sometimes with an extra field you can act on. Branch on code:
contact_not_joined(409): the person is not connected to your app, because they never texted your code or they left or switched apps. Share thejoin_urlfrom the error through a channel you already have.contact_opted_out(409): the person unsubscribed. Do not retry; they opt back in by texting your code.daily_limit_reached(429): your account sent its 100 messages for the day.resets_atsays when the count resets, at 00:00 UTC.awaiting_reply(429): you reached the limit of messages in a row, andremainingis0. Wait for amessage.receivedfrom that person.line_busy(429): the shared line reached its daily volume. Try again later.line_error(502): the line could not send this message. Retry later.unauthorized(401): check theAuthorizationheader. A key you replaced withPOST /v1/keysno longer works.invalid_json,text_required,text_too_longandto_required(400): fix the request, since sending it again unchanged will not help.
With sendText from earlier, the branching fits in one switch:
// errors.mjs
import { BluepingError, sendText } from "./send-text.mjs";
try {
await sendText("+14155550132", "Build 4821 failed on main.");
} catch (err) {
if (!(err instanceof BluepingError)) throw err; // no answer after 3 tries
switch (err.code) {
case "contact_not_joined":
console.log(`Not connected. Invite them: ${err.details.join_url}`);
break;
case "contact_opted_out":
console.log("Unsubscribed. Wait until they text your code again.");
break;
case "daily_limit_reached":
console.log(`Daily limit reached. It resets at ${err.details.resets_at}.`);
break;
case "awaiting_reply":
console.log("Waiting for their reply before sending more.");
break;
case "line_busy":
case "line_error":
console.log("The line cannot send right now. Retry later with a new key.");
break;
default:
throw err; // 400 or 401: fix the request or the key
}
}Poll for new messages instead of using a webhook
If your code cannot receive HTTP requests, as with a laptop script or a cron job, call GET /v1/messages instead. Without after, it returns your latest messages, oldest first. Pass the next_after value of each page as after in the next request to get only newer ones, and keep those whose direction is in, since the list also holds what you sent:
// poll.mjs
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let after; // keep this in a file or a database in a real app
async function poll() {
const url = new URL("https://blueping.pages.dev/v1/messages");
url.searchParams.set("limit", "50");
if (after) url.searchParams.set("after", after);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.BLUEPING_API_KEY}` },
});
if (!res.ok) throw new Error(`GET /v1/messages returned ${res.status}`);
const page = await res.json();
for (const message of page.data) {
if (message.direction === "in") {
console.log(`${message.contact.handle}: ${message.text}`);
}
}
if (page.next_after) after = page.next_after;
return page.data.length;
}
while (true) {
let count = 0;
try {
count = await poll();
} catch (err) {
console.error(err.message);
}
if (count < 50) await sleep(5000); // a full page means more may be waiting
}The first page is recent history, so the script prints a few older messages before it follows new ones. Save after somewhere durable and a restart resumes where it stopped.
What the free plan includes
The free plan costs $0 and is the only plan today, with these limits:
- 100 messages a day per account, reset at 00:00 UTC.
GET /v1/meshows today's usage. - At most 5 messages in a row to one person, until they reply.
- Text only in API v1, up to 2,000 characters per message, with no attachments yet.
- iMessage only, so the people you text need an iPhone, iPad or Mac (no SMS or Android).
- No typing indicators, read receipts or tapbacks.
A dedicated number is coming soon, with a waitlist.
Questions
Does Apple offer an official iMessage API for Node.js?
No. Apple has no public iMessage API. Blueping is an independent service, not affiliated with Apple, with an HTTPS API you can call from Node.js using fetch.
Do I need a Mac to send iMessages from Node.js?
No. Your code only sends HTTPS requests to blueping.dev, so it runs wherever Node.js 18 or later runs. You do need an iPhone to sign up, because you get your key by texting a code from it.
Can I send an iMessage to any phone number?
No. You can text yourself and the people who texted your join code to the line. A request to anyone else returns a contact_not_joined error that includes your join link.
Can I text Android phones or send SMS?
No. Blueping is iMessage only, so the person needs an iPhone, iPad or Mac. There is no SMS fallback.
How many iMessages can I send for free?
Up to 100 a day per account, resetting at 00:00 UTC, with at most 5 in a row to one person before they reply. A dedicated number is coming soon, with a waitlist.
Can I send images or attachments?
Not yet. API v1 sends text only, up to 2,000 characters per message.