Skip to content
Book a callStart free

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.

View as Markdown

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"
}'

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.

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

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"]

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.

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.