# Webhooks

Receive the complete result as soon as it is ready.

Add `webhook_url` to a run and Fusara sends the complete result to your endpoint. No endpoint registration or shared-secret setup is required. You can save the payload directly without another API request.

## Request a webhook

```bash
curl 'https://fusara.ai/api/v3/run' \
  -H "Authorization: Bearer $FUSARA_API_KEY" \
  -H 'Content-Type: application/json' \
  --data-raw '{
  "model": "qwen-image21-create",
  "prompt": "A white ceramic lamp on a pale lavender background, soft studio light.",
  "webhook_url": "https://your-app.example/webhooks/fusara"
}'
```

Use a public HTTPS URL with a valid certificate. Credentials, fragments, private network destinations and custom ports are not supported. Keep the URL within 2,048 characters. You can choose a different URL for each run.

## Receive the result

Fusara sends an HTTP POST with a JSON body. The body is the run itself, with the same shape as `GET /run/{id}`; there is no event envelope to unwrap.

```json
{
  "id": 123456,
  "status": "succeeded",
  "cost": {
    "unit": "common_fuz",
    "expected": "40.0000",
    "status": "settled",
    "charged": "40.0000"
  },
  "outputs": [
    {
      "type": "image",
      "media_id": 901,
      "url": "https://media.example/result.png",
      "url_expires_at": "2026-10-25T12:00:00Z"
    }
  ]
}
```

Your receiver should:

1. Read the run's `id` and the `webhook-id` request header.
2. Save the result durably, using `webhook-id` to recognize a repeated delivery.
3. Respond with a successful `2xx` status promptly, then do any slow downstream work.

Use the run ID to match a result to your own submitted job. A webhook can arrive before your submission handler has finished saving the initial response; make the receiver tolerant of that ordering.

**Signature verification is optional.** A basic integration can parse and save the JSON without cryptographic setup. Fusara signs every webhook; verification lets you authenticate the sender. Without it, sender authenticity is unverified. The optional verification details are below, after the delivery behavior.

## A minimal receiver

Save this as `receiver.py` and run `python3 receiver.py`. It uses only Python's standard library and writes each event to SQLite before returning `204`. Retrying the same event cannot insert a second row, including after a restart.

```python receiver.py
import json
import sqlite3
from contextlib import closing
from http.server import BaseHTTPRequestHandler, HTTPServer

DATABASE = "webhooks.sqlite3"
MAX_BODY = 8 * 1024 * 1024  # This example's receiver limit, not an API limit.

with closing(sqlite3.connect(DATABASE)) as db, db:
    db.execute("""CREATE TABLE IF NOT EXISTS events (
        event_id TEXT PRIMARY KEY,
        run_id INTEGER NOT NULL,
        payload TEXT NOT NULL
    )""")

class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/webhooks/fusara":
            self.send_error(404)
            return
        try:
            size = int(self.headers.get("Content-Length", "0"))
            if not 0 < size <= MAX_BODY:
                self.send_error(413)
                return
            raw = self.rfile.read(size).decode("utf-8")
            run = json.loads(raw)
            event_id = self.headers.get("webhook-id")
            if (not event_id or not isinstance(run, dict)
                    or type(run.get("id")) is not int or run["id"] <= 0
                    or run.get("status") not in ("succeeded", "failed", "canceled")):
                raise ValueError("Invalid webhook")
        except (ValueError, UnicodeError):
            self.send_error(400)
            return
        try:
            with closing(sqlite3.connect(DATABASE)) as db, db:
                db.execute("""INSERT INTO events VALUES (?, ?, ?)
                    ON CONFLICT(event_id) DO NOTHING""", (event_id, run["id"], raw))
        except sqlite3.Error:
            self.send_error(503)  # No acknowledgement until storage succeeds.
            return
        self.send_response(204)
        self.end_headers()

if __name__ == "__main__":
    HTTPServer(("127.0.0.1", 8000), Receiver).serve_forever()
```

This is a local receiving example. For deployment, use your application's HTTP server behind public HTTPS, keep the database on persistent storage, and set request size/time limits for your workload. Forward `/webhooks/fusara` to the handler; `localhost` itself cannot receive a Fusara callback.

