OpenAPI 3.1
Every path, schema and error, with each model's params as its own schema. Import it into an API client or a code generator.
/api/v1/openapi.json Available when the API opens.
POPCORN Developer API
Generate video and images over HTTPS. Every API key holds its own prepaid USD balance, so you pay only for the jobs you run, at clear prices per model.
The Developer API is not available yet. You can read how it works now; keys, the live model list and the machine-readable docs open at launch.
POST /api/v1/generations
→ 202 { "id": "job_…", "status": "queued" }
GET /api/v1/generations/job_…
→ 200 { "status": "succeeded",
"outputs": [{ "kind": "video", "url": "https://…" }],
"charged": { "usd": "…", "micros": "…" } }Outputs stay on our storage, so there is no download deadline.
01 / Quickstart
Everything runs on your server with one key. Keep the key secret: never put it in a browser, an app or a repository.
Every call sends Authorization: Bearer pk_live_…. A missing or invalid key returns 401 INVALID_API_KEY.
Sign in and open Account → API. Create a key and copy its secret: it is shown only once. Each key has its own balance, jobs and assets.
Add US dollars to the key by card on the same page. Balances never expire. Turn on auto-recharge so a low balance never stops your jobs.
Start a generation, then poll it every 10 seconds until it is final. Put your key in POPCORN_API_KEY first.
curl
# 1. List the models your key may use
curl https://popcornstudio.ai/api/v1/models \
-H "Authorization: Bearer $POPCORN_API_KEY"
# 2. Start a generation
curl https://popcornstudio.ai/api/v1/generations \
-H "Authorization: Bearer $POPCORN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"model":"<model id>","params":{"prompt":"A paper boat drifting down a rainy street at dusk"}}'
# 3. Poll every 10 s until the status is final
curl https://popcornstudio.ai/api/v1/generations/job_... \
-H "Authorization: Bearer $POPCORN_API_KEY"Node.js
// quickstart.mjs (Node 18+)
import { randomUUID } from "node:crypto";
const base = "https://popcornstudio.ai/api/v1";
const headers = { Authorization: `Bearer ${process.env.POPCORN_API_KEY}`, "Content-Type": "application/json" };
const res = await fetch(`${base}/generations`, {
method: "POST",
headers: { ...headers, "Idempotency-Key": randomUUID() },
body: JSON.stringify({
"model": "<model id>",
"params": {
"prompt": "A paper boat drifting down a rainy street at dusk"
}
}),
});
let job = await res.json();
if (!res.ok) throw new Error(`${job.code}: ${job.error}`);
while (job.status === "queued" || job.status === "running") {
await new Promise((r) => setTimeout(r, 10000));
job = await (await fetch(`${base}/generations/${job.id}`, { headers })).json();
}
console.log(job.status, job.outputs.map((o) => o.url), job.error);02 / The flow
Jobs are asynchronous. The estimate is free and has no side effects. A generation holds the estimate's maximum from your balance and charges only the actual usage when it finishes.
GET /api/v1/vendorsReturns the vendors your key may use.
GET /api/v1/modelsReturns each model's params_schema, your prices and your limits. Filter with ?vendor=…&kind=video|image.
POST /api/v1/estimateSend the generation body to get min, typical and max. Estimates may vary; the final charge uses the actual usage.
POST /api/v1/generationsSend it with an Idempotency-Key to get 202 and a job id. The estimate's maximum, plus a small padding, is held from the balance.
GET /api/v1/generations/{id}Poll every 10 seconds until the status is final, or set callback_url and receive one signed POST.
GET outputs[].urlOn succeeded, outputs[] holds each file's url. Outputs stay on our storage, so there is no deadline.
queued, running are in progress; succeeded, failed, expired, cancelled are final. Failed, expired and cancelled jobs are not charged.error.code, one of: INPUT_REJECTED, CONTENT_REJECTED, CONTENT_RESTRICTED, VENDOR_UNAVAILABLE, EXPIRED, STORAGE_FAILED, CAPACITY_UNAVAILABLE, CANCELLED, RATE_LIMITED, VENDOR_ERROR, ACCOUNT_SUSPENDED, KEY_BLOCKED, KEY_NOT_ACTIVE, MODEL_UNAVAILABLE, SERVICE_PAUSED, ENDPOINT_LIMIT.CONTENT_REJECTED means content review refused the request or its result for sexual content, content involving minors or violence; repeated refusals like it block every key of the account. CONTENT_RESTRICTED means it was refused for another reason, for example a real person, a brand or protected material; it never counts toward a block. Neither is charged.POST /generations/{id}/cancel; its hold is released.?limit (1 to 100, default 20) and ?cursor, and answer { data, next_cursor }. Stop when next_cursor is null.03 / Assets
Register an image, a video or an audio file once, then use it in any job of the same key.
url, or upload the file as multipart/form-data. Uploads are limited to about 4 MB; send a url for larger files.GET /assets/{id} every 10 seconds until it is active."images": [{ "asset": "ast_…", "role": "reference_image" }].REAL_FACE_NOT_ALLOWED; the file's content is checked, not its name.DELETE /assets/{id} is permanent and cannot be undone. It answers 409 ASSET_IN_USE while a queued or running job uses the asset.04 / Idempotency
Send an Idempotency-Key (1 to 64 characters, for example a UUID) on POST /generations. For 24 hours:
409 IDEMPOTENCY_MISMATCH.409 IDEMPOTENCY_IN_PROGRESS; send the same request again shortly.Always reuse the same key when you retry after a timeout or a network error.
05 / Errors
Every error has the same shape. Every response carries X-Request-Id; quote it to support.
Error
{
"error": "<sentence>",
"code": "<STABLE_CODE>",
"vendor_code": "…",
"details": { … },
"request_id": "req_…"
}Retry only 429, 500 and 503. Wait Retry-After seconds when it is present; otherwise back off: 10 s, 20 s, 40 s and so on. Do not retry other 4xx codes without changing the request.
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
400 | INVALID_REQUEST | The body, a query value or a model parameter is missing or invalid. details.issues lists each problem as {path, message}. | Fix the request. |
400 | MODEL_REQUIRED | The key may use more than one model, so `model` must be set. | Fix the request. |
400 | DURATION_REQUIRED | `duration` is required for this mode. | Fix the request. |
400 | UNSUPPORTED_PARAMETER | A resolution, ratio, size or duration is not available for this model. | Fix the request. |
400 | INPUT_REJECTED | An input file is not supported (format, size, aspect, duration or count). | Change the input. |
400 | INVALID_CALLBACK_URL | callback_url must be a public https URL without credentials. | Fix the request. |
400 | ASSET_NOT_READY | A referenced asset does not exist on this key or is not active yet. details.assets lists them. | Wait until the asset is active, then retry. |
400 | ASSETS_NOT_SUPPORTED | This vendor does not support reference assets. | Do not retry. |
400 | REAL_FACE_NOT_ALLOWED | The file looks like a real person or did not pass review. Only virtual portraits and non-person references are accepted. | Use a different file. |
400 | CONTENT_REJECTED | The request asks for sexual content involving minors and was refused before anything started. Nothing was charged. Repeated content refusals block every key of the account. | Change the request. |
401 | INVALID_API_KEY | The Authorization header is missing, malformed, or the key is not valid. | Do not retry with the same key. |
402 | INSUFFICIENT_BALANCE | The key's available balance does not cover the hold for this job. details has needed and available. | Top up, then retry. |
402 | BALANCE_NEGATIVE | The key's balance is below zero. | Top up, then retry. |
403 | MODEL_NOT_ALLOWED_FOR_KEY | The model exists but this key's allowlist does not include it. | Use another model or change the key's allowlist. |
403 | ACCOUNT_SUSPENDED | API access on this account is paused. | Contact support. |
403 | API_ACCESS_DISABLED | API access is not enabled for this account. | Contact support. |
403 | KEY_BLOCKED | This key is blocked, by an admin or automatically after repeated content refusals. | Contact support or use another key. |
403 | KEY_NOT_ACTIVE | The key is blocked or being deleted. | Use another key. |
404 | NOT_FOUND | No such job or asset on this key (or the API is not available). | Do not retry. |
404 | MODEL_NOT_FOUND | No model with that id is available. | List models, then retry. |
404 | VENDOR_NOT_FOUND | That vendor is not available. | List vendors, then retry. |
409 | IDEMPOTENCY_MISMATCH | This Idempotency-Key was already used with a different request body. | Use a new key for a new request. |
409 | IDEMPOTENCY_IN_PROGRESS | A request with this Idempotency-Key is still being processed. | Retry the same request shortly. |
409 | NOT_CANCELLABLE | The job has already started; only queued jobs can be cancelled. | Do not retry. |
413 | FILE_TOO_LARGE | The uploaded file is above the upload limit. details.max_bytes has the limit. | Send a public https url instead. |
409 | ASSET_IN_USE | A queued or running job references this asset. | Delete after the job finishes. |
409 | STORAGE_LIMIT | The account's reference-asset storage is full. details.max_bytes has the limit. | Delete assets you no longer need, then retry. |
429 | RATE_LIMITED | Too many requests per minute: generations per key and model, or new assets per key, per account or overall. details.limit has the limit. | Wait Retry-After seconds. |
429 | CONCURRENCY_LIMIT | The account or key already runs its maximum number of jobs on this model. | Wait Retry-After seconds or until a job finishes. |
429 | QUEUE_FULL | The account already has its maximum number of jobs waiting to start. details.limit has the limit; nothing was held. | Wait Retry-After seconds or until some of your waiting jobs have started. |
500 | INTERNAL_ERROR | An unexpected error on our side. | Retry with backoff; quote request_id to support if it persists. |
503 | VENDOR_UNAVAILABLE | The model is temporarily unavailable. | Wait Retry-After seconds. |
503 | CAPACITY_UNAVAILABLE | Something went wrong on our side setting up your key's resources (our vendor capacity is used up). | Do not retry; contact support with the request id. |
503 | PROVISIONING | Your key's resources are still being set up. | Wait Retry-After seconds. |
503 | RATE_LIMIT_UNAVAILABLE | The rate limiter could not be checked, so the request was not accepted. | Wait Retry-After seconds. |
503 | PRICING_UNAVAILABLE | The model cannot be priced right now, so nothing was started or charged. | Wait Retry-After seconds; contact support if it persists. |
503 | SERVICE_UNAVAILABLE | The API could not confirm it is available right now, so nothing was accepted. Your jobs and assets are unaffected. | Wait Retry-After seconds. |
503 | UPLOAD_TIMEOUT | Fetching and storing the asset file took too long, so nothing was registered. | Retry, or send a smaller file or a faster URL. |
06 / Callbacks
Set callback_url (public https) and we send one POST when the job reaches a final status. Polling still works as a fallback.
Content-Type: application/json
Popcorn-Event: generation.succeeded | generation.failed | generation.expired | generation.cancelled
Popcorn-Delivery: dlv_…
Popcorn-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(webhookSecret, t + "." + rawBody)>Events: generation.succeeded, generation.failed, generation.expired, generation.cancelled.
Verify on the raw body before parsing, compare in constant time, and reject a timestamp more than 300 seconds from now. The webhook secret (whsec_…) is per key, in Account → API.
Answer any 2xx within 10 seconds, then do the work. Failed deliveries are retried after 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h, 24 h, then stop.
Delivery is at least once: deduplicate by job id plus event.
Node.js (Express)
const crypto = require("crypto");
app.post("/hooks/popcorn", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Popcorn-Signature") || "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
const expected = crypto.createHmac("sha256", process.env.POPCORN_WEBHOOK_SECRET).update(t + "." + req.body.toString("utf8")).digest("hex");
const given = Buffer.from(parts.v1 || "", "hex");
const ok = given.length === 32 && crypto.timingSafeEqual(given, Buffer.from(expected, "hex")) && Math.abs(Date.now() / 1000 - t) <= 300;
if (!ok) return res.status(400).end();
res.status(200).end();
const event = JSON.parse(req.body.toString("utf8")); // { event, id, status, outputs, ... }
});Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", "")) and abs(time.time() - t) <= 30007 / Money and limits
Every amount is USD as { "usd": "1.23", "micros": "1230000" }. micros is exact (1 USD = 1,000,000 micros) and sent as an integer string; usd is rounded to cents for display. Do arithmetic on micros.
A job holds the estimate's maximum plus a small padding, then charges the actual usage and releases the rest. Failed, expired and cancelled jobs are not charged. GET /balance shows the balance, the amount held and what is available.
402 INSUFFICIENT_BALANCE / BALANCE_NEGATIVE means a person must top up the key.
Balances never expire. Top-ups are not refunded as cash, and deleting a key forfeits its balance. See the Terms for the full billing rules.
Limits are per model and depend on your tier. GET /models shows yours in limits { max_concurrency, max_rpm }.
Too many requests per minute for a key and model returns 429 RATE_LIMITED with Retry-After.
Too many running jobs on a model returns 429 CONCURRENCY_LIMIT; wait for a job to finish.
When shared capacity is busy, an accepted job stays queued and starts by itself; keep polling.
Prices drop as your average monthly spend rises. GET /balance shows your tier and how much you have saved.
08 / Machine-readable
Both are generated from the live catalog, so they always match the models on this page.
Every path, schema and error, with each model's params as its own schema. Import it into an API client or a code generator.
/api/v1/openapi.json Available when the API opens.
One plain-text guide an AI agent can follow from the first call to the downloaded file.
/llms.txt Available when the API opens.