Skip to content

POPCORN Developer API

Video and images,
from one API key.

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.

  • A USD balance on every key, topped up by card
  • Asynchronous jobs: poll, or receive a signed callback
  • OpenAPI 3.1 and an agent guide, generated from the live catalog
One job, end to end
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

Your first video in three steps.

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.

  1. 1

    Create a 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.

  2. 2

    Top up its balance

    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.

  3. 3

    Send the first request

    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

Six calls, one job.

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.

  1. 1

    List vendors

    GET /api/v1/vendors

    Returns the vendors your key may use.

  2. 2

    List models

    GET /api/v1/models

    Returns each model's params_schema, your prices and your limits. Filter with ?vendor=…&kind=video|image.

  3. 3

    Estimate

    POST /api/v1/estimate

    Send the generation body to get min, typical and max. Estimates may vary; the final charge uses the actual usage.

  4. 4

    Generate

    POST /api/v1/generations

    Send 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.

  5. 5

    Poll, or get a callback

    GET /api/v1/generations/{id}

    Poll every 10 seconds until the status is final, or set callback_url and receive one signed POST.

  6. 6

    Download the outputs

    GET outputs[].url

    On 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.
  • A finished job that did not succeed carries 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.
  • Only a queued job can be cancelled, with POST /generations/{id}/cancel; its hold is released.
  • Lists take ?limit (1 to 100, default 20) and ?cursor, and answer { data, next_cursor }. Stop when next_cursor is null.

03 / Assets

Reuse reference media.

Register an image, a video or an audio file once, then use it in any job of the same key.

  • Send JSON with a public https url, or upload the file as multipart/form-data. Uploads are limited to about 4 MB; send a url for larger files.
  • The answer is 201 when the asset is already active, or 202 while it is processing. Poll GET /assets/{id} every 10 seconds until it is active.
  • Use it inside params instead of a url: "images": [{ "asset": "ast_…", "role": "reference_image" }].
  • Virtual portraits and non-person references only. A real human face is refused with 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

Retry without paying twice.

Send an Idempotency-Key (1 to 64 characters, for example a UUID) on POST /generations. For 24 hours:

  • The same key with the same body returns the original job again, with HTTP 200 instead of 202. Nothing new is held or charged.
  • The same key with a different body returns 409 IDEMPOTENCY_MISMATCH.
  • While the first request is still running, a repeat returns 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

Branch on the code, not the sentence.

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_…"
}

Retries

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.

Every error code
HTTPCodeMeaningWhat to do
400INVALID_REQUESTThe body, a query value or a model parameter is missing or invalid. details.issues lists each problem as {path, message}.Fix the request.
400MODEL_REQUIREDThe key may use more than one model, so `model` must be set.Fix the request.
400DURATION_REQUIRED`duration` is required for this mode.Fix the request.
400UNSUPPORTED_PARAMETERA resolution, ratio, size or duration is not available for this model.Fix the request.
400INPUT_REJECTEDAn input file is not supported (format, size, aspect, duration or count).Change the input.
400INVALID_CALLBACK_URLcallback_url must be a public https URL without credentials.Fix the request.
400ASSET_NOT_READYA 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.
400ASSETS_NOT_SUPPORTEDThis vendor does not support reference assets.Do not retry.
400REAL_FACE_NOT_ALLOWEDThe file looks like a real person or did not pass review. Only virtual portraits and non-person references are accepted.Use a different file.
400CONTENT_REJECTEDThe 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.
401INVALID_API_KEYThe Authorization header is missing, malformed, or the key is not valid.Do not retry with the same key.
402INSUFFICIENT_BALANCEThe key's available balance does not cover the hold for this job. details has needed and available.Top up, then retry.
402BALANCE_NEGATIVEThe key's balance is below zero.Top up, then retry.
403MODEL_NOT_ALLOWED_FOR_KEYThe model exists but this key's allowlist does not include it.Use another model or change the key's allowlist.
403ACCOUNT_SUSPENDEDAPI access on this account is paused.Contact support.
403API_ACCESS_DISABLEDAPI access is not enabled for this account.Contact support.
403KEY_BLOCKEDThis key is blocked, by an admin or automatically after repeated content refusals.Contact support or use another key.
403KEY_NOT_ACTIVEThe key is blocked or being deleted.Use another key.
404NOT_FOUNDNo such job or asset on this key (or the API is not available).Do not retry.
404MODEL_NOT_FOUNDNo model with that id is available.List models, then retry.
404VENDOR_NOT_FOUNDThat vendor is not available.List vendors, then retry.
409IDEMPOTENCY_MISMATCHThis Idempotency-Key was already used with a different request body.Use a new key for a new request.
409IDEMPOTENCY_IN_PROGRESSA request with this Idempotency-Key is still being processed.Retry the same request shortly.
409NOT_CANCELLABLEThe job has already started; only queued jobs can be cancelled.Do not retry.
413FILE_TOO_LARGEThe uploaded file is above the upload limit. details.max_bytes has the limit.Send a public https url instead.
409ASSET_IN_USEA queued or running job references this asset.Delete after the job finishes.
409STORAGE_LIMITThe account's reference-asset storage is full. details.max_bytes has the limit.Delete assets you no longer need, then retry.
429RATE_LIMITEDToo 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.
429CONCURRENCY_LIMITThe account or key already runs its maximum number of jobs on this model.Wait Retry-After seconds or until a job finishes.
429QUEUE_FULLThe 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.
500INTERNAL_ERRORAn unexpected error on our side.Retry with backoff; quote request_id to support if it persists.
503VENDOR_UNAVAILABLEThe model is temporarily unavailable.Wait Retry-After seconds.
503CAPACITY_UNAVAILABLESomething 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.
503PROVISIONINGYour key's resources are still being set up.Wait Retry-After seconds.
503RATE_LIMIT_UNAVAILABLEThe rate limiter could not be checked, so the request was not accepted.Wait Retry-After seconds.
503PRICING_UNAVAILABLEThe model cannot be priced right now, so nothing was started or charged.Wait Retry-After seconds; contact support if it persists.
503SERVICE_UNAVAILABLEThe API could not confirm it is available right now, so nothing was accepted. Your jobs and assets are unaffected.Wait Retry-After seconds.
503UPLOAD_TIMEOUTFetching and storing the asset file took too long, so nothing was registered.Retry, or send a smaller file or a faster URL.

06 / Callbacks

Signed callbacks.

Set callback_url (public https) and we send one POST when the job reaches a final status. Polling still works as a fallback.

Headers

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 every delivery

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) <= 300

07 / Money and limits

Money, limits and tiers.

Money units

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.

Rate and concurrency limits

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.

Spend tiers

Prices drop as your average monthly spend rises. GET /balance shows your tier and how much you have saved.

08 / Machine-readable

OpenAPI and llms.txt.

Both are generated from the live catalog, so they always match the models on this page.

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.

llms.txt

One plain-text guide an AI agent can follow from the first call to the downloaded file.

/llms.txt Available when the API opens.