BetaThe V4 API is in beta — endpoints and functionality may change.

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.js

Warning

Always encodeURIComponent phone 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