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

# Use the Makernice API with Next.js

In a Next.js App Router app, add two route handlers: one quotes and creates the generation on the server so your API key never reaches the browser, and one receives the finished generation as a signed webhook. Both are plain TypeScript with no packages.

[Makernice Team](https://makernice.com/about/) · Updated November 11, 2026

## Before you start

-   Create an API key in the [console](https://makernice.com/console/keys/) and set `MAKERNICE_API_KEY`.
-   Copy the webhook signing secret from [console settings](https://makernice.com/console/settings/) into `MAKERNICE_WEBHOOK_SECRET`.
-   Set `APP_URL` to your app's public https address; the create route uses it to build the `callback_url`.

## The two route handlers

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

## What the code does

1.  The browser POSTs `{ "prompt": "…" }` to `/api/videos`. The route rejects an empty prompt with 400.
2.  **Quote**: `POST /v1/generations/quote` returns the exact amount as an integer string in micro-USD, locked for 12 minutes. Nothing is charged yet.
3.  **Create**: `POST /v1/generations` with the `quote_id`, the same input and an `Idempotency-Key` header. Retrying with the same key returns the same generation instead of a second one.
4.  **Wait**: the create call includes `callback_url`, so when the generation is `succeeded`, `failed` or `canceled` we POST it to `/api/makernice-webhook`.
5.  API errors such as 402 `insufficient_balance` or 429 `rate_limited` are passed back to the browser with the same status.

## Webhook notes

-   Verify `Makernice-Signature` over the raw request body before parsing it. The signing secret is in [console settings](https://makernice.com/console/settings/); `callback_url` deliveries use the same secret.
-   `callback_url` must be a public https address, so `localhost` does not receive webhooks during development. Poll instead, or expose your dev server through a tunnel.
-   Answer with any 2xx quickly and do heavy work afterwards. Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h (7 attempts in total), so the same event can arrive more than once: deduplicate on the event `id` (it equals the `Makernice-Event-Id` header).
-   The webhook route sets `runtime = "nodejs"` because it uses `node:crypto`; the Edge runtime does not have it. It reads the body with `arrayBuffer()` so the signature is checked over the exact bytes.

## FAQ

### Does this work with the Pages Router?

Yes. Put the same logic in an API route under `pages/api/`, and turn off the body parser for the webhook route (`export const config = { api: { bodyParser: false } }`) so you can read the raw body for the signature.

### How does the browser know the video is ready?

Store the result when the webhook arrives (for example in your database, keyed by the generation `id` returned from `/api/videos`), then let the page poll your own API or push it with your realtime setup.

### Do I pay if a generation fails?

No. The amount is held when you create the generation and released in full if it fails or is canceled. An output blocked by content moderation after the run is still charged. Details: [No charge for failed generations](https://makernice.com/pricing/failed-generations/).

## Related

-   [API docs: Quickstart](https://makernice.com/docs/#quickstart)
-   [API docs: Webhooks](https://makernice.com/docs/#webhooks)
-   [API docs: Errors](https://makernice.com/docs/#errors)
-   [Use the Makernice API with Python](https://makernice.com/docs/python/)
-   [Use the Makernice API with Node.js](https://makernice.com/docs/node/)
-   [Use the Makernice API with FastAPI](https://makernice.com/docs/fastapi/)
-   [Use it from Claude Code, Cursor and other AI tools (MCP)](https://makernice.com/mcp/)
