SMS Platform / Delivery webhooks
- Developer guide
- Delivery receipts (DLR)
- HMAC-SHA256 signed
SMS Delivery Status WebhooksKnow the moment every SMS is delivered
Add one optional status_callback URL to any Crazytel SMS send and we will POST a signed JSON delivery receipt to your server each time that message changes state: sent, then delivered, undelivered or failed.
- Single, bulk and scheduled sends
- Signed with your own 64-character secret
- Automatic retries for up to 7 days
- Stable
event_idfor deduplication - Machine-readable
error_codevalues - Self-service secret rotation
Overview
Whenever you send an SMS through Crazytel, you can ask for real-time delivery receipts. Opt in per message or per campaign, and ignore it everywhere else.
-
Opt in per message or campaign
Include
status_callbackon any send request when you want webhook notifications, or leave it out when you do not. -
Works across all SMS send APIs
Supported on Single Send, Bulk Send (up to 50,000 recipients) and Scheduled Send. Bulk and scheduled sends post a separate event for each recipient.
-
Signed with HMAC-SHA256
Every webhook carries
X-CrazyAPI-SignatureandX-CrazyAPI-Timestampheaders, signed with your account's 64-character signing secret, so you can prove each report came from Crazytel. -
Self-service secret management
Fetch or rotate your signing secret at any time from the Crazytel Portal or through the API.
-
Automatic retries for up to 7 days
If your server is unreachable or returns
5xxor429, we retry with backoff of 1 minute, 10 minutes, then hourly. -
Built-in deduplication key
Every lifecycle event has a unique
event_id(also sent asX-CrazyAPI-Event-ID) that stays the same across retries.
How it works in 4 steps
Each message produces at most one sent event followed by exactly one terminal event.
-
Add a callback URL
Pass an
https://URL in the optionalstatus_callbackfield of your send request. -
We accept the message
Crazytel posts a
sentevent once the message has been accepted for delivery. -
The outcome arrives
When handset delivery is confirmed or fails, you receive
delivered,undeliveredorfailed. -
Verify and reply 2xx
Check the signature, deduplicate on
event_id, update your records and respond with any2xxstatus.
1. Enable delivery webhooks when sending
Pass an https:// URL in status_callback on any Crazytel V2 SMS send endpoint.
- Your account is identified automatically from the
x-crazytel-api-keyheader. You never send an account code in the request body. status_callbackmust usehttps://. Plainhttp://URLs are rejected.- Maximum length is 2,048 characters.
- The host must resolve to a publicly routable IP address.
localhost,.localhosts and private network ranges are blocked.
Option A: single SMS
POST /api/v2/sms/send. Save the returned uuid: every webhook for this message includes that exact value as message_uuid.
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"
}{
"uuid": "b7a9e7d8-2b11-4b4f-9a6a-2a1f47d2b0ab",
"status": "sent",
"to": "+61400111222",
"from": "61412345678",
"provider": "Crazytel",
"provider_message_id": "a1b2c3d4",
"accepted_at": "2026-10-21T08:30:00Z"
}Option B: bulk SMS
POST /api/v2/sms/bulk-send with inline to[], a list_uuid, or both. Crazytel sends individual webhook events for each recipient as their message is sent and delivered.
POST /api/v2/sms/bulk-send HTTP/1.1
Host: api.crazytel.io
Content-Type: application/json
x-crazytel-api-key: YOUR_API_KEY
Idempotency-Key: campaign-2026-10-21-001
{
"from": "61412345678",
"to": ["61400111222", "61400333444"],
"text": "Reminder: our office will be closed on Monday. Reply STOP to opt out.",
"opt_out_list": "018f2c98-65c0-7a3a-9bd7-16f61cb24502",
"status_callback": "https://hooks.example.com/crazytel/sms-status"
}Option C: scheduled SMS
POST /api/v2/sms/schedule-send. We save the webhook URL with your schedule and post per-recipient delivery status updates when the batch is dispatched at send_at.
POST /api/v2/sms/schedule-send HTTP/1.1
Host: api.crazytel.io
Content-Type: application/json
x-crazytel-api-key: YOUR_API_KEY
Idempotency-Key: schedule-2026-11-01-001
{
"from": "61412345678",
"to": ["61400111222", "61400333444"],
"text": "Reminder: your appointment is tomorrow at 9:00am.",
"send_at": "2026-11-01T09:00:00Z",
"status_callback": "https://hooks.example.com/crazytel/sms-status"
}2. Manage your webhook signing secret
Every account has one active 256-bit (64 lowercase hexadecimal characters) HMAC-SHA256 signing secret used to sign outbound delivery status webhooks.
Get your current signing secret
GET /api/v1/sms_center/status_callback_secret HTTP/1.1
Host: api.crazytel.io
x-crazytel-api-key: YOUR_API_KEY{
"secret": "5b2c7e1d9a08f43b1c6d8e0a2f47b9c08e1d3a5f6b7c8d9e0a1b2c3d4e5f6a7b",
"created_at": "2026-04-23T09:30:00Z",
"last_rotated_at": "2026-04-25T11:15:00Z"
}- If your account has never used delivery webhooks, the first
GETautomatically generates and saves your first signing secret. GETis read-only and idempotent. It always returns your current active secret without changing it.last_rotated_atis omitted if the secret has never been rotated since creation.
Rotate your signing secret
POST /api/v1/sms_center/status_callback_secret/rotate HTTP/1.1
Host: api.crazytel.io
x-crazytel-api-key: YOUR_API_KEY{
"secret": "a9f4e2c1b8d70653412ef9801bc3456789abcdef0123456789abcdef01234567",
"created_at": "2026-04-23T09:30:00Z",
"last_rotated_at": "2026-10-21T08:45:00Z"
}- Immediate cutover. Rotating generates a new 64-character secret and invalidates the old one straight away. There is no grace period.
- Every webhook dispatched after rotation, including queued retries of earlier events, is signed with the new secret.
3. What your server receives
Each delivery status update arrives at your status_callback URL as an HTTP POST with a JSON body.
HTTP headers
| Header | Example | Description |
|---|---|---|
Content-Type | application/json; charset=utf-8 | Always UTF-8 JSON. |
X-CrazyAPI-Timestamp | 1730000000 | Unix epoch timestamp (seconds) when Crazytel signed this delivery attempt. |
X-CrazyAPI-Signature | 9f2e4a... (64 hex characters) | Lowercase hex HMAC-SHA256(secret, timestamp + "." + raw_body). |
X-CrazyAPI-Event-ID | 0192a5c7-2c8e-7c67-bfa4-9f1a2e3b4c5d | Unique UUID for this status transition. Reused across retries of the same event, so use it to deduplicate. |
X-CrazyAPI-Attempt | 1 | 1-based attempt number. 1 on first delivery, increasing on retries. |
Payload examples
sent Crazytel has accepted the message for delivery.
{
"schema_version": 1,
"event_id": "0192a5c6-88a1-7b10-9c12-1a2b3c4d5e6f",
"message_uuid": "b7a9e7d8-2b11-4b4f-9a6a-2a1f47d2b0ab",
"account_code": "295327",
"status": "sent",
"previous_status": "queued",
"from": "61412345678",
"to": "61400111222",
"provider_message_id": "a1b2c3d4",
"error_code": "",
"error_message": "",
"segments": 1,
"cost_micros": 50000,
"occurred_at": "2026-10-21T08:30:01Z",
"attempt_number": 1
}delivered Handset delivery confirmed.
{
"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
}undelivered or failed The message could not be delivered.
{
"schema_version": 1,
"event_id": "0192a5c8-9f01-7d44-a123-9876543210ab",
"message_uuid": "b7a9e7d8-2b11-4b4f-9a6a-2a1f47d2b0ab",
"account_code": "295327",
"status": "undelivered",
"previous_status": "sent",
"from": "61412345678",
"to": "61400111222",
"provider_message_id": "a1b2c3d4",
"error_code": "carrier-undeliverable",
"error_message": "Destination handset unreachable or number disconnected",
"segments": 1,
"cost_micros": 50000,
"occurred_at": "2026-10-21T10:15:42Z",
"attempt_number": 1
}Payload field reference
| Field | Type | Description |
|---|---|---|
schema_version | integer | Always 1. Use it to keep your webhook handler forward-compatible. |
event_id | UUID string | Unique ID for this lifecycle transition. Retries reuse it, so use it to deduplicate. |
message_uuid | UUID string | Matches the uuid returned when you submitted the SMS. Use it to link the webhook back to your message record. |
account_code | string | Your Crazytel account code. |
status | string | One of sent, delivered, undelivered or failed. |
previous_status | string | State immediately before this transition (for example sent when moving to delivered). Empty on the first callback if no prior state existed. |
from | string | Normalised sender number (digits only, e.g. 61412345678) or alphanumeric sender ID. |
to | string | Normalised recipient number (digits only, no +, e.g. 61400111222). |
provider_message_id | string | Crazytel network reference ID when available. |
error_code | string | Stable machine-readable code on failed or undelivered events (empty string on sent and delivered). Always branch on error_code, not error_message. |
error_message | string | Human-readable explanation when a message fails (up to 512 characters). For display and logging only. |
segments | integer | Billable SMS segment count (0 if rejected before sending). |
cost_micros | integer | Message charge in micro-units of your account currency. 10,000 micros = 1 cent, so 50,000 micros = $0.05. |
occurred_at | RFC 3339 string | UTC time the lifecycle event occurred on Crazytel's platform. Use it to order events. |
attempt_number | integer | 1-based webhook delivery attempt counter. 1 on first attempt, increasing on retries. |
Status and error code reference
For each outbound SMS you receive at most one sent event followed by exactly one terminal event. If a pre-send check rejects a message, sent is skipped and failed fires directly.
| status | error_code | Stage | Meaning |
|---|---|---|---|
| sent | empty | Submit | Crazytel accepted the message for delivery. |
| delivered | empty | Terminal | Handset delivery confirmed. |
| undelivered | carrier-undeliverable | Terminal | The message could not be delivered to the handset (for example the handset was switched off until the validity period expired, or the mobile number is invalid or disconnected). |
| failed | billing-insufficient-balance | Pre-send | Rejected before sending because the account balance or credit limit was insufficient. |
| failed | billing-account-unknown | Pre-send | Rejected because the account code was not found. |
| failed | billing-account-disabled | Pre-send | Rejected because the account is currently disabled. |
| failed | billing-calls-disabled | Pre-send | Rejected because outbound SMS or calls are administratively paused on the account. |
| failed | billing-sender-not-allowed | Pre-send | Rejected because from is not a verified Caller ID, active Virtual Mobile Number or approved alphanumeric sender on the account. |
| failed | spam-blocked | Pre-send | Rejected because the message matched an outbound spam or scam protection rule. |
| failed | opt-out-blocked | Pre-send | Rejected because the recipient number is on the active Opt-Out suppression list. |
| failed | provider-rejected | Submit | Rejected due to invalid message content or formatting. |
| failed | provider-unavailable | Submit | Temporary upstream failure after automatic retries. |
| failed | carrier-rejected | Terminal | Rejected by the destination mobile network. |
| failed | carrier-failed | Terminal | Delivery failed on the destination mobile network. |
| failed | invalid-phone | Pre-send (bulk) | Bulk send only: the recipient number could not be normalised to a valid phone number. |
| failed | internal-error | Internal | Unexpected processing error. |
4. Verify webhook signatures
Confirm that a request genuinely came from Crazytel and was not tampered with in transit.
- Read
X-CrazyAPI-TimestampandX-CrazyAPI-Signaturefrom the request headers. - Check that the timestamp is within 5 minutes (300 seconds) of your server's current Unix time, to prevent replay attacks.
- Read the raw HTTP request body bytes before parsing any JSON.
- Build the signed payload by joining the timestamp, a literal period and the raw body:
<X-CrazyAPI-Timestamp>.<raw_request_body> - Compute the HMAC-SHA256 digest using your 64-character secret string directly as the UTF-8 key (do not hex-decode it first) and format the output as lowercase hexadecimal.
- Compare your result with
X-CrazyAPI-Signatureusing a constant-time comparison function.
import hashlib
import hmac
import os
import time
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["CRAZYTEL_SMS_WEBHOOK_SECRET"] # 64-char string from status_callback_secret API
@app.post("/crazytel/sms-status")
def handle_sms_status():
signature = request.headers.get("X-CrazyAPI-Signature", "")
timestamp = request.headers.get("X-CrazyAPI-Timestamp", "")
# 1. Reject timestamps older than 5 minutes
try:
if abs(time.time() - int(timestamp)) > 300:
return "Stale timestamp", 400
except ValueError:
return "Invalid timestamp", 400
# 2. Compute HMAC-SHA256 over "<timestamp>.<raw_body>"
raw_body = request.get_data()
expected = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
timestamp.encode("ascii") + b"." + raw_body,
hashlib.sha256,
).hexdigest()
# 3. Constant-time signature check
if not hmac.compare_digest(signature, expected):
return "Invalid signature", 400
event = request.get_json(force=True)
# Deduplicate on event["event_id"], match message via event["message_uuid"]
print(f"Message {event['message_uuid']} -> {event['status']} (error_code={event.get('error_code', '')})")
return "", 204import crypto from "node:crypto";
import express from "express";
const app = express();
const WEBHOOK_SECRET = process.env.CRAZYTEL_SMS_WEBHOOK_SECRET; // 64-char string
app.post(
"/crazytel/sms-status",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.header("X-CrazyAPI-Signature") || "";
const timestamp = req.header("X-CrazyAPI-Timestamp") || "";
// 1. Reject timestamps older than 5 minutes
const skewSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(skewSeconds) || skewSeconds > 300) {
return res.status(400).send("Stale or invalid timestamp");
}
// 2. Compute HMAC-SHA256 over "<timestamp>.<raw_body>"
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body)
.digest("hex");
// 3. Constant-time signature check
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Deduplicate on event.event_id, match message via event.message_uuid
console.log(`Message ${event.message_uuid} -> ${event.status}`);
return res.status(204).end();
}
);<?php
$secret = getenv('CRAZYTEL_SMS_WEBHOOK_SECRET');
$signature = $_SERVER['HTTP_X_CRAZYAPI_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_CRAZYAPI_TIMESTAMP'] ?? '';
if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) {
http_response_code(400);
exit('Stale or invalid timestamp');
}
$rawBody = file_get_contents('php://input');
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
// Deduplicate on $event['event_id'], update message $event['message_uuid']
http_response_code(204);package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"strconv"
"time"
)
func verifyCrazytelWebhook(secret string, w http.ResponseWriter, r *http.Request) ([]byte, bool) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return nil, false
}
ts := r.Header.Get("X-CrazyAPI-Timestamp")
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil || abs64(time.Now().Unix()-tsInt) > 300 {
http.Error(w, "stale timestamp", http.StatusBadRequest)
return nil, false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte{'.'})
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
sig := r.Header.Get("X-CrazyAPI-Signature")
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return nil, false
}
return body, true
}
func abs64(x int64) int64 {
if x < 0 {
return -x
}
return x
}5. Response and retry policy
Reply quickly with a 2xx status. Your server has 10 seconds to respond before the attempt counts as a network timeout.
| Your server's response | How Crazytel handles it |
|---|---|
2xx (200 OK, 204 No Content, etc.) | Delivery is marked successful. No further retries are sent. |
408, 429, 5xx, or a network or TLS timeout | Treated as a temporary failure and retried automatically. |
Any other 4xx (400, 401, 403, 404, 422) | Treated as a permanent rejection. Crazytel stops retrying that event. |
Retry schedule
| Retry | attempt_number | When |
|---|---|---|
| Retry 1 | 2 | 1 minute after the first failure |
| Retry 2 | 3 | 10 minutes after Retry 1 |
| Retry 3 onwards | 4+ | Every 1 hour, for up to 7 days from the first failure |
Every retry reuses the same event_id (X-CrazyAPI-Event-ID), so you can safely deduplicate. Each retry gets a fresh X-CrazyAPI-Timestamp and X-CrazyAPI-Signature generated at the moment of the attempt.
Quick reference: endpoints
All endpoints are on https://api.crazytel.io and authenticated with the x-crazytel-api-key header.
| Method | Endpoint | Purpose |
|---|---|---|
GET | /api/v1/sms_center/status_callback_secret | Fetch (or auto-create) your account's 64-character HMAC-SHA256 signing secret. |
POST | /api/v1/sms_center/status_callback_secret/rotate | Rotate your signing secret immediately. |
POST | /api/v2/sms/send | Send one SMS (accepts optional status_callback). |
POST | /api/v2/sms/bulk-send | Send bulk SMS to up to 50,000 recipients (accepts optional status_callback). |
POST | /api/v2/sms/schedule-send | Schedule SMS to up to 500 recipients (accepts optional status_callback). |
Full request and response schemas are in the Crazytel API documentation. For the rest of the SMS feature set, see the Business SMS platform page.
Frequently asked questions
Practical answers about callbacks, signatures, retries and error codes.
Do I have to use delivery webhooks?
Which SMS APIs support status_callback?
What does my status_callback URL need to look like?
How many webhooks will I get for each message?
What happens if my server is down?
How do I avoid processing the same event twice?
How do I verify a webhook really came from Crazytel?
Where do I get my signing secret?
Is there a grace period when I rotate the secret?
Should I branch on error_message or error_code?
Start receiving delivery receipts
Fetch your signing secret, add status_callback to a test send, and watch the events arrive.





