Node.js image generation with webhooks instead of polling
2 min readWritten by Unified Image API Team
Polling works, but at scale it wastes requests and adds seconds of latency. Pass a webhook_url when creating a job and we POST to you the moment it finishes — signed, retried, and exactly the payload you need. This tutorial is plain Node 20+, no SDK.
Submit with a webhook
const BASE = process.env.IMAGE_API_BASE!;
const KEY = process.env.IMAGE_API_KEY!;
export async function submit(prompt: string) {
const res = await fetch(`${BASE}/v1/generations`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "nanobanana",
prompt,
resolution: "1K",
ratio: "1:1",
webhook_url: "https://yourapp.com/hooks/image-api",
}),
});
if (!res.ok) throw new Error(`submit ${res.status}: ${await res.text()}`);
return res.json(); // { id, status: "queued", cost, ... }
}Receive and verify
Every delivery carries an HMAC-SHA256 signature over timestamp + "." + rawBody with your account's webhook secret (Dashboard → Settings). Verify before trusting anything:
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
app.post("/hooks/image-api", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.header("X-Webhook-Timestamp")!;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400); // stale
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(`${ts}.${req.body}`).digest("hex");
const ok = (req.header("X-Webhook-Signature") ?? "").split(",").some((s) => {
const v = s.replace(/^v1=/, "");
return v.length === expected.length && timingSafeEqual(Buffer.from(v), Buffer.from(expected));
});
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString());
// { type: "generation.succeeded" | "generation.failed", id, status, error_code, ... }
queueMicrotask(() => handle(event)); // ack fast, work later
res.sendStatus(200);
});Three habits that make this production-grade:
- Verify on the raw body. Any JSON re-serialisation breaks the signature — note the
express.rawmiddleware. - Ack fast, process async. We retry up to 8 times with backoff until you return 2xx; slow handlers cause duplicate work on your side.
- Be idempotent. Retries mean you may see the same event twice — key your processing on the job
id.
Fetch the image
The webhook payload intentionally has no image URL (payloads should be small and non-sensitive). On generation.succeeded, GET the job for fresh presigned URLs:
async function handle(event: { id: string; type: string }) {
if (event.type !== "generation.succeeded") return;
const job = await (await fetch(`${BASE}/v1/generations/${event.id}`, {
headers: { Authorization: `Bearer ${KEY}` },
})).json();
const buf = Buffer.from(await (await fetch(job.images[0].url)).arrayBuffer());
// store buf wherever your app keeps assets
}During local development, expose your handler with a tunnel (cloudflared tunnel --url http://localhost:3000) — webhook URLs must be public https. Full reference: webhooks docs.