Skip to content

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
  1. Creation. POST /media/generate with the model and parameters returns an id and a polling link:
json
{ "id": "beefb531-…", "model": "image/nano-banana-2", "status": "processing",
  "poll": "https://nordrouter.com/media/job/beefb531-…" }
  1. Waiting. The job moves from processing → done (or failed). Images take seconds, while video and music take from tens of seconds to a couple of minutes.

  2. Result. A completed job has a populated result_url (a link to nordrouter.com); the file is stored for a limited time (retention_days from GET /media/models) — download it to keep it.

The response format for GET /media/job/:id (and the webhook body — see below) is the same:

json
{ "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 for done, otherwise null.
  • result_url_2 — the second track for music models (Suno), otherwise null.
  • 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 when failed, otherwise null.

Method 1. Polling ​

The simplest approach is to periodically request GET /media/job/:id until status becomes done/failed:

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

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

json
{ "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) or media.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:

python
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 comparison
javascript
import 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:

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

python
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 retried

Test 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 ​

PollingWebhook
Public https URLnot requiredrequired
Delivery latencyup to the polling intervalalmost instant
Guaranteeyou control itbest-effort + polling as a fallback
Best suited forscripts, CI, laptopsservers, production backends

Recommendation: on a backend, use a webhook + polling as a fallback; in a script/on a laptop, use polling alone.