ReluAutomate
Core concepts

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, or Unapproved).
  • 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=true

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

  1. Reject if |now − timestamp| > 300s (replay window).
  2. Fetch the public key with the matching kid from https://api.automate.relu.ai/.well-known/webhook-keys.json (cache for at least 5 minutes; a kid you don't recognise is a hint to refresh).
  3. 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 False

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

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

On this page