Skip to content

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

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

The shape of GET /media/job/:id (and the webhook body — see below) is identical:

json
{ "id": "beefb531-…", "status": "done",
  "result_url": "https://nordrouter.com/media/file/beefb531-…",
  "cost_usd": 0.052, "error": null }
  • statusprocessing | done | failed.
  • result_url — set only on done, otherwise null.
  • result_url_2 — second track for music models (Suno), otherwise null.
  • cost_usd — the actual cost (charged after the fact; failed generations are not billed — the reserve is refunded).
  • error — the error text on failed, otherwise null.

Option 1. Polling

The simplest path — 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 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:

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

json
{ "event": "media.completed", "id": "beefb531-…", "status": "done",
  "result_url": "https://nordrouter.com/media/file/beefb531-…",
  "cost_usd": 0.052, "error": null }
  • eventmedia.completed (success) or media.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:

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

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

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

Test 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 2xx or 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

PollingWebhook
Public https addressnot neededrequired
Latencyup to poll intervalnear-instant
Guaranteeyou control itbest-effort + polling backup
Best forscripts, CI, laptopservers, production backends

Recommendation: on a backend — webhook + polling as insurance; in a script/notebook — just polling.