Docs

Working with the API

Webhooks

A signed POST the moment a job settles, verifiable against a public key you can fetch — in our headers and in the common queue protocol's.

Polling works. For anything longer than a few seconds, being told is better.

There are two ways to be told, and they carry the same signatures:

  • Per request. Add ?webhook=<url> (or ?fal_webhook=) to a submit, and that one job reports to that one URL when it settles, in the common queue protocol's shape. Nothing to register — which also means no challenge: any account can name any public URL here, and the account check is what makes that harmless.
  • Per account. Register an endpoint once and every job on the account reports to it, as an event envelope.

Every body names the account it is for, inside what the signature covers.

#Register an endpoint

curl -X POST https://api.auray.ai/v1/webhooks \
  -H "Authorization: Bearer $AURAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/hooks/auray" }'

Creating and deleting an endpoint needs webhooks:write. Listing them needs jobs:read — reading is reading.

A registered endpoint receives nothing until it has proved it is yours. Registration posts one signed challenge to the URL, and the endpoint has to answer 2xx with the nonce somewhere in its response body. We never tell the caller what the nonce is, so echoing it requires having received it, which requires being the server at that address.

The challenge
{
  "id": "evt_challenge_…",
  "type": "webhook.challenge",
  "created": 1757000482,
  "account": "86e97633-…",
  "webhook_id": "…",
  "data": { "webhook_id": "…", "nonce": "…", "how": "…" }
}

Check account before you echo. It is your account id — the id that GET /v1/me returns — and it is inside the signed body. A challenge naming any other account is somebody else registering your URL; answer 4xx and the endpoint never switches on. An endpoint that echoes every nonce proves only that it can be reached.

201 Created, endpoint verified
{
  "id": "…",
  "object": "webhook",
  "url": "https://example.com/hooks/auray",
  "products": null,
  "status": "active",
  "created_at": "…",
  "verify_keys": "https://api.auray.ai/.well-known/jwks.json",
  "signature_algorithm": "EdDSA",
  "verified": true,
  "request_id": "…"
}

If the endpoint did not echo the nonce the answer is still 201, with "status": "pending", "verified": false and a verification object that says what it answered instead and how to try again. Fix the endpoint, then POST /v1/webhooks/{id} to re-run the challenge.

The challenge is signed exactly like a real delivery, so an endpoint verifies it with the code it will use for real events — and finds out at registration time if that code is wrong, rather than the first time a real job settles.

#What arrives

POST /hooks/auray HTTP/1.1
Content-Type: application/json
Auray-Event: job.succeeded
Auray-Delivery: 7f3e…
Auray-Attempt: 1
Auray-Signature: t=1757000482,v2=Zm9vYmFy…,kid=9f2c…
X-Fal-Webhook-Request-Id: photo_docs-mtpb1vnh
X-Fal-Webhook-User-Id: 86e97633-…
X-Fal-Webhook-Timestamp: 1757000482
X-Fal-Webhook-Signature: 4a1c…
Per request: the queue protocol's shape
{
  "request_id": "photo_docs-mtpb1vnh",
  "gateway_request_id": "…",
  "status": "OK",
  "payload": { "id": "photo_docs-mtpb1vnh", "object": "job", "product": "photo", "status": "succeeded", … },
  "error": null,
  "account": "86e97633-…",
  "webhook_id": null
}
A registered endpoint: the event envelope
{
  "id": "evt_photo_docs-mtpb1vnh_succeeded",
  "type": "job.succeeded",
  "created": 1757000482,
  "data": {
    "id": "photo_docs-mtpb1vnh",
    "object": "job",
    "product": "photo",
    "status": "succeeded",
    "error_code": null,
    "credits_charged": 4,
    "credits_reserved": 4
  },
  "account": "86e97633-…",
  "webhook_id": "…"
}

