API v1

API Documentation

Version 1 (v1), additive only: new endpoints and optional fields are added; nothing existing is removed or changed.

Keep a copy of your videos

We never delete videos, but we recommend downloading each finished video once and hosting it on your own server. Prices and your wallet are the same as on mystage.film.

End to end flow

From a category to a video on your own server. Every POST sends a new Idempotency-Key. Full guide as text: docs/api/guides/quickstart.md, also served at /llms.txt.

  1. 1.Get a key: request access on /account/api; after our approval press Create key. The key is shown once.
  2. 2.GET /v1/me: customer.id (send it as customer_id) and wallet.balance_cents. Orders are paid from this wallet.
  3. 3.GET /v1/video-categories: the category slugs.
  4. 4.GET /v1/videos?category=slug: each video has poster_url, video_url (HLS), duration_seconds, category_slugs and product_id. product_id null means an example only. GET /v1/videos/{id} returns one video.
  5. 5.GET /v1/products/{product_id} (the slug works too): prices (length_seconds, price_cents), duration_prices, aspect_ratios and has_storyboard. GET /v1/products also filters by category, tag, q, aspect and length_seconds.
  6. 6.GET /v1/products/{id}/intake-form: the fields to answer, by key. Leave out fields hidden by show_if and fields with api_settable false.
  7. 7.POST /v1/uploads for each file, then PUT the bytes to url with exactly the returned headers (the signed content-type) within 10 minutes. Keep the upload_id.
  8. 8.Show the price and get a yes, then POST /v1/orders with customer_id, product_id, length_seconds, answers and uploads. 402 insufficient_credit: nothing charged.
  9. 9.Follow the order: GET /v1/orders/{id} every 30 to 60 seconds, or register a webhook for order.* events.
  10. 10.Only when has_storyboard is true and the status is awaiting_approval: GET /v1/orders/{id}/storyboard, then approve with version_id or send change requests.
  11. 11.When delivered: GET /v1/orders/{id}/video gives a link valid 10 minutes. Download the MP4 once and host it yourself; ask again for a fresh link.

Posters and 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 use Referrer-Policy no-referrer (strict-origin or the default works). A server that downloads media must send a Referer, any https origin such as your own site. Hotlinking our CDN is not supported for production sites: download the poster and the video once and serve them from your own storage or CDN. The finished video link (GET /v1/orders/{id}/video) needs no Referer but expires at expires_at.

For AI agents and MCP tools

