Send SMS marketing broadcasts

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 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 before a large send; messages past the cap are not sent and not charged. Full rates: 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.

More in use cases