---
title: "Webhooks"
description: "Receive inbound messages, delivery receipts and opt-outs in real time, and verify that webhooks came from us."
---

> Documentation Index
> Fetch the complete documentation index at: https://dev.touchsms.com.au/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

Webhooks notify your application when something happens on your account, such as a reply arriving or a message being delivered. We send an HTTP request to a URL you choose each time the event occurs. You can have as many webhook subscriptions as you need.

Webhooks are the recommended way to receive replies and delivery receipts. Polling [List inbound messages](/api/inbounds/list/) works, but is slower and uses up your [rate limit](/concepts/rate-limits/).

## Create a subscription

In [touchSMS](https://app.touchsms.com.au), open **Settings** in the sidebar, then **Webhooks** (or go to `https://app.touchsms.com.au/settings/webhooks`). Click **Create webhook subscription**, or edit an existing one.

For each subscription you choose:

- the **event** to subscribe to,
- the **URL** to call,
- the **request method**: `POST` or `GET`,
- the **encoding** for `POST`: `json` or `form`,
- up to 10 **custom headers**, for example an API key your endpoint expects,
- for `message_dlr`, whether to **include the sent status**. See [Sent status](#sent-status).

You can also manage subscriptions over the API:

| Task | Endpoint |
|---|---|
| List subscriptions | [List webhook subscriptions](/api/webhook-subscriptions/list/) |
| Create a subscription | [Create webhook subscription](/api/webhook-subscriptions/create/) |
| Get one subscription | [Get webhook subscription](/api/webhook-subscriptions/get/) |
| Update a subscription | [Update webhook subscription](/api/webhook-subscriptions/update/) |
| Delete a subscription | [Delete webhook subscription](/api/webhook-subscriptions/delete/) |

```json
{
  "name": "Inbound to CRM",
  "event": "message_received",
  "url": "https://example.com/hooks/inbound",
  "request_method": "POST",
  "request_encoding_type": "json",
  "headers": [
    { "key": "X-Api-Key", "value": "abc123" }
  ]
}
```

A few things to know when using the API:

- `headers` is an array of `{ "key", "value" }` objects, not a plain object. It is required: send `[]` if you have none. Header names must be 3 to 50 characters, and values 3 to 200.
- `request_encoding_type` is required when `request_method` is `POST`.
- `include_sent_status` is optional and only applies to `message_dlr`. It defaults to `false`, and is always `false` for other events.
- Updates replace the whole subscription, so send every field, not only the ones you're changing.- The URL can't point at one of our own domains.

### Subscription status

| Status | Meaning |
|---|---|
| `Active` | Enabled and receiving events. |
| `Disabled` | Disabled manually. |
| `Disabled Failing` | Disabled automatically after repeated delivery failures. |

A subscription in `Disabled Failing` gets no events until you re-enable it. If your endpoint has been down, check for this once it's back up.

## Events

| Event | Sent when |
|---|---|
| [`message_received`](#message_received) | An inbound message (SMS, MMS or web chat) arrives on your account. |
| [`message_dlr`](#message_dlr) | The delivery status of a message you sent changes. |
| [`optout_created`](#optout_created) | A number opts out. |
| `message_received_3cx` | An inbound message arrives, formatted for the 3CX phone system integration. Use `message_received` for your own integrations. |

### `message_received`

Sent when an inbound message arrives, to a dedicated number or to the shared number pool.

```json
{
  "event": "message_received",
  "date": "2022-01-13T04:23:57Z",
  "id": 123456,
  "to": "SHARED_NUMBER",
  "from": "61412345678",
  "content": "Hello Reply",
  "media": [],
  "is_opt_out": false,
  "channel": null,
  "in_reply_to_message": {
    "id": 654321,
    "content": "Hello World",
    "metadata": {
      "your_system_id": "c5969b2c-d33a-402d-9d7e-f82c98f85be7"
    },
    "reference": null,
    "campaign_id": 123456
  }
}
```

| Field | Description |
|---|---|
| `event` | Always `message_received`. |
| `date` | When the message was received. |
| `id` | Inbound message ID. |
| `to` | The dedicated number that received the message, or `SHARED_NUMBER` for the shared pool. |
| `from` | The sender's number. |
| `content` | Message text. An empty string for an MMS sent without text. |
| `media` | MMS media files. An empty array for SMS. See [Inbound MMS media](#inbound-mms-media). |
| `is_opt_out` | `true` if the message opted the sender out (for example, a `STOP` reply). |
| `channel` | `webchat` if the message came from the web chat widget, `null` for SMS and MMS. |
| `in_reply_to_message` | The outbound message this is a reply to, including its `metadata`, `reference` and `campaign_id`. `null` if no outbound message matched. |

### Inbound MMS media

For an MMS, `media` has one entry per file. Each entry has a `key` (the file's unique, stable identifier) and a `url` (a direct link to the file). The payload doesn't include the file contents.

```json
"media": [
  { "key": "01gw171s65ecm8mhpx2086f7pg", "url": "https://mms.example.com/m/01gw171s65ecm8mhpx2086f7pg.jpeg" },
  { "key": "01gw171s65ecm8mhpx2086f7ph", "url": "https://mms.example.com/m/01gw171s65ecm8mhpx2086f7ph.png" }
]
```

- **Downloading needs no authentication.** Fetch the `url` with a plain `GET`, with no API key or login.
- **Treat URLs as secrets.** Anyone with a URL can fetch the file. Don't forward links to untrusted recipients; attach the downloaded file instead.
- **Don't assume a file type.** Extensions reflect the type received from the carrier (commonly `.jpeg`, `.png` or `.gif`). Read the `Content-Type` header of the download.
- **Iterate the array.** An MMS can contain several files, in the order the carrier sent them, so don't just read `media[0]`.
- **Download straight away and keep your own copy.** Files are kept according to your account's retention policy, and URLs aren't guaranteed to resolve forever. If a download fails, retry promptly, and don't return a success response until the media is stored.

[List inbound messages](/api/inbounds/list/) returns the same media as `media_urls`, a flat array of URLs.

### `message_dlr`

Sent each time the delivery status of a message you sent changes.

```json
{
  "event": "message_dlr",
  "date": "2022-06-06T10:35:45Z",
  "id": 123456,
  "to": "61411802860",
  "from": "61419046139",
  "content": "Test",
  "parts": 1,
  "cost": 1,
  "reference": null,
  "metadata": {
    "your_system_id": "c5969b2c-d33a-402d-9d7e-f82c98f85be7"
  },
  "campaign_id": 618,
  "status": "delivered"
}
```

| Field | Description |
|---|---|
| `event` | Always `message_dlr`. |
| `date` | When the status changed. |
| `id` | Outbound message ID. |
| `to` | Recipient number. |
| `from` | Sender ID the message was sent from, or `SHARED_NUMBER` for the shared pool. |
| `content` | Message text. |
| `parts` | Number of SMS parts. |
| `cost` | Cost of the message. |
| `reference`, `metadata` | The values you supplied when sending. |
| `campaign_id` | Campaign ID, used in reporting. |
| `status` | The new status. See below. |

#### Delivery statuses

Treat any status starting with `failed` as a delivery failure.

| Status | Meaning |
|---|---|
| `sent` | Handed to the carrier, awaiting a delivery receipt. Only sent if the subscription [includes the sent status](#sent-status). |
| `delivered` | Delivered to the handset (or, in some cases, to the recipient's carrier). |
| `failed` | Delivery failed. |
| `failed_rejected` | The carrier rejected the message. |
| `failed_expired` | The message couldn't be delivered before it expired. |
| `failed_insufficient_balance` | Your account balance was too low to send the message. Not billed. |
| `cancelled` | The message was cancelled before sending. Not billed. |
| `opt_out` | The recipient had opted out. |
| `filtered` | Blocked by spam filtering, ours or upstream. |
| `filtered_duplicate` | Filtered as a duplicate. Not billed. |

#### Sent status

By default, `message_dlr` only reports the outcome of a message: delivered, failed, filtered and so on. Turn on **Include sent status** on the subscription (or set `include_sent_status` to `true` over the API) to also get a `sent` receipt as soon as each message is handed to the carrier. Use it to sync outbound messages into your own system as they go out, including ones sent from the web app or a campaign.

The `sent` receipt has the same payload as any other `message_dlr`, with `status` set to `sent`.

The `sent` receipt and the final status are separate webhooks, each with its own [retries](#responding-and-retries), so they can arrive out of order. If your endpoint fails the `sent` one, its retry can land after `delivered`. Treat every status except `sent` as final, and don't overwrite it with `sent`.

### `optout_created`

Sent each time a number opts out on your account: through a reply, an opt-out link, the web interface or the API. Imported opt-outs don't trigger it.

```json
{
  "event": "optout_created",
  "id": 123456,
  "number": "61419046139",
  "method": "reply",
  "country": {
    "id": 1,
    "iso": "AU",
    "name": "Australia"
  },
  "opt_out_list": {
    "id": 1,
    "name": "Default Opt-Out List",
    "is_default": true
  },
  "created": "2022-06-06T10:35:45Z"
}
```

| Field | Description |
|---|---|
| `id` | Opt-out ID. |
| `number` | The number that opted out. |
| `method` | How the opt-out was created. See below. |
| `country` | The number's country: `id`, two-letter `iso` code and `name`. |
| `opt_out_list` | The [opt-out list](/guides/opt-outs/) it was added to. |
| `created` | When the opt-out was created. |

| `method` | Created by |
|---|---|
| `reply` | An SMS reply, such as `STOP`. |
| `link` | An opt-out link. |
| `manual` | Someone in the web interface. |
| `api` | The API. |
| `chat` | The chat inbox. |
| `workflow` | An automated workflow. |

## Responding and retries

Return a `2xx` status as soon as you've safely stored the event. Any other response, or a timeout, counts as a failure.

Failed webhooks are retried up to 15 times, with exponentially increasing delays capped at 12 hours. Process events idempotently (for example, by event `id` and `status`), since the same event can arrive more than once.
## Signature verification

Webhooks are signed with your account's webhook secret, which you'll find in your API settings in [touchSMS](https://app.touchsms.com.au). The signature is sent in the `Signature` header:

```
Signature: HMAC-SHA256(raw_json_request_body, webhook_secret)
```

Compute the HMAC-SHA256 of the raw request body with your secret and compare it to the header in constant time. In PHP:

```php
$expected = hash_hmac('sha256', $rawBody, $webhookSecret);

if (!hash_equals($expected, $request->header('Signature'))) {
    abort(401);
}
```

In Node.js:

```js
import crypto from 'node:crypto';

const expected = crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
```

Use the raw body exactly as received. Parsing and re-serialising the JSON can change it and break the signature. For `form`-encoded and `GET` webhooks, the signature is still computed over the JSON encoding of the payload.

## Legacy HTTP callbacks

Older accounts may use the legacy delivery receipt and incoming SMS callbacks configured in account settings. They still work, but new integrations should use webhook subscriptions.

<details>
<summary>Legacy callback parameters</summary>

Both callbacks are sent as `POST` requests with these body parameters.

**Delivery receipt callback**

| Parameter | Description |
|---|---|
| `reference` | The reference you supplied when sending. |
| `description` | `DELIVERED` or `FAILED`. |
| `time` | When the receipt was generated, as `YYYY-MM-DD HH:MM:SS`. |
| `timezone` | Timezone of `time`. Always your account's timezone. |

**Incoming SMS callback**

| Parameter | Description |
|---|---|
| `reference` | The reference of the message being replied to. |
| `message` | The reply text. |
| `number` | The sender's number, in international format. |
| `time` | When the reply was received, as `YYYY-MM-DD HH:MM:SS`. |
| `timezone` | Timezone of `time`. Always your account's timezone. |

</details>

Source: https://dev.touchsms.com.au/guides/webhooks/index.mdx
