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.
Before you start
- Create an API key in the console and set
MAKERNICE_API_KEY. - Copy the webhook signing secret from console settings into
MAKERNICE_WEBHOOK_SECRET. - Set
APP_URLto your app's public https address; the create route uses it to build thecallback_url.
The two route handlers
// 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: 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
- The browser POSTs
{ "prompt": "…" }to/api/videos. The route rejects an empty prompt with 400. - Quote:
POST /v1/generations/quotereturns the exact amount as an integer string in micro-USD, locked for 12 minutes. Nothing is charged yet. - Create:
POST /v1/generationswith thequote_id, the same input and anIdempotency-Keyheader. Retrying with the same key returns the same generation instead of a second one. - Wait: the create call includes
callback_url, so when the generation issucceeded,failedorcanceledwe POST it to/api/makernice-webhook. - API errors such as 402
insufficient_balanceor 429rate_limitedare passed back to the browser with the same status.
Webhook notes
- Verify
Makernice-Signatureover the raw request body before parsing it. The signing secret is in console settings;callback_urldeliveries use the same secret. callback_urlmust be a public https address, solocalhostdoes 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 theMakernice-Event-Idheader). - The webhook route sets
runtime = "nodejs"because it usesnode:crypto; the Edge runtime does not have it. It reads the body witharrayBuffer()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.