Send order and delivery notifications by SMS
You build the texts a customer gets as an order moves: confirmed, shipped, out for delivery, delivered, or delayed. Each order event in your system triggers one API call, keyed so a retried job cannot text twice, and delivery status comes back to you by webhook.
What you build
- A notification worker that listens to order state changes and calls
POST /v1/messagesonce per change. - An idempotency key per order and state, for example
order-A-1042-shipped. Queue workers retry; the key makes the retry return the original response instead of sending a second "your order shipped". - A status table fed by
message.sent,message.deliveredandmessage.failedwebhooks, so support can see whether a customer was actually told. - A fallback rule: when a text fails for a permanent reason, or the customer has opted out, send the email instead.
- A bulk path for events that hit many orders at once (a delayed truck, a recalled item), using
POST /v1/batcheswith per-recipient merge variables.
How it works
- Buy a number and file a registration with the
delivery_notificationuse case, whose description is order and delivery status. - On each order event, call
POST /v1/messageswithfrom,to,bodyand anIdempotency-Keyheader. The same key with the same payload replays the first response and addsIdempotent-Replayed: true. The same key with a different payload answers409 idempotency_conflict. Keys last 24 hours, and a failed request releases its key. - Read the status code.
201means the message was accepted.202means the carrier had a temporary problem and the message isqueued; SimpleSMS retries it after 30 seconds, 2 minutes and 10 minutes and tells you the outcome by event.502 carrier_erroris a permanent rejection. - Store the
msg_id against the order. Webhooks reference it asmessage_id. - On
message.failed, switch onfailure.code.invalid_numberandlandlinemean stop texting that number;unreachablemeans the phone was off or out of coverage;carrier_filteredmeans the content was treated as spam. - For a bulk notice, call
POST /v1/batcheswith"dry_run": truefirst. It returns counts, each rejected row with its reason, a rendered preview and the estimated cost, and sends nothing.
Code
One notification with an idempotency key:
curl -X POST https://api.joinsimplesms.com/v1/messages \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-A-1042-shipped" \
-d '{
"from": "+15005550100",
"to": "+15005550006",
"body": "Acme Outfitters: order A-1042 has shipped and arrives Thursday. Track it at https://acme.example/t/A-1042. Reply STOP to opt out."
}'Node.js. The SDK generates a key when you pass none; pass your own so a retry from a different process is also safe:
import { SimpleSMS, SimpleSMSError } from 'joinsimplesms';
const sms = new SimpleSMS(process.env.SIMPLESMS_API_KEY);
export async function notifyShipped(order) {
try {
const message = await sms.messages.send(
{
from: '+15005550100',
to: order.phone,
body: 'Acme Outfitters: order ' + order.id + ' has shipped and arrives ' +
order.eta + '. Reply STOP to opt out.',
},
{ idempotencyKey: 'order-' + order.id + '-shipped' }
);
return { messageId: message.id, status: message.status };
} catch (err) {
if (err instanceof SimpleSMSError && err.status === 403) {
return { skipped: err.code }; // e.g. recipient_opted_out: send the email instead
}
throw err;
}
}Python:
from joinsimplesms import SimpleSMS, SimpleSMSError
client = SimpleSMS() # reads SIMPLESMS_API_KEY
def notify_shipped(order) -> dict:
try:
message = client.messages.send(
order.phone,
f"Acme Outfitters: order {order.id} has shipped and arrives {order.eta}. Reply STOP to opt out.",
from_="+15005550100",
idempotency_key=f"order-{order.id}-shipped",
)
return {"message_id": message["id"], "status": message["status"]}
except SimpleSMSError as err:
if err.status == 403:
return {"skipped": err.code}
raiseA bulk notice with merge variables. The SDKs do not wrap batches, so this is a direct call:
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}}, order {{order_id}} is delayed one day. Sorry about that. Reply STOP to opt out.",
"dry_run": true,
"recipients": [
{"to": "+14155550132", "variables": {"first_name": "Jane", "order_id": "A-1042"}},
{"to": "+14155550133", "variables": {"first_name": "Sam", "order_id": "A-1043"}}
]
}'The delivery status webhook, in Python:
import os
from flask import Flask, request
from joinsimplesms import SimpleSMSError, verify_webhook
app = Flask(__name__)
@app.post("/webhooks/simplesms")
def simplesms_webhook():
try:
event = verify_webhook(
request.get_data(), # raw bytes, unparsed
request.headers, # the SDK finds the signature header
os.environ["SIMPLESMS_WEBHOOK_SECRET"],
)
except SimpleSMSError:
return "", 400
if already_processed(event["id"]): # deliveries can repeat; dedupe on the event id
return "", 200
data = event["data"]
if event["type"] == "message.delivered":
mark_notified(data["message_id"])
elif event["type"] == "message.failed":
mark_failed(data["message_id"], data["failure"]["code"])
return "", 200In the sandbox, sending to +15005550009 produces an invalid_number failure and +15005550010 a landline failure, so the fallback branch can be tested before going live.
Compliance notes
- Collect consent at checkout and store it.
POST /v1/consent/{phone}acceptsproofwithsource: "checkout", the page URL and the exact disclosure wording the buyer saw. - Keep order texts about the order. A registration states one kind of traffic. Adding a discount code to a shipping update is sending a different kind of message than you registered, which is a policy violation; marketing needs its own registration.
- Do not pre-check opt-outs; handle the error. A send to someone who replied STOP answers
403 recipient_opted_out, nothing is sent and nothing is billed. The suppression list is account-wide, so a STOP sent to an order text also stops every other text from your account to that number. - Use tracking links on your own domain. The registration's example check rejects public link shorteners, and the broadcast compliance check warns on them.
- Carrier scan events arrive at any hour. SimpleSMS does not hold messages for quiet hours. If a "delivered" scan can fire at 3am, decide in your worker whether to send it then or at a reasonable local hour.
- Before approval, a US local number reaches only your verified numbers. A send to anyone else answers
403 sender_not_registered.
What it costs
- $0.009 per message, one rate whatever the length, carrier fees included.
- $0.95 per number, per month.
- Not billed: sends that fail before the carrier accepts them, including a
202send whose every retry failed, and anything in the sandbox. - Worked example: 5,000 orders a month with three texts each (confirmed, shipped, delivered) is 15,000 messages: $135.95 with one number.
- A batch is billed as exactly its number of messages. The dry run reports the estimate before you commit.
A monthly spend limit stops sends at a cap you set. Rates are on the pricing page.
Limits and caveats
- US and Canadian destinations only.
- Text only. Outbound MMS returns
mms_not_enabled, so no product photos or proof-of-delivery images. bodyholds up to 1600 characters. One emoji or curly quote switches the message to UCS-2, 70 characters per part instead of 160.- Idempotency keys expire after 24 hours. A job retried two days later with the same key sends again.
- If the carrier stops answering after the message was handed over, the send fails with
failure_reason: "carrier_timeout"and is not retried, because a retry could deliver twice. You decide whether to send again. - Some messages never get a delivery report. Their status stays
sent,receipt_statusbecomesmissingafter 72 hours, and no event fires for that. - The default rate limit is 60 requests a minute per key. For a burst, use a batch: up to 10,000 recipients, paced at about 100 messages a minute.
- Paid accounts have an abuse ceiling of 10,000 messages a day, raised on request.
- Webhook deliveries can arrive out of order and, rarely, twice.
- Live sending requires live-access review and carrier registration; the sandbox simulates delivery.
Related docs
- Messages: sending, idempotency, automatic retries, statuses
- Webhooks: delivery events, signatures, retries and replay
- Errors: error codes and the delivery failure catalog
- Batches and broadcasts: merge variables, dry runs, progress
- Sandbox and test numbers: magic numbers for each failure
- Compliance and registration: the
delivery_notificationuse case - Deliverability: delivery rates by carrier and number