# Securing webhooks

> Verify 23 Telecom webhook authenticity with HMAC-SHA256 signatures — X-Webhook-Signature header, constant-time comparison, secret rotation and replay protection.
> Source: https://docs.23telecom.co.uk/webhooks/security/

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

Anyone who discovers your webhook URL can POST fake events to it. A signing
secret lets you verify that events really come from 23 Telecom.

## How signing works

Set a signing secret in the portal (**Settings → Webhooks**) or via
`PUT /user/webhooks`. Every webhook request then includes:

| Header | Value |
| --- | --- |
| `X-Webhook-Timestamp` | Unix timestamp (seconds) |
| `X-Webhook-Signature` | `sha256={hex}` |

**Verification:**

```
expected = HMAC-SHA256(your_secret, "{timestamp}.{raw_body}")
compare:   "sha256=" + expected  ==  X-Webhook-Signature
```

Three rules:

- Compute over the **raw request body** — before any JSON parsing or
  re-serialization.
- Use **constant-time comparison** (`hmac.compare_digest`,
  `crypto.timingSafeEqual`, `hash_equals`) — never `==` on signatures.
- Reject stale timestamps (e.g. older than 5 minutes) to limit replay windows.

Verification code in Python, Node.js and PHP is included in the
[delivery webhook examples](/webhooks/delivery#receiver-examples).

## Managing the secret

The secret is **write-only**: `GET /user/webhooks` returns only
`signing_secret_set: true|false`, never the value.

| Operation | PUT body |
| --- | --- |
| Preserve existing secret | Omit `signing_secret`, or send `signing_secret: null` |
| Set / rotate secret | `{ "signing_secret": "your-new-secret" }` |
| Clear secret | `{ "clear_signing_secret": true }` |
| Invalid (`400 INVALID_SECRET`) | `signing_secret: ""` or longer than 64 chars |

  Rotating or clearing the secret atomically rewrites all pending, retrying
  and failed deliveries in the retry queue — the old secret never lingers in
  queued events. During rotation, accept signatures computed with either the
  old or new secret on your side until the queue drains.

## Defense in depth

- **HTTPS only** — webhook URLs must be TLS; plain HTTP exposes payloads and
  signatures in transit.
- **Don't reflect errors verbosely** — a `401` with no body is enough for a
  failed signature.
- **Idempotency by `message_id`** — replayed or retried events must not
  double-process.
- **Secret hygiene** — generate 32+ random characters, store alongside your
  other secrets, rotate on staff changes.