Jobs and Webhooks
Media generation (video, images, music) is asynchronous: you create a job and retrieve the result when it is ready. There are two ways to find out that a job has completed: polling and a webhook (push). Below is how a job works and how to reliably retrieve the result.
Job Lifecycle
POST /media/generate → status: processing ──► done (result_url ready)
│ └─► failed (error, funds returned)
└── (optional) callback_url → push webhook on done/failed- Creation.
POST /media/generatewith the model and parameters returns anidand a polling link:
{ "id": "beefb531-…", "model": "image/nano-banana-2", "status": "processing",
"poll": "https://nordrouter.com/media/job/beefb531-…" }Waiting. The job moves from
processing→done(orfailed). Images take seconds, while video and music take from tens of seconds to a couple of minutes.Result. A completed job has a populated
result_url(a link tonordrouter.com); the file is stored for a limited time (retention_daysfromGET /media/models) — download it to keep it.
The response format for GET /media/job/:id (and the webhook body — see below) is the same:
{ "id": "beefb531-…", "status": "done",
"result_url": "https://nordrouter.com/media/file/beefb531-…",
"cost_usd": 0.052, "error": null }status—processing|done|failed.result_url— populated only fordone, otherwisenull.result_url_2— the second track for music models (Suno), otherwisenull.results— all files in the job:[{ "index": 1, "name": "Result 1", "ext": "png", "url": "…" }](for 3D, the model and preview; for track separation, tracks such as “Vocals” and “Instrumental”).cost_usd— the actual cost (charged based on actual usage; failed generations are not charged — the reserve is returned).error— the error text whenfailed, otherwisenull.
Method 1. Polling
The simplest approach is to 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 does not require a public address, making it suitable for scripts, notebooks, and CI.
Method 2. Webhooks (push)
To avoid polling, pass callback_url in POST /media/generate, and we will send a POST to your URL 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-you-choose"
}'If callback_url is accepted, the creation response contains "webhook": "registered". callback_url is validated immediately when the job is created: if it is unsuitable (not https, or a private/local address), the request returns 400 with the reason ({ "error": "callback_url отклонён: …", "code": "invalid_callback" }), and no job is created.
Webhook body
When the job reaches done/failed, your callback_url receives a POST with the same body as GET /media/job/:id, plus the 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(failure).
Signature verification
If you set callback_secret, the X-NR-Signature header contains sha256=<hmac> — the HMAC-SHA256 of the raw request body, using your secret as the key. Verify it to confirm that the request is from us and is 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 comparisonimport 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 from the bytes exactly as received, before parsing the JSON — otherwise, the signature will not match.
Ready-to-use receiver in 10 lines
A working server that receives the webhook, verifies the signature, and responds with 2xx. Use the same secret you passed in callback_secret.
Node.js / Express:
import express from "express";
import crypto from "node:crypto";
const SECRET = "any-string-you-choose"; // = 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 our request
const job = JSON.parse(req.body);
if (job.event === "media.completed") console.log("done:", job.result_url, "$" + job.cost_usd);
res.sendStatus(200); // respond with 2xx, otherwise delivery will be retried
});
app.listen(3000);Python / FastAPI:
import hmac, hashlib
from fastapi import FastAPI, Request, Response
SECRET = b"any-string-you-choose" # = 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 our request
job = await request.json()
if job["event"] == "media.completed":
print("done:", job["result_url"], "$", job["cost_usd"])
return Response(status_code=200) # respond with 2xx, otherwise delivery will be retriedTest locally
callback_url must be a public https URL. For development on a laptop, start a tunnel — for example, ngrok http 3000 — and pass the https URL it provides as callback_url.
Delivery and reliability
- Up to 4 attempts with increasing delays (≈3 → 6 → 9 s), each with a 15 s timeout. Respond with
2xx, otherwise delivery will be retried. - Redirects are not followed — respond directly at
callback_url. - Delivery is best-effort: if all 4 attempts fail (your server was down), the webhook is not resent later. Therefore:
Webhooks do not replace polling
A webhook speeds things up but is not a guarantee. Keep GET /media/job/:id as a fallback: if the push does not arrive within a reasonable time, poll the status yourself. Handle idempotency on your side: the same job may arrive as a webhook and be detected by polling — use id to identify it.
Requirements for callback_url
- Only
https://. - Only a public address. 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 to protect against SSRF. The address is also revalidated before each delivery attempt (protection against DNS rebinding).
Which to choose
| Polling | Webhook | |
|---|---|---|
Public https URL | not required | required |
| Delivery latency | up to the polling interval | almost instant |
| Guarantee | you control it | best-effort + polling as a fallback |
| Best suited for | scripts, CI, laptops | servers, production backends |
Recommendation: on a backend, use a webhook + polling as a fallback; in a script/on a laptop, use polling alone.