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

# Webhooks

> Receive real-time notifications for Avala events

Webhooks let you build event-driven integrations with Avala. Instead of polling the API for changes, register an HTTPS endpoint and Avala will send an HTTP POST whenever a relevant event occurs.

## Quick Setup

<CodeGroup>
  ```python Python SDK theme={null}
  from avala import Client

  client = Client()

  # Create a webhook subscription
  webhook = client.webhooks.create(
      target_url="https://example.com/webhooks/avala",
      events=["export.completed", "dataset.created"],
  )
  print(f"Webhook {webhook.uid} created")
  print(f"Secret: {webhook.secret}")  # only shown once — store it!
  ```

  ```bash CLI theme={null}
  avala webhooks create \
    --url https://example.com/webhooks/avala \
    --events export.completed,dataset.created
  ```

  ```bash cURL theme={null}
  curl -X POST https://api.avala.ai/api/v1/webhooks/ \
    -H "X-Avala-Api-Key: $AVALA_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "target_url": "https://example.com/webhooks/avala",
      "events": ["export.completed", "dataset.created"]
    }'
  ```
</CodeGroup>

<Warning>
  The webhook **secret** is only returned in the creation response. Store it securely — you'll need it to verify signatures.
</Warning>

## Events

| Event              | Trigger                                                  |
| ------------------ | -------------------------------------------------------- |
| `dataset.created`  | A new dataset is created.                                |
| `dataset.updated`  | A dataset's metadata is modified.                        |
| `dataset.deleted`  | A dataset is deleted.                                    |
| `export.completed` | An export finishes processing and is ready for download. |
| `export.failed`    | An export fails to process.                              |
| `task.completed`   | A task's results are finalized.                          |

## Payload Format

Every webhook delivery is a JSON POST with three custom headers:

| Header                      | Description                                         |
| --------------------------- | --------------------------------------------------- |
| `X-Avala-Webhook-Signature` | HMAC-SHA256 hex digest for payload verification.    |
| `X-Avala-Webhook-Event`     | The event type (e.g., `export.completed`).          |
| `X-Avala-Webhook-Delivery`  | Unique delivery ID (UUID) for idempotency tracking. |

The request body contains the event payload as JSON. The payload structure matches the corresponding API resource.

### `export.completed` / `export.failed`

```json theme={null}
{
  "uid": "exp_abc123",
  "status": "completed",
  "download_url": "https://api.avala.ai/api/v1/exports/exp_abc123/download",
  "format": "avala_json",
  "created_at": "2026-02-21T14:30:00Z"
}
```

### `dataset.created` / `dataset.updated`

```json theme={null}
{
  "uid": "ds_abc123",
  "name": "Pedestrian Detection v2",
  "owner": "acme-ai",
  "slug": "pedestrian-detection-v2",
  "data_type": "image",
  "item_count": 1250,
  "created_at": "2026-02-21T10:00:00Z",
  "updated_at": "2026-02-21T14:30:00Z"
}
```

### `dataset.deleted`

```json theme={null}
{
  "uid": "ds_abc123",
  "name": "Pedestrian Detection v2",
  "owner": "acme-ai",
  "slug": "pedestrian-detection-v2"
}
```

### `task.completed`

```json theme={null}
{
  "uid": "tsk_abc123",
  "status": "completed",
  "task_type": "box",
  "project_uid": "proj_abc123",
  "dataset_item_uid": "item_abc123",
  "completed_at": "2026-02-21T14:30:00Z"
}
```

## Verifying Signatures

Every delivery is signed with HMAC-SHA256 using the subscription's secret. **Always verify signatures before processing a webhook** to ensure the request came from Avala.

The signature is computed over the JSON-serialized payload with deterministic formatting (compact separators, sorted keys):

```
HMAC-SHA256(secret, json_serialize(payload, separators=(",",":"), sort_keys=True))
```

### Python

```python theme={null}
import hmac
import hashlib
import json

def verify_webhook(payload: dict, signature: str, secret: str) -> bool:
    """Verify that a webhook payload was sent by Avala."""
    payload_bytes = json.dumps(
        payload, separators=(",", ":"), sort_keys=True
    ).encode("utf-8")
    expected = hmac.new(
        secret.encode("utf-8"),
        payload_bytes,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
```

### TypeScript

```typescript theme={null}
import { createHmac, timingSafeEqual } from "crypto";

function verifyWebhook(
  payload: Record<string, unknown>,
  signature: string,
  secret: string,
): boolean {
  const body = JSON.stringify(payload, Object.keys(payload).sort())
    .replace(/: /g, ":")
    .replace(/, /g, ",");
  const expected = createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
```

<Warning>
  Always use constant-time comparison (`hmac.compare_digest` in Python, `timingSafeEqual` in Node.js) to prevent timing attacks.
</Warning>

## Example: Flask Webhook Handler

A complete Flask application that receives and verifies Avala webhooks:

```python theme={null}
import hmac
import hashlib
import json
from flask import Flask, request, jsonify

app = Flask(__name__)

WEBHOOK_SECRET = "your-webhook-secret"  # from the creation response

def verify_signature(payload: dict, signature: str) -> bool:
    payload_bytes = json.dumps(
        payload, separators=(",", ":"), sort_keys=True
    ).encode("utf-8")
    expected = hmac.new(
        WEBHOOK_SECRET.encode("utf-8"),
        payload_bytes,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

@app.route("/webhooks/avala", methods=["POST"])
def handle_webhook():
    signature = request.headers.get("X-Avala-Webhook-Signature", "")
    event_type = request.headers.get("X-Avala-Webhook-Event", "")
    delivery_id = request.headers.get("X-Avala-Webhook-Delivery", "")

    payload = request.json
    if not verify_signature(payload, signature):
        return jsonify({"error": "Invalid signature"}), 401

    if event_type == "export.completed":
        print(f"Export ready: {payload['download_url']}")

    elif event_type == "dataset.created":
        print(f"New dataset: {payload['name']}")

    elif event_type == "task.completed":
        print(f"Task completed: {payload['uid']}")

    # Return 2xx to acknowledge receipt
    return jsonify({"received": True}), 200

if __name__ == "__main__":
    app.run(port=5000)
```

