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
- File a registration with
use_case: "marketing"(ormixedfor 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. - Import contacts. Each row can carry
opt_inwith a status, date and source, which is stored in the consent ledger. Add"dry_run": trueto check a file without writing anything. - Create a segment with
POST /v1/segments:matchisallorany, with up to 10 rules over tags, names, state, custom fields, topic subscription and creation date.GET /v1/segments/{id}/preview?topic_id=tp_marketingreturnsmatched,opted_out,topic_unsubscribedandeligible. - Dry-run the batch:
POST /v1/batcheswithsegment_id,topic_id, thebodyand"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. - Send it by repeating the call without
dry_run, or addscheduled_atup to 30 days out. The response is a batch with abc_id and livecounts. - Control it:
POST /v1/batches/{id}/pause,/resume,/cancel, and/retryonce it is complete. Retry re-queues only transient failures, at most 3 attempts per recipient. - Read
GET /v1/batches/{id}/resultsfor delivered, failed, pending, replies, stops andspent_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):
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:
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:
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:
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'sopt_incolumn. 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_outand 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_ataccordingly. - Topics only narrow. A batch with a
topic_idskips 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/messagesdoes 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. deliveredandfailedin results come from carrier receipts. Some messages never get one and staypending.repliescounts 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: dry runs, variables, pause and retry, results
- Contacts: import, segments, subscription topics, templates
- Compliance and registration: the
marketinguse case, example messages, opt-in proof - Opt-out, consent and TCPA: suppression list and consent ledger
- Scheduled messages
- Spend limits
- Deliverability: delivery rates by carrier and number
- Pricing: free tier and billing