> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shortext.ny-corp.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive Shortext events in real time on your own HTTPS endpoint, signed and retried.

Webhooks push events to your server as they happen: a contact writes, a message is delivered, a human takes a conversation over, a client finishes connecting their WhatsApp number. Prefer them to polling the API.

## Create an endpoint

Create endpoints in the dashboard (**Developer > Webhooks**) or with the API (`POST /webhooks`, **Manage integrations** permission):

```json theme={null}
{
  "url": "https://example.com/webhooks/shortext",
  "events": ["message.*", "conversation.handover", "conversation.released"],
  "filters": { "app": "AGI-XXXXX" },
  "description": "CRM sync"
}
```

| Field | Description |
| - | - |
| `url` | A public **HTTPS** URL. Private and local addresses are refused. |
| `events` | Exact types (`message.received`), a prefix wildcard (`message.*`) or `*` for everything. |
| `filters` | Optional. Only receive events whose context matches: `app` (an agent id), `channel` (`whatsapp`, `mail`, `shortext`), `channel_id`. |
| `description` | Optional, for you. |

The response contains the signing **`secret`, shown only once**. Store it with your other secrets.

> [!NOTE]
>
> An endpoint receives payloads in the API version used to create it (`api_version`), so a new API version never changes what you receive.

## Events

`GET /webhooks/events` returns the catalog.

