@popnow/proxy is a small server-side proxy for the PopNow API: it lets a browser,
mobile or desktop app generate AI video, images and music through your own backend, while the API key stays
on the server.
PopNow is a browser-based AI studio for videos, images and music: image-to-video, text-to-video, AI images, music and speech, one-tap trending video effects and image styles, with models such as Kling, Seedance, Wan, Vidu, MiniMax, Nano Banana, GPT Image, Flux and Suno.
A key compiled into a client is a public key. Anyone can open the bundle or watch one request, and then spend
your API balance until it runs out. The fix is not obfuscation; it is to never put the key there. The app calls
your server, and your server adds the credential. This package is that server-side hop, for Next.js,
Express and any runtime that speaks fetch's Request and Response. It has no runtime dependencies.
1. Install (Node.js 22.12 or newer):
npm install @popnow/proxy2. Set your API key on the server from popnow.ai/api/console/keys.
It is read from POPNOW_API_KEY and never sent to the client. Requests spend the key's USD API balance, which is
separate from website credits; add funds in API billing.
POPNOW_API_KEY="sk-popnow-..."3. Add one route (Next.js App Router shown) and call it from the browser:
// app/api/popnow/proxy/route.ts
export { GET, POST, PUT, PATCH, DELETE } from "@popnow/proxy/nextjs";
export const runtime = "nodejs";// In the browser: create an image task. The key never leaves your server.
const response = await fetch("/api/popnow/proxy", {
method: "POST",
headers: {
"content-type": "application/json",
"x-popnow-target-url": "https://api.popnow.ai/api/v1/jobs/createTask",
"idempotency-key": crypto.randomUUID(),
},
body: JSON.stringify({
model: "alibaba/z-image-turbo/text-to-image",
input: { prompt: "A small red fox curled up in a teacup, soft morning light" },
}),
});
const { code, msg, data } = await response.json();
if (code !== 200) throw new Error(msg);
console.log(data.taskId); // poll GET /api/v1/jobs/recordInfo?taskId=... through the same route| Feature | What it does |
|---|---|
| Key stays on the server | Adds Authorization: Bearer $POPNOW_API_KEY; client-supplied credentials are always dropped |
| Origin allow-list | Forwards only to https://api.popnow.ai by default, compared on the exact origin |
| Route allow-list | Forwards only the documented public API routes, each with its documented method |
| Framework adapters | Next.js App Router, Express, and a generic (Request) => Promise<Response> handler |
| Streaming pass-through | Request and response bodies are streamed, not buffered; redirects are passed back, not followed |
| Envelope-shaped refusals | Refusals use the API's {code, msg, request_id} envelope, so clients need one parsing path |
| Explicit User-Agent | Every forwarded request says popnow-proxy/<version> |
| Key rotation | apiKey can be a function, read on every request |
By default the proxy forwards exactly the routes of the public API contract
(openapi.yaml) and refuses everything else with 403:
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/jobs/quote |
Price a request in USD without holding funds |
POST |
/api/v1/jobs/createTask |
Create a generation task |
GET |
/api/v1/jobs/recordInfo |
Poll one task |
GET |
/api/v1/jobs |
List tasks |
POST |
/api/v1/jobs/retry |
Create a new task from a failed one |
POST |
/api/v1/jobs/purge |
Destroy a finished task's stored content |
GET |
/api/v1/models, /api/v1/models/{model} |
Models, prices and input schemas |
GET |
/api/v1/usage, /api/v1/chat/credit |
Usage and the API USD balance |
POST |
/api/v1/common/upload-url |
Upload ticket |
POST |
/api/v1/files/{fileId}/commit |
Commit an uploaded file |
POST |
/api/v1/files/import |
Import LoRA weights from a link |
POST |
/api/v1/common/download-url |
Signed download link for a result |
GET POST |
/api/v1/assets |
List or save assets |
GET |
/api/v1/assets/usage, /api/v1/assets/entities, /api/v1/assets/entities/{entityId} |
Library usage and characters |
GET PATCH DELETE |
/api/v1/assets/{assetId} |
Get, rename or delete one asset |
GET |
/v1/models |
OpenAI-compatible model list |
POST |
/v1/videos |
OpenAI-compatible video creation |
GET |
/v1/videos/{videoId}, /v1/videos/{videoId}/content |
OpenAI-compatible status and download |
Narrow the list to what your app actually calls:
import { createRoute } from "@popnow/proxy/nextjs";
export const { GET, POST } = createRoute({
allowedRoutes: [
{ method: "POST", path: "/api/v1/jobs/quote" },
{ method: "POST", path: "/api/v1/jobs/createTask" },
{ method: "GET", path: "/api/v1/jobs/recordInfo" },
],
});
export const runtime = "nodejs";Patterns match whole path segments; {name} matches one segment and {name+} (last only) matches the rest
of the path. DEFAULT_ROUTES is exported if you want to extend the defaults instead.
// app/api/popnow/proxy/route.ts
export { GET, POST, PUT, PATCH, DELETE } from "@popnow/proxy/nextjs";
export const runtime = "nodejs";runtime = "nodejs" is not optional. Edge runtimes do not see server-only environment variables, so the proxy
would answer 500 in production while working perfectly on your machine. To pass options, build the handlers
yourself with createRoute({ ... }) as shown above.
import { createExpressHandler } from "@popnow/proxy/express";
app.all("/api/popnow/proxy", createExpressHandler());Mount it before any body parser. express.json() consumes the request body, and the proxy would then
forward an empty one, which surfaces as a missing-parameter error that points nowhere near your middleware
order.
import { createProxyHandler } from "@popnow/proxy";
const handle = createProxyHandler({ apiKey: () => process.env.POPNOW_API_KEY });
// handle(request: Request) => Promise<Response>An upload is three requests, and only two of them go through the proxy:
async function viaProxy(target: string, body?: unknown) {
const response = await fetch("/api/popnow/proxy", {
method: "POST",
headers: { "content-type": "application/json", "x-popnow-target-url": target },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const json = await response.json();
if (json.code !== 200) throw new Error(json.msg);
return json.data;
}
// `file` is a File, for example from <input type="file">.
// 1. A ticket, through the proxy. contentType must be a type the API accepts (image/png, video/mp4, ...).
const ticket = await viaProxy("https://api.popnow.ai/api/v1/common/upload-url", {
contentType: file.type,
bytes: file.size,
});
// 2. The bytes, straight to storage - NOT through the proxy.
const headers = new Headers(ticket.headers);
headers.delete("Content-Length"); // browsers derive it from the File
const put = await fetch(ticket.uploadUrl, { method: ticket.method, headers, body: file });
if (!put.ok) throw new Error(`upload failed with HTTP ${put.status}`);
// 3. Commit, through the proxy. Its `uri` (popnow://f/...) goes into the task input, e.g. `image_url`.
const { uri } = await viaProxy(`https://api.popnow.ai/api/v1/files/${ticket.fileId}/commit`);Step 2 must not go through the proxy, and the proxy refuses it anyway: the presigned uploadUrl points at a
storage host, not at https://api.popnow.ai, and it is a credential in its own right.
GET /v1/videos/{videoId}/content answers with a redirect to a short-lived signed download link. The proxy
passes the redirect back unchanged, so the browser downloads the file directly from storage, without your key.
| Option | Default | Meaning |
|---|---|---|
apiKey |
process.env.POPNOW_API_KEY |
A string, or a function read on every request (for rotation) |
allowedOrigins |
["https://api.popnow.ai"] |
Origins it may forward to; widen only for local development |
allowedRoutes |
DEFAULT_ROUTES |
Method and path patterns it may forward |
timeoutMs |
120000 |
How long to wait for the upstream response headers |
fetch |
globalThis.fetch |
An injected fetch, for tests |
An empty allowedOrigins or allowedRoutes throws a ProxyConfigurationError instead of meaning "allow
everything". timeoutMs covers only the wait for the response headers; once they arrive, the body is passed
through for as long as it takes.
| Situation | Status |
|---|---|
No x-popnow-target-url header, or not a URL |
400 |
| Target origin not allow-listed | 403 |
| Method and path not allow-listed | 403 |
| No key configured on the server | 500 |
| Upstream could not be reached | 502 |
| Upstream sent no headers in time | 504 |
Refusals use the API's envelope, {"code": 403, "msg": "...", "request_id": null}, so a client needs no
separate parsing path. Every other status and body comes from the PopNow API itself; see
popnow.ai/docs/errors for its codes.
The origin allow-list is the whole point. Forwarding to whatever the header says would let a stranger make
your server hand your key to a host they control. The comparison is on the exact origin, never a prefix:
https://api.popnow.ai.attacker.example starts with our domain too.
Client-supplied authorization, x-api-key, x-goog-api-key, cookie and user-agent headers are dropped
before forwarding, so the proxy cannot be turned into an open relay for someone else's credentials.
- The client's
accept-encodingis not forwarded. The server-side fetch negotiates compression itself and decodes the reply, so the upstreamcontent-encodingis not passed back either. - Hop-by-hop headers (
connection,transfer-encoding,host,content-lengthand the like) are recomputed for each hop rather than copied. user-agentis set topopnow-proxy/<version> (+https://github.com/popnow-ai/popnow-proxy).- Everything else, such as
content-type,idempotency-key,x-popnow-retentionand response headers likeretry-after, passes through unchanged.
This hop authenticates to PopNow. It does not authenticate your users: anyone who can reach the route can
spend your API balance. Put your own session check, rate limit and per-user quota in front of it, exactly as you
would for any endpoint that costs money. Use allowedRoutes to expose only what your app needs, for example
no jobs/purge or asset deletion.
Put the key on your server and forward the browser's requests through a route that adds it. With this package
that is one Next.js route file or one Express line; the browser sends the real API URL in the
x-popnow-target-url header and never sees sk-popnow-....
Upload the image through the proxy (ticket and commit) with the bytes going straight to storage, then call
POST /api/v1/jobs/createTask with an image-to-video model and the committed popnow://f/... URI as
image_url, and poll GET /api/v1/jobs/recordInfo?taskId=.... The live model list is GET /api/v1/models.
No. Only https://api.popnow.ai (exact origin) and the documented API routes are forwarded; everything else is
refused with 403 before the key is attached. It is still your endpoint, so add your own user authentication
and rate limits in front of it.
On any runtime with the standard Request, Response and fetch and access to a server-side secret. On
Next.js set runtime = "nodejs"; on other runtimes pass the key explicitly with
createProxyHandler({ apiKey: () => env.POPNOW_API_KEY }).
Forward POST /api/v1/jobs/quote with the same model and input first. It returns the exact USD price
without holding anything: maxChargeUsd (the most it can cost, which is held on acceptance) and
expectedChargeUsd (the typical charge). Both are decimal strings with up to nine decimal places; show them as
they are rather than parsing them into floats. To lock that price, send createTask with the same body plus
quoteId and expectedCostUsd: quote.maxChargeUsd; the proxy forwards both unchanged. Prices per model are on
popnow.ai/api/pricing. The integer credit fields such as maxCredits are
deprecated; read the USD fields.
No; it forwards each request once. Send an Idempotency-Key with createTask so a retried request from the
browser never creates a second task.
git clone https://github.com/popnow-ai/popnow-proxy && cd popnow-proxy
npm install
npm run typecheck && npm testTests use loopback servers and injected fetch functions and never call the live API.
- Website: popnow.ai · API pricing: popnow.ai/api/pricing · API billing: popnow.ai/api/console/billing
- Docs: popnow.ai/docs · API reference: popnow.ai/docs/openapi (openapi.yaml) · llms.txt
- Developer MCP server for AI coding agents (API key, USD): popnow.ai/api/mcp
- Agent skills for developers: popnow.ai/api/skills
- Other PopNow repositories: popnow-js · popnow-cli · popnow-api-mcp · popnow-skills · popnow-python · popnow-api · popnow-blender · popnow-comfyui · n8n-nodes-popnow · popnow-go · popnow-php
MIT © PopNow