> Markdown copy of https://makernice.com/zh-CN/docs/nextjs/ for AI tools. The HTML page is the canonical version.

# 用 Next.js 接入 Makernice API

在 Next.js App Router 应用里加两个路由：一个在服务端报价并创建生成，API Key 不会到浏览器；另一个接收带签名的 webhook，拿到完成的生成。两段都是纯 TypeScript，不需要装包。

[Makernice 团队](https://makernice.com/zh-CN/about/) · 更新于 2026 年 11 月 11 日

## 开始之前

-   在[控制台](https://makernice.com/console/keys/)创建 API Key，设成 `MAKERNICE_API_KEY`。
-   把[控制台设置](https://makernice.com/console/settings/)里的 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 });
}
```

## 代码做了什么

1.  浏览器把 `{ "prompt": "…" }` POST 到 `/api/videos`，提示词为空时路由返回 400。
2.  **报价**：`POST /v1/generations/quote` 返回精确金额（micro-USD 整数字符串），锁定 12 分钟，这一步不扣费。
3.  **创建**：`POST /v1/generations` 带上 `quote_id`、同一份输入和 `Idempotency-Key` 请求头。用同一个键重试，拿回的是同一个生成，不会重复下单。
4.  **等待**：创建时带了 `callback_url`，生成变成 `succeeded`、`failed` 或 `canceled` 时，我们把它 POST 到 `/api/makernice-webhook`。
5.  402 `insufficient_balance`、429 `rate_limited` 等 API 错误按原状态码返回给浏览器。

## webhook 要注意的地方

-   先对原始请求体验证 `Makernice-Signature`，再解析 JSON。签名密钥在[控制台设置](https://makernice.com/console/settings/)里，`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` 存进数据库），再让页面轮询你自己的接口，或用你现有的实时推送通知页面。

### 生成失败要付钱吗？

不用。创建时冻结金额，失败或取消就全额释放；生成完成后被内容审核拦截的仍会扣费。详见[失败不扣费](https://makernice.com/zh-CN/pricing/failed-generations/)。

## 相关链接

-   [API 文档：快速上手](https://makernice.com/zh-CN/docs/#quickstart)
-   [API 文档：Webhooks](https://makernice.com/zh-CN/docs/#webhooks)
-   [API 文档：错误码](https://makernice.com/zh-CN/docs/#errors)
-   [用 Python 调用 Makernice API](https://makernice.com/zh-CN/docs/python/)
-   [用 Node.js 调用 Makernice API](https://makernice.com/zh-CN/docs/node/)
-   [用 FastAPI 接入 Makernice API](https://makernice.com/zh-CN/docs/fastapi/)
-   [在 Claude Code、Cursor 等 AI 工具里用（MCP）](https://makernice.com/zh-CN/mcp/)
