# mystage.film API: guide for AI agents and MCP tools You are calling a paid video production API on behalf of a human. Follow these rules exactly. ## Basics - Base URL: `https://mystage.film/api/v1`. Machine readable spec: `https://mystage.film/api/v1/openapi.json` (public, no key). This guide: `https://mystage.film/llms.txt`. - Every request: header `Authorization: Bearer `. Never print, log or echo the key. - Every POST: header `Idempotency-Key: ` per operation. On a timeout or 5xx, retry with the SAME key. Never reuse a key for a different request. - JSON bodies are strict: unknown fields answer 400. - Money is integer cents, EUR (`1359` = 13.59 EUR). ## Money rules - `POST /orders` charges the human's wallet immediately. A paid change request (`POST /orders/{id}/change-requests`) can charge it too. - Before any charging call: show the product title, length, add ons and the exact price from `GET /products/{id}`, plus the balance from `GET /me`, and get an explicit yes from the human. Never place an order on your own initiative, never retry a 402. - `402 insufficient_credit`: nothing was charged. Tell the human to top up; after that use a NEW Idempotency-Key. - `POST /orders/{id}/cancel` only files a request for staff. It does not stop production and refunds nothing automatically. Say so. ## Delivery modes - By default our staff check each finished video before it is delivered; an account can be switched to automatic delivery. `POST /orders` accepts `"review": "required"` to hold one order for our review. Either way `status` goes `in_production` then `delivered`, so wait for `delivered` (poll or webhook) and tell the human it can take longer than production alone. ## Call order 1. `GET /me`: `customer.id` (use as `customer_id`), `wallet.balance_cents`. 2. `GET /video-categories`: category `slug` values. 3. `GET /videos?category=` or `GET /products?category=` (filters: `tag`, `q`, `aspect`, `length_seconds`; pages: `limit` up to 100, `cursor` = `next_cursor` while `has_more`). 4. A video's `product_id` is the product to order; `null` = example only, not orderable. 5. `GET /products/{id}` (id or slug): `prices[].length_seconds` and `price_cents`, `aspect_ratios`, `duration_options` and `duration_prices`, `resolution_choice` and `price_1080_cents`, `has_storyboard`. Empty `prices` = not orderable for this account. 6. `GET /products/{id}/intake-form`: build `answers` from `fields[]` (rules below). 7. Files: `POST /uploads` `{ customer_id, purpose: "intake", content_type, size_bytes }`, then HTTP PUT the exact bytes to `url` with exactly the returned `headers`, within 10 minutes. 8. Confirm the price with the human, then `POST /orders` `{ customer_id, product_id, length_seconds, aspect?, add_ons?, answers, uploads }`. 9. Follow: `GET /orders/{id}` every 30 to 60 seconds (not faster), or webhooks (`POST /webhook-endpoints`, events `order.*`). Typical delivery takes minutes to hours depending on the product. 10. Only if `has_storyboard` is true and status is `awaiting_approval`: `GET /orders/{id}/storyboard`, show the scenes to the human, then `POST .../storyboard/approve { version_id }` or `POST .../change-requests { version_id, changes: [{ scene_index, comment }] }` as the human decides. If `has_storyboard` is false, never wait for a storyboard. 11. `status: delivered`: `GET /orders/{id}/video` gives a download `url` valid 10 minutes (`expires_at`). Download the file and store it on the human's own storage. Ask again for a fresh link instead of saving it. ## Filling intake forms - Use `key` as the answer name. Respect `required`, `min`, `max`, `options[].value` (select), `upload.accept`, `upload.min_files`, `upload.max_files`. - `show_if { field, value, negate }`: include the field only when the condition holds; otherwise leave it out. - `require_one_of`: at least one field with the same group key must be filled. - Values: text fields string; `number` number; `toggle` boolean; `photo_set` `[{ upload_id, room_label?, comment? }]` (`room_label` required when `upload.room_label_required`); `grouped_ordered_photos` `[{ group_id, group_label, photos: [{ upload_id, order }] }]` (ids from the field `groups`, ordered photos, see the quickstart); `file_mp3` an `upload_id` string; `model-picker` a model `id` from `GET /model-library?gender=`; `person-spec` `{ mode: "auto" }` or `{ mode: "described", attrs }` or `{ mode: "photo", upload_id, consent: true }` (only with the human's consent to use that person's image) or `{ mode: "library", model_id }`. - `api_settable: false` fields (voice-library, avatar-picker, consent): leave out. - List every `upload_id` used in `answers` also in `uploads`. - A 400 `invalid_input` lists `errors[]` with `path` (for example `answers.photos`) and `message`: fix those fields and retry with a new Idempotency-Key. ## Media URLs `poster_url`, `video_url`, `example_poster_url`, `example_video_url` answer 403 without a `Referer` header. When you fetch them, send `Referer: https://` (any https origin). In a browser do not use `Referrer-Policy: no-referrer`. Do not hotlink them on production sites: download and re-host. ## Errors Problem JSON (`application/problem+json`) with `status`, `code`, `request_id`. Branch on `code`. - 400: fix the request. 401: bad key. 403: missing scope or the account cannot order yet. 404: unknown id or not ready (`storyboard_not_ready`). - 409: wrong state, or `idempotency_in_flight` (wait, retry same key). 422: `idempotency_mismatch` or `*_not_offered`. - 429, 5xx, 503 `offer_unavailable`: back off, retry with the same Idempotency-Key. - Quote `request_id` when reporting a problem. ## Links - [OpenAPI document (public)](https://mystage.film/api/v1/openapi.json) - [API reference for people](https://mystage.film/developers/api) - [Get an API key](https://mystage.film/account/api) ## Quickstart: from a category to your own hosted video Base URL `https://mystage.film/api/v1`. Every call sends `Authorization: Bearer `. Every POST that creates something sends an `Idempotency-Key` (one new UUID per operation, the same one when you retry it). The API is for your server: it sends no CORS headers, never put the key in a browser. ### 1. Get a key Request access on mystage.film/account/api. After our approval press "Create key"; the key is shown once. ### 2. Who am I, how much credit `GET /me` returns `customer.id` (send it as `customer_id` in uploads and orders) and `wallet.balance_cents` (integer cents, EUR). Every order is paid from this wallet at the website price. ### 3. Browse the catalog - `GET /video-categories` lists `{ slug, name, name_el, video_count }`. - `GET /videos?category=` lists showcase videos with `poster_url`, `video_url` (HLS), `duration_seconds`, `category_slugs` and `product_id`. `GET /videos/{id}` returns one. - `product_id` is the product that orders a video like this one. `null` means the video is an example only and cannot be ordered. - Or go straight to products: `GET /products?category=` (also `tag`, `q`, `aspect`, `length_seconds`). Each product has `categories`, `tags`, `example_poster_url`, `example_video_url` and `example_duration_seconds`. ### 4. Read the product `GET /products/{product_id}` (the slug works too) returns: - `prices[]`: `length_seconds` (send it in the order) and `price_cents`; `price_1080_cents` when `resolution_choice` is true; `duration_prices` when `duration_options` is set. - `aspect_ratios`: the shapes you may order; `extra_formats_offered`. - `has_storyboard`: `true` means production stops once for a storyboard the customer approves (step 8). `false` means the order goes straight to the finished video. An empty `prices` list means this account cannot order the product yet. ### 5. Read the intake form `GET /products/{product_id}/intake-form` returns `fields[]`. Answer each by `key`: - `short_text`, `long_text`: a string. `number`: a number. `toggle`: true or false. - `select`: one of `options[].value`; the first option is the default. `options[].image` is an absolute URL when the choice has a picture. A select with categories (backgrounds: cities, sea, indoor ...) also returns `option_groups[]` (`id`, `label`, `options` = values in tab order); otherwise it is null. - `photo_set`: `[{ "upload_id": "...", "room_label": "...", "comment": "..." }]`, between `upload.min_files` and `upload.max_files` entries. - `grouped_ordered_photos` (photos in order, grouped by area; the real estate drone product): `[{ "group_id": "exterior", "group_label": "Exterior", "photos": [{ "upload_id": "...", "order": 0 }, { "upload_id": "...", "order": 1 }] }]`. `group_id` and `group_label` come from `groups` of the field (a custom group is allowed when `upload.allow_custom_groups` is true). A used group needs at least `upload.min_photos_per_group` photos and the whole field at least `upload.min_files`. - `file_mp3`: the `upload_id` string. - `model-picker`: a model `id` from `GET /model-library` (respect `model_library.gender`). - `person-spec`: `{ "mode": "auto" }`, `{ "mode": "described", "attrs": { ... } }` from `person_attributes`, `{ "mode": "photo", "upload_id": "...", "consent": true }`, or `{ "mode": "library", "model_id": "..." }` when `model_library` is set. - `show_if`: the field counts only when the other field has that value. Leave hidden fields out. - `api_settable: false` (voice-library, avatar-picker, consent): leave them out; the default applies. ### 6. Upload files `POST /uploads` with `{ "customer_id", "purpose": "intake", "content_type", "size_bytes" }` returns `upload_id`, `url`, `headers` and `expires_at` (10 minutes). PUT the bytes to `url` sending exactly `headers` (the signed `content-type`) and exactly `size_bytes` bytes. Put the `upload_id` in the answers and in `uploads[]`. ### 7. Place the order (this charges money) Show the price to the person and get a yes first. Then: ``` POST /orders Idempotency-Key: { "customer_id": "...", "product_id": "...", "length_seconds": 15, "answers": { ... }, "uploads": ["", ...] } ``` `201` returns the order (`status`, `total_cents`). `402 insufficient_credit` means nothing was charged: top up and retry with a NEW Idempotency-Key (the 402 is stored for the old one). ### 8. Follow the order Poll `GET /orders/{id}` every 30 to 60 seconds, or register a webhook (`POST /webhook-endpoints`, `order.*` events of your own orders). Statuses: `received`, `in_production`, `awaiting_approval`, `changes_requested`, `delivered`, `failed`, `cancelled`, `awaiting_balance`. Delivery is either automatic or checked by our staff first (your account's mode; `"review": "required"` on `POST /orders` holds one order for our review): in both cases the status goes `in_production` then `delivered`. Details in the "Delivery modes" section of the API reference. When `has_storyboard` is true and the status is `awaiting_approval`: - `GET /orders/{id}/storyboard` returns `version_id` and the scenes (image links valid 10 minutes). - Approve: `POST /orders/{id}/storyboard/approve` with `{ "version_id" }`. - Or ask for changes: `POST /orders/{id}/change-requests` with `{ "version_id", "changes": [{ "scene_index", "comment" }] }` (a paid round may debit the wallet; 15 second videos can only be approved). ### 9. Download and host the video yourself `GET /orders/{id}/video` returns `url` (an MP4 download valid until `expires_at`, 10 minutes) and `filename`. Download it once and serve it from your own storage or CDN. Never store the link: ask again for a fresh one. We never delete videos. ### Media URLs (posters, sample videos) `poster_url`, `video_url`, `example_poster_url` and `example_video_url` are on our media CDN, which answers `403` to a request without a `Referer` header. - In a browser, do not set `Referrer-Policy: no-referrer`; `strict-origin` or the default works. - From a server, send a `Referer` (any https origin, for example your own site). - Hotlinking is not supported for production sites: download the poster and the video once and host them yourself. ### Errors and what to do | Status and code | What to do | |---|---| | 400 `invalid_input` | Fix the fields listed in `errors[]` (answers paths start with `answers.`) | | 400 `unknown_category`, `invalid_aspect`, `invalid_length`, `invalid_cursor` | Fix the query parameter | | 401 `unauthenticated` | Check the key; it may be revoked or expired | | 402 `insufficient_credit` | Top up the wallet, then retry with a new Idempotency-Key | | 403 `forbidden`, `prices_not_available`, `key_not_approved` | The key lacks the scope, or the account cannot order yet: contact us | | 404 `not_found`, `storyboard_not_ready`, `upload_not_found` | Wrong id, or not ready yet (storyboard): wait and poll | | 409 `idempotency_in_flight` | The first request is still running: wait and retry with the same key | | 409 `not_delivered`, `not_awaiting_approval`, `version_not_current`, `cancellation_pending`, `not_cancellable` | The order is in another state: read `GET /orders/{id}` | | 422 `idempotency_mismatch` | You reused a key for a different request: use a new key | | 422 `*_not_offered` | Order only what the product offers (aspect_ratios, durations, add ons) | | 429 | Slow down, retry later | | 5xx, 503 `offer_unavailable` | Retry later with the same Idempotency-Key | `POST /orders/{id}/cancel` files a cancellation request for our staff (`201`, status `pending`). It does not stop production and refunds nothing by itself.