<!-- https://missblue.dev/blog/send-imessage-with-node -->

[All articles](https://missblue.dev/blog) Miss Blue field notes

# How to send iMessage with Node.js

Node cannot talk to Apple’s delivery layer directly, on any platform. What it can do is drive a provider that operates that layer — and the difference between a script that works once and a service that keeps working is almost entirely about retries, idempotency, and inbound events.

Published August 27, 2026 15 minute read

In this guide [What Node can and cannot do](#section-1) [Keep the call server-side](#section-2) [The request shape](#section-3) [Validate before you spend a send](#section-4) [Retries without duplicate messages](#section-5) [Map HTTP outcomes into errors your application understands](#section-6) [Receiving replies and delivery events](#section-7) [Ordering is not guaranteed](#section-8) [Where the send belongs in a Node app](#section-9) [Test without messaging real customers](#section-10) [Protect the credentials and the conversation data](#section-11) [A staged rollout](#section-12)

Build with Miss Blue **Turn the next message into a real reply.**

Get a blue line for your product, agent, or team. Use the Message Center today and connect the API anytime.

[Create your account](https://missblue.dev/signup)  [Explore the API](https://missblue.dev/imessage-api)

Build with Miss Blue **Turn the next message into a real reply.**

Get a blue line for your product, agent, or team. Use the Message Center today and connect the API anytime.

[Create your account](https://missblue.dev/signup)  [Explore the API](https://missblue.dev/imessage-api)

01

## What Node can and cannot do

There is no npm package that sends an iMessage. Apple publishes no general-purpose send API, so nothing installed from a registry can reach the delivery layer on its own. Packages that appear to do it either shell out to AppleScript on a local Mac or wrap a third-party provider’s HTTP API.

The AppleScript path deserves a moment. It works, on a Mac, for the account signed into Messages on that Mac, driven by a process with the right permissions. It is fine for a personal automation and unsuitable as a service: it needs a machine that stays awake, it has no delivery events, no idempotency, no team access, and no recovery story. Everything below assumes the other path — an HTTP API in front of infrastructure somebody else operates.

02

## Keep the call server-side

The API key is a bearer credential that can send from your line. It belongs in a server environment variable, never in a Next.js client component, a browser bundle, a mobile app, or anything shipped to a device you do not control. A key in a client bundle is a key that will eventually send messages you did not write.

In a Next.js application that means a route handler, a server action, or a background job — never a module that can be imported into the client. Read the key from the environment at call time and let the process fail loudly at startup if it is missing, rather than at 2am on the first send.

03

## The request shape

A send is one POST. Miss Blue’s takes the number to send from, the recipient, and the text, with a bearer credential in the header. Set an explicit timeout — Node’s fetch has none by default, and a request with no deadline is a worker that eventually stops doing anything else.

Return the provider’s message identifier to your caller and store it. It is the key you will need to reconcile delivery events, to look up what happened to a specific send, and to answer the support question that starts with “did she ever get the message?”

```
// lib/messages.ts — server only
const BASE = "https://api.missblue.dev";

export async function sendMessage({
  numberId,
  recipient,
  text,
  idempotencyKey,
}: {
  numberId: string;
  recipient: string;
  text: string;
  idempotencyKey: string;
}) {
  const response = await fetch(`${BASE}/v1/messages`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MISS_BLUE_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({ number_id: numberId, recipient, text }),
    signal: AbortSignal.timeout(15_000),
  });

  if (!response.ok) {
    throw new SendError(response.status, await response.text());
  }

  return response.json() as Promise<{ id: string; status: string }>;
}
```

04

## Validate before you spend a send

Check the recipient format, the text length, and the suppression list in your own code before the request leaves. A rejected request costs a round trip; a successfully delivered message to someone who opted out costs considerably more.

Normalize phone numbers to a single canonical form at the boundary and store them that way. Two representations of the same number become two conversations, two consent records, and eventually two messages to one person about the same thing.

-   Normalize the recipient to one canonical format
-   Check the suppression list on every send path, not just campaigns
-   Bound the text length before the provider does
-   Reject empty sends early — a message needs text or attachments
-   Confirm the number ID belongs to the project you think it does

05

## Retries without duplicate messages

Node’s failure modes make duplicates easy. A request times out, a worker retries, and the original send had already succeeded — the customer gets the message twice and your log shows one send. An idempotency key makes the retry safe: derive it from the thing you are sending, not from a fresh random value on each attempt.

Generate the key once, at the point where you decide a message should exist, and reuse it across every retry of that decision. A key generated inside the retry loop defeats the entire mechanism.

```
import { setTimeout as sleep } from "node:timers/promises";

const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);

export async function sendWithRetry(input: SendInput, attempts = 4) {
  // Derived once, outside the loop — a fresh key per attempt is a duplicate.
  const idempotencyKey = input.idempotencyKey;

  for (let attempt = 1; ; attempt++) {
    try {
      return await sendMessage({ ...input, idempotencyKey });
    } catch (error) {
      const retryable =
        error instanceof SendError ? RETRYABLE.has(error.status) : isTransient(error);

      if (!retryable || attempt >= attempts) throw error;

      // Exponential backoff with jitter, so a provider blip does not
      // become a synchronized stampede from every worker at once.
      const base = 250 * 2 ** (attempt - 1);
      await sleep(base + Math.random() * base);
    }
  }
}
```

06

## Map HTTP outcomes into errors your application understands

A 4xx and a 5xx mean different things to your workflow. A 401 is a deployment problem and should page someone. A 422 is a bad recipient and should mark the contact, not retry forever. A 429 is pacing and should slow the queue. A 503 is transient and should back off.

Collapse all of them into a generic “send failed” and you lose the ability to act. Define a small error type that carries the status and the provider’s body, and branch on it at the call site.

-   401 or 403 — credential or scope problem, alert immediately, do not retry
-   404 — wrong number ID or message ID, a code or config bug
-   422 — invalid recipient or payload, mark and stop retrying
-   429 — slow the queue, respect any retry hint
-   5xx — retry with backoff, then dead-letter
-   Timeout — retry with the same idempotency key, never a new one

07

## Receiving replies and delivery events

Sending is the easy half. A conversation exists because messages come back, and those arrive as webhooks — a POST to an endpoint you expose, at a time you do not control, sometimes more than once for the same event.

Verify the signature before you trust the body, deduplicate on the event identifier, and acknowledge quickly. Miss Blue signs with the same scheme Stripe uses — a Miss-Blue-Signature header carrying a timestamp and a hex HMAC-SHA256 over the timestamp joined to the raw body — so the timestamp is covered by the signature and a captured delivery cannot be replayed at you later. Do the real work on a queue. A webhook handler that opens a database transaction, calls a model, and posts to Slack before responding is a handler that will time out and be retried, producing exactly the duplicates you were trying to avoid.

```
// app/api/webhooks/missblue/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export async function POST(request: Request) {
  const raw = await request.text();          // raw body, before JSON.parse
  const header = request.headers.get("Miss-Blue-Signature") ?? "";

  // `t=<unix>,v1=<hex hmac>` over `"{t}.{body}"` — the same scheme Stripe
  // uses, so the timestamp is inside the signed string and a captured
  // delivery cannot be replayed at you later.
  const parts = Object.fromEntries(
    header.split(",").map((pair) => pair.split("=", 2) as [string, string]),
  );
  const timestamp = Number(parts.t);
  const provided = parts.v1 ?? "";

  if (!Number.isFinite(timestamp)) return new Response("bad signature", { status: 401 });
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    return new Response("stale signature", { status: 401 });
  }

  const expected = createHmac("sha256", process.env.MISS_BLUE_WEBHOOK_SECRET!)
    .update(`${timestamp}.${raw}`)
    .digest("hex");

  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response("bad signature", { status: 401 });
  }

  const event = JSON.parse(raw);

  // Idempotent by event id: a redelivery must be a no-op, not a second reply.
  if (await alreadyProcessed(event.id)) return new Response(null, { status: 204 });

  await enqueue(event);                       // acknowledge fast, work later
  return new Response(null, { status: 204 });
}
```

08

## Ordering is not guaranteed

Webhook deliveries can arrive out of order, and a naive handler that applies the latest payload wins will happily overwrite a delivered status with a stale queued one. Store the provider’s timestamp or sequence and ignore updates older than what you already have.

The same applies to two replies arriving within a second of each other. Order by the event’s own ordering field when rendering a thread, not by the time your server happened to process it.

09

## Where the send belongs in a Node app

Do not send from inside a request handler that a user is waiting on. Enqueue the intent, return, and let a worker perform the send with retries. This keeps request latency independent of provider latency and gives retries somewhere to live.

In a serverless deployment this matters more, not less: a function that ends when the response is returned will kill an in-flight retry loop. Use a queue, a durable workflow, or a background job runner rather than a promise you forgot to await.

-   Enqueue the intent in the request path
-   Send from a worker with retries and backoff
-   Never fire-and-forget a floating promise in a serverless function
-   Persist the message ID before you consider the send done
-   Reconcile with delivery events rather than assuming success

10

## Test without messaging real customers

Point the base URL at a stub in tests and assert on the request you would have sent: the number ID, the normalized recipient, the body, the idempotency key, and the timeout. That catches the majority of integration bugs without a single real send.

For end-to-end checks, use a sandbox or shared testing number and a short list of internal recipients. Keep a hard guard in code that refuses to send to anything outside that list unless an explicit environment flag is set — the guard costs nothing and prevents the single worst incident in this category.

11

## Protect the credentials and the conversation data

Keep API keys and webhook secrets out of the repository and out of logs. Redact message bodies from error reporting; a stack trace with a customer’s text in it will be replicated into every observability system you use and will outlive the conversation.

Scope credentials narrowly where the provider allows it, rotate them on a schedule, and make revocation something you have actually tested rather than something you assume works.

12

## A staged rollout

Ship it in the order that surfaces problems while they are cheap. Each stage exercises a failure mode that the previous stage could not.

By the last stage you have a service rather than a script, and the difference will be obvious the first time a provider has a bad ten minutes.

-   One send to your own number from a local script
-   The same send from a deployed worker, with timeouts and structured errors
-   Idempotent retries, verified by forcing a timeout
-   Webhook receipt with signature verification and deduplication
-   Delivery reconciliation against stored message IDs
-   Queue-backed sending with backoff and a dead-letter path
-   Suppression, consent checks, and a human takeover route

Frequently asked questions

## Quick answers

Is there an npm package to send iMessage?+

Nothing that reaches Apple directly. Packages either shell out to AppleScript on a local Mac — fine for personal automation, unsuitable as a service — or wrap a third-party provider’s HTTP API. Apple publishes no general-purpose send API.

Can I send iMessage from Node on Linux or in a container?+

Only through a provider. The Apple side has to run on Apple software; Node’s job is to make an authenticated HTTP call and to handle the events that come back.

How do I avoid sending duplicate messages?+

Generate one idempotency key at the moment you decide a message should exist and reuse it across every retry of that decision. A key generated inside the retry loop produces exactly the duplicates it was meant to prevent.

Should I send from a Next.js route handler?+

Enqueue there and send from a worker. A serverless function that returns its response will kill an in-flight retry loop, and users should not wait on provider latency.

Primary sources

## Read the documentation.

-   [Miss Blue API documentation](https://missblue.dev/docs)
-   [GitHub Docs: validating webhook deliveries](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)
-   [Apple Developer: Messages framework](https://developer.apple.com/documentation/messages)

Explore the platform

## Turn the research into a working conversation.

[For developers

### iMessage API

Build two-way blue-bubble conversations into your product or agent.

Explore](https://missblue.dev/imessage-api) [For teams

### Message Center

Use a complete shared conversation platform without writing code.

Explore](https://missblue.dev/features/message-center) [For industry teams

### iMessage use cases

See practical playbooks for real estate, plumbing, HVAC, roofing, and home services.

Explore](https://missblue.dev/use-cases) [Miss Blue Research

### iMessage vs SMS study

Read the beta observations, reported benchmarks, methodology, caveats, and pilot framework.

Explore](https://missblue.dev/imessage-response-rate-study)

Ready to build?

## Send your first blue bubble with Miss Blue.

[Explore the iMessage API](https://missblue.dev/imessage-api)
