genmux API 现已全面开放。 查看文档 →
genmux API

博客

Node.js 图像生成:用 webhook 取代轮询

1 分钟阅读作者 genmux API Team


轮询能用,但规模上去之后既浪费请求,又平白多出几秒延迟。创建任务时传一个 webhook_url,任务一完成我们就 POST 给你——带签名、会重试,载荷正是你需要的那些字段。本教程用的是纯 Node 20+,不依赖 SDK。

带 webhook 提交

ts
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, ... }
}

接收并校验

每次投递都带一个 HMAC-SHA256 签名,签的是 timestamp + "." + rawBody,密钥是你账户的 webhook secret(控制台 → 设置)。在信任任何内容之前先校验:

ts
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);
});

有三个习惯能让这套东西真正达到生产可用:

  1. 对原始 body 校验。 任何一次 JSON 重新序列化都会破坏签名——注意那个 express.raw 中间件。
  2. 先快速应答,再异步处理。 我们会按退避重试最多 8 次,直到你返回 2xx;处理器太慢会让你这边做重复的活。
  3. 保持幂等。 有重试就意味着同一个事件你可能会看到两次——按任务 id 做去重。

取回图片

webhook 的载荷刻意不含图片 URL(载荷应当短小且不敏感)。收到 generation.succeeded 后,用 GET 取回任务拿到新的预签名 URL:

ts
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
}

本地开发时,用隧道把你的处理器暴露出去(cloudflared tunnel --url http://localhost:3000)——webhook URL 必须是公网 https。完整说明见 webhook 文档。

关于作者

作者 genmux API Team — genmux API.

了解我们