For AI agents: this page is also available as Markdown at https://docs.refabric.com/task-apis/calling-tasks/webhooks.md, and the index of every page is https://docs.refabric.com/llms.txt.

Task APIs › Calling tasks

Webhooks

Instead of polling, let us call you when a job ends — and, if you ask for it, when it starts and each time it delivers an output.

Asking for a callback

Per request — add webhook_url to the submit call:

curl -s -X POST "$REFABRIC_API/tasks/image.change_background?webhook_url=https://example.com/hooks/refabric" \
  -H "Authorization: Key $REFABRIC_API_KEY" \
  -H "Content-Type: application/json" -d @body.json

A per-request webhook_url receives the terminal events only (job.succeeded, job.failed, job.cancelled; live list: GET /v1/vocab/webhook_event).

Registered endpoints — register URLs on your account in Panel › Developers › Webhooks (register while signed in to the panel) and receive the events they subscribe to for every job started with one of your API keys. Jobs started in the Refabric app are never sent to them.

  1. Choose Add endpoint, enter the https:// URL and, optionally, a description.
  2. Pick its events (the list below). Left as it is, an endpoint receives the terminal events only.
  3. Choose Send ping to receive a signed test event (ping) and check your verification.
Per-request webhook_urlRegistered endpoint
Set upon each submit callonce, in the panel
Eventsterminal onlythe ones it subscribes to
Jobsthe one jobevery job started with one of your keys
In the delivery logyesyes

An endpoint that keeps failing deliveries is disabled, and the panel says why (disabled_reason); re-enable it there when your receiver is fixed — its failure count starts over.

Rules for the URL: https:// only; it must resolve to a public address (private, loopback and our own addresses are refused with 422); redirects are never followed.

Events

EventWhenSubscribed by default
job.startedthe job left the queue (lifecycle moved out of queued) — once per jobno — opt in
job.progressthe job delivered one more output (progress.done went up)no — opt in
job.succeededthe job finished and its files are readyyes
job.failedthe job failed; the body carries erroryes
job.cancelledthe job was cancelled; the body carries error (cancelled)yes

To receive the progress events, pick them for the endpoint in Panel › Developers › Webhooks. An endpoint registered without them — and every endpoint registered before these events existed — keeps receiving the terminal events only.

Progress events are coalesced and unordered. A job sends at most one job.progress per delivered output, never more often; the body is read from the job when the delivery is made, so two events can carry the same progress.done, and a slow retry may arrive after a later one or after job.succeeded (live list: GET /v1/vocab/webhook_event). There is no ordering guarantee across events — read progress.done (and lifecycle / outcome) rather than counting or ordering events. progress.total may be null while the job is still planning.

What we send

One POST per event.

POST /hooks/refabric HTTP/1.1
Content-Type: application/json
X-Refabric-Request-Id: req_…
X-Refabric-User-Id: 12345
X-Refabric-Timestamp: 1790589600
X-Refabric-Signature: 3q2+7w…==

Every event has one body: {id, occurred_at, event, api_version, job_id, task, lifecycle, outcome, progress}. id (evt_…) is the EVENT's identity — the same for every delivery of that event to every endpoint, every retry and every replay — occurred_at is when it happened (UTC), and api_version the contract version the body follows (Refabric-Version). The rest are the same words GET /v1/jobs/{id} answers (task is the task's public name, progress is {done, total, phase}, outcome is null until the job ends). A terminal event adds result | error: result is the job result view — the same object GET /v1/jobs/{id}/result returns — and a failed or cancelled job carries error in the public error shape instead. Like the result, it has a summary only when the task has counts or warnings to say. X-Refabric-Request-Id is the DELIVERY id (whd_…): the same for every retry of one delivery, new for a replay. X-Refabric-User-Id is your numeric user id, as text (12345).

{ "id": "evt_…", "occurred_at": "2026-10-02T09:00:02Z", "event": "job.progress",
  "api_version": "2026-09-29", "job_id": "j_…", "task": "image.rotate_views", "lifecycle": "running",
  "outcome": null, "progress": { "done": 2, "total": 3, "phase": "" } }
{ "id": "evt_…", "occurred_at": "2026-10-02T09:00:09Z", "event": "job.succeeded",
  "api_version": "2026-09-29", "job_id": "j_…", "task": "image.change_background", "lifecycle": "terminal",
  "outcome": "succeeded", "progress": { "done": 1, "total": 1, "phase": "" },
  "result": { "job_id": "j_…", "files": [ { "file": "art:x2", "url": "https://…", "media_type": "image/png",
    "task": "image.change_background", "job_id": "j_…", "created_at": "…" } ],
    "has_more": false } }

Verifying the signature

