# Callbacks

> Pass a callback_url and we send one signed event when your job ends. Verify the signature, answer 2xx, ignore repeats.

## Turn it on

Add `callback_url` to any make call. It must be a public `https://` address with no username or password. A bad address answers `422 CALLBACK_URL_INVALID` before anything is charged. Polling `GET /results/{id}` keeps working too.

**cURL**

```bash
curl -X POST https://app.diffusr.ai/api/v2/make_video \
  -H "Authorization: Bearer $DIFFUSR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
  "prompt": "A woman turns to camera on a sunny street, smiles",
  "duration_sec": 5,
  "callback_url": "https://example.com/diffusr-hook"
}'
```

**TypeScript**

```ts
import { Diffusr } from "diffusr";

const d = new Diffusr(); // reads DIFFUSR_KEY
const job = await d.makeVideo({
  prompt: "A woman turns to camera on a sunny street, smiles",
  duration_sec: 5,
  callback_url: "https://example.com/diffusr-hook",
});
const result = await d.waitFor(job.id);
console.log(result.output?.url);
```

**Python**

```python
from diffusr import Diffusr

d = Diffusr()  # reads DIFFUSR_KEY
job = d.make_video(
    prompt="A woman turns to camera on a sunny street, smiles",
    duration_sec=5,
    callback_url="https://example.com/diffusr-hook",
)
result = d.wait_for(job["id"])
print(result["output"]["url"])
```

## What we send

One `POST` when the job ends (succeeded, failed or canceled):

```json
{
  "id": "evt_...",
  "type": "result.finished",
  "created_at": "2026-11-09T00:20:00Z",
  "data": { "id": "run_...", "status": "SUCCEEDED", "output": { "kind": "VIDEO", "url": "https://..." } }
}
```

`data` is the same object `GET /results/{id}` returns.

## Check the signature

Callbacks follow [Standard Webhooks](https://www.standardwebhooks.com). Three headers come with each one:

- `webhook-id`: the event id. It is the same on every retry. Remember it and ignore repeats.
- `webhook-timestamp`: unix seconds when this try was signed. Reject anything more than 5 minutes off.
- `webhook-signature`: `v1,<base64>`, the HMAC-SHA256 of `"<webhook-id>.<webhook-timestamp>.<raw body>"`, keyed with the bytes of the base64 part of your `whsec_...` secret. After a secret change it carries two signatures separated by a space for 24 hours: accept either.

Use the **raw** body, not a parsed and re-written one. Compare in constant time. Get your secret with `GET /callbacks/secret` (it is made the first time you ask). Live keys and test keys have separate secrets. `POST /callbacks/secret/rotate` makes a new one; the old one keeps signing for 24 hours.

The SDKs do all of this for you:

```ts

// Express: use the RAW body, not the parsed one.
app.post("/diffusr", express.text({ type: "*/*" }), (req, res) => {
  if (!Diffusr.verifyCallback(req.body, req.headers, process.env.DIFFUSR_WHSEC!)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body); // { id, type: "result.finished", data: <result> }
  res.sendStatus(200); // any 2xx; drop repeats of event.id
});
```

```python
from diffusr import Diffusr

# Flask: use the RAW body, not the parsed one.
@app.post("/diffusr")
def hook():
    raw = request.get_data(as_text=True)
    if not Diffusr.verify_callback(raw, request.headers, os.environ["DIFFUSR_WHSEC"]):
        return "", 400
    event = json.loads(raw)  # {id, type: "result.finished", data: <result>}
    return "", 200  # any 2xx; drop repeats of event["id"]
```

## Retries

Answer any `2xx` to say it arrived. A `410` stops all further tries. Anything else, a timeout, or a redirect (we do not follow redirects) counts as a failure and is retried.

- **Try 1** is sent when the job ends. If it fails, tries 2 to 6 follow after 1 min, 5 min, 30 min, 2 h, 8 h. That is 6 tries in all.
- Each try waits up to 10 seconds for your answer.
- After the last try the event is marked FAILED. You can retry a FAILED event by hand up to 3 times (`POST /callbacks/{event_id}/retry`).
- `callback_url` can be up to 2048 characters.

## The log

`GET /callbacks` lists your events, newest first: event id, result id, URL, status, number of tries, last HTTP status, a short last error, next try time. `POST /callbacks/{event_id}/retry` gives a FAILED event one more try now. The Developers page in the app shows the same log.

Callbacks are for convenience. If one never arrives, `GET /results/{id}` still has the answer.

See also: [get_callback_secret](https://diffusr.ai/docs/reference/get_callback_secret), [list_callbacks](https://diffusr.ai/docs/reference/list_callbacks), [retry_callback](https://diffusr.ai/docs/reference/retry_callback).

Source: https://diffusr.ai/docs/callbacks
