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; TextKit 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.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:
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:
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}
raiseA bulk notice with merge variables. The SDKs do not wrap batches, so this is a direct call:
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:
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 "", 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. 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
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 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. 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