Every delivery is signed with Ed25519. Verify before trusting the body.

  1. Read the four X-Refabric-* headers and the raw body bytes (before JSON parsing).
  2. Reject if X-Refabric-Timestamp (Unix seconds) is further from your clock than webhook.signature.timestamp_window_seconds of GET /v1/meta.
  3. Build the message — four lines joined by \n, no trailing newline:
    <X-Refabric-Request-Id>
    <X-Refabric-User-Id>
    <X-Refabric-Timestamp>
    <hex(sha256(raw body))>
  4. X-Refabric-Signature is the base64-encoded signature. Verify it against the keys in https://api.refabric.com/.well-known/jwks.json (OKP / Ed25519). Accept if any key verifies.
  5. Cache the JWKS for at most 24 hours. On a verification failure with a cached document, refetch it once before rejecting (we may have rotated) — at most once per short cooldown, so a flood of forged signatures cannot turn into a flood of fetches.
import base64, hashlib, os, time
import requests
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

API_KEY = os.environ["REFABRIC_API_KEY"]
API = "https://api.refabric.com"
JWKS_URL = f"{API}/.well-known/jwks.json"
CACHE_SECONDS = 24 * 3600


def _window_seconds() -> int:
    """How far a timestamp may be from your clock — read once from GET /v1/meta."""
    meta = requests.get(f"{API}/v1/meta", headers={"Authorization": f"Key {API_KEY}"}, timeout=10)
    return meta.json()["webhook"]["signature"]["timestamp_window_seconds"]


WINDOW_SECONDS = _window_seconds()
_cache = {"keys": [], "at": 0.0}


def _b64url(data: str) -> bytes:
    return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))


def _keys(force: bool = False) -> list[Ed25519PublicKey]:
    if force or not _cache["keys"] or time.time() - _cache["at"] > CACHE_SECONDS:
        doc = requests.get(JWKS_URL, timeout=10).json()
        _cache["keys"] = [
            Ed25519PublicKey.from_public_bytes(_b64url(k["x"]))
            for k in doc.get("keys", [])
            if k.get("kty") == "OKP" and k.get("crv") == "Ed25519"
        ]
        _cache["at"] = time.time()
    return _cache["keys"]


def verify(headers: dict[str, str], body: bytes) -> bool:
    h = {k.lower(): v for k, v in headers.items()}
    try:
        request_id = h["x-refabric-request-id"]
        user_id = h["x-refabric-user-id"]
        timestamp = h["x-refabric-timestamp"]
        signature = base64.b64decode(h["x-refabric-signature"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - int(timestamp)) > WINDOW_SECONDS:
        return False
    message = "\n".join(
        [request_id, user_id, timestamp, hashlib.sha256(body).hexdigest()]
    ).encode()
    for force in (False, True):
        for key in _keys(force=force):
            try:
                key.verify(signature, message)
                return True
            except InvalidSignature:
                continue
    return False

Flask example:

from flask import Flask, request, abort

app = Flask(__name__)
seen: set[str] = set()          # use your database in production

@app.post("/hooks/refabric")
def refabric_hook():
    if not verify(dict(request.headers), request.get_data()):
        abort(401)
    event = request.get_json()
    if event["id"] in seen:      # every retry and replay of an event repeats its `id`
        return "", 200
    seen.add(event["id"])
    enqueue_processing(event)    # do the work after answering
    return "", 200

Delivery and retries

Your answerWhat we do
2xxdelivered; done
3xx, 4xx (except 408, 429)permanent failure; no retry, redirects are not followed
408, 429, 5xx, no answerretried
  • Up to webhook.max_attempts attempts (GET /v1/meta) with backoff — first retries seconds apart, later ones minutes apart. During a deploy, answer 503 and we will come back.
  • Answer quickly (2xx within a few seconds) and process asynchronously.
  • Delivery is at least once: the same event can arrive more than once. Dedupe by the body's id (one per event; X-Refabric-Request-Id names one delivery of it to one endpoint).
  • Order is not guaranteed — across jobs, nor across one job's events (read progress.done).
  • If every attempt fails, the result is still available at GET /v1/jobs/{id}/result.

The delivery log

Every delivery — to a registered endpoint or to a per-request webhook_url — is in the delivery log of Panel › Developers › Webhooks, newest first, filtered by event, status and time. A delivery shows each attempt: when it was made, your endpoint's status code, how long it took, and when the next attempt is due. Replay re-sends the stored body of any delivery as a new delivery (its own X-Refabric-Request-Id, the same event id). Finished deliveries are kept for a retention period, then removed.

The test event (ping), the delivery log and replay work the same for every event: same signing, same retries.

IP addresses

Verify the signature on every delivery.