---
title: "SMS webhook"
description: "Notifications for each message's journey — and, when someone replies, the text of that reply."
lang: en
canonical: https://doc.apibrasil.io/webhooks/api-sms
markdown: https://doc.apibrasil.io/webhooks/api-sms.md
source: apibrasil-documentation
---

# SMS webhook

> Notifications for each message's journey — and, when someone replies, the text of that reply.

Every message reports each step it takes. And when the recipient replies, that reply reaches your endpoint with its text — two-way SMS, without you asking for anything.

## How to receive

The URL goes in the body of the send request itself, as webhook_url, and it is the provider that calls it. The skeleton below unwraps the array, responds 200 and separates the reply from the other notifications.

**Where to register the URL and what lands on it**

| Field | What it receives | Events |
| --- | --- | --- |
| `webhook_url` | Every notification from this channel, on the same URL. | every event of the channel |

The events of this channel arrive in sequence for the same id, in the order below.

## Events (4)

### `inserted_for_processing` — Accepted for processing

**When it fires:** As soon as the message enters the queue. It has not been validated or sent yet.

**Fields**

| Name | Type | Meaning |
| --- | --- | --- |
| `version` | string | The format version. Current captures carry "v2". |
| `id` | string | The message identifier. It is the SAME across the notifications for one send — use it to join them. |
| `status` | string | The message state. This is what you route on: there is no event type field. |
| `number` | string | The recipient, digits only and with the area code. No + and no country code. |
| `created_at` | string | When the message entered the queue. |
| `sent_at` | string \| null | When it left. Null until it does. |
| `delivered_at` | string \| null | When the handset confirmed. Null in all four captured notifications. |
| `error_at` | string \| null | When it failed. Null in all four captured notifications. |
| `blocked_at` | string \| null | When it was blocked. Null in all four captured notifications. |

**Sample body, as it arrives**

```json
[
  {
    "version": "v2",
    "id": "9098235346a90edb2b868d892310556",
    "status": "inserted_for_processing",
    "number": "31994359434",
    "created_at": "2026-08-28 02:08:50",
    "sent_at": "2026-08-28 02:08:50",
    "delivered_at": null,
    "error_at": null,
    "blocked_at": null
  }
]
```

### `valid` — Number validated

**When it fires:** When the recipient passes format and blocklist validation.

**Fields**

| Name | Type | Meaning |
| --- | --- | --- |
| `version` | string | The format version. Current captures carry "v2". |
| `id` | string | The message identifier. It is the SAME across the notifications for one send — use it to join them. |
| `status` | string | The message state. This is what you route on: there is no event type field. |
| `number` | string | The recipient, digits only and with the area code. No + and no country code. |
| `created_at` | string | When the message entered the queue. |
| `sent_at` | string \| null | When it left. Null until it does. |
| `delivered_at` | string \| null | When the handset confirmed. Null in all four captured notifications. |
| `error_at` | string \| null | When it failed. Null in all four captured notifications. |
| `blocked_at` | string \| null | When it was blocked. Null in all four captured notifications. |

**Sample body, as it arrives**

```json
[
  {
    "version": "v2",
    "id": "9098235346a90edb2b868d892310556",
    "status": "valid",
    "number": "31994359434",
    "created_at": "2026-08-27 23:08:52",
    "sent_at": "2026-08-27 23:08:52",
    "delivered_at": null,
    "error_at": null,
    "blocked_at": null
  }
]
```

### `sent_to_carrier` — Handed to the carrier

**When it fires:** When the carrier receives the message. This is not the handset's confirmation — delivered_at stays null.

**Fields**

| Name | Type | Meaning |
| --- | --- | --- |
| `version` | string | The format version. Current captures carry "v2". |
| `id` | string | The message identifier. It is the SAME across the notifications for one send — use it to join them. |
| `status` | string | The message state. This is what you route on: there is no event type field. |
| `number` | string | The recipient, digits only and with the area code. No + and no country code. |
| `created_at` | string | When the message entered the queue. |
| `sent_at` | string \| null | When it left. Null until it does. |
| `delivered_at` | string \| null | When the handset confirmed. Null in all four captured notifications. |
| `error_at` | string \| null | When it failed. Null in all four captured notifications. |
| `blocked_at` | string \| null | When it was blocked. Null in all four captured notifications. |

**Sample body, as it arrives**

```json
[
  {
    "version": "v2",
    "id": "9098235346a90edb2b868d892310556",
    "status": "sent_to_carrier",
    "number": "31994359434",
    "created_at": "2026-08-27 23:08:53",
    "sent_at": "2026-08-27 23:08:53",
    "delivered_at": null,
    "error_at": null,
    "blocked_at": null
  }
]
```

### `reply` — The recipient replied

**When it fires:** When the recipient replies to your SMS. It arrives with the reply text, and with the same id as the original message — that is what ties the conversation together.

**Fields**

