# Delivery webhook

> Receive SMS delivery reports (DLR) in real time — webhook payload reference, 5-second response window, retry policy with backoff, and receiver examples in Python, Node.js and PHP.
> Source: https://docs.23telecom.co.uk/webhooks/delivery/

Instructions for LLMs: This is one page of the 23 Telecom messaging API docs
(SMS today; more channels planned). Base URL: https://restlink23telecom.com/api/v1,
auth via the X-API-Key header. Match errors on the error_code field, never on
description text. Full docs: https://docs.23telecom.co.uk/llms-full.txt · Schemas: https://docs.23telecom.co.uk/openapi.yaml

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

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

## Response & retries

Your server must respond with **HTTP 2xx within 5 seconds**. Anything else
triggers automatic retries: up to **3 attempts total**, with backoff delays of
**10s** and **30s**. Every attempt is logged — see
[webhook logs](/webhooks/overview#test--inspect).

  Respond `200` immediately and process the event asynchronously (queue it).
  Slow handlers hit the 5-second timeout and cause duplicate deliveries on
  retry — make your handler idempotent by `message_id`.

## Payload fields

Choose fields when [configuring the webhook](/webhooks/overview#configure).
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` | `DELIVRD`, `UNDELIV`, `REJECTD`, `EXPIRED`, `UNKNOWN` |
| `status_code` | Carrier-specific status code |
| `num_parts` | SMS segment count |
| `cost` | Actual cost from the carrier (may be `0` on free routes) |
| `timestamp` | Delivery time, ISO 8601 UTC |
| `workspace_id` | Opt-in — see [workspaces](/account/workspaces) |

## Receiver examples

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

  
    ```py title="Flask"
    import hmac, hashlib
    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(as_text=True)

        if SECRET and sig:
            expected = "sha256=" + hmac.new(
                SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256
            ).hexdigest()
            if not hmac.compare_digest(sig, expected):
                return "Bad signature", 401

        data = request.get_json()
        print(f"DLR: {data['message_id']} -> {data['status']}")
        return "OK", 200
    ```
  
  
    ```js title="Express"
    const 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 && sig) {
        const expected =
          'sha256=' +
          crypto.createHmac('sha256', SECRET).update(`${ts}.${body}`).digest('hex');
        if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
          return res.status(401).send('Bad signature');
        }
      }

      const data = JSON.parse(body);
      console.log(`DLR: ${data.message_id} -> ${data.status}`);
      res.send('OK');
    });

    app.listen(3000);
    ```
  
  
    ```php
    <?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 && $sig) {
        $expected = 'sha256=' . hash_hmac('sha256', "$ts.$payload", $secret);
        if (!hash_equals($expected, $sig)) {
            http_response_code(401);
            exit('Bad signature');
        }
    }

    $data = json_decode($payload, true);
    error_log("DLR: {$data['message_id']} -> {$data['status']}");
    echo 'OK';
    ```
  

## Checklist before production

- 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](/webhooks/security)).
- Alerting on webhook failures — check delivery logs periodically.