# Send SMS marketing broadcasts

Source: https://jointextkit.com/use-cases/marketing-broadcasts
Index: https://jointextkit.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 { TextKit } from 'jointextkit';

const sms = new TextKit(process.env.TEXTKIT_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.jointextkit.com/v1/batches', {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + process.env.TEXTKIT_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 jointextkit import TextKit

client = TextKit()  # reads TEXTKIT_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.jointextkit.com/v1/contacts/import \
  -H "Authorization: Bearer $TEXTKIT_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.jointextkit.com/v1/segments \
  -H "Authorization: Bearer $TEXTKIT_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.jointextkit.com/v1/batches \
  -H "Authorization: Bearer $TEXTKIT_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.jointextkit.com/v1/batches/bc_a1B2c3D4e5F6/pause \
  -H "Authorization: Bearer $TEXTKIT_API_KEY"
curl https://api.jointextkit.com/v1/batches/bc_a1B2c3D4e5F6/results \
  -H "Authorization: Bearer $TEXTKIT_API_KEY"
```

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

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

const app = express();

app.post('/webhooks/textkit', 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.TEXTKIT_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 held per recipient.** A recipient for whom the send time is outside 8am to 9pm local time (8pm in Florida, Maryland and Oklahoma) (read from the number's area code) gets it at 8am their time. The pre-send check tells you how many. State rules that are stricter than the federal window are not applied.
- **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.0032 per segment**, plus carrier fees at cost ($0.0035 to $0.005 a segment, set by the recipient's carrier). A batch is billed for the messages it sends, and the dry run returns the estimate first.
- **Worked example:** one full batch of 10,000 one-segment texts is $67.00 to $82.00 with carrier fees. A list of 50,000 takes five batches: $335.00 to $410.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