| Name | Type | Meaning |
| --- | --- | --- |
| `version` | string | The format version. Current captures carry "v2". |
| `id` | string | The message identifier. It is the SAME across the notifications for one send — use it to join them. |
| `status` | string | The message state. This is what you route on: there is no event type field. |
| `number` | string | The recipient, digits only and with the area code. No + and no country code. |
| `created_at` | string | When the message entered the queue. |
| `sent_at` | string \| null | When it left. Null until it does. |
| `delivered_at` | string \| null | When the handset confirmed. Null in all four captured notifications. |
| `error_at` | string \| null | When it failed. Null in all four captured notifications. |
| `blocked_at` | string \| null | When it was blocked. Null in all four captured notifications. |
| `mensagem` | string | The text the person replied with. Portuguese-named in the middle of an English payload, and it exists only in this notification. |

**Sample body, as it arrives**

```json
[
  {
    "version": "v2",
    "id": "9098235346a90edb2b868d892310556",
    "status": "reply",
    "number": "31994359434",
    "created_at": "2026-08-27 23:13:13",
    "sent_at": "2026-08-27 23:13:13",
    "delivered_at": null,
    "error_at": null,
    "blocked_at": null,
    "mensagem": "The best apibrasil.io"
  }
]
```

## What usually bites

- **The body is an array, not an object** — Every POST arrives as [ { … } ], with one notification inside. Reading body.status returns undefined — the path is body[0].status.
- **The dates switch time zones between notifications** — In the reference capture, the first notification came in UTC and the next two in Brasília time, for the same id. Sorting by created_at puts the first event last, three hours ahead. Use arrival order, not the date.
- **The reply text field is called mensagem, in Portuguese** — Every other field in the payload is in English — number, created_at, sent_at. Only the reply text comes as mensagem. It appears exclusively in the reply status notification; in the other three the field does not exist.
- **You register the URL on the send, not in the dashboard** — The webhook_url goes in the body of the SMS send request itself. The gateway forwards it to the provider, and it is the provider that calls your URL.

## Good practices

- **Respond 200 before processing** — Acknowledge receipt and do the heavy work afterwards, outside the request cycle. An endpoint that writes to the database before responding turns database slowness into a webhook timeout.
- **The idempotency key is id + status** — The same id arrives once per status — four POSTs for a message that got a reply. Deduplicating on id alone would discard three legitimate notifications.
- **HTTPS, because of what the body carries** — The payload carries the recipient's number and, in the reply notification, the text that person wrote. Over HTTP that travels in the clear for any intermediary on the network.

## Sample receivers

**Node**

```node
app.post("/webhook/sms", express.json(), (req, res) => {
  // The body is an ARRAY with one notification inside.
  const [notification] = req.body
  if (!notification) return res.sendStatus(400)

  // Respond before processing: the heavy work leaves the request cycle.
  res.sendStatus(200)

  if (notification.status === "reply") {
    console.log(`${notification.number} replied: ${notification.mensagem}`)
    return
  }

  // The same id arrives once per status — the key is the pair, not just the id.
  console.log(`${notification.id} is now ${notification.status}`)
})
```

**Python**

```python
@app.post("/webhook/sms")
def webhook_sms():
    # The body is a LIST with one notification inside.
    notifications = request.get_json(silent=True) or []
    if not notifications:
        return "", 400

    notification = notifications[0]

    if notification["status"] == "reply":
        print(f"{notification['number']} replied: {notification['mensagem']}")
    else:
        # The same id arrives once per status — the key is the pair, not just the id.
        print(f"{notification['id']} is now {notification['status']}")

    return "", 200
```

**PHP**

```php
<?php

// The body is an ARRAY with one notification inside.
$notifications = json_decode(file_get_contents('php://input'), true) ?: [];
$notification = $notifications[0] ?? null;

if (!$notification) {
    http_response_code(400);
    exit;
}

http_response_code(200);

if ($notification['status'] === 'reply') {
    error_log("{$notification['number']} replied: {$notification['mensagem']}");
} else {
    // The same id arrives once per status — the key is the pair, not just the id.
    error_log("{$notification['id']} is now {$notification['status']}");
}
```

## Security

- **This webhook IS NOT SIGNED.** There is no HMAC, no signature header and no
  shared secret. Do not invent a signature check: it does not exist and the
  code would end up rejecting everything.
- Treat the body as UNTRUSTED input. Anyone who discovers the URL can post to
  it.
- Use a path nobody can guess (a UUID in the path, for example) and serve it
  over HTTPS only — the body carries the recipient's phone number and, in the
  reply notification, the text that person wrote.
- Before ACTING on a notification, check that the `id` matches a message you
  actually sent. Without that, a third party forges a `reply` and triggers your
  automation.
- Do not log the contents of `mensagem` without need: it is content written by
  an end user.

## See also

- Webhooks: [Webhooks](https://doc.apibrasil.io/webhooks.md)
- Documentation index: https://doc.apibrasil.io/llms.txt
- HTML version of this page: https://doc.apibrasil.io/webhooks/api-sms
