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; 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 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