FoxyLab

Developers / API

Make films from your own code

The same studio as the chat, over HTTP. Send a one-line ask or a site URL. Get a finished film in every format you need, and edit it with notes in plain words.

Base URLhttps://api.foxylab.ai/v1
  1. Step 1

    Get a key

    Make a key on the API keys page. The page shows it once.

  2. Step 2

    Start a film

    Send POST /films with a prompt, a url or both.

  3. Step 3

    Get the files

    Wait for film.ready, then call GET /films/{id}/renders.

Format
JSON in and JSON out, UTF-8
Ids
film_, evt_ and req_ come before the id
Times
UTC, in ISO 8601
Version
/v1 in the path. New fields can come at any time

Authentication

Send your API key in the Authorization header of every request, after the word Bearer. Make keys on the API keys page.

A key shows only once. Keep it on your server, in a secret manager or an environment variable. Do not put a key in a browser, an app or a repository.

Scopes

films:read
Read films, stages, versions and renders.
films:write
Make films, give notes and restore versions. Uses the usage limit.
Every request
GET /v1/films HTTP/1.1
Host: api.foxylab.ai
Authorization: Bearer $FOXYLAB_API_KEY

Usage limits

Every call draws on the same usage limit as the studio: the limit of your plan. A film made over the API counts the same as a film made in the chat.

There is no price for each video and no price for each token. Your team, your agents and your code all use one limit.

When what is left cannot pay for the work, calls that start work return 402 with usage_limit_reached. The message says what the work needs and what is left; add_usage_url is where to add extra usage, and resets_at says when the limit comes back. See the limit of each plan on Pricing.

Response402 Payment Required
{
  "error": {
    "type": "usage_limit_reached",
    "message": "A film needs about $2.40 of usage and the account has $0.35 left this week. Add extra usage at https://foxylab.ai/account/usage, or wait for the reset on Friday 04 December 2026 at 00:00 UTC.",
    "resets_at": "2026-12-01T00:00:00Z",
    "needed_usd": 2.4,
    "left_usd": 0.35,
    "add_usage_url": "https://foxylab.ai/account/usage",
    "request_id": "req_9Wc1Rb"
  }
}

POST/films

Create a film

Starts a new film from a one-line ask, a site or both. The call returns at once with the film id. The studio then runs the seven stages on its own.

The film also opens in your studio, so a person on your team can keep editing it in the chat. Send an Idempotency-Key header to make a retry safe. The same key within 24 hours returns the same film.

Body

promptstring
The ask in plain words, for example a one-line brief. Send prompt, url or both.
urlstring
A site for the studio to read. It takes the product, the colours, the fonts and the tone from it.
formatsstring[]
The sizes to make: 9:16, 4:5, 1:1, 16:9. The default is 9:16.
languagestring
The film's language, for example Ukrainian. The default is English.
voicestring
A voice name such as sulafat, a few words such as warm, slow, or none for music only. Without it, the studio picks a voice for the brand.
voice_modestring
Where the voice speaks. auto, the default: the studio decides whether a narrator helps this film, and where. parts: it stops where the picture and the music say more. full: every shot is spoken. none: no narrator.
musicstring
auto, the default: the studio decides whether the film needs a music bed, and picks it from free stock. on: always a bed. off: no music. A CC BY track comes with a credit line for the post; renders return it.
music_levelstring
How far the music sits under the voice: low, normal (the default) or high. The voice always stays at least 15 dB above it.
brand_kitstring
The id of a brand kit from your studio, for example kit_mossly. It wins over what the studio reads from url.
curl -X POST https://api.foxylab.ai/v1/films \
  -H "Authorization: Bearer $FOXYLAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A launch ad for Mossly, the plant care app",
    "url": "https://example.com",
    "formats": ["9:16", "16:9"],
    "voice": "sulafat",
    "voice_mode": "auto"
  }'
Response202 Accepted
{
  "id": "film_8Kq2xV",
  "object": "film",
  "status": "working",
  "stage": "brief",
  "version": 1,
  "formats": ["9:16", "16:9"],
  "studio_url": "https://foxylab.ai/studio?film=film_8Kq2xV",
  "created_at": "2026-11-04T10:12:03Z"
}

GET/films/{id}

Get a film

Returns the film and the status of each stage: waiting, working, done or failed. The film status is working until the render is done, then ready.

Poll every 10 seconds or more, or use webhooks and do not poll at all.

  1. Briefdone
  2. Branddone
  3. Ideadone
  4. Voicedone
  5. Shotsworking
  6. Reviewwaiting
  7. Renderwaiting
