Skip to main content

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_id for deduplication
  • Machine-readable error_code values
  • 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_callback on 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-Signature and X-CrazyAPI-Timestamp headers, 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 5xx or 429, 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 as X-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.

  1. Add a callback URL

    Pass an https:// URL in the optional status_callback field of your send request.

  2. We accept the message

    Crazytel posts a sent event once the message has been accepted for delivery.

  3. The outcome arrives

    When handset delivery is confirmed or fails, you receive delivered, undelivered or failed.

  4. Verify and reply 2xx

    Check the signature, deduplicate on event_id, update your records and respond with any 2xx status.

1. Enable delivery webhooks when sending

Pass an https:// URL in status_callback on any Crazytel V2 SMS send endpoint.

Base URL https://api.crazytel.io
Authentication header x-crazytel-api-key: YOUR_API_KEY
  • Your account is identified automatically from the x-crazytel-api-key header. You never send an account code in the request body.
  • status_callback must use https://. Plain http:// URLs are rejected.
  • Maximum length is 2,048 characters.
  • The host must resolve to a publicly routable IP address. localhost, .local hosts 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.

Request
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"
}
Response (200 OK)
{
  "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.

Bulk send request
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.

Scheduled send request
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

Request
GET /api/v1/sms_center/status_callback_secret HTTP/1.1
Host: api.crazytel.io
x-crazytel-api-key: YOUR_API_KEY
Response (200 OK)
{
  "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 GET automatically generates and saves your first signing secret.
  • GET is read-only and idempotent. It always returns your current active secret without changing it.
  • last_rotated_at is omitted if the secret has never been rotated since creation.

Rotate your signing secret

Request
POST /api/v1/sms_center/status_callback_secret/rotate HTTP/1.1
Host: api.crazytel.io
x-crazytel-api-key: YOUR_API_KEY
Response (200 OK)
{
  "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

HeaderExampleDescription
Content-Typeapplication/json; charset=utf-8Always UTF-8 JSON.
X-CrazyAPI-Timestamp1730000000Unix epoch timestamp (seconds) when Crazytel signed this delivery attempt.
X-CrazyAPI-Signature9f2e4a... (64 hex characters)Lowercase hex HMAC-SHA256(secret, timestamp + "." + raw_body).
X-CrazyAPI-Event-ID0192a5c7-2c8e-7c67-bfa4-9f1a2e3b4c5dUnique UUID for this status transition. Reused across retries of the same event, so use it to deduplicate.
X-CrazyAPI-Attempt11-based attempt number. 1 on first delivery, increasing on retries.

Payload examples

sent Crazytel has accepted the message for delivery.

Message accepted (status: sent)
{
  "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.

Handset delivery confirmed (status: delivered)
{
  "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.

Delivery failed (status: undelivered)
{
  "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

FieldTypeDescription
schema_versionintegerAlways 1. Use it to keep your webhook handler forward-compatible.
event_idUUID stringUnique ID for this lifecycle transition. Retries reuse it, so use it to deduplicate.
message_uuidUUID stringMatches the uuid returned when you submitted the SMS. Use it to link the webhook back to your message record.
account_codestringYour Crazytel account code.
statusstringOne of sent, delivered, undelivered or failed.
previous_statusstringState immediately before this transition (for example sent when moving to delivered). Empty on the first callback if no prior state existed.
fromstringNormalised sender number (digits only, e.g. 61412345678) or alphanumeric sender ID.
tostringNormalised recipient number (digits only, no +, e.g. 61400111222).
provider_message_idstringCrazytel network reference ID when available.
error_codestringStable machine-readable code on failed or undelivered events (empty string on sent and delivered). Always branch on error_code, not error_message.
error_messagestringHuman-readable explanation when a message fails (up to 512 characters). For display and logging only.
segmentsintegerBillable SMS segment count (0 if rejected before sending).
cost_microsintegerMessage charge in micro-units of your account currency. 10,000 micros = 1 cent, so 50,000 micros = $0.05.
occurred_atRFC 3339 stringUTC time the lifecycle event occurred on Crazytel's platform. Use it to order events.
attempt_numberinteger1-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.

statuserror_codeStageMeaning
sentemptySubmitCrazytel accepted the message for delivery.
deliveredemptyTerminalHandset delivery confirmed.
undeliveredcarrier-undeliverableTerminalThe 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).
failedbilling-insufficient-balancePre-sendRejected before sending because the account balance or credit limit was insufficient.
failedbilling-account-unknownPre-sendRejected because the account code was not found.
failedbilling-account-disabledPre-sendRejected because the account is currently disabled.
failedbilling-calls-disabledPre-sendRejected because outbound SMS or calls are administratively paused on the account.
failedbilling-sender-not-allowedPre-sendRejected because from is not a verified Caller ID, active Virtual Mobile Number or approved alphanumeric sender on the account.
failedspam-blockedPre-sendRejected because the message matched an outbound spam or scam protection rule.
failedopt-out-blockedPre-sendRejected because the recipient number is on the active Opt-Out suppression list.
failedprovider-rejectedSubmitRejected due to invalid message content or formatting.
failedprovider-unavailableSubmitTemporary upstream failure after automatic retries.
failedcarrier-rejectedTerminalRejected by the destination mobile network.
failedcarrier-failedTerminalDelivery failed on the destination mobile network.
failedinvalid-phonePre-send (bulk)Bulk send only: the recipient number could not be normalised to a valid phone number.
failedinternal-errorInternalUnexpected processing error.

4. Verify webhook signatures

Confirm that a request genuinely came from Crazytel and was not tampered with in transit.

  1. Read X-CrazyAPI-Timestamp and X-CrazyAPI-Signature from the request headers.
  2. Check that the timestamp is within 5 minutes (300 seconds) of your server's current Unix time, to prevent replay attacks.
  3. Read the raw HTTP request body bytes before parsing any JSON.
  4. Build the signed payload by joining the timestamp, a literal period and the raw body:
    <X-CrazyAPI-Timestamp>.<raw_request_body>
  5. 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.
  6. Compare your result with X-CrazyAPI-Signature using a constant-time comparison function.
Python (Flask or FastAPI)
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 "", 204
Node.js (Express)
import 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
<?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);
Go (net/http)
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 responseHow 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 timeoutTreated 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

Retryattempt_numberWhen
Retry 121 minute after the first failure
Retry 2310 minutes after Retry 1
Retry 3 onwards4+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.

MethodEndpointPurpose
GET/api/v1/sms_center/status_callback_secretFetch (or auto-create) your account's 64-character HMAC-SHA256 signing secret.
POST/api/v1/sms_center/status_callback_secret/rotateRotate your signing secret immediately.
POST/api/v2/sms/sendSend one SMS (accepts optional status_callback).
POST/api/v2/sms/bulk-sendSend bulk SMS to up to 50,000 recipients (accepts optional status_callback).
POST/api/v2/sms/schedule-sendSchedule 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?
No. status_callback is optional on every send request. Include it when you want webhook notifications for that message or campaign, and leave it out when you do not.
Which SMS APIs support status_callback?
Single Send (POST /api/v2/sms/send), Bulk Send (POST /api/v2/sms/bulk-send, up to 50,000 recipients) and Scheduled Send (POST /api/v2/sms/schedule-send, up to 500 recipients).
What does my status_callback URL need to look like?
It must use https://, be no longer than 2,048 characters, and resolve to a publicly routable IP address. Plain http://, localhost, .local hosts and private network ranges are rejected.
How many webhooks will I get for each message?
At most one sent event, followed by exactly one terminal event: delivered, undelivered or failed. If a pre-send check rejects the message, the sent event is skipped and failed fires directly.
What happens if my server is down?
Crazytel retries on a 408, 429, 5xx or network timeout: after 1 minute, then 10 minutes, then every hour for up to 7 days. Any other 4xx response is treated as a permanent rejection and retries stop.
How do I avoid processing the same event twice?
Every status transition has a unique event_id, also sent in the X-CrazyAPI-Event-ID header. It stays the same across retries, so store it and ignore repeats.
How do I verify a webhook really came from Crazytel?
Compute an HMAC-SHA256 over the timestamp, a period and the raw request body using your signing secret, then compare it to the X-CrazyAPI-Signature header with a constant-time check. Also reject timestamps more than 5 minutes from your server clock.
Where do I get my signing secret?
Call GET /api/v1/sms_center/status_callback_secret with your API key. The first call creates your secret. You can also fetch or rotate it from the Crazytel Portal.
Is there a grace period when I rotate the secret?
No. Rotation is an immediate cutover. The old secret stops working straight away and every webhook sent afterwards, including queued retries of earlier events, is signed with the new secret.
Should I branch on error_message or error_code?
Always branch on error_code. It is a stable machine-readable value. error_message is human-readable text for display and logging only.

Start receiving delivery receipts

Fetch your signing secret, add status_callback to a test send, and watch the events arrive.

Need Help?