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" }'import { createClient } from "@auray/client";
const auray = createClient(); // reads AURAY_API_KEY
const webhook = await auray.platform.webhooks.create({ url: "https://example.com/hooks/auray" });
console.log(webhook);from auray_client import Client
auray = Client() # reads AURAY_API_KEY
webhook = auray.platform.webhooks.create(url="https://example.com/hooks/auray")
print(webhook)const res = await fetch("https://api.auray.ai/v1/webhooks", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AURAY_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
"url": "https://example.com/hooks/auray"
}),
});
console.log(await res.json());import os, requests
r = requests.post(
"https://api.auray.ai/v1/webhooks",
headers={"Authorization": f"Bearer {os.environ['AURAY_API_KEY']}"},
json={
"url": "https://example.com/hooks/auray"
},
)
r.raise_for_status()
print(r.json())package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
body := []byte(`{
"url": "https://example.com/hooks/auray"
}`)
req, err := http.NewRequest("POST", "https://api.auray.ai/v1/webhooks", bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("AURAY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}<?php
$ch = curl_init("https://api.auray.ai/v1/webhooks");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("AURAY_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode([
"url" => "https://example.com/hooks/auray",
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
]);
$response = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response, PHP_EOL;require "net/http"
require "json"
uri = URI("https://api.auray.ai/v1/webhooks")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV.fetch("AURAY_API_KEY")}"
req["Content-Type"] = "application/json"
req.body = {
"url" => "https://example.com/hooks/auray"
}.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, res.bodyimport java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Main {
public static void main(String[] args) throws Exception {
String body = """
{
"url": "https://example.com/hooks/auray"
}""";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.auray.ai/v1/webhooks"))
.header("Authorization", "Bearer " + System.getenv("AURAY_API_KEY"))
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using System.Net.Http.Headers;
using System.Text;
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("AURAY_API_KEY"));
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.auray.ai/v1/webhooks");
var body = """
{
"url": "https://example.com/hooks/auray"
}
""";
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await client.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");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.
{
"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.
{
"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…{
"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
}{
"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 + "." + rawBodyThe 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.
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.jsonconst res = await fetch("https://api.auray.ai/.well-known/jwks.json", {
method: "GET",
});
console.log(res.status, await res.json());import requests
r = requests.get(
"https://api.auray.ai/.well-known/jwks.json",
)
r.raise_for_status()
print(r.json())package main
import (
"fmt"
"io"
"net/http"
)
func main() {
req, err := http.NewRequest("GET", "https://api.auray.ai/.well-known/jwks.json", nil)
if err != nil {
panic(err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(res.Status, string(out))
}<?php
$ch = curl_init("https://api.auray.ai/.well-known/jwks.json");
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => "GET",
CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response, PHP_EOL;require "net/http"
uri = URI("https://api.auray.ai/.well-known/jwks.json")
req = Net::HTTP::Get.new(uri)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
puts res.code, res.bodyimport java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class Main {
public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.auray.ai/.well-known/jwks.json"))
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
}
}using var client = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, "https://api.auray.ai/.well-known/jwks.json");
using var response = await client.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");{
"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.