The film in the example: four stages are done, shots is at 60 %, about 7 min to go.

Path

idstringrequired
The film id, for example film_8Kq2xV.
curl https://api.foxylab.ai/v1/films/film_8Kq2xV \
  -H "Authorization: Bearer $FOXYLAB_API_KEY"
Response200 OK
{
  "id": "film_8Kq2xV",
  "object": "film",
  "title": "The Diva",
  "status": "working",
  "version": 1,
  "stages": [
    { "id": "brief", "status": "done" },
    { "id": "brand", "status": "done" },
    { "id": "idea", "status": "done" },
    { "id": "voice", "status": "done" },
    { "id": "shots", "status": "working", "progress": 0.6 },
    { "id": "review", "status": "waiting" },
    { "id": "render", "status": "waiting" }
  ],
  "eta_seconds": 420
}

GET/films

List films

Returns the films of your team, the newest first. Films made in the studio chat are in the list too.

Query

statusstring
Only films with this status: working, ready or failed.
limitinteger
How many films to return, from 1 to 100. The default is 20.
cursorstring
The next_cursor from the last page, to get the next page.
curl "https://api.foxylab.ai/v1/films?status=ready&limit=20" \
  -H "Authorization: Bearer $FOXYLAB_API_KEY"
Response200 OK
{
  "object": "list",
  "data": [
    {
      "id": "film_8Kq2xV",
      "title": "The Diva",
      "status": "ready",
      "version": 2,
      "updated_at": "2026-11-04T10:24:51Z"
    },
    {
      "id": "film_3Jd9pL",
      "title": "Run the rain",
      "status": "ready",
      "version": 1,
      "updated_at": "2026-11-02T16:40:12Z"
    }
  ],
  "next_cursor": null
}

POST/films/{id}/notes

Add a note

Gives the film a note in plain words, the same as a note in the chat. The studio runs again only the stages the note touches and makes a new version.

The old version stays. You can bring it back with restore.

Path

idstringrequired
The film id, for example film_8Kq2xV.

Body

textstringrequired
The note in plain words, as you write it in the studio chat.
refsobject[]
Points the note at a part of the film: { "shot": "s3" }, { "time": 14.7 } in seconds, or { "format": "16:9" }.
curl -X POST https://api.foxylab.ai/v1/films/film_8Kq2xV/notes \
  -H "Authorization: Bearer $FOXYLAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Slow the voice in shot 3. Keep the offer on screen 1 s longer.",
    "refs": [
      { "shot": "s3" },
      { "time": 14.7 }
    ]
  }'
Response202 Accepted
{
  "film_id": "film_8Kq2xV",
  "version": 2,
  "based_on": 1,
  "status": "working",
  "stages": ["voice", "shots", "review", "render"]
}

GET/films/{id}/versions

List versions

Returns every version of the film with the note that made it. Version 1 has no note.

Path

idstringrequired
The film id, for example film_8Kq2xV.
curl https://api.foxylab.ai/v1/films/film_8Kq2xV/versions \
  -H "Authorization: Bearer $FOXYLAB_API_KEY"
Response200 OK
{
  "object": "list",
  "current": 2,
  "data": [
    {
      "version": 2,
      "note": "Slow the voice in shot 3. Keep the offer on screen 1 s longer.",
      "status": "ready",
      "created_at": "2026-11-04T10:24:51Z"
    },
    {
      "version": 1,
      "note": null,
      "status": "ready",
      "created_at": "2026-11-04T10:12:03Z"
    }
  ]
}

POST/films/{id}/restore

Restore a version

Brings back an earlier version. The film gets a new version with the old content, so nothing is lost. The files are already rendered, so the film is ready at once.

Path

idstringrequired
The film id, for example film_8Kq2xV.

Body

versionintegerrequired
The version to bring back. The film gets a new version with the same content, so you keep the history.
curl -X POST https://api.foxylab.ai/v1/films/film_8Kq2xV/restore \
  -H "Authorization: Bearer $FOXYLAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "version": 1
  }'
Response200 OK
{
  "film_id": "film_8Kq2xV",
  "version": 3,
  "restored_from": 1,
  "status": "ready"
}

GET/films/{id}/renders

Get renders

Returns the files for each format and file type: MP4 for ads and social, MOV in ProRes for editors, WebM for your site and GIF for chats.

The links work for 24 hours. Call again for fresh links.

Path

idstringrequired
The film id, for example film_8Kq2xV.

