npm package: @loopmessagesdk/loopmessage-sdk
Description: Send real iMessages (blue bubbles) from your backend. Fully-typed TypeScript SDK for the Loop Message cloud API. Covers send, campaigns, webhooks, opt-ins, reactions, and voice. Zero dependencies. Bun + Node.js.
Latest version: 1.1.1

# @loopmessagesdk/loopmessage-sdk

Send real iMessages (the blue-bubble kind) from your backend.

[Loop Message](https://loopmessage.com) 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](https://loopmessage.com).

## Install

```bash
bun add @loopmessagesdk/loopmessage-sdk
# or
npm install @loopmessagesdk/loopmessage-sdk
```

## Quick Start

```ts
import { 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);
```

## Auth

Your API key goes in the `Authorization` header with **no Bearer prefix**, per Loop Message docs.
Never expose it client-side.

```ts
const client = new LoopMessageClient({ apiKey: process.env.LOOP_API_KEY! });
```

## API Surface

### `client.messages`

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

```ts
// 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)

### `client.audience`

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

```ts
// 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",
});
```

### `client.campaigns`

```ts
// 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" },
  ],
});
```

### `client.senders` and `client.pools`

```ts
const { items: senders } = await client.senders.list();
const active = senders.filter((s) => s.status === "active");

const { items: pools } = await client.pools.list();
```

### `client.optIn`

```ts
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 fallback
```

## Webhooks

Loop Message POSTs events to your server. You must respond with HTTP 200 within 15 seconds.

### Bun server (recommended)

```ts
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 });
```

### Manual parsing

```ts
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) => { ... } })
```

### Webhook event types

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

## Error Handling

All API errors throw `LoopMessageError`:

```ts
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.

## Config Options

```ts
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
});
```

## Error Code Reference

| 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! ✝️❤️