## Example: Express Webhook Handler

```typescript theme={null}
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = "your-webhook-secret";

app.post("/webhooks/avala", (req, res) => {
  const signature = req.headers["x-avala-webhook-signature"] as string;
  const eventType = req.headers["x-avala-webhook-event"] as string;

  // Verify signature
  const body = JSON.stringify(req.body, Object.keys(req.body).sort())
    .replace(/: /g, ":")
    .replace(/, /g, ",");
  const expected = createHmac("sha256", WEBHOOK_SECRET)
    .update(body)
    .digest("hex");

  if (!timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  switch (eventType) {
    case "export.completed":
      console.log(`Export ready: ${req.body.download_url}`);
      break;
    case "dataset.created":
      console.log(`New dataset: ${req.body.name}`);
      break;
    case "task.completed":
      console.log(`Task done: ${req.body.uid}`);
      break;
  }

  res.json({ received: true });
});

app.listen(5000, () => console.log("Listening on port 5000"));
```

## Retry Policy

If your endpoint doesn't respond with a `2xx` status code, Avala retries with exponential backoff:

| Attempt   | Delay        |
| --------- | ------------ |
| 1st retry | \~1 minute   |
| 2nd retry | \~2 minutes  |
| 3rd retry | \~4 minutes  |
| 4th retry | \~8 minutes  |
| 5th retry | \~16 minutes |

After 5 failed retries, the delivery is marked as **failed**. Client errors (`4xx`) are treated as permanent failures and are not retried.

## Delivery Log

Every delivery attempt is recorded. You can inspect delivery history through the API:

```bash theme={null}
# List recent deliveries
curl https://api.avala.ai/api/v1/webhook-deliveries/ \
  -H "X-Avala-Api-Key: $AVALA_API_KEY"
```

Each delivery record includes:

| Field             | Description                                                      |
| ----------------- | ---------------------------------------------------------------- |
| `uid`             | Unique delivery ID.                                              |
| `event_type`      | The event that triggered this delivery.                          |
| `payload`         | The JSON payload that was sent.                                  |
| `status`          | `pending`, `delivered`, or `failed`.                             |
| `response_status` | HTTP status code from your endpoint (null if not yet attempted). |
| `attempts`        | Number of delivery attempts so far.                              |
| `created_at`      | When the delivery was created.                                   |

Delivered entries are automatically cleaned up after 30 days. Failed entries are retained for 90 days.

## Managing Webhooks

### API Endpoints

| Method   | Endpoint                            | Description                      |
| -------- | ----------------------------------- | -------------------------------- |
| `GET`    | `/api/v1/webhooks/`                 | List your webhook subscriptions. |
| `POST`   | `/api/v1/webhooks/`                 | Create a new subscription.       |
| `GET`    | `/api/v1/webhooks/{uid}/`           | Get subscription details.        |
| `PUT`    | `/api/v1/webhooks/{uid}/`           | Update a subscription.           |
| `DELETE` | `/api/v1/webhooks/{uid}/`           | Delete a subscription.           |
| `POST`   | `/api/v1/webhooks/{uid}/test/`      | Send a test `ping` event.        |
| `GET`    | `/api/v1/webhook-deliveries/`       | List delivery history.           |
| `GET`    | `/api/v1/webhook-deliveries/{uid}/` | Get delivery details.            |

### Create a Subscription

```bash theme={null}
curl -X POST https://api.avala.ai/api/v1/webhooks/ \
  -H "X-Avala-Api-Key: $AVALA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "target_url": "https://example.com/webhooks/avala",
    "events": ["dataset.created", "export.completed", "task.completed"]
  }'
```

A signing secret is auto-generated if you don't provide one. You can also pass your own:

```json theme={null}
{
  "target_url": "https://example.com/webhooks/avala",
  "events": ["export.completed"],
  "secret": "my-custom-secret"
}
```

### Test a Webhook

Send a `ping` event to verify your endpoint is reachable:

```bash theme={null}
curl -X POST https://api.avala.ai/api/v1/webhooks/{uid}/test/ \
  -H "X-Avala-Api-Key: $AVALA_API_KEY"
```

## Testing During Development

1. **Use a tunnel** like [ngrok](https://ngrok.com/) to expose a local server:
   ```bash theme={null}
   ngrok http 5000
   ```
   Copy the HTTPS URL and use it as your webhook `target_url`.

2. **Send a test ping** via the API to verify connectivity:
   ```bash theme={null}
   curl -X POST https://api.avala.ai/api/v1/webhooks/{uid}/test/ \
     -H "X-Avala-Api-Key: $AVALA_API_KEY"
   ```

3. **Check the delivery log** to inspect payloads, response codes, and timing:
   ```bash theme={null}
   curl https://api.avala.ai/api/v1/webhook-deliveries/ \
     -H "X-Avala-Api-Key: $AVALA_API_KEY"
   ```

## Security

* **HTTPS required** — Webhook endpoints must use HTTPS.
* **SSRF protection** — Avala blocks delivery to private IPs, loopback addresses, and cloud metadata endpoints.
* **No redirects** — Webhook delivery does not follow HTTP redirects.
* **Encrypted secrets** — Webhook secrets are encrypted at rest and never exposed after creation.
* **One subscription per URL** — Each organization can have at most one subscription per target URL.
