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

# Webhooks

> Receive real-time events when image / video generations complete or fail, when team balance crosses a low-credit threshold, and when an endpoint auto-disables.

Webhooks let your backend react to Aurous Labs events without polling. Register an endpoint, subscribe to one or more event types, and we POST a signed payload as soon as the event fires.

## Quick start

<Steps>
  <Step title="Register an endpoint">
    Send a `POST /v1/webhook_endpoints` with your HTTPS receiver URL. The response carries a one-time `secret` — store it; you cannot read it again.

    ```bash theme={null}
    curl -X POST https://api.aurous-labs.com/v1/webhook_endpoints \
      -H "X-Api-Key: $AUROUS_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://api.acme.dev/aurous-webhook",
        "events": ["image.completed", "image.failed"]
      }'
    ```

    Response (the only time `secret` is non-null):

    ```json theme={null}
    {
      "id": "we_01HXMQ7Z3K8Y2NABCDEFGHJKMN",
      "object": "webhook_endpoint",
      "url": "https://api.acme.dev/aurous-webhook",
      "events": ["image.completed", "image.failed"],
      "secret_preview": "whsec_8jK...wxyz",
      "secret": "whsec_8jKpQ4nXabc1234abcdef567890",
      "is_active": true,
      "consecutive_failures": 0,
      "last_success_at": null,
      "last_failure_at": null,
      "metadata": {},
      "created_at": "2026-05-04T01:00:00Z",
      "updated_at": "2026-05-04T01:00:00Z"
    }
    ```
  </Step>

  <Step title="Verify the signature on every delivery">
    Every delivery carries an `Aurous-Webhook-Signature` header of the form `t=<unix_sec>,v1=<hex>`. Reconstruct the canonical signed payload (`${t}.${raw_body}`) and HMAC-SHA256 it with your stored secret. See the [verifier examples](#receiver-cookbook).

    Aurous Labs uses Unix seconds (a 10-digit timestamp) for the `t=` parameter, matching the Stripe webhook convention.
  </Step>

  <Step title="Return 2xx to acknowledge">
    Acknowledge within 10 seconds. Any non-2xx, timeout, connection refused, or TLS error counts as a failed delivery and triggers a retry on the [exponential-backoff schedule](#retries-and-dead-letter).
  </Step>
</Steps>

## Event taxonomy

Subscribe to a subset via the `events` array, or pass `["*"]` to subscribe to every type at create time (the wildcard is **expanded server-side at create time** — events added in a later API version do NOT auto-subscribe).

| Event type                  | Fires when                                                                                                         |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `image.completed`           | Image generation reached `succeeded`. Payload contains the rendered URLs.                                          |
| `image.failed`              | Image generation failed (provider error, moderation, etc.).                                                        |
| `image.cancelled`           | Customer called `POST /v1/images/:id/cancel`.                                                                      |
| `video.completed`           | Video generation reached `succeeded`.                                                                              |
| `video.failed`              | Video generation failed.                                                                                           |
| `video.cancelled`           | Customer cancelled the video generation.                                                                           |
| `character.completed`       | Character synthesis reached `ready`. Payload carries the synthesized character + its reference renders.            |
| `character.failed`          | Character synthesis failed (provider error, moderation, or timeout). `error_message` carries a customer-safe code. |
| `character.cancelled`       | Customer cancelled an in-flight synthesis via `DELETE /v1/characters/:id`.                                         |
| `usage.balance_low`         | Team credits dropped to or below `balance_low_threshold` (1h debounced).                                           |
| `webhook.endpoint_disabled` | Another endpoint on this team auto-disabled after sustained failures.                                              |

`image.expired`, `video.expired`, `image.moderation_rejected`, and `video.moderation_rejected` are reserved enum values that are not emitted today — see the [Changelog](/changelog) for when each lights up.

## Routing by API key

By default every active endpoint subscribed to an event receives it. You can instead **bind an endpoint to a single API key** so it receives *only* the events generated with that key — ideal for keeping environments separate (your staging key delivers to your staging receiver, your production key to your production receiver).

Set `api_key_id` to a key's public id (`key_…`) on create or update:

```bash theme={null}
curl -X PATCH https://api.aurous-labs.com/v1/webhook_endpoints/we_01HXMQ7Z3K8Y2NABCDEFGHJKMN \
  -H "X-Api-Key: $AUROUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "api_key_id": "key_01HXMQ7Z3K8Y2NABCDEFGHJKMN" }'
```

Delivery is **most-specific**:

* An event generated with key `K` is delivered to the endpoint(s) bound to `K`.
* If no endpoint is bound to `K`, the event falls back to your **unscoped** endpoints (`api_key_id: null` — the catch-all).
* Events with no API key (dashboard activity, `usage.balance_low`) go to unscoped endpoints only.

<Warning>
  Binding the first endpoint to a key **removes that key's events from your catch-all endpoint(s)** — from then on they are delivered only to the bound endpoint. Bind one endpoint per key, or keep one unscoped as a deliberate fallback.
</Warning>

Pass `api_key_id: null` on `PATCH` to unbind (the endpoint becomes a catch-all again); omit the field to leave the binding unchanged. If the bound key is deleted, the endpoint automatically reverts to a catch-all.

**Finding a key id.** Use the `key_…` public id — it is case-sensitive. Read your key ids from the **API keys** page in the dashboard, or programmatically from `GET /v1/usage?group_by=api_key_id` (each bucket is keyed by its `api_key_id`).

## Payload shape

Every delivery body is a single JSON object — the **AurousEvent envelope**:

```json theme={null}
{
  "id": "evt_01HXMQ7Z3K8Y2NABCDEFGHJKMN",
  "object": "event",
  "type": "image.completed",
  "created_at": "2026-05-04T01:00:00Z",
  "synthetic": false,
  "data": {
    "id": "img_01HXMQ7Z3K8Y2NABCDEFGHJKMN",
    "object": "image",
    "status": "succeeded",
    "...": "the same shape returned by GET /v1/images/:id"
  }
}
```

`synthetic: true` appears on the envelope (never inside `data`) when the delivery was triggered via `POST /v1/webhook_endpoints/:id/test`. Receivers can filter test fires from production traffic with a single field check.

## Headers

Every delivery carries the following request headers:

| Header                     | Example                          | Purpose                                                                               |
| -------------------------- | -------------------------------- | ------------------------------------------------------------------------------------- |
| `Aurous-Webhook-Signature` | `t=1714867200,v1=8d3f...c4a1`    | HMAC-SHA256 of `${t}.${raw_body}` with your endpoint secret. Verify on every request. |
| `Aurous-Event-Id`          | `evt_01HXMQ7Z3K8Y2NABCDEFGHJKMN` | Same `id` as the envelope. Use for de-dup on your side.                               |
| `Aurous-Event-Type`        | `image.completed`                | Same `type` as the envelope. Lets you route without parsing the body.                 |
| `Aurous-Version`           | `2026-05-15`                     | API version that minted the event. Add a guard if your handler is version-locked.     |
| `User-Agent`               | `Aurous-Labs-Webhooks/1.0`       | Helps you allow-list our traffic at your edge.                                        |
| `Content-Type`             | `application/json`               | Always JSON.                                                                          |

## Signature format

`Aurous-Webhook-Signature: t=<unix_sec>,v1=<hex>`

Where:

* `<unix_sec>` is the moment we minted the signature (a 10-digit Unix timestamp in seconds — the Stripe convention).
* `<hex>` is the lowercase hex of `HMAC-SHA256(secret, "${t}.${raw_body}")`.

The `t` value goes INSIDE the HMAC payload, so an attacker cannot replay a captured body with a tweaked timestamp.

We recommend rejecting any delivery whose `t` is older than 5 minutes — adjust the tolerance if your receiver uses a lossy queue.

## Retries and dead-letter

Failed deliveries (non-2xx, timeout, connect refused, or TLS error) are retried on this schedule:

| Attempt | Delay before retry |
| ------- | ------------------ |
| 1       | initial delivery   |
| 2       | 5 seconds          |
| 3       | 30 seconds         |
| 4       | 2 minutes          |
| 5       | 10 minutes         |

After attempt 5, the delivery is marked `is_terminal: true` and the row is flagged with `is_dead_letter: true`. We stop retrying.

If `consecutive_failures >= 20` AND there has been no successful delivery in the last 24 hours, we **auto-disable the endpoint**:

* `is_active` flips to `false`.
* We email the team owner.
* Other active endpoints on the team receive a `webhook.endpoint_disabled` event so peer integrations can react.

Re-enable via `PATCH /v1/webhook_endpoints/:id` with `{ "is_active": true }` once you've fixed the receiver. The counter resets to 0 on re-enable.

## Receiver cookbook

The contract is symmetric: **we sign with the active secret only; you verify against the active secret first, then the previous secret as a 24h fallback**. This receiver-side fallback is what makes [secret rotation](#rotating-the-secret) zero-downtime.

### Node.js (Express)

```js theme={null}
import express from 'express';
import crypto from 'crypto';

const app = express();
const ACTIVE = process.env.AUROUS_WEBHOOK_SECRET;
const PREV = process.env.AUROUS_WEBHOOK_SECRET_PREV; // set ONLY for 24h after rotation

function verify(rawBody, sigHeader) {
  if (!sigHeader) return false;
  const parts = Object.fromEntries(
    sigHeader.split(',').map((p) => p.split('=')),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;
  // `t` is Unix seconds — compare in seconds.
  const nowSec = Math.floor(Date.now() / 1000);
  if (Math.abs(nowSec - Number(t)) > 5 * 60) return false; // 5min replay window
  const payload = `${t}.${rawBody}`;

  const tryWith = (secret) =>
    secret && crypto.timingSafeEqual(
      Buffer.from(crypto.createHmac('sha256', secret).update(payload).digest('hex')),
      Buffer.from(v1),
    );
  return tryWith(ACTIVE) || tryWith(PREV);
}

// Important: receive raw bytes — JSON.parse later, AFTER signature verify.
app.post(
  '/aurous-webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const raw = req.body.toString('utf8');
    if (!verify(raw, req.headers['aurous-webhook-signature'])) {
      return res.status(401).end();
    }
    const event = JSON.parse(raw);
    if (event.synthetic) return res.status(200).end(); // ignore test fires
    // ... handle event ...
    res.status(200).end();
  },
);
```

### Python (Flask)

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

ACTIVE = os.environ["AUROUS_WEBHOOK_SECRET"]
PREV = os.environ.get("AUROUS_WEBHOOK_SECRET_PREV")  # set ONLY for 24h after rotation

def verify(raw, sig_header):
    if not sig_header:
        return False
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False
    # `t` is Unix seconds — compare in seconds.
    if abs(int(time.time()) - int(t)) > 5 * 60:
        return False  # 5min replay window
    payload = f"{t}.{raw.decode('utf-8')}"

    def try_with(secret):
        if not secret:
            return False
        expected = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, v1)

    return try_with(ACTIVE) or try_with(PREV)

app = Flask(__name__)

@app.post("/aurous-webhook")
def aurous_webhook():
    raw = request.get_data()  # raw bytes — verify BEFORE json.loads
    if not verify(raw, request.headers.get("Aurous-Webhook-Signature")):
        abort(401)
    event = json.loads(raw)
    if event.get("synthetic"):
        return "", 200  # ignore test fires
    # ... handle event ...
    return "", 200
```

### Manual curl verification

Useful for one-off debugging: capture a delivery body + signature, then verify locally.

```bash theme={null}
# Save the body to body.json and the header value to a variable
SIG="t=1714867200,v1=8d3f...c4a1"
T=$(echo "$SIG" | sed -E 's/.*t=([^,]+).*/\1/')
V1=$(echo "$SIG" | sed -E 's/.*v1=([^,]+).*/\1/')

# Recompute over t.body
PAYLOAD="${T}.$(cat body.json | tr -d '\n')"
EXPECTED=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$AUROUS_WEBHOOK_SECRET" | awk '{print $2}')

# Constant-time compare (or eyeball)
if [ "$EXPECTED" = "$V1" ]; then echo "OK"; else echo "MISMATCH"; fi
```

## Rotating the secret

Hit `POST /v1/webhook_endpoints/:id/rotate_secret` to mint a new plaintext. The response carries the new `secret` exactly once.

The contract during rotation:

* **Sender (Aurous Labs):** signs every new delivery with the **active** secret only.
* **Receiver (you):** for the next 24 hours, store the previous secret as `PREV` and verify against `ACTIVE` first, then fall back to `PREV` (as shown in the cookbook).

Why? A delivery in flight at the moment of rotation may arrive seconds later, signed with the old secret. The 24h dual-validate window guarantees zero downtime for receivers under realistic clock skew + retry windows.

After 24 hours, drop `PREV`. We never sign with it again.

```bash theme={null}
curl -X POST https://api.aurous-labs.com/v1/webhook_endpoints/we_01HXMQ7Z3K8Y2NABCDEFGHJKMN/rotate_secret \
  -H "X-Api-Key: $AUROUS_KEY"
# → returns { "secret": "whsec_NEW_PLAINTEXT", ... } exactly once
```

## Test firing

Hit `POST /v1/webhook_endpoints/:id/test` with an `event_type` to enqueue a real, signed synthetic delivery. The envelope carries `synthetic: true` so receivers can filter test fires from production traffic.

```bash theme={null}
curl -X POST https://api.aurous-labs.com/v1/webhook_endpoints/we_01HXMQ7Z3K8Y2NABCDEFGHJKMN/test \
  -H "X-Api-Key: $AUROUS_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event_type": "image.completed" }'
# → 202 { "object": "webhook_test_fire", "endpoint_id": "we_...", "event_id": "evt_...", "enqueued": true }
```

Test fires use an isolated rate-limit bucket (`webhooks_test`, 30 / minute) so they never contend with normal webhook traffic.

## Inspecting deliveries

Walk the per-attempt log via `GET /v1/webhook_endpoints/:id/deliveries`:

```bash theme={null}
curl -G https://api.aurous-labs.com/v1/webhook_endpoints/we_01HXMQ7Z3K8Y2NABCDEFGHJKMN/deliveries \
  -H "X-Api-Key: $AUROUS_KEY" \
  --data-urlencode "limit=20"
```

Each row carries the attempt number, response status (or `null` for transport errors), error class (`http_4xx` / `http_5xx` / `timeout` / `connect_refused` / `tls_error` / `connect_error`), and the first 1KB of the response body if any. Cursor-paged via `?starting_after=<dlv_id>`.
