用 Next.js 接入 Makernice API
在 Next.js App Router 应用里加两个路由:一个在服务端报价并创建生成,API Key 不会到浏览器;另一个接收带签名的 webhook,拿到完成的生成。两段都是纯 TypeScript,不需要装包。
开始之前
- 在控制台创建 API Key,设成
MAKERNICE_API_KEY。 - 把控制台设置里的 webhook 签名密钥设成
MAKERNICE_WEBHOOK_SECRET。 - 把
APP_URL设成应用的公网 https 地址,创建路由用它拼callback_url。
两个路由
app/api/videos/route.ts
// app/api/videos/route.ts: runs on the server, so MAKERNICE_API_KEY never reaches the browser.
import { randomUUID } from "node:crypto";
const API = "https://api.makernice.com/v1";
const HEADERS = { Authorization: `Bearer ${process.env.MAKERNICE_API_KEY}`, "Content-Type": "application/json" };
class ApiError extends Error {
status: number;
body: unknown;
constructor(status: number, body: unknown) {
super(`Makernice API ${status}`);
this.status = status;
this.body = body;
}
}
async function post(path: string, body: unknown, headers: Record<string, string> = {}) {
const r = await fetch(API + path, { signal: AbortSignal.timeout(30_000),
method: "POST",
headers: { ...HEADERS, ...headers },
body: JSON.stringify(body),
});
const data = await r.json();
if (!r.ok) throw new ApiError(r.status, data);
return data;
}
export async function POST(req: Request) {
const { prompt } = (await req.json()) as { prompt?: unknown };
if (typeof prompt !== "string" || prompt.trim() === "") {
return Response.json({ error: "prompt is required" }, { status: 400 });
}
// Same job as the quickstart; swap model, duration or resolution as needed
const input = { model: "seedance-2.5", mode: "t2v", prompt, duration: 5, resolution: "720p" };
try {
// 1. Quote: the exact amount in micro-USD, locked for 12 minutes. Nothing is charged yet
const quote = await post("/generations/quote", input);
// 2. Create. callback_url: we POST the finished generation to the webhook route below.
// If the browser may retry this request, take the Idempotency-Key from it instead of a new UUID
const gen = await post(
"/generations",
{ quote_id: quote.id, input, callback_url: `${process.env.APP_URL}/api/makernice-webhook` },
{ "Idempotency-Key": randomUUID() },
);
return Response.json({ id: gen.id, status: gen.status, amount: quote.amount });
} catch (err) {
// 402 insufficient_balance, 422 invalid_request, 429 rate_limited… pass the API error through
if (err instanceof ApiError) return Response.json(err.body, { status: err.status });
throw err;
}
}app/api/makernice-webhook/route.ts
// app/api/makernice-webhook/route.ts: receives generation.succeeded / failed / canceled.
import { createHmac, timingSafeEqual } from "node:crypto";
export const runtime = "nodejs"; // node:crypto needs the Node.js runtime, not Edge
// Deliveries can repeat (retries, timeouts on your side). Keep seen event ids in your database in production.
const seen = new Set<string>();
// rawBody: the body exactly as received. Don't re-serialize parsed JSON.
function verifyWebhook(rawBody: Buffer, signatureHeader: string, secret: string, toleranceSeconds = 300): boolean {
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
export async function POST(req: Request) {
const raw = Buffer.from(await req.arrayBuffer());
const signature = req.headers.get("makernice-signature") ?? "";
if (!verifyWebhook(raw, signature, process.env.MAKERNICE_WEBHOOK_SECRET ?? "")) {
return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(raw.toString("utf8")) as {
id: string;
type: string;
data: { id: string; status: string; outputs?: { url: string; state: string }[] };
};
if (seen.has(event.id)) return new Response(null, { status: 200 });
seen.add(event.id);
if (event.type === "generation.succeeded") {
// outputs[].url is absolute here. Fetch it with your API key: it answers 302 with a signed file URL.
// A generation can arrive again with a new event id (an output released after review); the latest one wins.
console.log("ready", event.data.id, event.data.outputs?.map((o) => o.url));
}
// Any 2xx counts as delivered. Answer fast and do heavy work afterwards.
return new Response(null, { status: 200 });
}代码做了什么
- 浏览器把
{ "prompt": "…" }POST 到/api/videos,提示词为空时路由返回 400。 - 报价:
POST /v1/generations/quote返回精确金额(micro-USD 整数字符串),锁定 12 分钟,这一步不扣费。 - 创建:
POST /v1/generations带上quote_id、同一份输入和Idempotency-Key请求头。用同一个键重试,拿回的是同一个生成,不会重复下单。 - 等待:创建时带了
callback_url,生成变成succeeded、failed或canceled时,我们把它 POST 到/api/makernice-webhook。 - 402
insufficient_balance、429rate_limited等 API 错误按原状态码返回给浏览器。
webhook 要注意的地方
- 先对原始请求体验证
Makernice-Signature,再解析 JSON。签名密钥在控制台设置里,callback_url的推送用同一个密钥。 callback_url必须是公网 https 地址,本地开发时localhost收不到推送:可以先用轮询,或用隧道把开发服务器暴露出去。- 尽快返回任意 2xx,重活放到响应之后做。推送失败会在 1 分钟、5 分钟、30 分钟、2 小时、6 小时、12 小时 后重试(共 7 次),同一事件可能收到多次:按事件
id去重(与Makernice-Event-Id请求头相同)。 - webhook 路由设了
runtime = "nodejs":它用到node:crypto,Edge 运行时没有。请求体用arrayBuffer()读取,签名按收到的原始字节校验。
常见问题
Pages Router 能用吗?
能。把同样的逻辑放进 pages/api/ 下的 API 路由;webhook 路由要关掉 body parser(export const config = { api: { bodyParser: false } }),才能读到原始请求体去验签。
浏览器怎么知道视频好了?
webhook 到达时把结果存下来(比如按 /api/videos 返回的生成 id 存进数据库),再让页面轮询你自己的接口,或用你现有的实时推送通知页面。
生成失败要付钱吗?
不用。创建时冻结金额,失败或取消就全额释放;生成完成后被内容审核拦截的仍会扣费。详见失败不扣费。