Skip to content

When a carrier returns a delivery report, we POST a JSON payload to your delivery_url:

POST https://your-server.com/webhook/delivery
Content-Type: application/json
User-Agent: 23Telecom-Webhooks/1.0
Example payload
{
"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"
}

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.

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.

Signature verification shown below is optional but recommended — set a signing secret first (see securing webhooks).

Flask
import hmac, hashlib, time
from 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", 200
  • 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.