Crazytel SMS can push a real-time delivery receipt (DLR) to your server every time a message changes state. Include a status_callback URL when you send an SMS through the API and Crazytel will POST a signed JSON status callback to that URL as the message is accepted and then delivered, undelivered or failed. Works for single sends, bulk campaigns and scheduled sends.
How to turn delivery webhooks on
Add the optional status_callback field to any V2 SMS send request on https://api.crazytel.io:
POST /api/v2/sms/send- one recipientPOST /api/v2/sms/bulk-send- up to 50,000 recipients; you get one webhook per recipientPOST /api/v2/sms/schedule-send- the URL is saved with the schedule and used when the batch is dispatched
Leave status_callback out and no webhooks are sent - it is opt-in per message or campaign.
Example single send:
POST /api/v2/sms/send HTTP/1.1
Host: api.crazytel.io
Content-Type: application/json
x-crazytel-api-key: YOUR_API_KEY
{
"from": "61412345678",
"to": "61400111222",
"text": "Your order #4821 has been dispatched.",
"status_callback": "https://hooks.example.com/crazytel/sms-status"
}
The response returns a uuid for the message. Every webhook for that message carries the same value as message_uuid, so you can match callbacks to the send.
Callback URL rules: it must start with https://, be at most 2,048 characters, and resolve to a publicly routable address - localhost, .local names and private IP ranges are rejected.
The events you will receive
For each outbound SMS you receive at most one sent event, followed by exactly one terminal event: delivered, undelivered or failed. If a pre-send check rejects the message (for example an opt-out match or insufficient balance), sent is skipped and failed fires directly.
| Status | Meaning |
|---|---|
sent | Crazytel accepted the message for delivery. |
delivered | Handset delivery confirmed (final). |
undelivered | The message could not reach the handset (final) - see error_code. |
failed | Rejected before or during submission (final) - see error_code. |
HTTP headers on each webhook
| Header | Description |
|---|---|
Content-Type | application/json; charset=utf-8 |
X-CrazyAPI-Timestamp | Unix epoch seconds when this delivery attempt was signed. |
X-CrazyAPI-Signature | Lowercase hex HMAC-SHA256(secret, timestamp + "." + raw_body). |
X-CrazyAPI-Event-ID | Unique UUID for this status transition. Reused across retries - deduplicate on it. |
X-CrazyAPI-Attempt | 1-based attempt number; increments on each retry. |
Payload fields
Example delivered event:
{
"schema_version": 1,
"event_id": "0192a5c7-2c8e-7c67-bfa4-9f1a2e3b4c5d",
"message_uuid": "b7a9e7d8-2b11-4b4f-9a6a-2a1f47d2b0ab",
"account_code": "295327",
"status": "delivered",
"previous_status": "sent",
"from": "61412345678",
"to": "61400111222",
"provider_message_id": "a1b2c3d4",
"error_code": "",
"error_message": "",
"segments": 1,
"cost_micros": 50000,
"occurred_at": "2026-10-21T08:30:06Z",
"attempt_number": 1
}
| Field | Description |
|---|---|
schema_version | Always 1; use it to keep your handler forward-compatible. |
event_id | Unique ID for this lifecycle transition; identical across retries. |
message_uuid | The uuid returned when the SMS was submitted. |
account_code | Your Crazytel account code. |
status | sent, delivered, undelivered or failed. |
previous_status | State before this transition; empty on the first callback. |
from / to | Normalised sender and recipient in digits-only international format (no +). |
provider_message_id | Network reference ID when available. |
error_code | Stable machine-readable failure code; empty on sent and delivered. Always branch on this, not error_message. |
error_message | Human-readable explanation (up to 512 chars); for logging only. |
segments | Billable SMS segment count (0 if rejected before sending). |
cost_micros | Charge in micro-units of your account currency (10,000 micros = 1 cent). |
occurred_at | RFC 3339 UTC timestamp of the lifecycle event - use it to order events. |
attempt_number | 1-based delivery attempt counter. |
Failure codes
error_code | Stage | Meaning |
|---|---|---|
carrier-undeliverable (undelivered) | Terminal | Could not be delivered to the handset - switched off past validity, or invalid/disconnected number. |
billing-insufficient-balance | Pre-send | Balance or credit limit too low. |
billing-account-unknown | Pre-send | Account code not found. |
billing-account-disabled | Pre-send | Account is disabled. |
billing-calls-disabled | Pre-send | Outbound SMS/calls paused on the account. |
billing-sender-not-allowed | Pre-send | from is not a verified Caller ID, active Virtual Mobile Number or approved sender on the account. |
spam-blocked | Pre-send | Matched an outbound spam/scam protection rule. |
opt-out-blocked | Pre-send | Recipient is on the active opt-out suppression list. |
provider-rejected | Submit | Invalid message content or formatting. |
provider-unavailable | Submit | Temporary upstream failure after automatic retries. |
carrier-rejected | Terminal | Rejected by the destination mobile network. |
carrier-failed | Terminal | Delivery failed on the destination mobile network. |
invalid-phone | Pre-send (bulk) | Bulk send only: recipient could not be normalised to a valid phone number. |
internal-error | Internal | Unexpected processing error. |
Verifying the signature
Every delivery webhook is signed with your account's 64-character HMAC-SHA256 signing secret so you can prove the request came from Crazytel:
- Check
X-CrazyAPI-Timestampis within 5 minutes of your server's time. - Read the raw request body bytes before parsing JSON.
- Compute
HMAC-SHA256overtimestamp + "." + raw_body, using the 64-charactersecretstring itself as the key (do not hex-decode it). - Compare the lowercase hex result to
X-CrazyAPI-Signatureusing a constant-time comparison.
Python example:
import hashlib
import hmac
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["CRAZYTEL_SMS_WEBHOOK_SECRET"]
@app.post("/crazytel/sms-status")
def sms_status():
signature = request.headers.get("X-CrazyAPI-Signature", "")
timestamp = request.headers.get("X-CrazyAPI-Timestamp", "")
try:
if abs(time.time() - int(timestamp)) > 300:
return "Stale timestamp", 400
except ValueError:
return "Invalid timestamp", 400
raw_body = request.get_data()
expected = hmac.new(
SECRET.encode("utf-8"),
timestamp.encode("ascii") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
return "Invalid signature", 400
event = request.get_json(force=True)
# Deduplicate on event["event_id"]; match the send via event["message_uuid"]
return "", 204
More examples in other languages: Verifying webhook signatures.
Your signing secret
Each account has one active 256-bit signing secret, shown as 64 lowercase hex characters.
- Fetch it (auto-creates it on first use):
GET /api/v1/sms_center/status_callback_secretonapi.crazytel.io, or from the SMS Center in the Crazytel portal. - Rotate it:
POST /api/v1/sms_center/status_callback_secret/rotate. Rotation is an immediate cutover with no grace period - the old secret stops working at once and every webhook after that point, including queued retries, is signed with the new secret.
Responses and retries
| Your response | What Crazytel does |
|---|---|
Any 2xx | Marked delivered - no further attempts. |
408, 429, any 5xx, network or TLS timeout (10 s) | Retried automatically. |
Any other 4xx (400, 401, 403, 404, 422) | Treated as permanent - that event is not retried. |
Retry schedule after a temporary failure: 1 minute, then 10 minutes, then every 1 hour for up to 7 days from the first failure. Every retry keeps the same event_id / X-CrazyAPI-Event-ID and gets a fresh timestamp and signature. Reply 2xx quickly and process the event in the background - slow endpoints trigger unnecessary retries.
