Zadania i webhooki
Generowanie mediów (wideo, obrazów, muzyki) jest asynchroniczne: tworzysz zadanie (job), a wynik pobierasz, gdy będzie gotowy. Są dwa sposoby sprawdzenia, czy zadanie zostało ukończone: odpytywanie (polling) i webhook (push). Poniżej opisano, jak działa zadanie i jak niezawodnie odbierać wynik.
Cykl życia zadania
POST /media/generate → status: processing ──► done (result_url gotowy)
│ └─► failed (error, środki zwrócone)
└── (opcjonalnie) callback_url → webhook push po done/failed- Utworzenie.
POST /media/generatez modelem i parametrami zwracaidoraz adres do odpytywania:
{ "id": "beefb531-…", "model": "image/nano-banana-2", "status": "processing",
"poll": "https://nordrouter.com/media/job/beefb531-…" }Oczekiwanie. Zadanie przechodzi ze stanu
processingdodone(lubfailed). Generowanie obrazów trwa kilka sekund, a wideo i muzyki — od kilkudziesięciu sekund do kilku minut.Wynik. W ukończonym zadaniu pole
result_urljest wypełnione (link donordrouter.com); plik jest przechowywany przez ograniczony czas (retention_dayszGET /media/models) — pobierz go, aby go zachować.
Format odpowiedzi GET /media/job/:id (i treści webhooka — patrz poniżej) jest taki sam:
{ "id": "beefb531-…", "status": "done",
"result_url": "https://nordrouter.com/media/file/beefb531-…",
"cost_usd": 0.052, "error": null }status—processing|done|failed.result_url— wypełnione tylko przydone, w przeciwnym razienull.result_url_2— drugi utwór w modelach muzycznych (Suno), w przeciwnym razienull.results— wszystkie pliki zadania:[{ "index": 1, "name": "Результат 1", "ext": "png", "url": "…" }](w przypadku 3D — model i podgląd, a przy rozdzielaniu utworu — ścieżki „Wokal”, „Instrumental”…).cost_usd— rzeczywisty koszt (naliczany po wykonaniu; nieudane generacje nie są rozliczane — rezerwa jest zwracana).error— tekst błędu przyfailed, w przeciwnym razienull.
Sposób 1. Odpytywanie (polling)
Najprostszy sposób to okresowe wysyłanie żądań GET /media/job/:id, aż status zmieni się na done/failed:
curl https://nordrouter.com/media/job/beefb531-…Wysyłaj zapytanie co 2–3 sekundy. Odpytywanie działa zawsze i nie wymaga publicznego adresu — sprawdza się w skryptach, notebookach i CI.
Sposób 2. Webhooki (push)
Aby nie odpytywać serwera, przekaż callback_url w POST /media/generate, a sami wyślemy żądanie POST na ten adres po zakończeniu zadania:
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": "dowolny-twój-ciąg"
}'Jeśli callback_url zostanie zaakceptowany, odpowiedź na utworzenie zadania będzie zawierać "webhook": "registered". callback_url jest sprawdzany od razu podczas tworzenia zadania: jeśli jest nieprawidłowy (nie zaczyna się od https lub wskazuje adres prywatny/lokalny), żądanie zwróci 400 z przyczyną ({ "error": "callback_url отклонён: …", "code": "invalid_callback" }), a zadanie nie zostanie utworzone.
Treść webhooka
Gdy zadanie osiągnie stan done/failed, na Twój callback_url zostanie wysłane żądanie POST z tą samą treścią co GET /media/job/:id, uzupełnioną o pole event:
{ "event": "media.completed", "id": "beefb531-…", "status": "done",
"result_url": "https://nordrouter.com/media/file/beefb531-…",
"cost_usd": 0.052, "error": null }event—media.completed(sukces) lubmedia.failed(błąd).
Weryfikacja podpisu
Jeśli ustawisz callback_secret, nagłówek X-NR-Signature będzie zawierać sha256=<hmac> — HMAC-SHA256 z surowej treści żądania, z kluczem równym Twojemu sekretowi. Zweryfikuj go, aby upewnić się, że żądanie pochodzi od nas, a nie jest fałszerstwem:
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) # porównanie w stałym czasieimport 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));
}Obliczaj HMAC z dokładnie tych bajtów, które odebrano, przed parsowaniem JSON-a — w przeciwnym razie podpis się nie zgodzi.
Gotowy odbiornik w 10 liniach
Działający serwer, który odbiera webhook, weryfikuje podpis i odpowiada kodem 2xx. Sekret jest taki sam jak przekazany w callback_secret.
Node.js / Express:
import express from "express";
import crypto from "node:crypto";
const SECRET = "dowolny-twój-ciąg"; // = Twój callback_secret
const app = express();
app.post("/webhooks/nordrouter",
express.raw({ type: "application/json" }), // surowa treść — wymagana do weryfikacji podpisu
(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); // podpis się nie zgadza — żądanie nie pochodzi od nas
const job = JSON.parse(req.body);
if (job.event === "media.completed") console.log("gotowe:", job.result_url, "$" + job.cost_usd);
res.sendStatus(200); // odpowiedz kodem 2xx, w przeciwnym razie żądanie zostanie ponowione
});
app.listen(3000);Python / FastAPI:
import hmac, hashlib
from fastapi import FastAPI, Request, Response
SECRET = b"dowolny-twój-ciąg" # = Twój callback_secret
app = FastAPI()
@app.post("/webhooks/nordrouter")
async def hook(request: Request):
raw = await request.body() # surowe bajty — wymagane do weryfikacji podpisu
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) # podpis się nie zgadza — żądanie nie pochodzi od nas
job = await request.json()
if job["event"] == "media.completed":
print("gotowe:", job["result_url"], "$", job["cost_usd"])
return Response(status_code=200) # odpowiedz kodem 2xx, w przeciwnym razie żądanie zostanie ponowioneTestowanie lokalne
callback_url musi być publicznym adresem https. Na potrzeby programowania na laptopie uruchom tunel, na przykład ngrok http 3000, i przekaż wygenerowany przez niego adres https jako callback_url.
Dostarczanie i niezawodność
- Do 4 prób ze stopniowo wydłużaną przerwą (≈3 → 6 → 9 s), limit czasu każdej próby wynosi 15 s. Odpowiedz kodem
2xx, w przeciwnym razie żądanie zostanie ponowione. - Przekierowania nie są obsługiwane — odpowiadaj bezpośrednio pod adresem
callback_url. - Dostarczanie działa na zasadzie best-effort: jeśli wszystkie 4 próby się nie powiodą (Twój serwer nie działał), webhook nie zostanie wysłany ponownie później. Dlatego:
Webhooki nie zastępują odpytywania
Webhook przyspiesza powiadomienie, ale nie daje gwarancji. Zachowaj GET /media/job/:id jako rozwiązanie zapasowe: jeśli push nie dotrze w rozsądnym czasie, samodzielnie sprawdź status. Idempotencję obsługujesz po swojej stronie: to samo zadanie może dotrzeć jako webhook i zostać wykryte podczas odpytywania — identyfikuj je po id.
Wymagania dotyczące callback_url
- Tylko
https://. - Wyłącznie publiczny adres. Adresy lokalne i prywatne (
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) są odrzucane — to zabezpieczenie przed SSRF. Adres jest sprawdzany ponownie przed każdym wysłaniem (ochrona przed DNS rebindingiem).
Co wybrać
| Odpytywanie | Webhook | |
|---|---|---|
Publiczny adres https | niewymagany | wymagany |
| Opóźnienie odbioru | do następnego odpytywania | niemal natychmiast |
| Gwarancja | masz nad nią kontrolę | best-effort + odpytywanie jako rozwiązanie zapasowe |
| Gdzie się sprawdza | skrypty, CI, laptop | serwery, backendy produkcyjne |
Rekomendacja: na backendzie używaj webhooka i odpytywania jako zabezpieczenia; w skrypcie lub na laptopie po prostu odpytuj serwer.