A short, exact guide for agents: the call order, the required headers, the money rules (always show the price and get the human's confirmation before POST /v1/orders), how to fill intake forms from the schema, polling intervals and the media Referer rule. The OpenAPI document is public at /api/v1/openapi.json.

Open /llms.txt

Authentication

Every call sends your API key in the Authorization header, Bearer style. No cookies, no CORS: the API is called from your backend, not the end user's browser. The key can ONLY reach your own account: every order, file and money movement is bound to your account, whatever you send. The key is shown once at creation; if it leaks, revoke it from the portal and make a new one.

Authorization: Bearer vapi_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Base URL

All endpoints live under https://mystage.film/api/v1. Responses are JSON. Every response carries an x-request-id header: quote it when contacting support.

https://mystage.film/api/v1

Pagination

List endpoints return { data, has_more, next_cursor }. Pass next_cursor as the cursor query parameter on the next call. limit: 1 to 100 (default 20). Orders and usage are newest first; videos are in catalog order. With filters a page can hold fewer items while has_more is true.

Idempotency (safe retries)

Every POST that creates something (orders, uploads, change requests, approvals, cancel) REQUIRES the Idempotency-Key header with a unique value per operation (for example one UUID per operation). Same key and same body: the stored answer, never a second run. Same key and a different body: 422 idempotency_mismatch. A key still running: 409 idempotency_in_flight. Responses are kept for 48 hours; server errors (5xx) are not stored. A network retry can therefore never create a duplicate order or a double charge. A stored 402 replays too: after a top up, use a new key.

Idempotency-Key: 8f3c1d2a-77b9-4c1e-9a01-5f6e8d9c0b1a

Errors

All errors follow RFC 9457 (application/problem+json) with type, title, status, code, request_id and, for field problems, an errors list. Branch on code. The codes you will meet and what to do:

  • 400 invalid_input: fix the fields in the errors list (answers paths start with answers.)
  • 400 unknown_category, invalid_aspect, invalid_length, invalid_cursor, invalid_limit: fix the query parameter
  • 401 unauthenticated: wrong, expired or revoked API key
  • 402 insufficient_credit: the balance is not enough, nothing was charged; top up, then retry with a new Idempotency-Key
  • 403 forbidden, pinned_customer, prices_not_available: the key lacks the scope, you sent another customer_id, or your account cannot order yet
  • 404 not_found: unknown id, or another account's id (same answer, no information leak); storyboard_not_ready: wait and poll
  • 409 idempotency_in_flight: wait and retry with the same key; not_delivered, not_awaiting_approval, version_not_current, cancellation_pending, not_cancellable: read the order first
  • 422 idempotency_mismatch: use a new key; *_not_offered: order only what the product offers
  • 429: too many calls, retry with backoff
  • 5xx and 503 offer_unavailable: a problem on our side; retrying with the same Idempotency-Key is safe

Rate limits

Up to 120 calls per minute per key (reads and writes together) and up to 30 POST/PATCH/DELETE per minute per key. A 429 answer carries Retry-After. Refused calls are never charged.

Webhooks for your orders

Instead of polling GET /v1/orders, register a webhook and we notify you when one of your orders changes: order.created, order.paid, order.production_started, order.storyboard_ready, order.awaiting_approval, order.changes_requested, order.delivered, order.failed, order.cancelled. You only receive events for your own orders, never for other accounts. The body carries ids and status only: read the details with GET /v1/orders/order_id.

Every request follows Standard Webhooks: headers webhook-id, webhook-timestamp and webhook-signature, signed with the endpoint secret. Check the signature before you trust an event. If the endpoint does not answer 2xx we retry for about 27 hours; after 5 days of continuous failures we disable it.

Endpoints

All calls are relative to the base URL. Bodies are strict: unknown fields are refused.

GET/ping

Check that the key is valid. No fields.

response

  • ok: true
GET/me

Your key, your account, your wallet balance and a 30 day usage summary (calls, orders, charges in cents).

response

  • key { id, name, last4, scopes, created_at, last_used_at }
  • customer { id, email, name }
  • wallet { currency, balance_cents }
  • usage { window_days, requests, orders, credits_cents }
curl -H "Authorization: Bearer $KEY" https://mystage.film/api/v1/me
GET/usage

The key's request history: route, method, status, order_id when the call created an order, and credits_cents for what it debited. Cursor pagination.

body / query

  • cursor string?: next_cursor of the previous page
  • limit int?: 1..100 (default 20)

response

  • data: [{ id, created_at, route, method, status_code, order_id, credits_cents, product_name, order_status, delivered_at, currency }]
  • has_more: boolean
  • next_cursor: string | null
curl -H "Authorization: Bearer $KEY" "https://mystage.film/api/v1/usage?limit=50"
GET/video-categories

The categories of the showcase, with their slug, English and Greek name and how many videos each has.

response

  • data: [{ slug, name, name_el, video_count }]
curl -H "Authorization: Bearer $KEY" https://mystage.film/api/v1/video-categories
GET/videos

The showcase videos, all or one category. Each item has poster_url, video_url (HLS), duration_seconds, categories (names), category_slugs and product_id: the product that orders a video like this one, or null for an example only. Filters: category, tag, q, aspect. Media needs a Referer header.

body / query

  • category string?: a category slug
  • tag string?
  • q string?: text search
  • aspect string?: landscape | portrait | square
  • cursor string?
  • limit int?: 1..100

response

  • data: [{ id, title, title_el, subtitle, subtitle_el, categories, category_slugs, subcategory, hashtags, tags, aspect, duration, duration_seconds, video_url, poster_url, product_id, product_slug }]
  • product_id: null = an example only, not orderable
  • has_more, next_cursor
curl -H "Authorization: Bearer $KEY" "https://mystage.film/api/v1/videos?category=restaurants&limit=20"
GET/videos/{videoId}

One showcase video, the same shape as a list item. 404 not_found for an unknown id.

response

  • ShowcaseVideo: the same shape as a list item
  • 404 not_found
GET/model-library

Ready made AI models to answer a model-picker field (the model id) or a person-spec field in library mode. Filter: gender.

body / query

  • gender string?: female | male

response

  • data: [{ id, number, collection, gender, attributes, thumb_url }]
GET/model-library/{modelId}

One library model with its front, back, side and portrait images.

response

  • Model with signed front, back, side and portrait images
GET/products

Your catalog: title (EN, EL), subtitle, prices per length, duration_prices, aspect_ratios, has_storyboard, categories, tags, an example video and poster. Filters: category, tag, q, aspect, length_seconds. Cursor pagination.

body / query

  • category string?: a category slug
  • tag string?
  • q string?: text search
  • aspect string?: landscape | portrait | square (or 16:9, 9:16, 1:1)
  • length_seconds int?
  • cursor string?
  • limit int?: 1..100

response

  • data: [{ id, slug, title { en, el }, subtitle { en, el }, description, native_aspect, aspect, aspect_ratios, resolution_choice, duration_options, video_seconds, prices: [{ length_seconds, price_cents, price_1080_cents, subtitles_price_cents, revision_price_cents }], duration_prices, has_storyboard, categories, subcategories, tags, hashtags, example_video_url, example_poster_url, example_duration_seconds, currency, created_at }]
  • has_more, next_cursor
curl -H "Authorization: Bearer $KEY" "https://mystage.film/api/v1/products?category=restaurants"
GET/products/{productId}

One product by id or slug, all details. has_storyboard true means the order stops once for a storyboard the customer approves; false means it goes straight to the finished video.

body / query

  • productId: the product id or its slug

response

  • Product: one product, all details
  • has_storyboard: true = a storyboard to approve, false = straight to the video
GET/products/{productId}/intake-form

The answer fields the product asks for: type, label, required, options, show_if, upload slots, model_library and api_settable. Build your form from here; leave out hidden fields and fields with api_settable false (the default applies).

response

  • product_id, languages, aspect_ratios
  • fields: [{ key, type, required, label, help, options, min, max, show_if, require_one_of, upload, groups, person_attributes, model_library, api_settable }]
POST/uploadsIdempotency-Key required

Requests a presigned URL for one intake file. Body: purpose "intake", content_type (image/jpeg, image/png, image/webp, audio/mpeg, audio/mp4), size_bytes (up to 50 MB, exact). PUT to url with exactly the returned headers before it expires (10 minutes). Keep the upload_id for the order answers.

body / query

  • customer_id uuid: your own customer id (GET /me)
  • purpose "intake"
  • content_type enum: image/jpeg | image/png | image/webp | audio/mpeg | audio/mp4
  • size_bytes int: 1..52428800, exact

response

  • upload_id: reference it in the order answers
  • url: PUT the bytes here
  • method "PUT"
  • headers { content-type }: send exactly these
  • expires_at (10 minutes)
curl -X POST -H "Authorization: Bearer $KEY" -H "Idempotency-Key: $IDEM" \
  -H "Content-Type: application/json" -d '{"customer_id":"…","purpose":"intake","content_type":"image/jpeg","size_bytes":48123}' \
  https://mystage.film/api/v1/uploads

curl -X PUT -H "content-type: image/jpeg" --data-binary @photo.jpg "$UPLOAD_URL"
POST/ordersIdempotency-Key required

Creates the order and charges your wallet in one transaction. Body: customer_id, product_id, length_seconds (from the prices), aspect (optional, from aspect_ratios), add_ons { subtitles, extra_formats, resolution, duration_seconds } (optional, only what the product offers), answers (the intake form fields; files as upload_id), uploads (list of upload_id), review (optional). complimentary is not available through the API. 402 insufficient_credit when the balance is not enough: nothing is created or charged.

body / query

  • customer_id uuid: your own customer id
  • product_id uuid: from GET /products or a video product_id
  • length_seconds int: one of the product prices length_seconds
  • aspect enum?: one of the product aspect_ratios (default: native)
  • add_ons object?: { subtitles?, extra_formats?, resolution?, duration_seconds? } only what the product offers
  • answers object: the intake form fields; files as upload_id
  • uploads string[]: the upload_ids used in answers
  • review enum?: required | default

response

  • 201 Order: status "received", total_cents, add_ons
  • 402 insufficient_credit: nothing created, nothing charged
  • 422 *_not_offered: a choice the product does not sell
curl -X POST -H "Authorization: Bearer $KEY" -H "Idempotency-Key: $IDEM" \
  -H "Content-Type: application/json" -d '{"customer_id":"…","product_id":"…","length_seconds":15,"answers":{},"uploads":[]}' \
  https://mystage.film/api/v1/orders
GET/orders

Your orders, newest first. Filters: status, created_after (ISO), customer_id. Cursor pagination.

body / query

  • cursor string?
  • limit int?: 1..100
  • customer_id uuid?
  • status enum?: received | awaiting_approval | changes_requested | in_production | delivered | failed | cancelled | awaiting_balance
  • created_after string?: ISO timestamp

response

  • data: [Order]
  • has_more, next_cursor
curl -H "Authorization: Bearer $KEY" "https://mystage.film/api/v1/orders?status=delivered"
GET/orders/{orderId}

One order: status (received, awaiting_approval, changes_requested, in_production, delivered, failed, cancelled, awaiting_balance), details, add ons, total_cents, cancellation_requested.

response

  • Order: id, status, customer_id, product_id, length_seconds, aspect, add_ons, total_cents, currency, complimentary, cancellation_requested, created_at
POST/orders/{orderId}/cancelIdempotency-Key required

Files a cancellation REQUEST for our staff (201, status pending). Body: reason (required, 10 characters or more). Production is not stopped and nothing is refunded automatically: our team decides the request and any refund. The order can still be delivered meanwhile.

body / query

  • reason string: why you cancel, 10 to 1000 characters

response

  • 201 { order_id, status: "pending" }: a request for our staff; production is not stopped, nothing is refunded automatically
  • 409 cancellation_pending | not_cancellable
GET/orders/{orderId}/storyboard

Only for products with has_storyboard true: the current storyboard, its version_id, status and scenes with images (links valid 10 minutes). 404 storyboard_not_ready before it is ready.

response

  • order_id, version_id, version_number, status, revisions_allowed
  • scenes: [{ scene_index, label, caption, image_url }]
  • images_expire_at (10 minutes)
  • 404 storyboard_not_ready
POST/orders/{orderId}/storyboard/approveIdempotency-Key required

Approves the storyboard on behalf of the customer; production continues. Body: version_id from GET storyboard.

body / query

  • version_id uuid: from GET storyboard

response

  • { order_id, version_id, status: "approved" }: production continues
POST/orders/{orderId}/change-requestsIdempotency-Key required

Requests changes on scenes. Body: version_id and changes, a list of scene_index and comment (10 to 2000 characters). A round may be paid from the wallet (402 revision_payment_failed); 15 second videos can only be approved.

body / query

  • version_id uuid: from GET storyboard
  • changes array: [{ scene_index int, comment string (10..2000) }]

response

  • 201 { order_id, version_id, status: "changes_requested" }
  • 402 revision_payment_failed | 409 revisions_unavailable
GET/orders/{orderId}/video

Only when delivered: a download URL for the finished MP4 and expires_at (10 minutes). Do not store the link, ask again. Download the file once and host it on your own server.

response

  • url: MP4 download link
  • expires_at (10 minutes): ask again for a fresh link
  • filename
  • 409 not_delivered | order_cancelled
POST/webhook-endpoints

Registers a webhook for your own orders. Body: url (public https, port 443), events (order.* only), description (optional). The response holds the signing secret, shown only this once. Up to 5 endpoints per account.

body / query

  • url string: public https, port 443
  • events string[]: order.created | order.paid | order.production_started | order.storyboard_ready | order.awaiting_approval | order.changes_requested | order.delivered | order.failed | order.cancelled
  • description string?

response

  • WebhookEndpoint + secret (whsec_…, shown only once)
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/hooks/in","events":["order.delivered","order.storyboard_ready"]}' \
  https://mystage.film/api/v1/webhook-endpoints
GET/webhook-endpoints

Your webhook endpoints, newest first. The secret is never shown again.

response

  • data: [{ id, url, description, events, status, disabled_reason, created_at, updated_at }]
GET/webhook-endpoints/{endpointId}

One of your webhook endpoints.

response

  • WebhookEndpoint
PATCH/webhook-endpoints/{endpointId}

Changes url, events or description, or disables and enables it again (status active or disabled).

body / query

  • url string?
  • events string[]?
  • description string?
  • status enum?: active | disabled

response

  • WebhookEndpoint
DELETE/webhook-endpoints/{endpointId}

Deletes an endpoint that never received a delivery. If it has delivery history, disable it instead.

response

  • { id, deleted: true }
  • 409 endpoint_has_deliveries: disable it instead
POST/webhook-endpoints/{endpointId}/test

Sends a signed webhook.ping test event now and reports whether the endpoint answered 2xx.

response

  • { delivered, http_status, error_code }
POST/webhook-endpoints/{endpointId}/rotate-secret

A new signing secret, shown only in this response. Events are signed with it from now on.

response

  • WebhookEndpoint + new secret (shown only once)