Auray-Event is job.succeeded, job.failed or job.cancelled. In the per-request shape status is OK for the first and ERROR for the other two, with the reason in error and payload null. account is the account the job belongs to; webhook_id is the registered endpoint the delivery was sent to, or null for an address one request named. Auray-Delivery identifies this delivery and stays the same across retries; Auray-Attempt counts them. Neither header is signed.

A delivery carries the job, never its output: no signed URLs and no prompt. Fetch the result with your own key. data has the same fields on every event: error_code is the failure's code (null on a success), credits_charged what the job cost after any refund, and credits_reserved what was taken when it was charged — both read off the ledger once the money settled, and both null in the rare delivery whose ledger could not be read, where GET /v1/jobs/{id} answers them.

Every job but a chat turn reports, whoever stopped it. A job the model's service finished, a catalogue model's job, a job the platform gave up on because its service went quiet or it waited too long for a seat, a job our team closed, a waiting job you cancelled, and a queued job the model refused when its turn came: each sends one event when it settles.

A chat turn sends none. A turn on /v1/chat or /openai/v1/chat/completions is answered by the request that paid for it — its answer, or its refusal — and no event follows, not even when the platform later settles a turn whose connection was lost. chat is accepted in products so an endpoint registered with it keeps working, and delivers nothing; read a turn at GET /v1/jobs/{id}.

#Verify before you trust it

Anyone can POST to a public URL. Verification is what separates a real delivery from someone who guessed your endpoint. Two signatures travel on every delivery, made with the same Ed25519 key; verify whichever your code already speaks.

Ours. Auray-Signature is three comma-separated parts — t, Unix seconds when the delivery was signed; v2, the signature, base64url; kid, which public key signed it — and the signed message is the timestamp, a full stop, and the raw request body:

t + "." + rawBody

The queue protocol's. The four X-Fal-Webhook-* headers, signed over the request id, the user id, the timestamp and the hex SHA-256 of the body, joined by newlines, with the signature in hex. A receiver written for that protocol verifies ours without a line changed, pointed at our JWKS (Compatibility). The user id is your account id, the same value as the body's account.

Read the body as bytes before any JSON parsing. Re-serialising changes it and neither signature will match.

