# Send order and delivery notifications by SMS

Source: https://joinsimplesms.com/use-cases/order-notifications
Index: https://joinsimplesms.com/llms.txt

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](/docs/compliance) 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](/docs/spend-limits) stops sends at a cap you set. Rates are on the [pricing page](/pricing).

## 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.

## Related docs

- [Messages](/docs/messages): sending, idempotency, automatic retries, statuses
- [Webhooks](/docs/webhooks): delivery events, signatures, retries and replay
- [Errors](/docs/errors): error codes and the delivery failure catalog
- [Batches and broadcasts](/docs/broadcasts): merge variables, dry runs, progress
- [Sandbox and test numbers](/docs/sandbox): magic numbers for each failure
- [Compliance and registration](/docs/compliance): the `delivery_notification` use case
- [Deliverability](/docs/deliverability): delivery rates by carrier and number
