# SMS Delivery Status Webhooks (DLR) - Developer Guide | Crazytel

> Canonical URL: https://crazytel.com.au/business/sms/delivery-webhooks/
> SMS platform overview: https://crazytel.com.au/business/sms/
> REST API Documentation: https://crazytel.io/docs#/SMS%20Center%20%7C%20SMS%20Webhooks

Crazytel SMS supports real-time **Delivery Status Webhooks**, also known as **Delivery Receipts (DLRs)**. Whenever you send an SMS through Crazytel (a single SMS, a bulk campaign up to 50,000 recipients, or a scheduled send), you can optionally include a `status_callback` URL in your request. Crazytel then sends a signed JSON `POST` request to your server each time that message changes state:

1. `sent`: Crazytel has accepted your message for delivery.
2. `delivered`, `undelivered` or `failed`: the final delivery outcome once handset delivery is confirmed or fails.

Every webhook request is signed with **HMAC-SHA256** using your account's unique **Webhook Signing Secret**, so your server can verify that every delivery report genuinely came from Crazytel.

## Key Features

- **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**: Single Send (`POST /api/v2/sms/send`), Bulk Send (`POST /api/v2/sms/bulk-send`) and Scheduled Send (`POST /api/v2/sms/schedule-send`).
- **Signed with HMAC-SHA256**: every webhook carries `X-CrazyAPI-Signature` and `X-CrazyAPI-Timestamp` headers, signed with your account's 64-character signing secret.
- **Self-service secret management**: fetch or rotate your signing secret at any time from the Crazytel Portal or via the API (`GET /api/v1/sms_center/status_callback_secret` and `POST /api/v1/sms_center/status_callback_secret/rotate`).
- **Automatic retries for up to 7 days**: if your server is unreachable or returns `5xx` / `429`, Crazytel retries with backoff (1 minute, 10 minutes, then hourly) for up to 7 days.
- **Built-in deduplication key**: every lifecycle event has a unique `event_id` (`X-CrazyAPI-Event-ID`) that stays the same across retries.

## 1. Enable Delivery Webhooks When Sending

Pass an `https://` URL in the optional `status_callback` field on any Crazytel V2 SMS send endpoint at `https://api.crazytel.io`.

Your account is identified automatically from your `x-crazytel-api-key` header. You never send an account code in the request body.

Requirements for `status_callback` URLs:

- Must use `https://` (plain `http://` URLs are rejected).
- Maximum length of 2,048 characters.
- Must resolve to a publicly routable IP address (`localhost`, `.local` and private network ranges are blocked).

### Option A: Single SMS (`POST /api/v2/sms/send`)

```http
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`):

```json
{
  "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"
}
```

Save the returned `uuid`: every webhook for this message includes that exact value as `message_uuid`.

### Option B: Bulk SMS (`POST /api/v2/sms/bulk-send`)

When you set `status_callback` on a bulk send (inline `to[]`, `list_uuid`, or both), Crazytel sends individual webhook events for each recipient as their message is sent and delivered.

```http
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`)

When you set `status_callback` on a scheduled send, Crazytel saves the webhook URL with your schedule and posts per-recipient delivery status updates when the scheduled batch is dispatched at `send_at`.

```http
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

```http
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`):

```json
{
  "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

```http
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`):

```json
{
  "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 secret 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. Webhook Request Format

Each delivery status update is sent to your `status_callback` URL as an HTTP `POST` request 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 chars) | 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 example: message accepted (`status: "sent"`)

```json
{
  "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
}
```

### Payload example: handset delivery confirmed (`status: "delivered"`)

```json
{
  "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
}
```

### Payload example: delivery failed or undeliverable (`status: "undelivered"` or `"failed"`)

```json
{
  "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. |
| `account_code` | string | Your Crazytel account code. |
| `status` | string | One of `sent`, `delivered`, `undelivered` or `failed`. |
| `previous_status` | string | State immediately before this transition. Empty on the first callback if no prior state existed. |
| `from` | string | Normalised sender number (digits only) or alphanumeric sender ID. |
| `to` | string | Normalised recipient number (digits only, no `+`). |
| `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; 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. |

### Status and error code reference

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 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 number is invalid or disconnected). |
| `failed` | `billing-insufficient-balance` | Pre-send | Account balance or credit limit was insufficient. |
| `failed` | `billing-account-unknown` | Pre-send | The account code was not found. |
| `failed` | `billing-account-disabled` | Pre-send | The account is currently disabled. |
| `failed` | `billing-calls-disabled` | Pre-send | Outbound SMS or calls are administratively paused on the account. |
| `failed` | `billing-sender-not-allowed` | Pre-send | `from` is not a verified Caller ID, active Virtual Mobile Number or approved alphanumeric sender on the account. |
| `failed` | `spam-blocked` | Pre-send | The message matched an outbound spam or scam protection rule. |
| `failed` | `opt-out-blocked` | Pre-send | The recipient is on the active Opt-Out suppression list. |
| `failed` | `provider-rejected` | Submit | 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

To confirm a webhook 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 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 JSON.
4. Build the signed payload: `<X-CrazyAPI-Timestamp>.<raw_request_body>` (timestamp, a literal period, then the raw 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 to `X-CrazyAPI-Signature` using a constant-time comparison function.

### Python (Flask / FastAPI)

```python
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)

```javascript
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
<?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)

```go
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

| Your server's response | How Crazytel handles it |
|---|---|
| `2xx` (200 OK, 204 No Content, etc.) | Delivery is marked successful. No further retries. |
| `408`, `429`, `5xx`, or network / TLS timeout (10 second limit) | Temporary failure, retried automatically. |
| Any other `4xx` (400, 401, 403, 404, 422) | Permanent rejection. Crazytel stops retrying that event. |

Retry schedule:

- Retry 1 (`attempt_number: 2`): 1 minute after the first failure.
- Retry 2 (`attempt_number: 3`): 10 minutes after Retry 1.
- Retry 3 onwards (`attempt_number: 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, and gets a fresh `X-CrazyAPI-Timestamp` and `X-CrazyAPI-Signature` at the moment of the attempt.

## Endpoints Summary

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 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 (optional `status_callback`) |
| `POST` | `/api/v2/sms/bulk-send` | Send bulk SMS to up to 50,000 recipients (optional `status_callback`) |
| `POST` | `/api/v2/sms/schedule-send` | Schedule SMS to up to 500 recipients (optional `status_callback`) |
