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

# Receive Real-Time Delivery Events — Zexa Webhooks API

> Register a webhook endpoint via POST /webhooks to receive real-time delivery callbacks when messages are delivered, fail, or recipients opt out.

Webhooks let Zexa push delivery events to your server in real time instead of requiring you to poll the API for status updates. When a message is delivered, fails, or a recipient opts out, Zexa sends an HTTP `POST` request to your registered endpoint with a JSON payload describing the event. This makes it easy to keep your own database in sync with message delivery state and react to opt-outs immediately.

## Supported Events

| Event                   | Description                                                               |
| ----------------------- | ------------------------------------------------------------------------- |
| `message.delivered`     | Message was successfully delivered to the recipient                       |
| `message.failed`        | Message delivery failed after all retry attempts                          |
| `message.undeliverable` | Message could not be delivered (e.g. invalid number, deactivated account) |
| `message.optout`        | Recipient replied STOP or clicked an unsubscribe link                     |
| `campaign.sent`         | All messages in a campaign have been dispatched to carriers               |
| `campaign.completed`    | Campaign delivery is finished — all messages have reached a final status  |

## Register a Webhook

### Via the Dashboard

<Steps>
  <Step title="Open Webhook settings">
    Go to **Settings → Webhooks** in your Zexa dashboard and click **Add Webhook**.
  </Step>

  <Step title="Enter your endpoint URL">
    Provide the HTTPS URL of the endpoint on your server that will receive webhook events (e.g. `https://yourapp.com/webhooks/zexa`). HTTP endpoints are not accepted.
  </Step>

  <Step title="Select events">
    Choose the specific events you want to subscribe to. You can subscribe to all events or a targeted subset.
  </Step>

  <Step title="Save and verify">
    Click **Save**. Zexa will immediately send a test event to your endpoint to verify it is reachable and returning a `2xx` response.
  </Step>
</Steps>

### Via the API

You can also register webhooks programmatically:

```text theme={null}
POST https://api.zexa.ao/v1/webhooks
```

#### Request Parameters

<ParamField body="url" type="string" required>
  The HTTPS URL of your endpoint that will receive webhook events. Must use HTTPS — plain HTTP is not accepted.
</ParamField>

<ParamField body="events" type="array" required>
  An array of event names to subscribe to. Subscribe to one or more of: `message.delivered`, `message.failed`, `message.undeliverable`, `message.optout`, `campaign.sent`, `campaign.completed`. Pass `["*"]` to subscribe to all events.
