# Send order and delivery notifications by SMS

Source: https://jointextkit.com/use-cases/order-notifications
Index: https://jointextkit.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`; TextKit 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.jointextkit.com/v1/messages \
  -H "Authorization: Bearer $TEXTKIT_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 { TextKit, TextKitError } from 'jointextkit';

const sms = new TextKit(process.env.TEXTKIT_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 TextKitError && err.status === 403) {
      return { skipped: err.code }; // e.g. recipient_opted_out: send the email instead
    }
    throw err;
  }
}
```

Python:

```python
from jointextkit import TextKit, TextKitError

client = TextKit()  # reads TEXTKIT_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 TextKitError 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.jointextkit.com/v1/batches \
  -H "Authorization: Bearer $TEXTKIT_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 jointextkit import TextKitError, verify_webhook

app = Flask(__name__)

@app.post("/webhooks/textkit")
def textkit_webhook():
    try:
        event = verify_webhook(
            request.get_data(),  # raw bytes, unparsed
            request.headers,     # the SDK finds the signature header
            os.environ["TEXTKIT_WEBHOOK_SECRET"],
        )
    except TextKitError:
        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.** Quiet hours apply to every message, so an update sent before 8am or after 9pm (8pm in Florida, Maryland and Oklahoma) in the recipient's local time is held and sent at 8am. Only one-time codes and replies within 4 hours of the customer's own text go out at night. If a 3am "delivered" text would be stale by 8am, skip it in your worker.
- **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.0032 per segment**, plus carrier fees at cost ($0.0035 to $0.005 a segment, set by the recipient's carrier).
- **$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 one-segment messages: $101.45 to $123.95 with one number and carrier fees.
- A batch is billed for the messages it sends, per segment. 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
