Callbacks
Let Nubu tell your software when a run changes state instead of polling.
Pass a callback_url when you trigger a flow and Nubu POSTs to it every time the run changes state: creatives built, approved, queued, rendering, rendered or failed, and each delivery outcome. Polling GET /runs/<run_id> always works too; callbacks save you the polling.
The request
{
"event": "run.updated",
"event_id": "evt_2a4f6c1e-7d1b-4d64-9c2e-8d0a2f3b4c5d",
"run_id": "...",
"run_version": 17,
"status": "completed",
"external_id": "customer-job-48291",
"changes": {
"status": true,
"creatives": [{ "creative_id": "...", "from": "rendering|-", "to": "rendered|uploaded" }]
},
"run": { ... },
"sent_at": "..."
}
event_idnames this event. A retry re-sends the sameevent_idwith the same body, so de-duplicate on it.run_versionis the run's version after this change: a counter that only ever goes up, incremented on every state change Nubu records for the run. It is the ordering rule (below).statusis the run's state after the change (Runs and creatives).changessays what moved: whether the run's status changed, and each creative whose status or delivery state changed (fromandtoreadstatus|delivery, with-for no delivery).runis the full run asGET /runs/<run_id>would answer it at that moment, creatives included.
The request carries Content-Type: application/json, User-Agent: Nubu-Callbacks/1.0, X-Nubu-Event: run.updated, X-Nubu-Run-Id and the three signature headers below.
Verifying a callback
Every callback is signed, so your endpoint can prove it came from Nubu and that the body was not changed on the way. Three headers carry the proof:
| Header | Value |
|---|---|
X-Nubu-Event-Id | evt_..., the same as the body's event_id, stable across retries |
X-Nubu-Timestamp | Unix seconds when this attempt was signed |
X-Nubu-Signature | v1=<hex>: HMAC-SHA256, keyed with your signing secret, over the exact string event_id + "." + timestamp + "." + raw_request_body |
The signed string is the event id, a dot, the timestamp, a dot and the raw request body exactly as received (the bytes, not a re-serialised JSON). The signing secret is the organisation's, created with its first API key and shown once; the owner can rotate it in Settings > Integrations > API > Signing secret (rotation replaces it at once). API Request deliveries carry the same three headers, signed with the same secret (API Request delivery).
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyNubu(req, rawBody, secret) {
const eventId = req.headers["x-nubu-event-id"];
const timestamp = req.headers["x-nubu-timestamp"];
const signature = req.headers["x-nubu-signature"]; // "v1=<hex>"
if (!eventId || !timestamp || !signature?.startsWith("v1=")) return false;
// Refuse stale attempts: five minutes is a comfortable window.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${eventId}.${timestamp}.${rawBody}`)
.digest("hex");
const given = signature.slice(3);
return given.length === expected.length && timingSafeEqual(Buffer.from(given, "hex"), Buffer.from(expected, "hex"));
}
Read the raw body before any JSON parsing (most frameworks offer a raw-body option), compare with a constant-time comparison, and reject anything that fails: a wrong secret, an edited body or a replay outside your window. Then de-duplicate on event_id and order by run_version.
Answering
Answer with any 2xx status as quickly as you can and do your work afterwards. Nubu waits ten seconds for an answer, then makes up to three attempts (immediately, after two seconds, after eight more), every attempt carrying the same event_id and the same body. Redirects are never followed: a 3xx answer counts as a failure. After the third failure that change is not retried; the next change sends the current state again, and the run is always readable with GET /runs/<run_id>. The last attempt's status and error are recorded on the run.
Rules of thumb
- Use
httpson a public host; a private or local address is refused when the flow is triggered, and a redirect to one is never followed. - Verify the signature before acting on a callback, and de-duplicate on
event_id: a retry re-sends the same event with the same body. - Callbacks can arrive out of order under retries. Order them by
run_versionand ignore any callback whoserun_versionis lower than the last one you applied for that run: if 17 arrives and then 16, 16 is stale.sent_atis metadata about when the attempt was made, never the consistency rule.GET /runs/<run_id>carries the currentrun_versiontoo. - Treat a callback as a signal, not as the only record:
runcarries everything the API would answer, so you can act on it directly or read the run again. - Keep
external_idin your request and you never need to map run ids yourself.