> ## Documentation Index
> Fetch the complete documentation index at: https://www.carrothub.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 CarrotHub 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 CarrotHub 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 [CarrotHub Console](https://www.carrothub.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.

## 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.

### 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 [CarrotHub error object](https://www.carrothub.ai/docs/get-started/error-codes.md#carrothub-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 | `CarrotHub-Webhook/1.0` |
| `X-CarrotHub-Webhook-Id` | Delivery ID, unique per `(event, endpoint)`; stays the same across retries | `whd_01JSGP3X7K9M2N4Q5R6S7T8U9V` |
| `X-CarrotHub-Webhook-Timestamp` | Unix timestamp (seconds) at signing time | `1745402460` |
| `X-CarrotHub-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-CarrotHub-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.

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 CarrotHub Console
MAX_SKEW_SECONDS = 300         # 5 minutes

@app.post("/webhooks/carrothub")
def receive():
    raw_body = request.get_data()  # bytes — do not re-serialize
    delivery_id = request.headers.get("X-CarrotHub-Webhook-Id", "")
    timestamp = request.headers.get("X-CarrotHub-Webhook-Timestamp", "")
    signature_header = request.headers.get("X-CarrotHub-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-CarrotHub-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-CarrotHub-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, CarrotHub 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-CarrotHub-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.

## Idempotency

Two IDs cooperate to make retries safe:

* **`evt_...` (event ID)** — lives on the payload's top-level `id` field, identifies "something happened in CarrotHub".
* **`whd_...` (delivery ID)** — lives on the `X-CarrotHub-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.carrothub.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.


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