| Event | When |
| - | - |
| `message.received` | A contact sent a message to one of your agents. |
| `message.sent` | A message was sent to a contact (by the AI agent, a member of your team or the API). |
| `message.status` | A message status changed (delivered, read, failed...). |
| `conversation.handover` | A human took a conversation over: the agent stops answering. |
| `conversation.released` | A conversation was handed back to the agent. |
| `channel.connected` | A channel (WhatsApp number, mailbox, Shortext endpoint) was connected. |
| `channel.agent_changed` | A channel was attached to an agent, moved to another one or detached. |
| `agent.created` | An agent was created. |
| `agent.updated` | An agent was updated (settings or instructions). |
| `agent.deleted` | An agent was deleted. |
| `hosted_session.completed` | A [hosted session](https://docs.shortext.ny-corp.io/api-reference/hosted-sessions.md) was completed: `result` holds what it produced. |
| `hosted_session.failed` | A hosted session failed: `error` says why. |
| `webhook.test` | Sent on demand to check an endpoint. |

New event types can be added at any time: ignore the types you do not handle.

## Payload

Every delivery is a `POST` with a JSON body:

```json theme={null}
{
  "id": "evt_01JB8Z6X4Q2W3E5R7T9Y1U3I5O",
  "type": "message.received",
  "api_version": "v2.0",
  "created_at": "2026-10-24T09:12:44+00:00",
  "data": {
    "id": "k5Rz8pQw",
    "conversation_id": "c9Lw3nYb",
    "direction": "inbound",
    "channel": "whatsapp",
    "channel_id": "532867649916076",
    "contact": { "identifier": "237699999999", "identifier_type": "phone", "profile": { "firstname": "Brice" } },
    "message": { "id": "wamid.HBgM...", "type": "text", "content": { "text": "Hello, where is my order?" } },
    "status": "received",
    "created_at": "2026-10-24T09:12:44+00:00"
  }
}
```

`data` depends on the type:

| Events | `data` |
| - | - |
| `message.*` | A message, as in [Message format](https://docs.shortext.ny-corp.io/api-reference/message-format.md#messages-in-responses-and-webhooks). |
| `conversation.*` | `conversation_id`, `channel`, `channel_id` and `contact.identifier`. |
| `channel.*` | A channel, as returned by `GET /channels`; `channel.agent_changed` adds `previous_agent`. |
| `agent.*` | An agent, as returned by `GET /agents/{id}` (without its channels). |
| `hosted_session.*` | A hosted session, as returned by `GET /hosted-sessions/{id}`. |

## Headers

| Header | Value |
| - | - |
| `X-Shortext-Event` | The event type, for example `message.received`. |
| `X-Shortext-Delivery` | The delivery id (a uuid). A redelivery has a new one. |
| `X-Shortext-Timestamp` | The Unix time the request was signed. |
| `X-Shortext-Signature` | `sha256=` followed by the hex HMAC of the request. |
| `X-Shortext-Api-Version` | The version of the payload, for example `v2.0`. |

## Verify the signature

Check every request before trusting it:

```text theme={null}
expected = "sha256=" + hex(hmac_sha256(secret, "{X-Shortext-Timestamp}.{raw body}"))
```

1. Read the **raw** body, before any JSON parsing: re-encoding it changes the bytes and breaks the signature.
2. Reject requests whose timestamp is more than 5 minutes away from your clock (replays).
3. Compare `expected` with `X-Shortext-Signature` in constant time.

```php PHP theme={null}
<?php
// $secret: the signing secret returned when the webhook endpoint was created
function shortextSignatureIsValid(string $secret, string $timestamp, string $rawBody, string $signature): bool
{
    // Reject old deliveries: protects against replays
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $expected = 'sha256=' . hash_hmac('sha256', "{$timestamp}.{$rawBody}", $secret);

    return hash_equals($expected, $signature);
}

$rawBody = file_get_contents('php://input'); // the raw body, before any JSON decoding
$valid   = shortextSignatureIsValid(
    getenv('SHORTEXT_WEBHOOK_SECRET'),
    $_SERVER['HTTP_X_SHORTEXT_TIMESTAMP'] ?? '',
    $rawBody,
    $_SERVER['HTTP_X_SHORTEXT_SIGNATURE'] ?? ''
);

if (!$valid) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
// Acknowledge fast with a 2xx, then process $event (deduplicate on $event['id'])
http_response_code(200);
```

```javascript Node.js theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();

function signatureIsValid(secret, timestamp, rawBody, signature) {
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false;
  }
  const expected =
    "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw keeps the body exactly as it was sent
app.post("/webhooks/shortext", express.raw({ type: "application/json" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  const valid = signatureIsValid(
    process.env.SHORTEXT_WEBHOOK_SECRET,
    req.get("X-Shortext-Timestamp") ?? "",
    rawBody,
    req.get("X-Shortext-Signature")
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(rawBody);
  res.sendStatus(200); // acknowledge fast, then process (deduplicate on event.id)
});

app.listen(3000);
```

```python Python theme={null}
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)


def signature_is_valid(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)


@app.post("/webhooks/shortext")
def shortext_webhook():
    raw_body = request.get_data()  # the raw body, before any JSON decoding
    if not signature_is_valid(
        os.environ["SHORTEXT_WEBHOOK_SECRET"],
        request.headers.get("X-Shortext-Timestamp", ""),
        raw_body,
        request.headers.get("X-Shortext-Signature", ""),
    ):
        abort(401)

    event = request.get_json()
    # Acknowledge fast, then process (deduplicate on event["id"])
    return "", 200
```

## Respond and deduplicate

* Answer with any **2xx** status within **10 seconds**. Do the work after answering, in a queue.
* Anything else (another status, a timeout, a redirect, a network error) is a failure and is retried.
* The same event can arrive more than once. Deduplicate on the event `id` (`evt_...`), which stays the same on retries and redeliveries.
* Events can arrive out of order: rely on `created_at` and on the API when order matters.

## Retries

A failed delivery is retried with an increasing delay: after 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, that is 8 attempts over about **21 hours**. After the last one, the delivery is marked failed.

An endpoint that keeps failing for **72 hours** is **disabled**: `is_enabled` becomes `false` and `disabled_reason` says why. Fix your server, then enable the endpoint again (`PUT /webhooks/{id}` with `"is_enabled": true`).

## Test, inspect and redeliver

| Endpoint | Use |
| - | - |
| `POST /webhooks/{id}/test` | Send a `webhook.test` event now (not retried) and get your server's response. |
| `GET /webhooks/{id}/deliveries` | The deliveries of the last **30 days**, filterable by `status` (`pending`, `succeeded`, `failed`), with attempts, response status, the first 2,000 characters of your response and the payload. |
| `POST /webhooks/{id}/deliveries/{delivery}/retry` | Queue a new delivery of the same event (same event `id`). |
| `POST /webhooks/{id}/rotate-secret` | Generate a new secret. The previous one stops working **immediately**: deploy the new one first. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.