Callbacks
Receive a webhook when order-related events happen, processing complete, approval changes, exports unlocked.
Include a callback_url when creating an order, and Relu will POST to that URL when order-related events happen.
{
"order_name": "...",
"input_data": {
/* ... */
},
"callback_url": "https://your-app.com/relu-webhook"
}Event types
The callback body always includes event_type, order_id, and order_name. event_type can be one of:
order.job_status, processing reached a terminal state (Done,Failed,Cancelled). This is the one most integrators wait for.order.status, the order's approval status changed (Approved,Rejected, orUnapproved).order.outputs_edited, the user edited results in the viewer and saved.order.unlock, an export bundle was unlocked (creates a billing event).
You can filter to only what you care about on your webhook handler.
Fetching full order state
The callback body is minimal by design, it tells you which order moved and why, but not the full state. When you receive a callback, fetch the order to get everything:
GET /orders/{order_id}?include_output_download_urls=trueinclude_output_download_urls=true returns time-limited download_url fields on each output file. Without the flag, the response omits them.
The order carries organization_id, the organization that owns the order and is billed for it. It can differ from the organization of the user who created the order.
Security
Every callback is signed with Ed25519 over the raw request body, prefixed with a unix timestamp. The signature ships in the X-Relu-Signature header alongside the timestamp, and the request is tagged with User-Agent: relu-webhook/1.0.
POST /your-relu-webhook HTTP/1.1
Content-Type: application/json
User-Agent: relu-webhook/1.0
X-Relu-Signature: kid=2026-04;alg=ed25519;sig=8f3f9c2aQ1IY…
X-Relu-Signature-Timestamp: 1714305600
X-Relu-Delivery-Id: 01928f4c-9d2e-7a3b-8e1c-3f9a2b1c4d5e
{"event_type":"order.job_status","order_id":"…","order_name":"…"}The signed bytes are f"{timestamp}.{raw_body}". Receivers must:
- Reject if
|now − timestamp| > 300s(replay window). - Fetch the public key with the matching
kidfromhttps://api.automate.relu.ai/.well-known/webhook-keys.json(cache for at least 5 minutes; akidyou don't recognise is a hint to refresh). - Verify the signature against the raw bytes of the body — not the parsed JSON, since re-serializing changes byte-for-byte content.
import { createPublicKey, verify } from 'node:crypto';
const RELU_BASE_URL = 'https://api.automate.relu.ai';
let jwksCache = null; // { fetchedAt, keysByKid }
async function getReluKey(kid) {
const fresh =
jwksCache && Date.now() - jwksCache.fetchedAt < 300_000
? jwksCache
: null;
const cached = fresh?.keysByKid.get(kid);
if (cached) return cached;
const resp = await fetch(`${RELU_BASE_URL}/.well-known/webhook-keys.json`);
const { keys } = await resp.json();
const map = new Map(keys.map((k) => [k.kid, k]));
jwksCache = { fetchedAt: Date.now(), keysByKid: map };
return map.get(kid);
}
export async function verifyReluWebhook(rawBody, headers) {
const sigHeader = headers['x-relu-signature'];
const ts = headers['x-relu-signature-timestamp'];
if (!sigHeader || !ts) return false;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(ts)) > 300) return false;
const parts = Object.fromEntries(
sigHeader.split(';').map((s) => s.split('=', 2)),
);
if (parts.alg !== 'ed25519' || !parts.kid || !parts.sig) return false;
const jwk = await getReluKey(parts.kid);
if (!jwk) return false;
const pub = createPublicKey({ key: jwk, format: 'jwk' });
const signed = Buffer.concat([Buffer.from(`${ts}.`), Buffer.from(rawBody)]);
return verify(null, signed, pub, Buffer.from(parts.sig, 'base64'));
}
import base64
import time
from threading import Lock
import urllib.request
import json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
RELU_BASE_URL = "https://api.automate.relu.ai"
_jwks_cache: dict | None = None
_lock = Lock()
def _get_relu_key(kid: str):
global _jwks_cache
with _lock:
fresh = _jwks_cache and time.time() - _jwks_cache["fetched_at"] < 300
if fresh and kid in _jwks_cache["keys"]:
return _jwks_cache["keys"][kid]
with urllib.request.urlopen(
f"{RELU_BASE_URL}/.well-known/webhook-keys.json"
) as r:
payload = json.loads(r.read())
keys = {}
for entry in payload.get("keys", []):
x = entry["x"] + "=" * (-len(entry["x"]) % 4)
keys[entry["kid"]] = Ed25519PublicKey.from_public_bytes(
base64.urlsafe_b64decode(x)
)
_jwks_cache = {"fetched_at": time.time(), "keys": keys}
return keys.get(kid)
def verify_relu_webhook(raw_body: bytes, headers: dict) -> bool:
sig_header = headers.get("x-relu-signature") or headers.get("X-Relu-Signature")
ts = headers.get("x-relu-signature-timestamp") or headers.get(
"X-Relu-Signature-Timestamp"
)
if not sig_header or not ts:
return False
if abs(int(time.time()) - int(ts)) > 300:
return False
parts = dict(p.split("=", 1) for p in sig_header.split(";"))
if parts.get("alg") != "ed25519" or "kid" not in parts or "sig" not in parts:
return False
pub = _get_relu_key(parts["kid"])
if pub is None:
return False
signed = ts.encode("ascii") + b"." + raw_body
try:
pub.verify(base64.b64decode(parts["sig"]), signed)
return True
except InvalidSignature:
return FalseThere is no shared secret to provision — verification only needs the public keys served at /.well-known/webhook-keys.json. Reject any request whose signature does not verify, whose timestamp is outside the 300-second window, or whose kid is not in the JWKS after a fresh fetch.
See Webhook keys for rotation behavior and the JWKS contract.
Reliability
This section describes requests that Relu sends to your callback endpoint. For failures on requests that you send to Relu, see API errors and retries.
Callbacks go through a durable outbox: every event is persisted before any HTTP attempt, so a callback never gets lost if Relu's API process restarts mid-fire.
Each row is dispatched by a background worker with exponential backoff:
| Attempt | Delay before next try |
|---|---|
| 1 | ~1 minute |
| 2 | ~2 minutes |
| 3 | ~4 minutes |
| 4 | ~8 minutes |
| 5+ | 10 minutes (capped) |
A ±25% jitter is layered on top to avoid thundering-herd retries. Retries continue for up to 24 hours after the original event; after that the row is dropped.
Transient failures are retried (5xx, 408, 429, network errors). Other 4xx responses are treated as permanent and not retried — a malformed callback URL or a 404 won't burn your retry budget.
If your endpoint is occasionally unavailable for longer than 24 hours, fall back to polling as a safety net. See Poll vs webhook for the recommended pattern.
Idempotency
Because callbacks retry on transient failures, the same delivery can reach your endpoint more than once — for example, when your handler succeeds but the response is lost before we record the 2xx. Relu collapses duplicate engine retries server-side before they fan out, so the duplicates you still need to handle are introduced after that point — lost ack responses, network retries between Relu and you, and re-runs scheduled by your own queue. Treat every callback handler as idempotent.
Every request carries an X-Relu-Delivery-Id header, a UUID that is:
- Stable across retries of the same delivery. Two POSTs that represent the same fan-out attempt will share the same id.
- Unique across distinct events. A new status change, a separate subscriber, or a re-emitted event each get their own id.
Use it as the unique key in a dedup table: on every callback, attempt to insert the id; if it conflicts, return 200 OK without re-running side effects.
Tracking progress
If you only need to know when an order finishes, use POST /orders/job-statuses.
Send between 1 and 100 valid order IDs per request. It reads from Automate's
database, so it is faster and uses fewer resources than progress polling.
Use POST /orders/progress only when your UI needs a progress percentage. This
endpoint may contact processing engines to refresh stale progress values, which
uses additional resources.
The progress response identifies the Main or Recompute job behind each percentage.