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

# Webhooks

> Receive real-time HTTPS callbacks when your asynchronous tasks change state

Instead of polling `GET` endpoints for task status, register a webhook endpoint and MuleRouter will `POST` a signed callback to your URL the moment a task transitions.

Every delivery is signed with HMAC-SHA256 so you can verify it came from MuleRouter and the payload has not been tampered with.

## Event types

Events follow the `{resource}.{action}` convention:

| Event            | When it fires                                                       | Resource state   |
| :--------------- | :------------------------------------------------------------------ | :--------------- |
| `task.created`   | Asynchronous task created                                           | Task `pending`   |
| `task.succeeded` | Task finished successfully                                          | Task `completed` |
| `task.failed`    | Task failed during execution                                        | Task `failed`    |
| `balance.low`    | Available balance crosses below the system threshold (default \$10) | Wallet ALERTED   |

You choose which events each endpoint subscribes to. The intermediate task `processing` state does not emit an event. `balance.low` follows a one-shot edge-trigger semantics — see [Billing events](#billing-events) for details.

## Register an endpoint

Webhook endpoints are managed from the [MuleRouter Console](https://www.mulerouter.ai/app/webhooks). Open the `Webhooks` tab, click `Create endpoint`, and provide:

* **URL** — an HTTPS URL that can accept `POST` requests (max 2048 characters).
* **Events** — one or more of the event types above.
* **Description** — optional, up to 200 characters.

On creation, the console shows the full signing secret (`whsec_...`) exactly once. Save it to a secret store immediately — it is never displayed again.

<Note>
  Each user can register up to **5** webhook endpoints. If you need to deliver to more destinations, use a single endpoint that fans out on your side.
</Note>

## Delivery payload

Every delivery is a `POST` with `Content-Type: application/json`. All events share the same envelope:

```typescript theme={null}
interface WebhookPayload {
  id: string            // Event ID, use for idempotent deduplication
  type: string          // Event type, e.g. "task.succeeded"
  created_at: string    // Event creation time, ISO 8601
  data: Record<string, unknown>  // Event-specific payload, see below
}
```

The `data` shape depends on the event family:

| Event family | `data` shape                                                                                                        |
| :----------- | :------------------------------------------------------------------------------------------------------------------ |
| `task.*`     | Flat: `{ vendor, model_name, payload }` — `payload` mirrors the corresponding `GET /vendors/.../{task_id}` response |
| `balance.*`  | Nested: `{ user_id, payload: { ... } }` — fields under `data.payload.*`                                             |

<Warning>
  If you write a single dispatcher that handles both families, branch on the `type` field's prefix (`task.` vs `balance.`) before reading `data` fields. The two shapes are intentional — `task.*` keeps backward compatibility with earlier releases, while `balance.*` introduces a `data.user_id` + `data.payload.*` convention so future `balance.depleted` / `balance.topped_up` events can reuse the same outer locator.
</Warning>

### Task events

Task events use the flat `data` shape. The inner `data.payload` is the same JSON you would receive from the synchronous `GET /vendors/.../generation/{task_id}` endpoint at that point in the task lifecycle. That means your delivery handler can share result-parsing code with your polling handler, if you have one.

#### `task.created`

Fired when the task is accepted. `payload` only contains `task_info`.

```json theme={null}
{
  "id": "evt_01JSGP3X7K9M2N4Q5R6S7T8U9V",
  "type": "task.created",
  "created_at": "2026-04-23T10:00:00Z",
  "data": {
    "vendor": "google",
    "model_name": "nano-banana-2",
    "payload": {
      "task_info": {
        "id": "123e4567-e89b-12d3-a456-426614174000",
        "status": "pending",
        "created_at": "2026-04-23T10:00:00Z",
        "updated_at": "2026-04-23T10:00:00Z"
      }
    }
  }
}
```

#### `task.succeeded`

Fired when the task completes successfully. `payload` contains the full task response, including whatever fields the endpoint would normally return (e.g. `images`, `videos`, `audios`).

```json theme={null}
{
  "id": "evt_01JSGP4Y8L0N3O5P6Q7R8S9T0U",
  "type": "task.succeeded",
  "created_at": "2026-04-23T10:01:00Z",
  "data": {
    "vendor": "google",
    "model_name": "nano-banana-2",
    "payload": {
      "task_info": {
        "id": "123e4567-e89b-12d3-a456-426614174000",
        "status": "completed",
        "created_at": "2026-04-23T10:00:00Z",
        "updated_at": "2026-04-23T10:01:00Z"
      },
      "images": [
        "https://mulerouter.muleusercontent.com/public/123e4567/image.png"
      ]
    }
  }
}
```

#### `task.failed`

Fired when the task fails. Error details live on `payload.task_info.error`, using the same shape as the [MuleRouter error object](/docs/get-started/error-codes#mulerouter-errors).

```json theme={null}
{
  "id": "evt_01JSGP5Z9M1O4P6Q7R8S9T0U1V",
  "type": "task.failed",
  "created_at": "2026-04-23T10:01:00Z",
  "data": {
    "vendor": "google",
    "model_name": "nano-banana-2",
    "payload": {
      "task_info": {
        "id": "123e4567-e89b-12d3-a456-426614174000",
        "status": "failed",
        "created_at": "2026-04-23T10:00:00Z",
        "updated_at": "2026-04-23T10:01:00Z",
        "error": {
          "code": 3001,
          "title": "Task Execution Error",
          "detail": "The upstream provider returned an error during task execution."
        }
      }
    }
  }
}
```

### Billing events

Billing events use the nested `data` shape with `data.user_id` for routing and `data.payload` for business fields. They cover wallet-state changes that don't belong to any specific task.

#### `balance.low`

Fired when your wallet's available balance crosses **below** the system threshold (default \$10) following a charge. This is an **edge-trigger** event — it fires once when the balance drops past the threshold and stays silent until your balance recovers above the threshold and drops below it again.

```json theme={null}
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "type": "balance.low",
  "created_at": "2026-05-13T10:00:00Z",
  "data": {
    "user_id": "9b9c1d20-...",
    "payload": {
      "available_balance": "3.80",
      "trigger_threshold": "1000.00",
      "currency": "credits"
    }
  }
}
```

| Field                            | Type          | Description                                                                                                                                                                                   |
| :------------------------------- | :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data.user_id`                   | string (UUID) | The user whose balance dropped — the owner of this webhook endpoint                                                                                                                           |
| `data.payload.available_balance` | string        | Snapshot of available balance after the triggering charge, in **credits** with 2 decimals (e.g. `"3.85"`). Returned as a string to avoid floating-point precision issues on the receiver side |
| `data.payload.trigger_threshold` | string        | The threshold value that was just crossed, in **credits** with 2 decimals. Default `"1000.00"`                                                                                                |
| `data.payload.currency`          | string        | Always `"credits"` for now                                                                                                                                                                    |

##### Trigger semantics

* **Low balance detected**: when your `available_balance` drops below `trigger_threshold`, you'll receive a `balance.low` delivery shortly after.
* **Sustained low**: while you remain below threshold without a top-up, deliveries are throttled — you won't be spammed with repeated notifications.
* **Top-up then drop again**: if you top up above the threshold and later drop below again, you'll receive a fresh delivery without waiting out the throttle window.
* **Healthy recovery**: once your balance stays above the threshold, no further deliveries fire until it drops again.

##### When `balance.low` does **not** fire

* You don't have an active webhook endpoint subscribed to `balance.low`.
* Your `available_balance` is at or above the `trigger_threshold`.
* A recent delivery for the same wallet was already sent and your balance hasn't recovered above the threshold in between.
* The triggering activity is only a hold (frozen reservation) for an asynchronous task — `balance.low` fires when funds are **actually settled**, not when they're held. This avoids false positives if the task later fails and the hold is released.

##### Recommended client behavior

* Use this event as a hint to surface a low-balance banner in your UI, send your user a "top up" reminder, or pause non-critical workloads — not as the sole source of truth for your wallet balance.
* Always verify the current balance via your wallet's source of truth before acting on financial decisions, since `available_balance` in the payload is a snapshot at trigger time.

## Request headers

Each delivery includes the following HTTP headers:

| Header                           | Description                                                                | Example                          |
| :------------------------------- | :------------------------------------------------------------------------- | :------------------------------- |
| `Content-Type`                   | Always `application/json`                                                  | `application/json`               |
| `User-Agent`                     | Identifies the sender                                                      | `MuleRouter-Webhook/1.0`         |
| `X-MuleRouter-Webhook-Id`        | Delivery ID, unique per `(event, endpoint)`; stays the same across retries | `whd_01JSGP3X7K9M2N4Q5R6S7T8U9V` |
| `X-MuleRouter-Webhook-Timestamp` | Unix timestamp (seconds) at signing time                                   | `1745402460`                     |
| `X-MuleRouter-Webhook-Signature` | HMAC-SHA256 signature, prefixed with the algorithm version                 | `v1=5257a869e7...`               |

## Verify the signature

The signed content is three parts joined with `.`:

```
signed_content = "{delivery_id}.{timestamp}.{raw_body}"
```

The signature is computed as:

```
signature = HMAC-SHA256(key=<your whsec_... secret>, message=signed_content)
```

The header value carries a version prefix so we can upgrade the algorithm in the future without breaking existing receivers:

```
X-MuleRouter-Webhook-Signature: v1={hex_encoded_signature}
```

<Warning>
  Verify signatures against the **raw request body**, not a re-serialized JSON object. Re-encoding the payload will change byte-level whitespace and key ordering, and your computed signature will not match.
</Warning>

Here is a complete receiver in Python:

```python theme={null}
import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_..."   # from the MuleRouter Console
MAX_SKEW_SECONDS = 300         # 5 minutes

@app.post("/webhooks/mulerouter")
def receive():
    raw_body = request.get_data()  # bytes — do not re-serialize
    delivery_id = request.headers.get("X-MuleRouter-Webhook-Id", "")
    timestamp = request.headers.get("X-MuleRouter-Webhook-Timestamp", "")
    signature_header = request.headers.get("X-MuleRouter-Webhook-Signature", "")

    # Reject stale deliveries (protects against replay attacks).
    try:
        sent_at = int(timestamp)
    except ValueError:
        abort(400, "invalid timestamp")
    if abs(time.time() - sent_at) > MAX_SKEW_SECONDS:
        abort(400, "timestamp outside of allowed window")

    # Only v1 is currently defined; reject anything else so future algorithm
    # upgrades fail loudly instead of being silently trusted.
    if not signature_header.startswith("v1="):
        abort(400, "unsupported signature version")
    received_signature = signature_header[len("v1="):]

    signed_content = f"{delivery_id}.{timestamp}.".encode() + raw_body
    expected_signature = hmac.new(
        WEBHOOK_SECRET.encode(),
        signed_content,
        hashlib.sha256,
    ).hexdigest()

    # Constant-time comparison prevents timing attacks.
    if not hmac.compare_digest(received_signature, expected_signature):
        abort(401, "invalid signature")

    # At this point the payload is trusted. Deduplicate by delivery_id before
    # acting, since retries reuse the same X-MuleRouter-Webhook-Id.
    ...
    return ("", 204)
```

## Security

| Measure                      | Description                                                                                                                                                                               |
| :--------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **HTTPS only**               | `http://` URLs are rejected when registering or updating an endpoint.                                                                                                                     |
| **HMAC-SHA256**              | Each delivery is signed with your endpoint's secret.                                                                                                                                      |
| **Timestamp skew check**     | Reject requests whose `X-MuleRouter-Webhook-Timestamp` is more than 5 minutes from now to defend against replay attacks.                                                                  |
| **Constant-time comparison** | Use `hmac.compare_digest` (or your language's equivalent) to avoid leaking information through response timing.                                                                           |
| **Single-show secret**       | The full `whsec_...` secret is only returned on create and rotate. After that, only a masked preview (`whsec_...abcd`) is visible. Rotate through the Console if a secret is compromised. |

## Delivery and retries

| Setting            | Value               |
| :----------------- | :------------------ |
| HTTP Method        | `POST`              |
| `Content-Type`     | `application/json`  |
| Connection timeout | 30 seconds          |
| Success criterion  | HTTP `2xx` response |
| Max payload size   | 64 KB               |

If the target URL does not return a `2xx` within the timeout, MuleRouter retries with exponential backoff:

| Attempt | Delay      | Total elapsed |
| :------ | :--------- | :------------ |
| Retry 1 | 15 seconds | \~15 seconds  |
| Retry 2 | 1 minute   | \~1 min 15 s  |
| Retry 3 | 5 minutes  | \~6 min 15 s  |
| Retry 4 | 30 minutes | \~36 min 15 s |
| Retry 5 | 1 hour     | \~1 h 36 min  |

After 5 retries (6 total attempts), the delivery is marked `failed` and no further attempts are made for that event.

<Note>
  Every attempt of the same delivery reuses the same `X-MuleRouter-Webhook-Id`. The timestamp and signature are recomputed each time, so your receiver must always recompute the signature from the headers it actually received — do not cache.
</Note>

## Idempotency

Two IDs cooperate to make retries safe:

* **`evt_...` (event ID)** — lives on the payload's top-level `id` field, identifies "something happened in MuleRouter".
* **`whd_...` (delivery ID)** — lives on the `X-MuleRouter-Webhook-Id` header, identifies "an attempt to deliver that event to this endpoint". It is unique per `(event, endpoint)` and is **reused across retries of the same delivery**.

Use `whd_...` as your deduplication key: store it when you first successfully process a delivery, and short-circuit if you see it again. This is the standard way to handle retries without double-processing.

## Rate limits and quotas

| Limit                              | Value           |
| :--------------------------------- | :-------------- |
| Webhook endpoints per user         | 5               |
| Deliveries per endpoint per minute | 600             |
| Webhook URL length                 | 2048 characters |
| Description length                 | 200 characters  |
| Delivery log retention             | 30 days         |

## Next steps

* Open the [Webhooks tab](https://www.mulerouter.ai/app/webhooks) in the Console to register your first endpoint.
* Use the **Send test event** button on any endpoint to trigger a synthetic `task.succeeded` delivery (`id` prefixed with `evt_test_`) and verify your signature-verification logic end-to-end before sending real traffic.
