# Send SMS marketing broadcasts

Source: https://joinsimplesms.com/use-cases/marketing-broadcasts
Index: https://joinsimplesms.com/llms.txt

Two conditions come before any code. Every recipient must have agreed to receive marketing texts from you, and a US local number must be linked to an approved A2P 10DLC registration for the `marketing` use case before it can text anyone except your own verified numbers. With both in place, you build a contact list, cut it into segments, and send one personalized message to up to 10,000 people per batch.

## What you build

- **A synced contact list.** Your database pushes customers to `POST /v1/contacts/import`, 500 per request, with the opt-in evidence for each. An account holds up to 50,000 contacts, one per phone number.
- **Segments.** Saved filters over contacts ("state is TN and plan is pro"), evaluated when they are used.
- **A subscription topic** for marketing, so someone can stop promotions and keep order updates.
- **A broadcast pipeline**: dry run, review the rejects and the cost, send or schedule, watch progress, pause or cancel if something looks wrong, then read the results.

## How it works

1. File a [registration](/docs/compliance) with `use_case: "marketing"` (or `mixed` for service messages plus marketing). At least one of your example messages must say how to stop; the example check requires it for these two use cases.
2. Import contacts. Each row can carry `opt_in` with a status, date and source, which is stored in the consent ledger. Add `"dry_run": true` to check a file without writing anything.
3. Create a segment with `POST /v1/segments`: `match` is `all` or `any`, with up to 10 rules over tags, names, state, custom fields, topic subscription and creation date. `GET /v1/segments/{id}/preview?topic_id=tp_marketing` returns `matched`, `opted_out`, `topic_unsubscribed` and `eligible`.
4. Dry-run the batch: `POST /v1/batches` with `segment_id`, `topic_id`, the `body` and `"dry_run": true`. You get counts, every rejected row with its reason (`invalid`, `duplicate`, `opted_out`, `missing_variable`, `landline`, `empty_after_merge`), a rendered preview, the segment count and the estimated cost.
5. Send it by repeating the call without `dry_run`, or add `scheduled_at` up to 30 days out. The response is a batch with a `bc_` id and live `counts`.
6. Control it: `POST /v1/batches/{id}/pause`, `/resume`, `/cancel`, and `/retry` once it is complete. Retry re-queues only transient failures, at most 3 attempts per recipient.
7. Read `GET /v1/batches/{id}/results` for delivered, failed, pending, replies, stops and `spent_usd`, with failures grouped by code.

## Code

Sync contacts from Node.js with the SDK, then send the batch with `fetch` (the SDK does not wrap batches):

```js
import { SimpleSMS } from 'joinsimplesms';

const sms = new SimpleSMS(process.env.SIMPLESMS_API_KEY);

// Up to 500 per call. Existing contacts are enriched, never wiped.
export async function syncContacts(customers) {
  const result = await sms.contacts.import(
    customers.map((c) => ({
      phoneNumber: c.phone,
      name: c.name,
      tags: ['newsletter'],
      fields: { plan: c.plan },
    }))
  );
  return result; // { created, updated, skipped: [{ index, value, reason }] }
}

async function batches(body) {
  const res = await fetch('https://api.joinsimplesms.com/v1/batches', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.SIMPLESMS_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error((await res.json()).error.message);
  return res.json();
}

const broadcast = {
  from: '+15005550100',
  name: 'November sale',
  body: 'Acme Outfitters: {{first_name}}, 20% off boots through Sunday at acme.example/sale. Reply STOP to opt out.',
  tags: ['newsletter'],
  topic_id: 'tp_marketing',
};

const check = await batches({ ...broadcast, dry_run: true }); // sends nothing
console.log(check);
const batch = await batches(broadcast); // { id: "bc_...", counts: { ... } }
```

Python, syncing contacts:

```python
from joinsimplesms import SimpleSMS

client = SimpleSMS()  # reads SIMPLESMS_API_KEY

def sync_contacts(customers) -> dict:
    return client.contacts.import_([
        {"phone_number": c.phone, "name": c.name, "tags": ["newsletter"], "fields": {"plan": c.plan}}
        for c in customers
    ])
```

The same flow with curl, using a saved segment as the audience. In order: import with opt-in evidence, save a segment, dry-run the batch to that segment (drop `dry_run` to send), then pause a batch mid-send and read its results:

```bash
curl -X POST https://api.joinsimplesms.com/v1/contacts/import \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"contacts": [
        {"phone_number": "+14155550132", "first_name": "Jane", "state": "TN",
         "opt_in": {"status": true, "at": "2026-03-01", "source": "web_form"}}
      ]}'

curl -X POST https://api.joinsimplesms.com/v1/segments \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Tennessee, marketing on", "match": "all", "rules": [
        {"field": "state", "op": "is", "value": "TN"},
        {"field": "topic", "key": "tp_marketing", "op": "is", "value": "subscribed"}
      ]}'

curl -X POST https://api.joinsimplesms.com/v1/batches \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+15005550100",
    "body": "Acme Outfitters: {{first_name}}, 20% off boots through Sunday. Reply STOP to opt out.",
    "segment_id": "seg_a1B2c3D4e5F6",
    "topic_id": "tp_marketing",
    "dry_run": true
  }'

curl -X POST https://api.joinsimplesms.com/v1/batches/bc_a1B2c3D4e5F6/pause \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY"
curl https://api.joinsimplesms.com/v1/batches/bc_a1B2c3D4e5F6/results \
  -H "Authorization: Bearer $SIMPLESMS_API_KEY"
```

