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/messages once 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.delivered and message.failed webhooks, 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/batches with per-recipient merge variables.

How it works

  1. Buy a number and file a registration with the delivery_notification use case, whose description is order and delivery status.
  2. On each order event, call POST /v1/messages with from, to, body and an Idempotency-Key header. The same key with the same payload replays the first response and adds Idempotent-Replayed: true. The same key with a different payload answers 409 idempotency_conflict. Keys last 24 hours, and a failed request releases its key.
  3. Read the status code. 201 means the message was accepted. 202 means the carrier had a temporary problem and the message is queued; SimpleSMS retries it after 30 seconds, 2 minutes and 10 minutes and tells you the outcome by event. 502 carrier_error is a permanent rejection.
  4. Store the msg_ id against the order. Webhooks reference it as message_id.
  5. On message.failed, switch on failure.code. invalid_number and landline mean stop texting that number; unreachable means the phone was off or out of coverage; carrier_filtered means the content was treated as spam.
  6. For a bulk notice, call POST /v1/batches with "dry_run": true first. 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:

bash
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:

js
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:

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}
        raise

A bulk notice with merge variables. The SDKs do not wrap batches, so this is a direct call:

bash
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:

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 "", 200

In 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} accepts proof with source: "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 202 send 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.
  • body holds 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_status becomes missing after 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.

More in use cases