Media in your app
Let your users generate and view Windpaint images and video without ever exposing your API key: a backend proxy, signed URLs, and copying to your own storage.
Your API key can spend your organization’s credits and read every asset in it, so it must never reach a browser or a mobile app. The pattern in this recipe:
- The browser asks your backend to generate something.
- Your backend checks who the user is, submits the job with your key, and remembers which user owns the
request_id. - The browser polls your backend for the result.
- When the job completes, your backend hands the browser a signed URL for each output, valid for 15 minutes, which it can put straight into an
<img>or<video>tag.
If you need URLs that last longer than 15 minutes, copy the bytes to your own storage once and serve them from there.
How outputs are served
A completed job lists its outputs as {id, url, content_type, width, height, duration_s}. That url points at https://api.windpaint.ai/v1/generation/assets/{id}/content, which needs your key, so it’s no use to a browser. You have three options:
| Option | Endpoint | Lifetime | Use it when |
|---|---|---|---|
| Signed URL | GET /v1/generation/assets/{id}/url → {"data": {"url", "expires_in": 900}} | 15 minutes | Showing results in your UI. Mint a fresh one each time the page loads. |
| Proxy the bytes | Your route fetches the signed URL and streams it back | As long as your route exists | You need a stable URL on your own domain, or browser JavaScript has to fetch() the file |
| Copy to your storage | Fetch once, upload to your bucket or CDN | As long as you keep it | Public sharing, embedding in emails, heavy traffic |
Store the asset id in your database, never a signed URL. Assets stay in your Windpaint project (there’s no expiry today), so you can mint a new signed URL whenever you need one.
Signed URLs work in <img src>, <video src> and download links. Browser JavaScript that reads the bytes with fetch() (for example, to draw onto a canvas) can be blocked by CORS on the storage host; proxy the bytes through your backend for that case.
Next.js
A Next.js App Router project with three route handlers and one client component. The key lives only in server code.
Prerequisites
-
Next.js 15 or newer (App Router).
-
In
.env.local:.env.localWINDPAINT_API_KEY=aak_...
A server-only API helper
import "server-only";
const API = (process.env.WINDPAINT_API_URL ?? "https://api.windpaint.ai") + "/v1";
export class WindpaintError extends Error {
constructor(public status: number, public code: string | null, message: string) {
super(message);
}
}
export async function windpaint<T = any>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(API + path, {
...init,
cache: "no-store",
headers: {
Authorization: `Bearer ${process.env.WINDPAINT_API_KEY}`,
"Content-Type": "application/json",
...init.headers,
},
});
const body = await res.json().catch(() => null);
if (!res.ok) {
const error = body?.error;
// 401/403 bodies are {"status": false, "error": "Unauthorized"}; others are {"error": {code, message}}
throw typeof error === "object" && error
? new WindpaintError(res.status, error.code, error.message)
: new WindpaintError(res.status, null, String(error ?? res.statusText));
}
return body as T;
}
export async function signedUrl(assetId: string): Promise<string> {
const { data } = await windpaint<{ data: { url: string } }>(`/generation/assets/${assetId}/url`);
return data.url;
}
import "server-only" makes the build fail if a client component ever imports this file. Install it with npm install server-only.
Who owns which job
The status endpoint returns any job in your organization, so your backend has to check that the user asking for a result is the one who submitted it. Replace these placeholders with your session and database:
import "server-only";
// Replace with your auth: return the signed-in user's id, or null.
export async function currentUserId(): Promise<string | null> {
return "demo-user";
}
// Replace with a table: request_id -> user_id (plus whatever else you track).
const owners = new Map<string, string>();
export async function recordJob(requestId: string, userId: string) {
owners.set(requestId, userId);
}
export async function ownsJob(requestId: string, userId: string) {
return owners.get(requestId) === userId;
}
Submit: POST /api/images
import { NextResponse } from "next/server";
import { windpaint, WindpaintError } from "@/lib/windpaint";
import { currentUserId, recordJob } from "@/lib/store";
const ASPECT_RATIOS = new Set(["1:1", "4:5", "9:16", "16:9"]);
export async function POST(request: Request) {
const userId = await currentUserId();
if (!userId) return NextResponse.json({ error: "Sign in first" }, { status: 401 });
const { prompt, aspectRatio = "1:1" } = await request.json();
if (typeof prompt !== "string" || !prompt.trim() || prompt.length > 1000 || !ASPECT_RATIOS.has(aspectRatio)) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
try {
const job = await windpaint<{ request_id: string }>("/generation/capabilities/image.generate", {
method: "POST",
body: JSON.stringify({ prompt, aspect_ratio: aspectRatio }),
});
await recordJob(job.request_id, userId);
return NextResponse.json({ id: job.request_id }, { status: 202 });
} catch (err) {
if (err instanceof WindpaintError && err.code === "billing.insufficient_credits") {
console.error("Windpaint balance too low"); // alert yourself; don't tell users about your billing
return NextResponse.json({ error: "Generation is unavailable right now" }, { status: 503 });
}
if (err instanceof WindpaintError && err.status === 422) {
return NextResponse.json({ error: "That prompt couldn't be used" }, { status: 400 });
}
throw err;
}
}
The browser chooses only what you allow (a prompt and one of a few aspect ratios). Model, resolution and everything that affects cost stay on the server.
Status: GET /api/images/[id]
import { NextResponse } from "next/server";
import { windpaint, signedUrl } from "@/lib/windpaint";
import { currentUserId, ownsJob } from "@/lib/store";
type MediaRef = { id: string; content_type: string; width?: number; height?: number };
type Status = { status: string; outputs: MediaRef[]; error: string | null };
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const userId = await currentUserId();
if (!userId || !(await ownsJob(id, userId))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const job = await windpaint<Status>(`/generation/requests/${id}/status`);
const outputs =
job.status === "completed"
? await Promise.all(
job.outputs.map(async (o) => ({
assetId: o.id,
url: await signedUrl(o.id), // valid 15 minutes
contentType: o.content_type,
width: o.width,
height: o.height,
})),
)
: [];
return NextResponse.json({ status: job.status, outputs, error: job.status === "failed" ? "Generation failed" : null });
}
Return only what the page needs. The raw status includes your project id and API URLs your users don’t need to see.
Optional: a stable URL on your domain, GET /api/assets/[id]
For a URL that doesn’t expire, or one browser JavaScript can fetch(), stream the bytes through your server:
import { signedUrl } from "@/lib/windpaint";
import { currentUserId } from "@/lib/store";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
if (!(await currentUserId())) return new Response("Unauthorized", { status: 401 });
// Also check this user may see this asset, e.g. it's an output of a job they own.
const upstream = await fetch(await signedUrl(id)); // no Authorization header to the storage host
if (!upstream.ok || !upstream.body) return new Response("Not found", { status: 404 });
return new Response(upstream.body, {
headers: {
"Content-Type": upstream.headers.get("Content-Type") ?? "application/octet-stream",
"Cache-Control": "private, max-age=3600",
},
});
}
This puts the bandwidth on your server. For public or high-traffic media, copy to your own storage instead (below).
The page
"use client";
import { useState } from "react";
type Output = { assetId: string; url: string; width?: number; height?: number };
export default function Generate() {
const [prompt, setPrompt] = useState("");
const [state, setState] = useState<"idle" | "working" | "done" | "error">("idle");
const [outputs, setOutputs] = useState<Output[]>([]);
async function submit(e: React.FormEvent) {
e.preventDefault();
setState("working");
setOutputs([]);
const res = await fetch("/api/images", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, aspectRatio: "16:9" }),
});
if (!res.ok) return setState("error");
const { id } = await res.json();
let delay = 1500;
while (true) {
await new Promise((r) => setTimeout(r, delay));
const job = await (await fetch(`/api/images/${id}`)).json();
if (job.status === "completed") {
setOutputs(job.outputs);
return setState("done");
}
if (["failed", "nsfw", "canceled"].includes(job.status)) return setState("error");
delay = Math.min(delay * 1.5, 10000);
}
}
return (
<form onSubmit={submit}>
<input value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Describe an image" />
<button disabled={state === "working"}>{state === "working" ? "Generating..." : "Generate"}</button>
{state === "error" && <p>Something went wrong. Try a different prompt.</p>}
{outputs.map((o) => (
<img key={o.assetId} src={o.url} width={o.width} height={o.height} alt={prompt} />
))}
</form>
);
}
The src is a signed URL. If the user comes back later, call /api/images/{id} again for a fresh one rather than reusing the old URL.
Run npm run dev, open http://localhost:3000/generate, and submit a prompt. The image shows up a few seconds later; the Network tab shows only calls to your own /api routes and the signed storage URL, never your key.
Express
The same two routes for a Node backend (Node 18+ for built-in fetch):
import express from "express";
const API = (process.env.WINDPAINT_API_URL ?? "https://api.windpaint.ai") + "/v1";
const owners = new Map(); // replace with your database: request_id -> user_id
async function windpaint(path, init = {}) {
const res = await fetch(API + path, {
...init,
headers: { Authorization: `Bearer ${process.env.WINDPAINT_API_KEY}`, "Content-Type": "application/json" },
});
const body = await res.json().catch(() => null);
if (!res.ok) throw Object.assign(new Error("Windpaint API error"), { status: res.status, body });
return body;
}
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.userId = "demo-user"; // replace with your auth middleware
next();
});
app.post("/api/images", async (req, res, next) => {
try {
const { prompt } = req.body;
if (typeof prompt !== "string" || !prompt.trim()) return res.status(400).json({ error: "Invalid request" });
const job = await windpaint("/generation/capabilities/image.generate", {
method: "POST",
body: JSON.stringify({ prompt, aspect_ratio: "1:1" }),
});
owners.set(job.request_id, req.userId);
res.status(202).json({ id: job.request_id });
} catch (err) {
next(err);
}
});
app.get("/api/images/:id", async (req, res, next) => {
try {
if (owners.get(req.params.id) !== req.userId) return res.status(404).json({ error: "Not found" });
const job = await windpaint(`/generation/requests/${req.params.id}/status`);
const outputs =
job.status === "completed"
? await Promise.all(
job.outputs.map(async (o) => ({
assetId: o.id,
url: (await windpaint(`/generation/assets/${o.id}/url`)).data.url,
contentType: o.content_type,
})),
)
: [];
res.json({ status: job.status, outputs });
} catch (err) {
next(err);
}
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
WINDPAINT_API_KEY=aak_... node server.js
Copy to your own storage
For URLs that never expire, copy each output once when its job completes (from your status route, a webhook receiver, or a background job) and serve it from your bucket or CDN. With any S3-compatible bucket:
import "server-only";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { signedUrl } from "@/lib/windpaint";
const s3 = new S3Client({}); // region, endpoint and credentials from your environment
export async function copyOutput(assetId: string, contentType: string): Promise<string> {
const res = await fetch(await signedUrl(assetId));
if (!res.ok) throw new Error(`download failed: ${res.status}`);
const key = `generated/${assetId}`;
await s3.send(
new PutObjectCommand({
Bucket: process.env.MEDIA_BUCKET,
Key: key,
Body: Buffer.from(await res.arrayBuffer()),
ContentType: contentType,
}),
);
return key; // store this next to the asset id
}
Key the copy by asset id so copying the same output twice overwrites rather than duplicates.
Checklist
- The API key is only in server environment variables, never in client bundles,
NEXT_PUBLIC_*variables, or mobile apps. - Every status and asset route checks the user owns the job.
- Users choose only the inputs you allow; cost-affecting settings stay on the server.
- Your database stores
request_idand asset ids, not signed URLs. - A low balance shows up for you as an alert (set a low-balance threshold in Settings → Billing), and for users as a generic error.
- Jobs are async: video takes minutes, so for video show progress and let users leave and come back.