Send iMessage with Node.js
A Node.js (JavaScript and TypeScript) tutorial for sending and receiving iMessages via the Blooio REST API — fetch, async/await, Express webhooks, and bulk patterns.
This guide walks through sending and receiving iMessages from Node.js using the Blooio REST API. Works with any Node 18+ runtime — the native fetch means zero dependencies for the basic case.
What you'll need
- Node.js 18 or newer (for native
fetch) - A Blooio API key from the dashboard
- A phone number the recipient can receive iMessages on
No SDK required. Blooio's REST API works with fetch, axios, undici, or any HTTP client.
Send your first iMessage
JavaScript
const API_KEY = process.env.BLOOIO_API_KEY
const BASE_URL = 'https://api.blooio.com/v2/api'
async function sendImessage(to, text) {
const chatId = encodeURIComponent(to)
const res = await fetch(`${BASE_URL}/chats/${chatId}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text }),
})
if (!res.ok) {
throw new Error(`Blooio ${res.status}: ${await res.text()}`)
}
return res.json()
}
const result = await sendImessage('+15551234567', 'Hello from Node.js!')
console.log(`Message ID: ${result.message_id}, status: ${result.status}`)
TypeScript
type SendResponse = {
message_id: string
status: 'queued' | 'sent' | 'delivered' | 'failed'
}
const API_KEY = process.env.BLOOIO_API_KEY!
const BASE_URL = 'https://api.blooio.com/v2/api'
async function sendImessage(to: string, text: string): Promise<SendResponse> {
const chatId = encodeURIComponent(to)
const res = await fetch(`${BASE_URL}/chats/${chatId}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text }),
})
if (!res.ok) {
throw new Error(`Blooio ${res.status}: ${await res.text()}`)
}
return res.json() as Promise<SendResponse>
}
const result = await sendImessage('+15551234567', 'Hello from TypeScript!')
console.log(`Message ID: ${result.message_id}, status: ${result.status}`)Run it:
export BLOOIO_API_KEY=sk_live_your_key_here
node send.jsWarning
Always
encodeURIComponentphone numbers. Without it the+becomes a space and the request fails silently with a 400.
Check capability before sending
Blooio only charges for iMessages that successfully dispatch through Apple's network, but you should still check capability when building fallback flows:
async function hasImessage(phone: string): Promise<boolean> {
const chatId = encodeURIComponent(phone);
const res = await fetch(`${BASE_URL}/contacts/${chatId}/capabilities`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (!res.ok) return false;
const { capabilities } = await res.json();
return Boolean(capabilities?.imessage);
}
if (await hasImessage("+15551234567")) {
await sendImessage("+15551234567", "Blue bubbles only!");
}Attachments and multipart messages
Send images, videos, or PDFs by passing public URLs:
await fetch(`${BASE_URL}/chats/${chatId}/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text: "Check out this photo",
attachments: ["https://example.com/photo.jpg"],
}),
});For a single bubble containing text and a link/photo use parts:
body: JSON.stringify({
parts: [
{ text: 'Order update — tracking attached' },
{ url: 'https://tracking.example.com/abc', name: 'Track package' },
],
}),See Attachments for size limits and Link previews for rich-link behaviour.
Bulk sending with concurrency control
Don't fire 1,000 parallel requests. Use a concurrency limiter — the p-limit package is the simplest option:
npm install p-limit
const limit = pLimit(10); // 10 concurrent sends
const recipients = ["+15551234567", "+15557654321" /* ... */];
const results = await Promise.allSettled(
recipients.map((to) => limit(() => sendImessage(to, `Hi ${to}!`))),
);
const successes = results.filter((r) => r.status === "fulfilled").length;
const failures = results.filter((r) => r.status === "rejected").length;
console.log(`Sent ${successes}, failed ${failures}`);Note
For reliable bulk delivery use a queue (BullMQ, PgBoss) and persist an idempotency key per recipient so retries don't create duplicates.
Idempotency
Pass an Idempotency-Key header on POST requests to make them safe to retry:
await fetch(`${BASE_URL}/chats/${chatId}/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `order-${orderId}-shipped`,
},
body: JSON.stringify({ text: `Order ${orderId} shipped!` }),
});Retries with the same key within 24 hours return the original response without creating a duplicate. Use a deterministic key derived from your domain entity, not a random UUID.
Receive incoming iMessages (Express)
Blooio delivers inbound messages and status events via webhooks. Minimal Express receiver:
const app = express();
// Raw body is required for signature verification
app.post(
"/webhooks/blooio",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("x-blooio-signature") ?? "";
const event = req.header("x-blooio-event") ?? "";
const secret = process.env.BLOOIO_WEBHOOK_SECRET;
const expected = crypto
.createHmac("sha256", secret)
.update(req.body)
.digest("hex");
if (
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
) {
return res.sendStatus(401);
}
const payload = JSON.parse(req.body.toString("utf8"));
if (event === "message.received") {
const { from, text } = payload.data;
console.log(`${from}: ${text}`);
}
res.sendStatus(200);
},
);
app.listen(3001, () => console.log("Listening on :3001"));Register the webhook URL with Blooio once it's reachable — for local dev, tunnel with ngrok: see Receive webhooks locally.
Note
See Webhook signatures for the full signing algorithm and replay-attack prevention.
Error handling and retries
Blooio returns standard HTTP status codes and a consistent JSON error body:
async function sendWithRetry(
to: string,
text: string,
maxAttempts = 3,
): Promise<SendResponse> {
const idempotencyKey = crypto.randomUUID();
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const chatId = encodeURIComponent(to);
const res = await fetch(`${BASE_URL}/chats/${chatId}/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ text }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) return body;
// Messaging-safety and configured send limits do not use timer retries.
const retryAfter = res.headers.get("retry-after");
if (res.status >= 400 && res.status < 500 && !retryAfter) {
throw new Error(
`Blooio ${res.status} ${body.code ?? ""}: ${body.message ?? "request rejected"}`,
);
}
const retryAfterMs = Number(retryAfter ?? "0") * 1000;
const backoffMs = Math.max(retryAfterMs, 2 ** attempt * 250);
await new Promise((r) => setTimeout(r, backoffMs));
}
throw new Error(`Blooio: failed after ${maxAttempts} attempts`);
}See Messaging safety limits and recovery for 403
and 429 send-limit handling, and Error handling for
the complete error taxonomy.
Environment and secrets
Keep keys out of the codebase:
# .env
BLOOIO_API_KEY=sk_live_...
BLOOIO_WEBHOOK_SECRET=whsec_...Going further
- Message sending basics — chat IDs, mentions, reply threading
- iMessage REST API reference — conventions, pagination, idempotency rules
- Send with Python — same patterns in Python
- Twilio fallback integration — SMS backup for non-iMessage numbers
- API reference — every endpoint and parameter