Jobs and webhooks
Media generation (video, images, music) is asynchronous: you create a job and pick up the result when it's ready. There are two ways to learn a job has finished: polling and webhooks (push). Below — how a job is structured and how to get the result reliably.
Job lifecycle
POST /media/generate → status: processing ──► done (result_url ready)
│ └─► failed (error, funds refunded)
└── (optional) callback_url → push webhook on done/failed- Create.
POST /media/generatewith a model and parameters — returns anidand a poll link:
{ "id": "beefb531-…", "model": "image/nano-banana-2", "status": "processing",
"poll": "https://nordrouter.com/media/job/beefb531-…" }Wait. The job goes
processing→done(orfailed). Images take seconds; video and music from tens of seconds to a couple of minutes.Result. A finished job has
result_url(a link onnordrouter.com); the file is kept for a limited time (retention_daysfromGET /media/models) — download it to keep it.
The shape of GET /media/job/:id (and the webhook body — see below) is identical:
{ "id": "beefb531-…", "status": "done",
"result_url": "https://nordrouter.com/media/file/beefb531-…",
"cost_usd": 0.052, "error": null }status—processing|done|failed.result_url— set only ondone, otherwisenull.result_url_2— second track for music models (Suno), otherwisenull.cost_usd— the actual cost (charged after the fact; failed generations are not billed — the reserve is refunded).error— the error text onfailed, otherwisenull.
Option 1. Polling
The simplest path — periodically request GET /media/job/:id until status becomes done/failed:
curl https://nordrouter.com/media/job/beefb531-…Poll every 2–3 seconds. Polling always works and needs no public address — good for scripts, laptops, CI.
Option 2. Webhooks (push)
To avoid polling — pass a callback_url to POST /media/generate, and we'll POST to your address when the job finishes:
curl https://nordrouter.com/media/generate \
-H "Authorization: Bearer sk-nr-YOUR-KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "image/nano-banana-2",
"input": { "prompt": "…" },
"callback_url": "https://your-server.com/webhooks/nordrouter",
"callback_secret": "any-string-of-yours"
}'If callback_url is accepted, the create response includes "webhook": "registered". The callback_url is validated immediately on job creation: if it's unsuitable (not https, a private/local address) the request returns 400 with a reason and the job is not created.
Webhook body
When the job reaches done/failed, a POST hits your callback_url with the same body as GET /media/job/:id plus an event field:
{ "event": "media.completed", "id": "beefb531-…", "status": "done",
"result_url": "https://nordrouter.com/media/file/beefb531-…",
"cost_usd": 0.052, "error": null }event—media.completed(success) ormedia.failed(error).
Verifying the signature
If you set a callback_secret, the X-NR-Signature header carries sha256=<hmac> — HMAC-SHA256 of the raw request body, keyed by your secret. Verify it to confirm the request is from us and not forged:
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header) # constant-time compareimport crypto from "node:crypto";
function verify(rawBody, header, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}Compute the HMAC over the exact bytes as received, before JSON parsing — otherwise the signature won't match.
A webhook receiver in 10 lines
A working server that accepts the webhook, verifies the signature and replies 2xx. The secret is the same one you passed as callback_secret.
Node.js / Express:
import express from "express";
import crypto from "node:crypto";
const SECRET = "any-string-of-yours"; // = your callback_secret
const app = express();
app.post("/webhooks/nordrouter",
express.raw({ type: "application/json" }), // raw body — required for the signature
(req, res) => {
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const got = req.headers["x-nr-signature"] || "";
if (expected.length !== got.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got)))
return res.sendStatus(401); // signature mismatch — not from us
const job = JSON.parse(req.body);
if (job.event === "media.completed") console.log("done:", job.result_url, "$" + job.cost_usd);
res.sendStatus(200); // reply 2xx or we retry
});
app.listen(3000);Python / FastAPI:
import hmac, hashlib
from fastapi import FastAPI, Request, Response
SECRET = b"any-string-of-yours" # = your callback_secret
app = FastAPI()
@app.post("/webhooks/nordrouter")
async def hook(request: Request):
raw = await request.body() # raw bytes — required for the signature
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-NR-Signature", "")):
return Response(status_code=401) # signature mismatch — not from us
job = await request.json()
if job["event"] == "media.completed":
print("done:", job["result_url"], "$", job["cost_usd"])
return Response(status_code=200) # reply 2xx or we retryTest it locally
callback_url must be a public https address. For local development spin up a tunnel — e.g. ngrok http 3000 — and pass the https URL it gives you as the callback_url.
Delivery and reliability
- Up to 4 attempts with increasing backoff (≈3 → 6 → 9 s), each with a 15 s timeout. Respond
2xxor we retry. - Redirects are not followed — respond directly at the
callback_url. - Delivery is best-effort: if all 4 attempts fail (your server was down), the webhook is not re-sent later. So:
Webhooks don't replace polling
A webhook is an accelerator, not a guarantee. Keep GET /media/job/:id as a fallback: if the push doesn't arrive within a reasonable time — poll the status yourself. Idempotency is on your side: the same job may arrive as a webhook and be seen by polling — key off id.
callback_url requirements
https://only.- A public address only. Local and private addresses (
localhost,*.local,10.x,172.16–31.x,192.168.x,127.x,169.254.x,100.64–127.x, IPv6 loopback/ULA/link-local) are rejected — this is SSRF protection. The address is re-checked before each send too (DNS-rebinding protection).
Which to choose
| Polling | Webhook | |
|---|---|---|
Public https address | not needed | required |
| Latency | up to poll interval | near-instant |
| Guarantee | you control it | best-effort + polling backup |
| Best for | scripts, CI, laptop | servers, production backends |
Recommendation: on a backend — webhook + polling as insurance; in a script/notebook — just polling.