Windpaint
Recipes

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:

  1. The browser asks your backend to generate something.
  2. Your backend checks who the user is, submits the job with your key, and remembers which user owns the request_id.
  3. The browser polls your backend for the result.
  4. 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:

OptionEndpointLifetimeUse it when
Signed URLGET /v1/generation/assets/{id}/url → {"data": {"url", "expires_in": 900}}15 minutesShowing results in your UI. Mint a fresh one each time the page loads.
Proxy the bytesYour route fetches the signed URL and streams it backAs long as your route existsYou need a stable URL on your own domain, or browser JavaScript has to fetch() the file
Copy to your storageFetch once, upload to your bucket or CDNAs long as you keep itPublic 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.local
    WINDPAINT_API_KEY=aak_...
    

A server-only API helper

lib/windpaint.ts
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:

lib/store.ts
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

app/api/images/route.ts
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]

app/api/images/[id]/route.ts
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:

app/api/assets/[id]/route.ts
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

app/generate/page.tsx
"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):

server.js
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:

lib/copy-output.ts
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_id and 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.

Next steps