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

```bash
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_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.

```http
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}`](https://docs.refabric.com/task-apis/calling-tasks/asynchronous-jobs) 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](https://docs.refabric.com/task-apis/calling-tasks/asynchronous-jobs#result) — 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`).

```json
{ "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": "" } }
```

```json
{ "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.

```python
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:

```python
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 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_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](#verifying-the-signature) on every delivery.
