# Webhooks

> Original: https://api.6io.io/en/docs/webhooks/ (HTML). All pages: https://api.6io.io/llms.txt

Images, video, audio and background jobs run as a queue: the request returns an ID at once, and the result is ready later. To avoid asking for the status every few seconds, give the address of your server. When the request ends, we send a `POST` with the outcome to it.

Polling is not cancelled by this. A notification can fail to arrive (your server was down, a network fault), and the status by ID can always be fetched with a `GET`. Treat the notification as the fast way to learn about the end, and `GET` as the way to check.

## How to pass the address

| What you start | Where to put the address |
| --- | --- |
| a [Sixio](https://api.6io.io/md/en/sixio.md) model (`POST /native/sixio/tasks`) | the `webhook_url` field in the body, next to `model` and `input` |
| a [background job](https://api.6io.io/md/en/jobs.md) (`POST /v1/jobs`) | the `webhook_url` field in the body |
| a [fal](https://api.6io.io/md/en/fal.md) model (`POST /native/fal/queue/...`) | the `fal_webhook` query parameter, as at fal itself |

Chat `POST /v1/chat/completions` answers in the same connection and has no notifications.

```bash
curl -X POST "https://api.6io.io/native/sixio/tasks" \
  -H "Authorization: Bearer sixio_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "sixio/flux-3/klein-text-to-image",
    "input": {"prompt": "a red circle on a white background", "aspectRatio": "1:1", "resolution": "1K"},
    "webhook_url": "https://example.com/hooks/6io"
  }'
```

For fal the address goes in the query string and must be encoded: `?fal_webhook=https%3A%2F%2Fexample.com%2Fhooks%2F6io`.

### Which address fits

The address is checked before money is reserved. An unsuitable one gives `400 invalid_webhook_url`, and nothing is started.

- `https` only. At most 2048 characters, no login or password.
- Public internet only. Addresses in private networks (`10.x`, `192.168.x`, `localhost`), service and reserved addresses are refused. The check is repeated at connect time, so swapping the address through DNS does not work.
- Not the address of this API and not 6io.
- We do not follow redirects. Give the final address, without a redirect from `http` to `https` and without a slash the server would add.

To test reception on your own computer, open a temporary public address on it (any tunnel, for example `ngrok` or `cloudflared`) and pass that.

## What arrives

A `POST` to your address, the body is JSON, `Content-Type: application/json`. The format is [Standard Webhooks](https://www.standardwebhooks.com/): there are ready libraries for verifying the signature in many languages.

Headers:

| Header | Meaning |
| --- | --- |
| `webhook-id` | the notification ID, like `msg_req_…`; it does not change on retries |
| `webhook-timestamp` | the sending time, Unix seconds |
| `webhook-signature` | the signature, like `v1,<base64>` (if there are several, separated by spaces) |
| `X-Sixio-Request-Id` | the ID of your request |

The body:

```json
{
  "type": "request.finished",
  "timestamp": "2026-10-27T09:12:44Z",
  "data": { "...": "the same as the status GET of this request returns" }
}
```

`type` is always `request.finished`. `data` holds what the status request returns.

**Sixio** is like `GET /native/sixio/tasks/{id}`:

```json
{
  "id": "req_8fK2xQ1mZpVwT9sJdL4nAg",
  "object": "task",
  "model": "sixio/flux-3/klein-text-to-image",
  "status": "succeeded",
  "created_at": "2026-10-27T09:12:10Z",
  "finished_at": "2026-10-27T09:12:44Z",
  "billing": { "status": "settled", "charged_credits": "3.200000000000" },
  "status_url": "https://api.6io.io/native/sixio/tasks/req_8fK2xQ1mZpVwT9sJdL4nAg",
  "result": { "urls": ["https://api.6io.io/api/download/…"] }
}
```

**A background job** is like `GET /v1/jobs/{id}` (the chat answer itself is collected at `result_url`).

**fal** is in the shape of a fal webhook:

```json
{
  "request_id": "req_8fK2xQ1mZpVwT9sJdL4nAg",
  "status": "OK",
  "outcome": "succeeded",
  "payload": { "images": [{ "url": "https://api.6io.io/api/download/…" }] },
  "status_url": "https://api.6io.io/native/fal/requests/req_8fK2xQ1mZpVwT9sJdL4nAg/status",
  "response_url": "https://api.6io.io/native/fal/requests/req_8fK2xQ1mZpVwT9sJdL4nAg"
}
```

`status` is `OK` or `ERROR` (with `ERROR` there is `error` and `payload` is `null`). `outcome` is our state word. A result larger than 256 KB is not put into the notification: `payload` is `null`, and `payload_error` tells you to fetch it at `response_url`.

The state in `status` (`outcome` for fal): `succeeded` means done, `failed` means not done, `unknown` means the outcome is not confirmed yet and the money is being checked: the notification is sent once with this word, and you look at the outcome later in `GET /v1/requests/{id}`.

## How to verify the signature

Do not trust a request until you have verified the signature: anyone can write to your address.

The signing secret is in the console, on the Keys tab, the "Notification signing" block. It starts with `whsec_`, is shared by all keys of the account and is changed with the "Rotate the secret" button (the old one stops working at once).

The signed string is `webhook-id` + `.` + `webhook-timestamp` + `.` + the body. The signature is HMAC-SHA256 with the key from the secret (the part after `whsec_` is base64), the result in base64. Take the body as bytes, as it arrived: if you parse the JSON and build it again, the signature will not match. Reject notifications whose time differs from yours by more than 5 minutes.

Python:

```python
import base64, hashlib, hmac, time


def verify(secret: str, headers: dict, body: bytes) -> bool:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        return False  # too old (or from the future)
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        hmac.compare_digest(expected, s.removeprefix("v1,"))
        for s in headers["webhook-signature"].split()
        if s.startswith("v1,")
    )
```

A receiver on Flask:

```python
from flask import Flask, request

app = Flask(__name__)
SECRET = "whsec_..."  # from the console


@app.post("/hooks/6io")
def hook():
    if not verify(SECRET, request.headers, request.get_data()):
        return "bad signature", 400
    event = request.get_json()
    data = event["data"]
    print(data.get("id") or data.get("request_id"), data.get("status"))
    return "", 204
```

Node.js:

```javascript
import crypto from "node:crypto";

export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // too old (or from the future)
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest("base64");
  return headers["webhook-signature"].split(" ").some((s) => {
    if (!s.startsWith("v1,")) return false;
    const got = Buffer.from(s.slice(3));
    const want = Buffer.from(expected);
    return got.length === want.length && crypto.timingSafeEqual(got, want);
  });
}
```

In Express take the body as a `Buffer` (`express.raw({ type: "application/json" })`), not parsed JSON.

## What your receiver should do

- Answer with any `2xx` code within 10 seconds. The body of the answer is ignored. Do the heavy work after answering.
- Be ready for a repeat. The same notification (the same `webhook-id`) can arrive twice: your server may not have managed to answer and we retried. Remember the `webhook-id` values you have handled, or make the handling safe to repeat.
- Do not treat the body as a snapshot of the moment of the end. On a retry it is built again and reflects the state at the time of sending.

## Retries

If there is no `2xx` answer (an error, `4xx`, `5xx`, a timeout, the address unreachable), we send again. There are seven attempts in all, and the pauses between them grow:

| Attempt | How long after the previous one |
| --- | --- |
| 1 | at once, when the request ended |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |

About 17.5 hours in all. The answer `410 Gone` means "do not send any more": there are no retries. After the last failed attempt the notification is not sent, and the outcome has to be fetched with a `GET`.

## If the notification did not arrive

In the status answer (`GET /native/sixio/tasks/{id}`, `GET /v1/jobs/{id}`, `GET /native/fal/requests/{id}/status`) a request with an address has a `webhook` field:

```json
"webhook": { "status": "pending", "attempts": 2, "last_code": 503 }
```

`status` is `pending` (not delivered yet, attempts are going), `delivered` (the receiver answered `2xx`) or `failed` (the attempts are over). `attempts` is how many attempts were made, `last_code` is the code of the receiver's last answer (`null` if there was none).

## Limits

- The address is set in each request separately; there is no address for the whole account yet.
- A notification arrives once per request, when it left "running". There are no intermediate events (for example, "started").
- One address per request.
