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.jsonA 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.
- Choose Add endpoint, enter the
https://URL and, optionally, a description. - Pick its events (the list below). Left as it is, an endpoint receives the terminal events only.
- Choose Send ping to receive a signed test event (
ping) and check your verification.
Per-request webhook_url | Registered endpoint | |
|---|---|---|
| Set up | on each submit call | once, in the panel |
| Events | terminal only | the ones it subscribes to |
| Jobs | the one job | every job started with one of your keys |
| In the delivery log | yes | yes |
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
| Event | When | Subscribed by default |
|---|---|---|
job.started | the job left the queue (lifecycle moved out of queued) — once per job | no — opt in |
job.progress | the job delivered one more output (progress.done went up) | no — opt in |
job.succeeded | the job finished and its files are ready | yes |
job.failed | the job failed; the body carries error | yes |
job.cancelled | the 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.
- Read the four
X-Refabric-*headers and the raw body bytes (before JSON parsing). - Reject if
X-Refabric-Timestamp(Unix seconds) is further from your clock thanwebhook.signature.timestamp_window_secondsofGET /v1/meta. - 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))> X-Refabric-Signatureis the base64-encoded signature. Verify it against the keys inhttps://api.refabric.com/.well-known/jwks.json(OKP / Ed25519). Accept if any key verifies.- 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 FalseFlask 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 "", 200Delivery and retries
| Your answer | What we do |
|---|---|
2xx | delivered; done |
3xx, 4xx (except 408, 429) | permanent failure; no retry, redirects are not followed |
408, 429, 5xx, no answer | retried |
- Up to
webhook.max_attemptsattempts (GET /v1/meta) with backoff — first retries seconds apart, later ones minutes apart. During a deploy, answer503and we will come back. - Answer quickly (
2xxwithin 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-Idnames 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.