# Guide to Clickatell Webhooks: Features and Best Practices

Clickatell webhooks keep your application in step with the messages you send and receive through One API, Clickatell's API for SMS and WhatsApp. When a message is delivered or fails, falls back to another channel, or a customer replies with text, media, a location or a button press, Clickatell calls your server with the details, so you can update delivery status, continue a conversation or hand it to an agent.

This guide covers how One API callbacks are configured and shaped, how to authenticate them with Custom Security Headers, how Clickatell handles failures, and the practices that keep a Clickatell receiver reliable.

## What are Clickatell webhooks?

Clickatell calls them callbacks. A One API integration has two callback URLs: a **Status callback URL** for delivery updates and a **Reply callback URL** for inbound messages (mobile-originated, or "MO", in Clickatell's naming). Splitting them keeps conversations low-latency, because a burst of status updates doesn't queue up in front of a customer's reply.

Every callback is a JSON `POST` with the same three top-level fields: `integrationId`, `integrationName` and `event`. The kind of event is the name of the single key inside `event`, and its value is an array of events.

Clickatell doesn't sign callbacks. You authenticate them with credentials Clickatell sends on every request: Basic Auth configured on the callback URL, up to five Custom Security Headers, or both.

## Clickatell webhook features

| Feature | Details |
| --- | --- |
| Product | One API (SMS and WhatsApp). Legacy SMS Gateway callbacks are a separate product |
| Configuration | One API integration settings in the Clickatell portal |
| Callback URLs | Status callback URL and Reply callback URL, configured separately |
| Method and format | `POST`, `Content-Type: application/json` |
| Envelope | `integrationId`, `integrationName`, `event` |
| Event kind | The name of the one key inside `event` |
| Batching | `event` holds an array. Batch size is always 1 today, but may grow |
| Authentication | Basic Auth, up to 5 Custom Security Headers per callback URL, or both |
| Header limits | Names up to 64 characters (letters, digits, `-`, `_`), values up to 512 characters |
| Signature and timestamp header | None |
| Acknowledgement | Any `2xx` |
| Retries | Anything else except a redirect is retried. Slow responses are retried too |
| Ordering | Not guaranteed. Use the millisecond `timestamp` fields |
| Source IPs | Not published |

## Event kinds

Status and reply callbacks carry different kinds:

| Key in `event` | Callback | What it carries |
| --- | --- | --- |
| `messageStatusUpdate` | Status | `channel`, `messageId`, `clientMessageId`, `status`, `statusCode`, `timestamp` |
| `messageFallback` | Status | A message falling back to another channel, with `status` and `statusDescription` |
| `statusIsTyping` | Status | A typing indicator from a user, with `from` |
| `moText` | Reply | An inbound text message |
| `moMedia` | Reply | An inbound image, file or other media |
| `moLocation` | Reply | An inbound location |
| `moContacts` | Reply | Inbound contact cards |
| `moSuggestedResponse` | Reply | A tapped suggested response |
| `moListPickerResponse` | Reply | A selection from a list picker |
| `moButtonResponse` | Reply | A button press |

`channel` is `sms` or `whatsapp`. Clickatell says it adds new kinds and fields as channels gain features, and asks you to make sure new event types and extra parameters don't break your processing.

A status callback for one delivered message has this shape:

```json
{
  "integrationId": "1a2b3c4d5e6f",
  "integrationName": "Order notifications",
  "event": {
    "messageStatusUpdate": [
      {
        "channel": "whatsapp",
        "messageId": "f3a8a2c0e1c14b9e9d6b1c7a2d3e4f50",
        "clientMessageId": "order-1042",
        "status": "DELIVERED",
        "timestamp": 1761480000000
      }
    ]
  }
}

```

The values are illustrative: Clickatell's reference documents the schema, not a sample body, and the schema also allows a numeric `statusCode`. `clientMessageId` echoes the ID you supplied when sending, which makes it the easiest way to link a status back to your own record.

> **Inspect Clickatell callbacks as they arrive.** Set a [Hookdeck Inspect](https://inspect.hookdeck.com) URL as a callback URL on a test integration to see the exact headers and body Clickatell sends, with no account or setup required.

## Setting up Clickatell webhooks

Generate a long random value first, for example with `openssl rand -hex 32`, and save it somewhere safe. Clickatell encrypts header values and can't show them to you again.

1. Log in to the Clickatell portal and open your **One API integration settings**.
2. Set the **Status Callback URL** and, if you handle inbound messages, the **Reply Callback URL**.
3. In the **Custom Security Headers** section for each callback URL, click **Add Header**, enter a name such as `X-Webhook-Secret` and your value, and click **Save**.
4. Send a test message and confirm that the status callback arrives with your header.

If the Custom Security Headers section is missing, your user role needs the permission for it; ask Clickatell Support. Headers are configured per callback URL, so set the header on both URLs. You can use the same name on both.

Avoid naming the header `Authorization` unless you mean to: a custom `Authorization` header replaces any Basic Auth configured for that URL.

**With Hookdeck.** Clickatell is a supported source in Hookdeck. Its setup note reads: "Covers One API callbacks only. Add a Custom Security Header to the callback URL." Create a source with the **Clickatell** type and use its URL as the callback URL. Enter the header name in **API Key Header** and its exact value in **API Key**. Header names match case-insensitively; the value must match exactly. The source accepts `POST` only. Basic Auth on its own isn't checked by this source, so add a Custom Security Header even if you already use Basic Auth. A request with a missing or wrong header still gets a `200` from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.

For local development, `hookdeck listen 3000 clickatell --path /webhooks/clickatell` gives you a public URL that forwards to your machine.

## Securing Clickatell webhooks

Read the header you configured and compare its whole value with the one you stored, in constant time. Reject the request if the header is missing.

```javascript
const crypto = require('crypto');
const express = require('express');

const HEADER_NAME = (process.env.CLICKATELL_WEBHOOK_HEADER || 'x-webhook-secret').toLowerCase();
const SECRET = process.env.CLICKATELL_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/clickatell', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER_NAME); // case-insensitive lookup
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  let body;
  try {
    body = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // The kind is the key name; loop over every kind and every item,
  // because batches may grow beyond one event.
  for (const [kind, items] of Object.entries(body.event || {})) {
    for (const item of Array.isArray(items) ? items : []) {
      if (kind === 'messageStatusUpdate') {
        // Update the message with item.clientMessageId / item.messageId.
      } else if (kind === 'moText') {
        // Hand the reply to your conversation logic.
      }
    }
  }

  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

HEADER_NAME = os.environ.get("CLICKATELL_WEBHOOK_HEADER", "X-Webhook-Secret")
SECRET = os.environ.get("CLICKATELL_WEBHOOK_SECRET", "")

def verify_clickatell_request(headers) -> bool:
    if not SECRET:
        return False  # never accept when unconfigured
    received = headers.get(HEADER_NAME)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A Custom Security Header is a shared secret. It authenticates the sender but not the body, so a modified payload still passes, and nothing stops a captured request from being replayed. Use HTTPS only and a long random value, and rotate it if it might have leaked.

> **Make Clickatell webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks your Custom Security Header, answers Clickatell straight away, and queues every status update and reply so you can retry and replay them.

## Clickatell webhook limitations and pain points

### Stored values can't be read back

**The Problem:** If you lose the header value, you can't look it up in the portal.

**Why It Happens:** Clickatell encrypts header values when you save them and never displays them again.

**Workarounds:**

* Store the value in your secret manager before you save it in Clickatell.
* If it's lost, update the header with a new value and change your receiver at the same time.

**How Hookdeck Can Help:** Hookdeck keeps every request, including those that fail verification, with full headers, so you can see what Clickatell sent while you put matching values in place.

### Rotations aren't instant

**The Problem:** Right after you update a header value, some callbacks still carry the old one.

**Why It Happens:** Clickatell caches header values. Its docs say in-flight and immediately following requests can use the previous value for a brief period, typically seconds.

**Workarounds:**

* Accept both the old and the new value for a few minutes during a rotation.
* Rotate the status and reply URLs one at a time.

**How Hookdeck Can Help:** Failed-verification requests stay in Hookdeck's logs, so you can confirm when the old value stops arriving.

### Duplicates and out-of-order callbacks

**The Problem:** A `DELIVERED` status can arrive before an earlier status, and the same callback can arrive twice.

**Why It Happens:** Clickatell retries callbacks that aren't acknowledged fast enough, and it says events can be out of sequence.

**Workarounds:**

* Order by the millisecond `timestamp` in each item, not by arrival.
* Deduplicate on `messageId`, `status` and `timestamp` together, since one message has several statuses.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated callbacks based on fields you choose before they reach your service.

### The payload shape is designed to change

**The Problem:** A handler that expects exactly one event per callback, or a fixed set of kinds, breaks when Clickatell adds a kind or starts batching.

**Why It Happens:** The kind is a key name, not a field value, batch size is fixed at 1 only "for now", and new message types are added as channels evolve.

**Workarounds:**

* Iterate over every key in `event` and every item in each array.
* Log and acknowledge unknown kinds instead of returning an error.

**How Hookdeck Can Help:** Hookdeck stores every callback with its body, so you can replay one with a new kind against your updated handler.

## Best practices

### Use separate callback URLs

Keep status and reply callbacks on different URLs, or at least different paths, as Clickatell intends. Replies are time-sensitive; status updates can wait in a queue.

### Set `clientMessageId` when sending

Status callbacks echo it, so you can match a delivery update to your own record without storing Clickatell's `messageId` first.

### Acknowledge fast and process asynchronously

Clickatell retries callbacks your application doesn't answer quickly enough. Verify the header, store the body, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Make handlers idempotent

Retries mean the same callback can arrive more than once. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Clickatell One API sends status and reply callbacks as JSON, with the event kind as the key inside `event` and an array of events as its value. Callbacks aren't signed, so add a Custom Security Header with a long random value to each callback URL, compare it in full and in constant time, iterate over every kind and item, and order and deduplicate using `timestamp` and `messageId`.

[Hookdeck Event Gateway](https://hookdeck.com) checks that header, acknowledges Clickatell immediately, and lets you retry and replay callbacks, so a slow handler doesn't turn into duplicate deliveries.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Clickatell webhooks reliably in minutes.