A webhook that mirrors opt-outs into your own database and records completion:

```js
import express from 'express';
import { verifyWebhook } from 'joinsimplesms';

const app = express();

app.post('/webhooks/simplesms', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = await verifyWebhook({
      payload: req.body, // raw Buffer
      headers: req.headers,
      secret: process.env.SIMPLESMS_WEBHOOK_SECRET,
    });
  } catch {
    return res.status(400).end();
  }
  res.status(200).end();

  if (event.type === 'message.opted_out') await unsubscribeInCrm(event.data.phone);
  if (event.type === 'batch.complete') {
    const { batch_id, total, sent, failed, skipped_opt_out } = event.data;
    await recordBroadcast(batch_id, { total, sent, failed, skipped_opt_out });
  }
});
```

## Compliance notes

- **Consent is your responsibility, and a list is not consent.** The compliance check that runs with a dry run warns when contacts in the audience have no opt-in record, and always warns for an uploaded list. Warnings do not stop a send. Consent, timing and wording are yours under the messaging policy.
- **Keep the proof.** Store opt-in evidence (source, time, page URL, exact disclosure wording) with `POST /v1/consent/{phone}` or the import's `opt_in` column. The docs advise keeping it at least four years after your last message to the number.
- **Opt-outs are enforced per recipient.** Opted-out numbers are dropped when the batch is validated and checked again when each message is sent. Someone who replies STOP while a batch is running is counted in `opted_out` and not texted. An import never opts anyone back in.
- **Say how to stop.** The check warns when the body has no "Reply STOP" instruction.
- **Quiet hours are a warning only.** The check flags a send time outside 8am to 9pm on either US coast. SimpleSMS does not know recipients' time zones and does not hold messages; pick `scheduled_at` accordingly.
- **Topics only narrow.** A batch with a `topic_id` skips anyone unsubscribed from that topic. STOP still stops everything, and subscribing a number to a topic does not opt it back in. Topics apply to batches and broadcasts that name one; `POST /v1/messages` does not take a topic.
- **Register what you send.** Sending promotions on a sender registered for another use case is a policy violation. Carrier review of a registration usually takes 3 to 7 business days.
- **Avoid public link shorteners.** The check warns on them and the registration example check rejects them.

## What it costs

- **$0.009 per message**, any length. A batch is billed as exactly its number of messages, and the dry run returns the estimate first.
- **Worked example:** one full batch of 10,000 recipients is $90.00. A list of 50,000 takes five batches: $450.00.
- **$0.95 per number, per month.**
- **Not billed:** opted-out and topic-unsubscribed recipients (they are skipped), sends that fail before the carrier accepts them, and STOP confirmations.
- **Line-type checks on a contact import:** on a live account each check is a paid carrier lookup at $0.008, capped at the first 500 numbers.
- The rate table has no line for contacts, segments or topics.

Set a monthly [spend limit](/docs/spend-limits) before a large send; messages past the cap are not sent and not charged. Full rates: [pricing](/pricing).

## Limits and caveats

- 10,000 recipients per batch, paced at about 100 messages a minute, so a full batch takes well over an hour to finish.
- Paid accounts have an abuse ceiling of 10,000 messages a day, raised on request. Ask before sending to a list larger than that in one day.
- The free tier cannot broadcast to the public: 100 texts a month, 10 a day, to at most 3 verified numbers.
- A segment audience is resolved when the batch is created, including for a scheduled batch. Merge fields from contacts are rendered at send time.
- A message already in flight when you pause or cancel still sends.
- Text only: outbound MMS returns `mms_not_enabled`, so no images or coupons as pictures.
- US and Canadian numbers only; other rows are rejected as `invalid`.
- `delivered` and `failed` in results come from carrier receipts. Some messages never get one and stay `pending`.
- `replies` counts messages to the sending number within 72 hours of the batch finishing.
- Live sending requires live-access review and an approved registration. In the sandbox the entire flow runs and outcomes are simulated.

## Related docs

- [Batches and broadcasts](/docs/broadcasts): dry runs, variables, pause and retry, results
- [Contacts](/docs/contacts): import, segments, subscription topics, templates
- [Compliance and registration](/docs/compliance): the `marketing` use case, example messages, opt-in proof
- [Opt-out, consent and TCPA](/docs/opt-out): suppression list and consent ledger
- [Scheduled messages](/docs/scheduled)
- [Spend limits](/docs/spend-limits)
- [Deliverability](/docs/deliverability): delivery rates by carrier and number
- [Pricing](/docs/pricing): free tier and billing