The saved JSON already contains the result: no extra API read is needed. Process saved events in a worker, match `run_id` to your own job, and make downstream actions idempotent too. A later billing event gets its own row, so an out-of-order delivery cannot overwrite the earlier payload. The example intentionally leaves signature verification optional.

## Failures and partial outputs

A failed generation is delivered to the same endpoint:

```json
{
  "id": 123456,
  "status": "failed",
  "cost": {
    "unit": "common_fuz",
    "expected": "40.0000",
    "status": "settled",
    "charged": "0.0000"
  },
  "error": {
    "code": "execution_failed",
    "message": "The run could not be completed."
  }
}
```

Inspect `status`, then `error`. A failure can still include usable partial outputs; do not discard them automatically. Treat `canceled` as terminal too.

## Duplicates and billing updates

Delivery is at least once while retries remain. A retry preserves `webhook-id`; store that ID with the processed event so a repeated POST does not repeat your application action.

A later billing settlement can produce another webhook for the same run, with a **different** `webhook-id` and an updated `cost`. Do not deduplicate solely by run ID, or you will lose the update. Events can arrive out of order: a pending-cost payload must not overwrite a cost you already know is settled.

Useful delivery headers:

| Header | Meaning |
| --- | --- |
| `webhook-id` | Stable ID of this event across delivery retries. |
| `webhook-timestamp` | Unix timestamp in seconds for this delivery attempt. |
| `webhook-signature` | Signature over the headers and exact request body. |

## Delivery retries

Fusara makes **up to 10 attempts total**, including the first delivery. Delivery also stops after three days. Each HTTP attempt has a 30-second timeout.

The nine retry delays use jitter within these ranges:

| Retry | Delay after the previous attempt |
| --- | --- |
| 1 | 15–30 seconds |
| 2 | 30–60 seconds |
| 3 | 1–2 minutes |
| 4 | 2–4 minutes |
| 5 | 4–8 minutes |
| 6 | 8–16 minutes |
| 7 | 16–32 minutes |
| 8 | 32–64 minutes |
| 9 | 64–128 minutes |

These are scheduling ranges, not exact arrival times. Request duration and worker scheduling add time. A valid `Retry-After` can postpone another attempt, within the delivery deadline.

- Any `2xx` response acknowledges the delivery.
- Network failures, `408`, `429` and transient server errors are retried.
- Redirects are not followed. Most other `4xx` errors, and `501` or `505`, stop delivery.

If delivery is exhausted, the run still exists. Retrieve it with [Get a run](/api/reference/get-run) when you need recovery. Your normal successful webhook flow does not require that extra read.

## Optional signature verification

This is a digital signature, not encryption. HTTPS protects the connection; the signature proves that the exact payload was signed by Fusara.

Fusara uses Ed25519 with the Standard Webhooks asymmetric signature format. Public verification keys are available without authentication at:

```text
https://fusara.ai/.well-known/jwks.json
```

To verify a delivery:

1. Preserve the **raw request-body bytes** before parsing JSON.
2. Read `webhook-id`, `webhook-timestamp` and `webhook-signature`. Check that the timestamp is recent; a five-minute tolerance is a reasonable default when your clock is synchronized.
3. Construct the signed message from `webhook-id`, a period, `webhook-timestamp`, a period, then the raw body bytes. Do not reserialize the JSON.
4. Decode the base64 signature following `v1a,` and verify Ed25519 against the keys from Fusara's fixed JWKS URL. Accept a valid supported signature; reject the payload if none verify.
5. Match the run to your own submission and deduplicate the event before processing it.

Cache the keys, typically for one hour. Support key rotation by refreshing on a verification miss, with a refresh rate limit; do not download keys on every delivery. Use only the fixed Fusara key URL, never a URL supplied by an incoming webhook. There is no per-endpoint secret to exchange.

Verification does not replace duplicate handling, nor does it prove that a run belongs to your application. Keep the ID matching from the basic receiver. The [Standard Webhooks specification](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) describes the signing format.
