Be told, do not ask
Pass a callback_url and we send one signed event when your job ends. Verify the signature, answer 2xx, ignore repeats.
Turn it on
Section titled “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 -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"}'import { Diffusr } from "diffusr";
const d = new Diffusr(); // reads DIFFUSR_KEYconst 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);from diffusr import Diffusr
d = Diffusr() # reads DIFFUSR_KEYjob = 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
Section titled “What we send”One POST when the job ends (succeeded, failed or canceled):
{ "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
Section titled “Check the signature”Callbacks follow Standard Webhooks. 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 yourwhsec_...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:
import { Diffusr } from "diffusr";
// 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});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
Section titled “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_urlcan be up to 2048 characters.
The log
Section titled “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, list_callbacks, retry_callback.