Then check that it is yours. One key signs deliveries for every account, so a valid signature proves the delivery came from us — not that it was meant for you. Job ids are unique only inside one account: another account that knows your URL can submit with the same Idempotency-Key and name your URL in ?webhook=, and the genuine, signed result of their job arrives carrying your job id. Compare account (or, on the queue protocol's recipe, X-Fal-Webhook-User-Id) with your own account id — the id from GET /v1/me, fetched once and kept in configuration — and refuse anything else.

Node, our header
import { createPublicKey, verify } from "node:crypto";

// Fetch once and cache; key by `kid`.
const JWKS_URL = "https://api.auray.ai/.well-known/jwks.json";
let jwks = await fetch(JWKS_URL).then((r) => r.json());
let fetchedAt = Date.now();

// Yours: the `id` that GET /v1/me returns.
const ACCOUNT_ID = process.env.AURAY_ACCOUNT_ID;

// A key you have not seen: refetch the set once (at most once a minute) before verifying.
// A rotation publishes the next key ahead of signing with it ("The public keys" below says how long),
// so a copy older than that is the only reason a genuine delivery names a key you do not hold.
export async function refreshKeys(header) {
  const kid = /(?:^|,)kid=([^,]+)/.exec(header)?.[1];
  if (!kid || jwks.keys.some((k) => k.kid === kid) || Date.now() - fetchedAt < 60_000) return;
  jwks = await fetch(JWKS_URL).then((r) => r.json());
  fetchedAt = Date.now();
}

// Synchronous on purpose: it returns true or false, never a promise.
export function verifyDelivery(header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const jwk = jwks.keys.find((k) => k.kid === parts.kid);
  if (!jwk) return false;

  // Reject anything older than five minutes, so a captured delivery cannot be replayed later.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;

  const signed = verify(
    null, // Ed25519 signs the whole message; there is no prehash
    Buffer.from(`${parts.t}.${rawBody}`, "utf8"),
    createPublicKey({ key: jwk, format: "jwk" }),
    Buffer.from(parts.v2, "base64url"),
  );

  // The signature says we sent it; `account`, inside what it covers, says it was sent to you.
  return signed && JSON.parse(rawBody).account === ACCOUNT_ID;
}

// In your handler: refresh first (it does nothing unless the kid is new), then verify.
const header = request.headers.get("auray-signature") ?? "";
await refreshKeys(header);
if (!verifyDelivery(header, rawBody)) return new Response(null, { status: 401 });

The timestamp and the account are inside the signed message, so neither can be edited to get past these checks.

Refresh, then verify

refreshKeys is the only asynchronous step, and it is separate so that verifyDelivery stays a plain true or false. Keep it that way in your own code: if verifyDelivery became async, a handler that tests if (!verifyDelivery(…)) without an await would be testing a promise, which is always truthy — and every delivery, forged or not, would pass.

#The public keys

curl https://api.auray.ai/.well-known/jwks.json
{
  "keys": [
    { "kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", "x": "…", "kid": "…" }
  ]
}

Public keys, so this needs no authentication.

kid is an RFC 7638 thumbprint of the key itself, which is what makes rotation need no coordination with you. A rotation has three steps: the next key is published beside the current one at least 24 hours before anything is signed with it; then deliveries are signed with it, and the old key stays published for at least another 36 hours and 25 minutes — the retry ladder's span and the window a signature's timestamp is accepted in; then the old key goes. Every attempt is signed as it is sent, with the key current at that moment — a retry, and an event a paused endpoint held for days, included — so the only deliveries that carry the old key are ones sent before the switch. A verifier that looks the key up by kid, and fetches the set again when it meets a kid it does not hold, follows along without being told.

Key by kid, not by position

keys[0] works right up until the day of a rotation, and then fails on every delivery.

#Retries

Answer 2xx quickly. Acknowledge first, do the work afterwards. A delivery that is not acknowledged is tried again after 5 minutes, then 15 minutes, 1 hour, 3 hours, 8 hours and 1 day — six more times over roughly a day and a half, not counting any time its endpoint spends paused (below) — and a 4xx other than 408 or 429 stops the retries, because an endpoint that has looked at a delivery and said no will say no again.

Expect duplicates. At-least-once delivery means the same event can arrive twice. Key your handling on the job id only after the account check above has passed: a job id is unique inside one account and nowhere else. Auray-Delivery is not signed, so it is not a key to trust.

Do not order by arrival. Use the job's own status.

Ten consecutive failures pause the endpoint. Nothing more is sent to it while it is paused (a delivery already on its way when the tenth failure lands can still arrive), and nothing is lost: every event it would have received is queued against it. Fix the endpoint, then re-run the challenge (POST /v1/webhooks/{id}); once it passes, the queued events are released oldest first and paced, so an endpoint that has just recovered is not handed its whole backlog at once: the first is sent straight away, and the rest come due at 20 a minute. What sends them is the retry sweep, which runs every 5 minutes: each run sends what came due since the run before — about 100 of your queued events, one after another as fast as your endpoint answers, in turn with every other endpoint's deliveries — so they arrive in bursts, not one every few seconds. A backlog of a thousand events takes about 50 minutes. New events are sent as they happen meanwhile, so they can arrive before older queued ones. An event is kept for 72 hours: one older than that when the challenge passes is not sent, and its delivery record (in your account export) is closed with held_past_window as the reason; once released, a queued event is retried like any other.

An endpoint that answers 410 Gone is switched off at once, rather than paused, and nothing new is queued for it while it is off. Re-running the challenge switches it back on like a paused one; what it is then sent, paced as above, is only what was still waiting for it when it answered 410 — not the event it answered 410 to, and nothing older than 72 hours.

Start typing to search every page of the documentation.

Up arrow Down arrow to moveEnter to openEscape to close