Query

versioninteger
A version of the film. The default is the current version.
formatstring
Only this size, for example 16:9.
typestring
Only this file type: mp4, mov, webm or gif.
curl https://api.foxylab.ai/v1/films/film_8Kq2xV/renders \
  -H "Authorization: Bearer $FOXYLAB_API_KEY"
Response200 OK
{
  "film_id": "film_8Kq2xV",
  "version": 2,
  "renders": [
    {
      "format": "9:16",
      "width": 1080,
      "height": 1920,
      "files": [
        {
          "type": "mp4",
          "bytes": 6291456,
          "url": "https://files.foxylab.ai/film_8Kq2xV/v2/the-diva-9x16.mp4"
        },
        {
          "type": "webm",
          "bytes": 4404019,
          "url": "https://files.foxylab.ai/film_8Kq2xV/v2/the-diva-9x16.webm"
        }
      ]
    },
    {
      "format": "16:9",
      "width": 1920,
      "height": 1080,
      "files": [
        {
          "type": "mp4",
          "bytes": 7759462,
          "url": "https://files.foxylab.ai/film_8Kq2xV/v2/the-diva-16x9.mp4"
        }
      ]
    }
  ],
  "expires_at": "2026-11-05T10:24:51Z"
}

Webhooks

Webhooks tell your server about a film, so you do not poll. Add the endpoint URL on the API keys page. The page shows its signing secret, which starts with whsec_.

Events

film.stage
A stage is done or has failed.
film.ready
The film is reviewed and rendered in its first format.
render.ready
All files for one format are ready to download.

Check the signature

  1. 1Read the raw body before you parse it.
  2. 2Split the FoxyLab-Signature header into t and v1.
  3. 3Make an HMAC SHA-256 of t.body with your signing secret.
  4. 4Compare it with v1 in constant time. Refuse events older than 5 minutes.
  5. 5Answer with a 2xx status within 10 seconds. Do slow work after the answer.

A delivery without a 2xx answer comes again, with a longer wait each time, for 24 hours. An event can come more than once, so use its id to skip repeats.

Delivery
POST /hooks/foxylab HTTP/1.1
Content-Type: application/json
FoxyLab-Signature: t=1793787891,v1=bfc7dd7386a075c7d6c0607c6d11a6a81dfbe4537a99606830d459cc961aee20
Body
{
  "id": "evt_5Hn2Qw",
  "type": "film.ready",
  "created_at": "2026-11-04T10:24:51Z",
  "data": {
    "film_id": "film_8Kq2xV",
    "version": 1,
    "title": "The Diva",
    "seconds": 19
  }
}
import crypto from "node:crypto";

// rawBody: the request body as a string, before JSON.parse
export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=")),
  );
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const age = Date.now() / 1000 - Number(parts.t);
  return (
    age < 300 &&
    expected.length === parts.v1?.length &&
    crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(parts.v1),
    )
  );
}

Errors

Errors use HTTP status codes and one JSON shape. Send the request_id to support when you ask for help.

  • 400invalid_requestA field is missing or wrong. param names it.
  • 401unauthorizedThe key is missing, wrong or revoked.
  • 403forbiddenThe key does not have the scope for this call.
  • 404not_foundThere is no film with this id on your team.
  • 409film_not_readyThe film is still working. Wait for film.ready.
  • 422url_unreadableThe studio could not open the site. Send a prompt or a brand_kit.
  • 429rate_limitedToo many requests. Wait for the Retry-After seconds.
  • 402usage_limit_reachedThe week's usage cannot pay for this. The message says what it needs and what is left; add_usage_url is where to add extra usage, resets_at when the week resets (every Friday at 00:00 UTC).
  • 500server_errorOur fault. Retry with the same Idempotency-Key.
Response400 Bad Request
{
  "error": {
    "type": "invalid_request",
    "message": "Send prompt, url or both.",
    "param": "prompt",
    "request_id": "req_4Tz8Lm"
  }
}

Rate limits

Rate limits keep the service fast for everyone. They are not the usage limit of your plan.

Requests for each key
120 a minute
New films for each team
10 a minute
Films working at the same time
5 for each team

Every response has the RateLimit-* headers. After a 429, wait for the seconds in Retry-After, then try again.

Headers
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 120
RateLimit-Remaining: 0
RateLimit-Reset: 18
Retry-After: 18
NextUse it from an agentConnect Claude Code, Claude Desktop, Cursor or your own agent to the MCP server. The same films, the same usage limit.