Delivery webhook
Copy page
When a carrier returns a delivery report, we POST a JSON payload to your
delivery_url:
POST https://your-server.com/webhook/deliveryContent-Type: application/jsonUser-Agent: 23Telecom-Webhooks/1.0{ "message_id": "api_42_1743667200123456789_a3f8b2c1d9e45f67", "recipient": "+14155551234", "sender_id": "MyApp", "status": "DELIVRD", "status_code": "000", "num_parts": 1, "cost": 0.0085, "timestamp": "2026-02-13T10:30:04Z"}Response & retries
Section titled “Response & retries”Your server must respond with HTTP 2xx within 5 seconds. Anything else
does not always trigger a retry. Connection errors, timeouts, 408, 429 and
5xx responses are retried, up to 3 attempts total, with backoff delays of
10s and 30s. Any other non-2xx response, or a response body over the
size limit, ends delivery without retry. Every attempt is logged — see
webhook logs.
Payload fields
Section titled “Payload fields”Choose fields when configuring the webhook.
Defaults: message_id, recipient, sender_id, status, status_code,
num_parts, cost, timestamp.
| Field | Description |
|---|---|
message_id |
Your message ID (from the send response) |
telecom_message_id |
Carrier-assigned ID (deprecated — use message_id) |
recipient |
Destination phone number |
sender_id |
Sender ID used |
status |
Raw carrier status. Common values include SENT, ENROUTE, ACCEPTD, DELIVRD, UNDELIV, REJECTD, EXPIRED, UNKNOWN, DELETED; tolerate unknown strings. |
status_code |
Carrier-specific status code |
num_parts |
SMS segment count |
cost |
Rate from the delivery report. For API-send deliveries, a valid explicitly supplied rate is included, including 0. For campaign deliveries, cost is included only for billable statuses. Journey delivery reports do not emit delivery webhooks. Missing, invalid, or ineligible rates are omitted. |
timestamp |
Delivery time, ISO 8601 UTC |
sandbox |
Present and true only for simulated deliveries from sk_test_ sends; inside the signed body. Absent on live deliveries. |
workspace_id |
Opt-in — see workspaces |
cost is omitted when no rate is known or the rate is ineligible.
Receiver examples
Section titled “Receiver examples”Signature verification shown below is optional but recommended — set a signing secret first (see securing webhooks).
import hmac, hashlib, timefrom flask import Flask, request
app = Flask(__name__)SECRET = "your_webhook_signing_secret"
@app.route("/webhook/delivery", methods=["POST"])def delivery(): ts = request.headers.get("X-Webhook-Timestamp", "") sig = request.headers.get("X-Webhook-Signature", "") body = request.get_data(cache=True)
if SECRET: if not ts or not sig: return "", 401 try: timestamp = int(ts) except ValueError: return "", 401 if abs(int(time.time()) - timestamp) > 300: return "", 401
expected = "sha256=" + hmac.new( SECRET.encode(), ts.encode() + b"." + body, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(sig, expected): return "", 401
data = request.get_json() print(f"DLR: {data['message_id']} -> {data['status']}") return "OK", 200const crypto = require('crypto');const express = require('express');const app = express();const SECRET = 'your_webhook_signing_secret';
app.post('/webhook/delivery', express.raw({ type: '*/*' }), (req, res) => { const ts = req.headers['x-webhook-timestamp']; const sig = req.headers['x-webhook-signature']; const body = req.body.toString();
if (SECRET) { if (typeof ts !== 'string' || typeof sig !== 'string' || !/^\d+$/.test(ts)) { return res.status(401).end(); } const timestamp = Number(ts); if (!Number.isSafeInteger(timestamp) || Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) { return res.status(401).end(); }
const expected = Buffer.from( 'sha256=' + crypto.createHmac('sha256', SECRET).update(`${ts}.`).update(req.body).digest('hex') ); const provided = Buffer.from(sig); if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) { return res.status(401).end(); } }
const data = JSON.parse(body); console.log(`DLR: ${data.message_id} -> ${data.status}`); res.send('OK');});
app.listen(3000);<?php$secret = 'your_webhook_signing_secret';$payload = file_get_contents('php://input');$ts = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';$sig = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if ($secret) { if ($ts === '' || $sig === '' || !ctype_digit($ts)) { http_response_code(401); exit; } $timestamp = (int) $ts; if (abs(time() - $timestamp) > 300) { http_response_code(401); exit; }
$expected = 'sha256=' . hash_hmac('sha256', "$ts.$payload", $secret); if (strlen($sig) !== strlen($expected) || !hash_equals($expected, $sig)) { http_response_code(401); exit; }}
$data = json_decode($payload, true);error_log("DLR: {$data['message_id']} -> {$data['status']}");echo 'OK';Checklist before production
Section titled “Checklist before production”- Ignore or route separately any payload with
"sandbox": true. - Endpoint is HTTPS and responds 2xx in well under 5 seconds.
- Handler is idempotent — retries can deliver the same event twice.
- Signature verification enabled (securing webhooks).
- Alerting on webhook failures — check delivery logs periodically.
