6ioAPI

Webhooks

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 model (POST /native/sixio/tasks) the webhook_url field in the body, next to model and input
a background job (POST /v1/jobs) the webhook_url field in the body
a fal 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.

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: 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:

{
  "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}:

{
  "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:

{
  "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:

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:

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:

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:

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