</ParamField>

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.zexa.ao/v1/webhooks \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://yourapp.com/webhooks/zexa",
      "events": ["message.delivered", "message.failed", "message.optout"]
    }'
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      "https://api.zexa.ao/v1/webhooks",
      headers={
          "Authorization": "Bearer YOUR_API_KEY",
          "Content-Type": "application/json",
      },
      json={
          "url": "https://yourapp.com/webhooks/zexa",
          "events": ["message.delivered", "message.failed", "message.optout"],
      },
  )

  print(response.json())
  ```
</CodeGroup>

#### Response Fields

<ResponseField name="id" type="string">
  The unique webhook identifier, prefixed with `wh_` (e.g. `wh_ghi789`).
</ResponseField>

<ResponseField name="url" type="string">
  The registered endpoint URL.
</ResponseField>

<ResponseField name="events" type="array">
  The list of event names this webhook is subscribed to.
</ResponseField>

<ResponseField name="created_at" type="string">
  ISO 8601 UTC timestamp of when the webhook was registered.
</ResponseField>

**Example response (`201 Created`):**

```json theme={null}
{
  "id": "wh_ghi789",
  "url": "https://yourapp.com/webhooks/zexa",
  "events": ["message.delivered", "message.failed", "message.optout"],
  "created_at": "2026-06-24T10:00:00Z"
}
```

#### Error Scenarios

| Status | Error              | Description                                                                     |
| ------ | ------------------ | ------------------------------------------------------------------------------- |
| `400`  | `invalid_request`  | Missing required fields or malformed JSON body                                  |
| `401`  | `unauthorized`     | API key is missing or invalid                                                   |
| `422`  | `validation_error` | `url` is not a valid HTTPS URL, or `events` contains an unrecognised event name |

***

## Webhook Payload

When a subscribed event occurs, Zexa sends a `POST` request to your endpoint with a JSON body in the following structure:

```json theme={null}
{
  "event": "message.delivered",
  "timestamp": "2026-06-24T10:05:00Z",
  "data": {
    "message_id": "msg_abc123",
    "channel": "sms",
    "to": "+244912345678",
    "status": "delivered",
    "delivered_at": "2026-06-24T10:04:55Z"
  }
}
```

| Field       | Description                                                            |
| ----------- | ---------------------------------------------------------------------- |
| `event`     | The event type that triggered this delivery                            |
| `timestamp` | ISO 8601 UTC datetime of when the event occurred                       |
| `data`      | Event-specific payload containing resource IDs, status, and timestamps |

***

## Responding to Webhooks

Your endpoint must return a `2xx` HTTP status code within **10 seconds** of receiving the request. If your endpoint does not respond in time, or returns a non-`2xx` status, Zexa treats the delivery as failed and retries automatically up to **3 times** using exponential backoff (approximately 1 minute, 5 minutes, then 30 minutes between attempts).

<Tip>
  Acknowledge the webhook immediately by returning `200 OK` as soon as you receive the request, then process the event asynchronously (e.g. via a background job or message queue). This prevents timeout failures caused by slow processing logic inside the request handler.
</Tip>

***

## Verify Webhook Authenticity

Zexa signs every webhook request with an HMAC-SHA256 signature computed from the raw request body and your webhook secret. The signature is included in the `X-Zexa-Signature` request header. Always verify this signature before processing the payload to ensure the request genuinely originated from Zexa.

You can find your webhook secret in **Settings → Webhooks** next to the registered endpoint.

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac

  def verify_zexa_signature(raw_body: bytes, secret: str, header_signature: str) -> bool:
      """
      Verify the HMAC-SHA256 signature on an incoming Zexa webhook request.

      Args:
          raw_body: The raw, undecoded request body bytes.
          secret: Your webhook secret from the Zexa dashboard.
          header_signature: The value of the X-Zexa-Signature header.

      Returns:
          True if the signature is valid, False otherwise.
      """
      expected = hmac.new(
          key=secret.encode("utf-8"),
          msg=raw_body,
          digestmod=hashlib.sha256,
      ).hexdigest()

      return hmac.compare_digest(expected, header_signature)


  # Example usage in a Flask handler
  from flask import Flask, request, abort

  app = Flask(__name__)
  WEBHOOK_SECRET = "your_webhook_secret_here"

  @app.route("/webhooks/zexa", methods=["POST"])
  def handle_webhook():
      signature = request.headers.get("X-Zexa-Signature", "")

      if not verify_zexa_signature(request.get_data(), WEBHOOK_SECRET, signature):
          abort(403)

      event = request.get_json()
      # Queue the event for async processing here
      return "", 200
  ```

  ```javascript Node.js theme={null}
  const crypto = require("crypto");
  const express = require("express");

  const app = express();
  const WEBHOOK_SECRET = "your_webhook_secret_here";

  app.post(
    "/webhooks/zexa",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const signature = req.headers["x-zexa-signature"] || "";
      const expected = crypto
        .createHmac("sha256", WEBHOOK_SECRET)
        .update(req.body)
        .digest("hex");

      if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
        return res.status(403).send("Invalid signature");
      }

      const event = JSON.parse(req.body);
      // Queue the event for async processing here
      res.sendStatus(200);
    }
  );
  ```
</CodeGroup>

<Warning>
  Always verify the `X-Zexa-Signature` header before processing any webhook payload. Skipping signature verification makes your endpoint vulnerable to spoofed events that could trigger unintended actions in your application.
</Warning>
