Send real iMessages (the blue-bubble kind) from your backend.
Loop Message is a cloud API that lets you send Apple iMessages programmatically. Unlike SMS or email, iMessages land in the same app your contacts use all day. No carrier fees. No SMS character limits. Full support for effects, reactions, attachments, group threads, and voice messages. This SDK wraps their entire API in TypeScript so you can ship in minutes.
Zero runtime dependencies. Fully typed. Bun-native. Node.js compatible.
You need a Loop Message account and an API key to use this SDK. Get one at loopmessage.com.
bun add @loopmessagesdk/loopmessage-sdk
# or
npm install @loopmessagesdk/loopmessage-sdkimport { LoopMessageClient } from "@loopmessagesdk/loopmessage-sdk";
const client = new LoopMessageClient({
apiKey: process.env.LOOP_API_KEY!,
defaultSender: process.env.LOOP_SENDER_ID,
});
// Send
const { message_id } = await client.messages.send({
contact: "+13231112233",
text: "hey",
});
// Check status
const { status } = await client.messages.getStatus(message_id);Your API key goes in the Authorization header with no Bearer prefix, per Loop Message docs.
Never expose it client-side.
const client = new LoopMessageClient({ apiKey: process.env.LOOP_API_KEY! });| Method | Description |
|---|---|
send(params) |
Send text to a contact (phone or email) |
sendGroup(params) |
Send text to an iMessage group |
sendVoice(params) |
Send an audio file (mp3, wav, m4a, caf, aac) |
sendReaction(params) |
React to a message (prefix - to remove) |
showTyping(params) |
Show typing indicator and/or mark read |
markRead(params) |
Mark conversation as read |
getStatus(messageId) |
Check delivery status |
// Effects
await client.messages.send({ contact, text: "boom", effect: "fireworks" });
// With attachment (max 3 HTTPS image URLs)
await client.messages.send({
contact,
text: "check this",
attachments: ["https://..."],
});
// Reply to a specific message
await client.messages.send({
contact,
text: "got it",
reply_to_id: inbound_message_id,
});
// Show typing then reply
await client.messages.showTyping({ message_id: inbound_id, typing: 3 });
await client.messages.send({ contact, text: "one sec" });
// Reaction / un-reaction
await client.messages.sendReaction({
contact,
message_id: "uuid",
reaction: "love",
});
await client.messages.sendReaction({
contact,
message_id: "uuid",
reaction: "-love",
});
// Voice
await client.messages.sendVoice({
contact,
media_url: "https://example.com/audio.m4a",
});
// Status check
const { status, error_code } = await client.messages.getStatus(message_id);
// status: 'processing' | 'delivered' | 'failed' | 'unknown'Effects: slam loud gentle invisibleInk echo spotlight balloons confetti love lasers fireworks shootingStar celebration
Channels: imessage sms rcs whatsapp (SMS doesn't support subject, effect, or reply)
| Method | Description |
|---|---|
list(params?) |
One page of contacts |
listAll(params?) |
Async generator that yields every page automatically |
collectAll(params?) |
Flat array of all contacts, auto-paged |
checkStatus(contact) |
Check opt-in status for one contact |
unsubscribe(contact) |
Remove a contact from your audience |
messageHistory(contact, params?) |
Full inbound/outbound history for a contact |
// One page
const { items, count } = await client.audience.list({ per_page: 100 });
// All contacts as a flat array (auto-pages through everything)
const allContacts = await client.audience.collectAll();
console.log(`Total: ${allContacts.length}`);
// Stream page-by-page (better for large lists)
for await (const page of client.audience.listAll()) {
for (const contact of page) console.log(contact.value);
}
const { status } = await client.audience.checkStatus("+13231112233");
// status: 'active' | 'unsubscribed' | 'unknown'
await client.audience.unsubscribe("+13231112233");
const history = await client.audience.messageHistory("+13231112233", {
direction: "inbound",
});// Broadcast: same message to all
await client.campaigns.create({
name: "April drop",
text: "new drop. check it.",
contacts: ["+13231112233", "+13232223344"],
timezone: "America/Los_Angeles",
});
// Individualized: different text per contact
await client.campaigns.create({
name: "Personal outreach",
messages: [
{ contact: "+13231112233", text: "yo alex" },
{ contact: "+13232223344", text: "yo jordan" },
],
});const { items: senders } = await client.senders.list();
const active = senders.filter((s) => s.status === "active");
const { items: pools } = await client.pools.list();const links = await client.optIn.generateUrl({
body: "Reply [opt-in-code] to subscribe.",
utm_campaign: "spring-launch",
});
// links.url: universal HTTPS
// links.imessage: iOS 13+ deep link
// links.sms: iOS 12 fallbackLoop Message POSTs events to your server. You must respond with HTTP 200 within 15 seconds.
import {
LoopMessageClient,
createWebhookHandler,
} from "@loopmessagesdk/loopmessage-sdk";
const client = new LoopMessageClient({ apiKey: process.env.LOOP_API_KEY! });
const handler = createWebhookHandler({
secret: process.env.WEBHOOK_SECRET, // optional auth
handlers: {
onMessageInbound: async ({ contact, text, message_id }) => {
await client.messages.showTyping({ message_id, typing: 3 });
await client.messages.send({ contact, text: `got it: ${text}` });
},
onMessageFailed: ({ error_code, contact }) => {
console.error(`Delivery to ${contact} failed: code ${error_code}`);
},
onOptIn: ({ contact }) => {
console.log(`${contact} opted in`);
},
onSenderNameUpdated: ({ sender, status }) => {
console.log(`Sender ${sender} => ${status}`);
},
onAny: (payload) => {
// Fires after the specific handler for every event
console.log("event received:", payload.event);
},
},
});
Bun.serve({ port: 8080, fetch: handler });import { parseWebhook, dispatchWebhook, isMessageInbound } from '@loopmessagesdk/loopmessage-sdk'
// Parse raw body string or pre-parsed object
const payload = parseWebhook(rawBodyString)
// Type-safe switch via type guards
if (isMessageInbound(payload)) {
console.log(payload.contact, payload.text, payload.channel)
if (payload.group) console.log('group message:', payload.group.id)
}
// Or full dispatch
await dispatchWebhook(payload, { onMessageInbound: async (p) => { ... } })| Event | When |
|---|---|
message_inbound |
Contact sent you a message |
message_delivered |
Your message was delivered |
message_failed |
Your message failed (check error_code) |
message_reaction |
Contact reacted to your message |
opt-in |
Contact completed opt-in |
inbound_call |
Contact attempted a FaceTime call |
sender_name_updated |
Your sender name changed status |
unknown |
Unrecognized event |
All API errors throw LoopMessageError:
import { LoopMessageError } from "@loopmessagesdk/loopmessage-sdk";
try {
await client.messages.send({ contact: "+13231112233", text: "hey" });
} catch (err) {
if (err instanceof LoopMessageError) {
console.error(err.code); // numeric Loop error code
console.error(err.httpStatus); // HTTP status
console.error(err.message); // human-readable description
console.error(err.isRetryable); // true for 1010, 1020, 1030
console.error(err.isFatal); // true for auth/billing errors
}
}The client auto-retries on codes 1010, 1020, 1030 (transient send failures) up to 2 times.
const client = new LoopMessageClient({
apiKey: "your-key", // required
defaultSender: "sender-uuid", // optional
timeout: 15_000, // ms, default 15s
retries: 2, // retries on transient errors
retryDelay: 2_000, // base delay between retries (multiplied by attempt)
baseUrl: "https://a.loopmessage.com", // override for testing
});| Code | Meaning |
|---|---|
| 100 | Bad request |
| 110 | Missing credentials |
| 130 | Invalid API key |
| 160 | Invalid contact |
| 240 | Sender not activated |
| 340 | Recipient blocked messages |
| 500 | Contact opted out |
| 510 | No recent conversation |
| 520 | Recipient must initiate first |
| 580 | Invalid effect |
| 1010-1030 | Transient send failure (auto-retried) |
Full table in src/errors.ts.
All glory to God! ✝️❤️