# ZenityX Studio Agent API

Generate AI images, videos, speech and music (songs) on ZenityX Studio
(studio.zenityx.com), billed to the key owner's Studio credit balance.

Base URL: https://api.zenityx.com/functions/v1/api-v1
(https://api.zenityx.com/functions/v1/api-v1 is ZenityX's own address for this
API — same keys, same paths; prefer it.)
MCP endpoint (same capabilities as tools): https://mcp.zenityx.com

## Authentication
Send the user's API key on every request (keys look like zxs_live_...):
    Authorization: Bearer zxs_live_xxx     (or header: x-api-key: zxs_live_xxx)
Keys are created at studio.zenityx.com -> profile menu -> API Keys.
Users often hand you the key as a zenityx.env file (contains
ZENITYX_API_KEY=zxs_live_... plus ZENITYX_API_BASE / ZENITYX_MCP_URL) —
read it from the path or attachment they provide; never echo the key back.
Accounts without API access get 403 (see the error table).

## Typical flow
1. GET /models                      -> pick a model_key and see prices
2. (optional) POST /files           -> upload a local reference image, get a URL
3. POST /images, /videos, /voice,  -> returns generation_id immediately (202)
   /avatar or /music
4. GET /generations/{id}            -> poll every 5-10s until status is
                                       "success" (media URL ready) or "failed"

## Endpoints

### GET /models
Active models. Fields: model_key, model_name, model_type
(image|video|upscale|voice|avatar|music), description, credit_cost,
pricing_rules, pricing. Music models also carry music_guide (modes + required/optional
fields, limits, credits_per_job, section tags). How pricing works — every model's
"pricing" says it in one line: {"unit": per_image | per_clip | per_second |
per_1000_characters | per_job, "how": "<the formula>"}. Read it before you
estimate: credit_cost is only a starting price.
- images: credit_cost is the base (1K) price. If the model ALSO has
  pricing_rules keyed by resolution (e.g. {"1K":2,"2K":3,"4K":4}), the price
  of the resolution you request applies (gpt-image-2-5 at 4K = 4, not 2).
- videos: pricing_rules is keyed by combination, e.g. "720p_8_sound" = 720p,
  8s, with audio. A mode prefix like "fast_480p" or "pro_1080p" is selected
  with the "size" parameter of POST /videos. On "per_second" models the value
  is a RATE: credits per second × duration (grok-imagine-video-1-5 {"480p": 2}
  for 8s = 16 credits).
- avatars and video upscalers bill per second of the media ZenityX measures.

### GET /models/guide?model=<model_key>[&topic=<topic>][&format=text]
How to use one model well. "must" = hard rules (break one and the job is
refused or credits are wasted); everything else is advice from the model
maker's guide or a ZenityX test — a starting point, not a rule. No topic = the
core (must, settings, prompt formula, things to avoid) + a list of modes;
topic=<mode id> = the core + that mode's fields, prompt steps, template and
examples (one call); several topics comma-separated; settings, formula,
vocabulary, changelog (history + sources) or all. format=text returns the same
as compact Markdown in "text" (fewer tokens for an LLM). Models whose GET /models
entry has a "guide" field (its version and date) have one — most main models: nano-banana-pro,
nano-banana-2-1, gpt-image-2-6, gpt-image-2-5, seedream-5-5, seedream-5-pro,
grok-imagine-2, grok-imagine-video-1-5, veo-3-1-fast, gemini-omni-video,
kling-4.0/video, kling-4.0-flash/video, wan/3-0-video and the seedance-2 family;
GET /models/guide with no model lists them with their versions. Each guide's
"topics" lists its own mode ids (e.g. text, edit, combine, frames, references,
audio). "live" carries the catalog's current limits and prices. Read-only,
free. MCP: get_model_guide.
EXACT PRICE, FREE: add "quote_only": true to any billable POST (same body you
would submit) → 200 {"quote_only": true, "estimated_credits": N,
"charged_credits": 0}. Nothing is submitted, no idempotency_key needed.

### GET /credits
-> {"credits": <number>, "api_key": {"daily_credit_limit", "spent_today",
    "remaining_today", "resets_at"}}
credits = the key owner's balance. api_key (API keys only — absent on OAuth
sessions, which have no per-key cap) = THIS key's own daily cap: a job priced
above remaining_today is refused with 429 daily_limit_exceeded (nothing
charged) until resets_at (midnight Asia/Bangkok). daily_credit_limit null =
no cap. Check it before a batch of jobs. A running job counts its price; a
job that fails gives it back, and one billed below its price when it is
delivered (a held or length-billed job) counts only what it was billed.
billing (team accounts) = which wallet pays for THIS caller: {"payer":
"personal"} or {"payer": "team", "team", "role", "quota": {"limit_5h",
"used_5h", "remaining_5h", "resets_5h_at", "limit_week", "used_week",
"remaining_week", "resets_week_at"}, "team_balance"?}. With payer "team" the
jobs are paid from the team's credit pool within this member's quota (null
limit = unlimited) and "credits" (the personal wallet) is NOT used;
team_balance is shown to the team owner / managers only. A job above the
remaining quota is refused with 429 team_quota (nothing charged).

### POST /files
Upload a reference file. THREE ways — prefer the first:
0. NO KEY AT HAND (e.g. OAuth session)? POST https://api.zenityx.com/functions/v1/api-v1/files/upload-url (empty
   body, authenticated) → returns a one-time upload command ("how") you can
   run as-is: curl --fail-with-body --show-error --silent --request POST      '<upload_url>' --data-binary '@/path/to/file'
   SENSITIVE URL: valid ~10 min, this account only, no Authorization header
   needed — don't log or share it. The response names the right param next.
1. RAW BINARY with your API key (the file never passes through your context):
       curl -s -X POST https://api.zenityx.com/functions/v1/api-v1/files          -H "Authorization: Bearer $ZENITYX_API_KEY"          --data-binary @/path/to/photo.jpg
2. JSON {"content_base64": "<base64, no data: prefix>", "filename": "optional.png"}
   — only for small files (base64 through your context is ~1.5 chars/token;
   downscale to ~500px first if you must use this). Base64 path caps at 8MB.
Size limits by media kind (raw path): image 20MB · audio 25MB · video 100MB
(video refs are for avatar motion-control; per-model fit like dimensions and
durations is validated at generation time). Magic-byte validated.
A raw upload near 100MB through api.zenityx.com can be refused (413) before it
reaches us — use way 0 (/files/upload-url) for big files.
PREP TIP: convert PNG references to JPEG before uploading (sips -s format
jpeg in.png --out ref.jpg / magick in.png ref.jpg) — models ignore
transparency and raw PNGs intermittently trigger provider errors.
-> 201 {"url": "https://...", ...} — pass this url in image_urls.
TIP: a previous ZenityX generation needs NO upload — its URL from
GET /generations works directly in image_urls.

### POST /images
    {"prompt": "...", "model_key": "nano-banana-2-lite",
     "aspect_ratio": "1:1", "resolution": "1K",
     "image_urls": ["https://..."]}
Only prompt is required. Default model: nano-banana-2-lite (1 credit).
output_format: png | jpg | webp ("jpeg" = jpg) — only where the model offers
it (GET /models output_formats + default_output_format; jpg when omitted):
jpg or png on nano-banana-2-1, nano-banana-pro, seedream-5-lite,
seedream-5-pro, seedream-5-5, qwen-image-3, qwen-image-3-pro; jpg, png or
webp on flux-3-klein; jpg, png, webp, gif or svg on gpt-image-2-6 (below). The others return
one fixed type (gpt-image: png; nano-banana-2-lite, grok-imagine, grok-imagine-2, flux-2: jpg;
recraft vector: svg) — omit it there; another value is a 400 invalid_output_format, nothing charged.
GIF (gpt-image-2-6, output_format "gif"): a short looping animation. frame_count 2-16
(default 9), frame_duration_ms 20-2000 in steps of 10 (default 200), frame_size = ONE frame
"WIDTHxHEIGHT", multiples of 16, at least 64 (default 512x512; 768x768 takes up to 12 frames;
256x256 needs 10-16; 1024x1024 is too big). aspect_ratio and resolution are ignored: the frames set the size. All
frames are drawn on one sheet, so the price is the gpt-image-2-6 still price of the size that
sheet fits in: 512x512 frames (2-16) = the 2K price, 768x768 = the 4K price, 256x256 = 1K
(at the job's quality_level, + references). quote_only shows it.
SVG (gpt-image-2-6, output_format "svg"): a vector file (logos, icons, flat illustrations).
aspect_ratio / resolution / quality_level as for a PNG. svg_model picks who writes the SVG:
luna (default, +1 credit), sol (+6, more detailed), terra (+8, most detailed) on top of the
still price. GIF / SVG fields with another output_format are a free 400.
BATCH: send "prompts": ["...", ...] (2-8) instead of prompt — ONE request
submits all variations (identical other params). idempotency_key REQUIRED
(per-item keys derived as key#1..#N); max_credits = TOTAL budget split evenly.
Returns submitted[] with the generation_ids — then poll them together with
GET /generations?ids=... until every item is terminal.
"google_search" is no longer available: it belonged to nano-banana-2, which is
retired. Every image model rejects it with a clear 400 (nothing charged).
Model suggestions — recommendations, not rules: every active model in GET
/models is yours to pick by what the job needs, and the user's choice wins.
Prices are not typed here: GET /models pricing_rules, exact = quote_only.
ZenityX team picks (the team's choices from testing; advice, not rules — the user's choice or a better fit wins):
- Real-looking people, and posters or graphics with a person in them → nano-banana-2-1 (rather than gpt-image-2-6 / gpt-image-2-5); when the user has photos of their own AI model, attach them as references.
- Posters, graphics, infographics, images with text, and product shots without a person → gpt-image-2-6; on a budget → gpt-image-2-5.
- Editing a photo of a person (outfit, colour, background, light, add or remove things) while keeping the face → seedream-5-5 (its largest size is 2K).
What each model offers:
- nano-banana-2-lite (1K only) — the default when model_key is omitted.
- nano-banana-2-1 (1K/2K/4K, up to 10 refs) — photos, products, people,
  scenes, edits. Google's successor to nano-banana-2: better quality, prompt
  adherence, text in images and character consistency at the SAME price.
- nano-banana-2 — RETIRED (2026-10-09): requests that still send it run on
  nano-banana-2-1 at the same price (the 202 carries model_key + notice).
  Send nano-banana-2-1.
- nano-banana-pro (any resolution, one price; up to 8 refs) — Google's premium
  model for complex scenes.
- gpt-image-2-6 (price by quality_level x resolution, +credits per pair of
  image_urls; up to 16 refs; 16 ratios incl. auto; background transparent as
  png or webp; also animated GIF and SVG vector output, see output_format) —
  posters, graphics, infographics, images with text, product / ad images.
  quality_level low … max (medium default; OpenAI: low for quick drafts).
  quality=flare = faster, sunburst = more detail, SAME price. Its provider
  filter refuses reference photos of people in revealing outfits (swimwear,
  low-cut dresses): a free failure — seedream-5-5 takes those edits.
- seedream-5-5 (1K/1.5K/2K, no 4K; +credits per pair of image_urls; up to 10
  refs) — edits a photo (outfit or its colour, background, lighting, add or
  remove objects) or makes a new image. A 4K source comes back at 2K.
  Attach references uploaded with POST /files or previous ZenityX outputs: a
  photo linked from another site cannot be age-checked and is refused. Any
  job whose reference may show someone under 18 is refused whatever the
  prompt, and so is a sexual prompt about a minor (failed, not charged) —
  use nano-banana-2-1 for photos of children. The provider's own filter
  blocks nudity.
- gpt-image-2-5 (1K/2K/4K; quality=flare | sunburst, SAME price; 1:1 at 4K
  allowed; 27:16/16:27/9:8/8:9 are 1K only; up to 16 refs) — OpenAI's earlier
  model for graphics, infographics, diagrams, UI mockups, posters and images
  with text. It supersedes gpt-image-2 (legacy — still runs).
- flux-3-klein (1K/2K/4K, up to 10 refs; text-to-image, or editing when
  image_urls are attached — aspect_ratio auto then follows the first
  reference) — NO adult-content filter: swimwear, glamour and adult 18+
  images the nano-banana models refuse. A sexual prompt about a child or
  minor is a free 400 generation_rejected. When an attached reference photo
  may show someone under 18, that job runs with the provider's nudity filter
  on, and a sexual prompt with it is refused (400 generation_rejected,
  nothing charged). Upload references with POST /files: a photo linked from
  another site cannot be age-checked, so it counts as possibly a minor.
  Thai text in images can be misspelled; slower (about 40-120 s).
- qwen-image-3 (1K/2K) and qwen-image-3-pro (1K/2K; sharper text and detail)
  — text and layout in the image: posters, infographics, document pages,
  menus, multilingual signage, long detailed prompts; people too.
  Text-to-image, or editing when image_urls are attached (1-3 refs). 8
  ratios (1:1 3:2 2:3 4:3 3:4 16:9 9:16 21:9, no auto). NO adult-content
  filter (adult 18+ images go through). A sexual prompt about a child or
  minor is refused, and so is any reference photo that may show someone under
  18 or is linked from outside ZenityX (upload it with POST /files) — failed,
  not charged; use nano-banana-2-1 for photos of children. Thai text in
  images can be misspelled; about 50-80 s.
- grok-imagine-2 (one size, 1.5K = about 2 MP) — takes long, detailed prompts
  (up to 8,000 characters). Text-to-image, or editing / combining when
  image_urls are attached (1-5 refs). Ratios 1:1 2:3 3:2 16:9 9:16, plus auto
  when editing (keeps the first reference's shape; auto with no reference
  renders 1:1). Prompt guide: GET /models/guide?model=grok-imagine-2.
  Edit ONLY the parts you pick: POST /images/segments {image_url} (free) lists
  the objects in the photo (segments[].index, name, mask_url) and returns a
  segment_token; then POST /images with segment_token + mask_indexes [..] and a
  prompt saying what those parts become (same price; image_urls may be omitted).
  References must be uploaded with POST /files or be previous ZenityX outputs;
  any reference that may show someone under 18 is refused, and so is a sexual
  prompt about a minor (failed, not charged).
Other models (seedream-5-pro, flux-2, grok-imagine, recraft-vector): see each
model's description in GET /models. Full catalog + prices: GET /models.
image_urls (optional) enables image-to-image / editing; use URLs from POST
/files or from previous generations. Per-model cap = max_reference_images in
GET /models (gpt-image-2-6 / gpt-image-2-5 16, most others 8-10, grok-imagine-2 5, qwen-image-3 3, grok-imagine 1,
recraft-vector none). quality (optional): only recraft-vector
(normal|pro — changes the price) and gpt-image-2-6 / gpt-image-2-5 (flare|sunburst — same price:
flare = fast, for drafts and variations; sunburst = more detailed, for final
images; choose by the job — omitted means flare);
any other value there is a free 400 invalid_quality, other models ignore it.
quality_level (optional): only models whose GET /models entry lists
quality_levels (price tiers, with default_quality_level); the price is
pricing_rules[<quality_level>_<resolution>]. Anywhere else, or a level not
listed, is a free 400 invalid_quality_level. Models whose pricing.how says so
add credits per pair of image_urls: quote_only shows the exact total.
background (optional): auto (default, the model decides) | transparent (native
transparent background) | opaque (always a solid background). Only on
gpt-image-2-6 (transparent as png or webp), seedream-5-5 (png), gpt-image-2-5
and gpt-image-2 (png, 1K only) — GET /models transparent_background lists the
types / sizes; a transparent job with no output_format gets png. Anything else
is a free 400 invalid_background. Same price.
-> 202 {"generation_id", "estimated_credits", "poll": "/functions/v1/api-v1/generations/<id>"}
"poll" (every create endpoint) is a path from the HOST root — it already
contains /functions/v1/api-v1, so don't append it to Base URL. Simplest: GET
https://api.zenityx.com/functions/v1/api-v1/generations/<generation_id>.
A 202 with "confirmation": "pending" (any create endpoint) means our generator's
answer was lost after the job may have been charged: poll that id like any
other — never resubmit. If it fails it is not charged (credits held for it are
released; a debit already taken is refunded automatically).
Lite/default models usually finish in under a minute; premium image models
(nano-banana-pro, gpt-image-2) typically take 1-2 minutes and can reach 3-5 —
keep polling, do not resubmit.

### POST /videos
    {"prompt": "...", "model_key": "kling-3.0/video", "duration": 5,
     "resolution": "720p", "aspect_ratio": "16:9", "sound": true,
     "image_urls": ["https://..."]}
Only prompt is required. sound DEFAULTS TO TRUE (most users want audio) —
send "sound": false explicitly to save credits on models that price audio.
Videos are EXPENSIVE (2 to 100+ credits depending on
model/duration/resolution/sound). Many video models price PER SECOND (the
model's pricing.unit in GET /models): the pricing_rules value × duration is the
price. Send the same body with "quote_only": true first for the exact,
free quote.
UPLOAD SLOTS — two different things, don't mix them up:
- image_urls = FIRST/LAST FRAME: [first] or [first, last] (max 2; enforced);
  the video starts (and optionally ends) exactly on these images.
- reference_image_urls = REFERENCE mode: subject/style references the model
  composes from freely. Per-model caps: seedance-2-5 30, rest of the
  seedance-2 family 9, wan/3-0-video 10, kling-4.0/video and kling-4.0-flash/video
  10, minimax/h3 9, veo-3-1 family 3, gemini-omni-video 6, cinematic-2.0 4; others reject with a
  clear 400.
- image_urls and reference_image_urls are MUTUALLY EXCLUSIVE — sending both
  is a 400. Pick ONE way: frames OR references. To keep a character from a
  photo while using references, put every image in reference_image_urls and
  say in the prompt what each one is for.
- gemini-omni-video (Gemini Omni 1.2 Flash) picks one mode from what you
  attach: text, FRAMES = first_frame_url (+ last_frame_url), REFERENCES =
  reference_image_urls (≤6; image_urls also counts as references here, as before),
  or EDIT = one clip in reference_video_urls (≤15s, ZenityX upload; the output
  keeps the clip's length, duration is ignored, one price per resolution) with
  up to 5 reference images. Frames never mix with references or a clip (free
  400). duration whole seconds 3-15 (3-10 with a last frame); 360p, 720p,
  1080p or 4k; audio (speech, music, effects) is always generated.
- reference_video_urls (Seedance 2 family 3 / seedance-2-5 10 / wan/3-0-video
  5 / minimax/h3 3): video references — upload them first (POST /files); links on
  any other host are a free 400 ref_video_url_refused. These are BILLED: every family meters
  (reference + output) seconds, so a 10s clip attached to a 6s render prices
  16s. wan/3-0-video additionally caps reference + output at 30s. The clips'
  TOTAL length is capped too: seedance-2 / seedance-2-fast 15s, seedance-2-5
  30s (input_slots.reference_videos_total_seconds_max) — over it is a free 400.
  We measure each clip ourselves (MP4/MOV); a fragmented clip (ffmpeg
  frag_keyframe / -frag_duration, browser MediaRecorder) counts to its last
  fragment, and one we can't read to the end is a free 422
  ref_video_duration_unknown — export it with ffmpeg -movflags +faststart.
  reference_audio_urls (same per-model caps): audio guidance, e.g. a
  POST /voice result. bytedance/seedance-2-5: EACH audio clip must be 2-30s
  (input_slots.reference_audio_seconds_min/max) — outside it is a free 400
  ref_audio_duration_out_of_range naming the clip. wan/3-0-video: each clip
  at least 1s AND all clips together at most 15s
  (input_slots.reference_audio_seconds_min/max +
  reference_audio_total_seconds_max) — otherwise the same free 400, naming
  the short clip or the measured total.
- reference_file_urls / reference_link_urls (wan/3-0-video only): ONE
  document (pdf, docx, pptx, xlsx, txt or md — upload it first with POST
  /files; a .txt/.md needs ?filename=) OR ONE public web page that needs no
  login (e.g. a product page) for the model to read as the brief. Never both,
  never with image_urls (frames); they combine with reference images, videos
  and audio (20 items at most in total). Free: neither changes the price.
  Name them in the prompt ("Use the product facts from the document").
- aspect_ratio "adaptive" (wan/3-0-video, bytedance/seedance-2,
  bytedance/seedance-2-fast, bytedance/seedance-2-5): the model picks the
  ratio from what you attach — use it when editing or extending a clip in
  reference_video_urls so the output keeps the clip's own ratio.
- bytedance/seedance-2 reference images: each one's width/height must be
  between 0.4 and 2.5, i.e. 2:5 to 5:2
  (input_slots.reference_image_aspect_ratio_min/max). A taller or wider one
  (long phone screenshot, panorama) is a free 400 image_aspect_out_of_range
  naming its index — crop it first.
- edit_mode: true (bytedance/seedance-2-5 only): EDIT one existing clip —
  exactly ONE reference_video_urls entry (4-30s), no image_urls; write the
  prompt as the change ("Change the jacket to red. Keep everything else the
  same."). Output keeps the clip's length and aspect ratio, so duration /
  aspect_ratio are ignored. Price = rate x (clip + clip) seconds, held while
  it renders; after success you are charged once for the clip rendered (a
  shorter render costs less), never above the quote. edit_mode on ANY other model is a 400 — minimax/h3 edits
  footage WITHOUT it (clip in reference_video_urls + the change in the prompt).
  Without edit_mode, seedance-2-5 itself may decide a prompt + reference clip
  is an edit: ZenityX then re-runs the job as an edit when it has one 4-30s
  clip no longer than duration, no image_urls (you are charged once at the
  edit price, never above the quote); otherwise it fails and is not charged.
- task_type (Seedance 2.x, with reference_* inputs only — never with image_urls
  frames or a bare prompt, free 400): "reference" (a new clip guided by the
  references), "extend" (continue the clip in reference_video_urls; the
  ratio becomes adaptive, you still pick duration; price = rate x (clip +
  duration) seconds), "edit" (bytedance/seedance-2-5 only, = edit_mode: true),
  or "auto"/omitted (the model decides). Naming it stops the model from
  re-reading an extend as an edit. On seedance-2 / seedance-2-fast "extend"
  sets adaptive and the prompt does the rest ("Extend Video 1 forward: …").
- return_last_frame: true (bytedance/seedance-2-5): the result also carries
  last_frame_url (a JPEG of the final frame) — send it as image_urls[0] of
  the next clip to continue the shot. Free.
- output_format "mov" (bytedance/seedance-2-5; default "mp4"): H.264 4:4:4
  with PCM audio for editing software; browsers may not preview it. Free.
- draft: true (bytedance/seedance-2-5, resolution 480p, a fixed duration; not
  with edit_mode or duration -1): a quick 480p preview at the normal 480p
  price. Within 7 days, re-render the one you like at 1080p by sending
  model_key bytedance/seedance-2-5 + upgrade_draft_id = the draft's
  generation_id (prompt optional, no references or frames; duration, ratio
  and reference-video billing come from the draft). The re-render is priced
  as a 1080p clip of the draft's length (quote it with quote_only first), so
  draft + 1080p costs more than a direct 1080p clip: draft several, upgrade
  the best.
- duration -1 (Seedance 2.x, not with edit_mode): auto length — the model
  picks 4-15 s (seedance-2, seedance-2-fast) or 4-30 s (seedance-2-5). The
  quote is the longest clip's price, held while it renders; after success
  you are charged once for the seconds rendered (team-pool jobs too). Never
  above the quote; GET /generations/:id charged_credits shows the final amount.
- bytedance/seedance-2 resolution "4k" (size normal only; the fast tier and
  seedance-2-fast stop at 720p, seedance-2-5 at 1080p).
- Seedance reference clips: each 2-30 s (shorter is a free 400
  ref_video_too_short). seedance-2-5 never takes reference_audio_urls
  together with image_urls frames (free 400).
- kling-4.0/video picks its mode from what you attach, never both (free 400):
  FRAMES = image_urls [first] or [first, last] + keyframe_urls (up to 8
  middle frames in time order; frames in total capped by duration: 3s 4 …
  8s 9, 9s+ 10) — the clip copies the first frame's exact aspect ratio.
  REFERENCES = reference_image_urls (10), reference_video_urls (5, each
  2-30s), elements (7: {name, image_urls 2-4 | video_url 3-8s, tag,
  description, voice_url}) and voices (3: {name, audio_url}). Name each
  thing in the prompt with @ (@first_frame @keyframe_1 @last_frame |
  @image_1 @video_1 @<name>); an @ that names nothing attached is a free 400.
  Any reference video (clip or video element) bills duration + max(reference
  seconds, duration) at the '_video' rate. param_guide.rules has the detail.
  kling-4.0-flash/video works the same way with fewer options: 720p only,
  3-20s, a first frame only (no middle or last frame), reference clips 2-20s
  each and 2-20s in total, no standalone voices (an element's voice_url works).
- grok-imagine-video-1-5 takes image_urls (1-7 input images, @image1 …) OR
  pinned frames, never both (free 400): first_frame_url, last_frame_url,
  keyframes (up to 4 [{url, timestamp_s}] at whole seconds inside the clip)
  or loop_url (one image as start and end, alone). With a frame the clip takes the
  image's own ratio; one image in image_urls does not fix the ratio (send it as
  first_frame_url for that); with 2-7 images aspect_ratio applies. 1080p takes
  one start image only. Frames cost nothing extra (2/3/5 cr/s at 480p/720p/1080p).
- Seedance duration: whole seconds, 4-15 on seedance-2 / seedance-2-fast,
  4-30 on seedance-2-5 — 2-3 s (or longer) is a free 400 with
  allowed_duration_range, before any job is created.
- Machine-readable: every video model in GET /models carries
  param_guide.input_slots (e.g. first_last_frame_max / reference_images_max /
  reference_videos_max / reference_audio_max) — trust it over prose — and,
  where agents kept tripping, param_guide.rules (plain sentences).
Video model routing — suggestions, not rules; the user's choice wins. Prices
are not typed here: GET /models pricing_rules, exact = quote_only.
ZenityX team picks (the team's choices from testing; advice, not rules — the user's choice or a better fit wins):
- Film and movie scenes → bytedance/seedance-2-5: the team rates it the best video model today, but it is expensive.
- Film and movie scenes at a lower price → wan/3-0-video: it does them well too and speaks Thai, like bytedance/seedance-2-5.
- Products, ads, fashion, portraits and general footage → kling-4.0/video; on a budget → kling-4.0-flash/video.
- A clip with Thai speech → grok-imagine-video-1-5 when you have an image, gemini-omni-video when you don't; an ad where someone speaks Thai → gemini-omni-video; the most natural voice, when the user asks for it or budget is no issue → flux-3-video or kling-4.0/video; the cheapest → veo-3-1-lite.
- Bringing one product photo to life on a budget → grok-imagine-video-1-5.
- 4K video → kling-3.0/video (quality=4k).
What each model offers:
- model_key omitted → kling-4.0-flash/video: send duration 3-20; 720p only,
  one image_urls entry (first frame), ratio 16:9/9:16/1:1/21:9, no "@" in the
  prompt unless it names an element.
- Thai speech / dialogue in the video (ZenityX listening test, 2026-10-31). Write
  'speaks Thai: "<the line>"' — the line in quotes, about 2 short sentences per
  6 s: a longer line gets cut off, a shorter one leaves silence. Most natural
  by ear in that test: flux-3-video, gemini-omni-video,
  grok-imagine-video-1-5, then veo-3-1-fast / veo-3-1-lite, with
  kling-4.0/video near the top. ltx-2.5/video (natural, but hard words come
  out less clear), minimax/h3, wan/3-0-video and bytedance/seedance-2-5 also
  speak Thai, less naturally. hailuo/2-3-image-to-video-pro has no audio.
  Other kling/seedance models do NOT speak Thai.
- kling-4.0/video: 720p/1080p, 3-30s, sound always on; a first frame, fixed
  frames (first → up to 8 middle → last), or up to 10 reference images /
  elements, up to 7 recurring characters/props and 3 named voices.
- kling-4.0-flash/video: 720p, 3-20s, a first frame or references (no last
  frame, no standalone voices); billed seconds with a reference video cost
  less per second.
- grok-imagine-video-1-5: 1-15s, sound always on, needs at least one image.
- 4K output → kling-3.0/video (quality=4k), or bytedance/seedance-2
  (resolution "4k", size normal).
- bytedance/seedance-2 / -2-5: multi-reference work; seedance-2-5 takes the
  most references (30 images / 10 videos / 10 audios) and edits a 4-30s clip
  with edit_mode=true.
- 2K output with built-in stereo sound, or editing existing footage (swap a
  subject, change wardrobe/dialogue, transfer motion) → minimax/h3 — NO
  edit_mode there: the clip goes in reference_video_urls. Reference mode needs
  duration >= 6.
- bytedance/seedance-1.5-pro: footage without dialogue.
- wan/3-0-video: 2-30s, 10 images / 5 videos / 5 audios as references, or a
  document / web page as the brief; quality=prime is the faster endpoint at a
  higher per-second rate, same output.
- ltx-2.5/video: 6-20s with built-in sound from text or a first (+ last)
  frame; quality=fast (720p-4K, 6-20s) or pro (720p/1080p, 6/8/10s).
Video params are model-specific. DON'T guess — GET /models returns a
`param_guide` for every video model telling you exactly which params affect its
price and the valid combinations. Read it first, then send params that match one
`valid_combos` entry:
- size (mode): fast|normal|std|pro|standard|prime — when combos start with one
  (e.g. "std_nosound", "prime_720p"). On wan/3-0-video prime = the faster
  endpoint at ~1.5x the price, same output quality; default standard.
- resolution: 480p|720p|768p|1080p|2k|4k — when combos contain one (e.g. "480p_8_sound")
- duration: whole seconds — when combos contain a number (e.g. "5","10","480p_4")
- sound: true|false — when combos end in _sound / _nosound
If the params you send don't form a priced combination (e.g. a per-second model
with duration omitted), you get a 400 with the model's valid_combos — NO credits
spent, just retry with a valid combination. An out-of-range duration returns 400
with allowed_durations (or allowed_duration_range on per-second models).
-> 202 {"generation_id", "estimated_credits", "poll": "..."}

### POST /upscale
    {"model_key": "ultimate-image-upscaler", "source_url": "https://...",
     "resolution": "4k", "output_format": "png"}
Upscale an existing image or video to higher resolution (model_type 'upscale'
in GET /models). source_url is the media to upscale (from a previous generation
or POST /files). Image upscalers (input_type image, e.g. ultimate-image-
upscaler) charge a fixed price. VIDEO upscalers bill per second on the length
WE measure (see Duration billing), so the video must be a ZenityX file (POST
/files, or a previous generation's URL) — a link on any other host is a free
400 media_url_refused. resolution
must be one the model lists (400 with allowed_resolutions otherwise).
output_format (image upscale only): png (default) | jpeg | webp — "jpg" is
accepted as jpeg; anything else is a 400 before any charge. Scope: an
image upscale needs the 'images' scope, a video upscale needs 'videos'.
-> 202 {"generation_id", "estimated_credits", "poll": "..."}

### GET /voices
-> {"voices": [{"voice_id", "name", "description", "use_case", "gender",
    "age", "accent", "thai_verified", "aliases": [...], "model_keys": [...]}, ...],
    "default_voice_id": "..."}
The Thai voice catalog (all Thai-verified; `model_keys` = the voice models a voice works on —
most work on every one, a few only on the newer model). CALL THIS FIRST when
picking a voice — names are curated data and DO change. Use `voice_id` (always
safe) or any listed alias. use_case is one of: narration · conversational ·
social_media · characters_voice_acting · informative_educational · meditation.

### POST /voice
    {"text": "สวัสดีค่ะ ยินดีต้อนรับสู่ ZenityX",
     "voice": "วาริน", "language_code": "auto", "stability": 0.5}
Direct ElevenLabs text-to-speech / dialogue: Eleven v4 by default (v3 while v4 is not
open to your account), `model_key` picks one (GET /models type voice); no v2 fallback.
Thai works out of the box — leave language_code "auto" or pass "th". Simple
case: send `text` + a `voice` from GET /voices (a voice_id or a listed alias
such as "วาริน" / "Sarah"). Multi-speaker: send
`dialogues: [{"text": "...", "voice": "วาริน"}, {"text": "...", "voice":
"ภูผา"}]` with at most 10 unique voices. Omitting voice uses the default
voice_id from GET /voices. An unknown voice returns 400 naming the discovery
route; an ambiguous alias returns 400 listing the matching voice_ids. Max 5000
characters total. Cost uses the active model's explicit per-1000-character
pricing and fails closed if pricing is unavailable. Needs the 'videos' scope.
-> 202 {"generation_id", "estimated_credits", "poll": "..."} — the finished
audio URL comes back in the generation's image_url.

### POST /avatar
    {"model_key": "infinitetalk", "image_url": "https://...",
     "audio_url": "https://...", "duration_seconds": 10, "resolution": "480p"}
Make a person in a photo talk (lipsync) or copy a motion. Each avatar model
(model_type 'avatar' in GET /models) needs specific inputs:
- LIPSYNC (infinitetalk, ltx-lipsync): image_url + audio_url → the person
  speaks the audio. Pairs with POST /voice (generate speech, feed its URL here).
  Audio: infinitetalk up to 600s (10 min), ltx-lipsync up to 20s.
  infinitetalk also lip-syncs an existing VIDEO: send video_url INSTEAD of
  image_url (+ audio_url) → the person in the clip speaks the new audio. Photo
  OR video, never both (free 400). Same price, billed on the audio; the output
  follows the audio's length. Upload the clip via POST /files first.
- VIDEO LIPSYNC (volcengine/lipsync-lite, volcengine/lipsync-basic): video_url (a
  clip of ONE person speaking, 360p-1080p) + audio_url (the new speech, pure voice),
  no image_url → the same clip with the mouth re-timed to the new speech; face,
  scene and picture stay. Output as long as the audio (a longer clip is cut, a
  shorter one looped). lite = frontal face, faster, audio up to 240s; basic =
  harder scenes (cuts, angles), up to 210s. Both files must be ZenityX files
  (POST /files): other hosts = free 400 media_url_refused. A clip that may show
  anyone under 18 with sexual speech is refused and not charged (minor_sexual_content).
- MOTION (kling-2.6/motion-control, kling-3.0/motion-control, wan-animate):
  image_url + video_url → the subject copies the motion video. kling 3-30s,
  wan-animate 3-120s.
- video-translate: video_url (3-120s) + output_language, optional quality: "speed"
  (default) or "precision" (sharper lip-sync, e.g. visible teeth; twice the
  speed price) — GET /models modes/default_mode, prices in pricing_rules
  (480p = speed, precision_480p = precision). Other avatar models take no
  quality (400 invalid_quality).
Each model's limits are in GET /models as duration_limits {media, min_seconds,
max_seconds}: max_seconds applies to the billed (rounded-up) seconds, so 20.02s
of audio is 21s and over a 20s limit; min_seconds to the measured length. Out of
range = free 400 media_duration_out_of_range with the limits and the measure.
Billed per second on the length WE measure of the audio (lipsync) or the video
(motion/translate) — see Duration billing. That file must be a ZenityX file
(POST /files, or a POST /voice / previous generation URL); a link on any other
host is a free 400 media_url_refused. The photo may be any https link.
resolution must be one the model lists. Missing an input returns a 400 that
names it. Scope: 'videos'.
Local photo/audio/video files: upload via POST /files first (raw binary or
/files/upload-url), then use the returned url here.
-> 202 {"generation_id", "estimated_credits", "poll": "..."} — result video in image_url.
Avatar jobs render much slower than real time, in proportion to the length:
infinitetalk takes about 15-45s per second of audio at 720p (7-17s at 480p), so
1 min of audio is ~15-45 min and 10 min is ~2.5-7.5 hours. The job stays
"generating" the whole time — that is normal: poll every few minutes (not every
5-10s) and never resubmit. If the provider fails it, it is not charged (its held credits are released).

### POST /music
    {"mode": "idea",
     "idea": "เพลงป๊อปไทยสดใส เล่าเรื่องนักเรียนที่เพิ่งแต่งเพลงแรกด้วย AI อบอุ่น มีกำลังใจ",
     "style": "Thai pop, bright piano, acoustic guitar, warm female vocal",
     "idempotency_key": "<uuid>", "max_credits": 5}
Songs with the Suno V6 family. ONE job = 2 songs (two takes of the same
request), each with its own MP3, cover image, title and lyrics. Price per job
(for the pair): music_guide.credits_per_job in GET /models (type music) — the
same on every music model. model_key (optional): suno/v6 (default, most natural
vocals) · suno/v6-mini (faster, for trying ideas) · suno/v6-wild (experimental).
Pick ONE mode — the same three ways as the Music Studio page:
- "idea" — describe the song in plain words: mood, story, instruments, and the
  language to sing in (Thai works). The AI writes the lyrics, melody,
  arrangement and title, and picks the length (usually 2-4 minutes).
  Required: idea (≤3000 chars). Optional: style (≤1000), instrumental: true
  (no vocals). NOT used in this mode (400 if sent): title, lyrics,
  duration_seconds, vocal_gender, exclude_styles — say those things in idea/style.
- "lyrics" — the AI sings the user's own words. Required: title (≤80), lyrics
  (≤5000), rights_confirmed: true. Optional: style, exclude_styles (≤1000,
  what to avoid, e.g. "heavy metal, autotune"), vocal_gender "female"|"male",
  duration_seconds (10-360, default 180 = 3 min).
  Lyrics = only the sung words plus section tags, each tag on its own line:
      [Verse]
      สมุดการบ้านวางอยู่ข้างหน้าต่าง
      ดินสอขีดคำที่คิดมาทั้งวัน
      [Chorus]
      นี่คือเพลงแรกของฉัน
  Tags: [Intro] [Verse] [Pre-Chorus] [Chorus] [Bridge] [Outro]. The sound
  (genre, mood, instruments, voice, tempo) goes in style, not in lyrics.
  Set rights_confirmed: true ONLY after the user confirms they wrote the lyrics
  or have the right to use them.
- "instrumental" — no vocals (background for clips, presentations, podcasts).
  Required: title, style (e.g. "lo-fi hip hop, soft electric piano, rain
  ambience, 80 bpm"). Optional: exclude_styles, duration_seconds.
style reads best as comma-separated English tags. NEVER use artist or band
names, or lyrics of existing songs, in any field: the provider refuses the job
— it fails about a minute later and is not charged, but the user waited for
nothing. Describe the sound instead ("90s Thai rock ballad, raspy male vocal").
Every mistake in the body is a free 400 that names the field (codes
bad_request / unknown_field) — nothing is charged. Scope: 'videos'.
-> 202 {"generation_id", "status": "generating", "mode", "estimated_credits",
"songs": 2, "poll"}. Songs usually take 1-3 minutes: poll GET /generations/{id}
every 10-15 s. On "success" the generation has tracks: [{"index", "title",
"audio_url" (MP3), "cover_url", "duration_seconds", "style", "lyrics"}, ...]
(2 songs); image_url / image_urls hold the MP3 links too. Give the user BOTH
songs — they were made and paid for together.
duration_seconds is a target, not a promise: most takes land within a few
seconds of it, but now and then one take runs much shorter or longer (asked 60
→ got 60 and 162). Read tracks[].duration_seconds and tell the user which take
fits the length they wanted.

### POST /voice with Eleven v4
POST /voice uses "model_key": "elevenlabs-text-to-dialogue-v4" (ElevenLabs v4, the
newer model) when model_key is left out: same fields, voices and price per character
as v3 (GET /models type voice). Send "elevenlabs-text-to-dialogue-v3" to keep v3.
POST /voice speaks only with catalog voices (GET /voices). To speak in a real person's
own voice (a voice clone from a short clip), use POST /audio below with
reference_audio_urls.

### POST /audio
    {"scene": "ร้านกาแฟตอนเช้า ฝนตกเบาๆ มีเสียงแก้วกระทบกัน",
     "lines": [{"text": "สวัสดีครับ วันนี้รับอะไรดี", "clip": 1},
               {"text": "ลาเต้ร้อนแก้วนึงค่ะ", "clip": 0}],
     "voice": "vivi_mixed_en_zh_ja_es_id",
     "reference_audio_urls": ["https://img.zenityx.xyz/..."],
     "idempotency_key": "<uuid>", "max_credits": 30}
Scene audio with Seed Audio 1.0: ONE job = one MP3 with the spoken lines, sound
effects and ambience together. scene = the sound in plain words (optional when there
are lines; alias "prompt"). lines = what is said, in order (≤6): clip 0 or omitted =
the preset voice; 1-3 = the voice of reference_audio_urls[clip - 1] — up to 3 short
clips of a real person (mp3/wav, ≤30 s, ≤10 MB), each one this account's own, sent as the
exact URL: uploaded with POST /files, still in its Studio uploads, or a voice it made in
Studio (another account's file or a deleted upload = free 400). The model
copies the voice while it renders; ZenityX keeps no voice profile of it. voice = a preset id
from GET /models (type audio) audio_guide.voices (default: the first one). Optional
image_url (a start image uploaded with POST /files; not together with clips), speed /
volume 0.5-2, pitch -12..12. A field it doesn't use is a free 400 unknown_field.
Price: per second of the OUTPUT (pricing_rules.credits_per_minute, rounded up per
second). The length is only known at the end, so estimated_credits is a HOLD of
hold_seconds: set aside while the job runs (max_credits is checked against it); when the MP3
is ready only the real length is charged — a longer MP3 is billed in full at the same rate,
even below a zero balance. quote_only returns the hold. Credits held by the
user's running jobs can't start another: 400 credits_held. Scope: 'videos'.
-> 202 {"generation_id", "estimated_credits", "billing": "hold", "poll"}. Usually under
a minute. The MP3 is in image_url; charged_credits is final once billing_status is
"charged" ("settling" = the real length is being billed, poll again).

### GET /generations/{id}
-> {"generation": {"status": "generating"|"finalizing"|"success"|"failed", "image_url":
"<media URL when success — also used for videos>", "image_urls": [<all files
when a generation returns several>], "actual_credit_cost",
"error_message", "error_code", "retryable", ...}}
"finalizing" (with media_pending: true) means the job is DONE and charged and its
files are being copied to permanent storage — keep polling (seconds, rarely a few
minutes); it is not a failure, never resubmit. Only "success" carries the URLs.
Poll every 5-10s. Lite image models usually finish in under a minute; premium
image models typically 1-2 minutes (up to 3-5); videos 1-5 minutes (up to 10+);
avatar/lipsync jobs scale with the audio length and can take hours (see POST
/avatar); music 1-3 minutes (a music result also has tracks[] — see POST /music).

### GET /packages
Credit top-up packages: {id, name, credits, bonus_credits, total_credits,
price_thb, is_popular}. Bigger packages include FREE bonus credits — the
ladder matters: e.g. 1,000 THB -> +50 free, 5,000 THB -> +350 free,
10,000 THB -> +1,000 free (10%).

### POST /topup
    {"package_id": "<id from GET /packages>"}
-> 201 {"purchase_id", "amount_thb", "qr_image_url", "expires_at", ...}
A caller billed to a team (GET /credits → billing.payer "team") gets 409
team_billing: team credits are added by ZenityX for the team owner, never here.
Creates a Thai PromptPay QR that credits the key owner's account. Show the QR
(image or link) to the user; they scan it with any Thai banking app. Credits
apply automatically on payment — poll GET /credits every ~10s, then retry the
original request.
Agent etiquette for top-ups:
- Suggest the smallest package that covers the shortfall, AND always show the
  full ladder with what the next size up adds for FREE — the USER decides.
- Never call POST /topup before the user explicitly agrees to a package.
- Card payment is web-only (studio.zenityx.com) — never handle card data.

### GET /generations?limit=10
Recent generations submitted through this API (newest first, max 20).
This is the RECOVERY endpoint: if a generate request times out on your side,
the job and its charge still exist on the server — find it here (match by
prompt/created_at) and resume polling its id. Never resubmit first; a
resubmit creates a second job and charges again.
BULK POLL: GET /generations?ids=<uuid,uuid,...> (max 10) returns exactly
those generations in one roundtrip — poll a whole batch with a single
request instead of one call per id.

### Kling 3.0 quality tiers
"quality" (= "size") on kling-3.0/video takes std|pro|4k and picks BOTH the
price tier and the rendered output quality (std→720p, pro→1080p, 4k→4K).
Do NOT send resolution for kling — it has no effect there.

### Duration billing (avatar + video upscale)
The server MEASURES the real duration of the billed media (audio for lipsync,
video for motion/translate/upscale) and bills on that, rounded up to whole
seconds (5.04 s bills 6) — duration_seconds you send is informational only (echoed back as requested_duration_seconds next to
detected/billable). Each model has a minimum billed length (5 s on the
WaveSpeed lipsync/animate/translate and video upscale models: a 3 s clip bills
5 s). estimated_credits and billable_duration_seconds already include it —
estimated_credits is exactly what the job is charged, from the same price
table. Unverifiable media (unsupported container, unreachable
URL) is rejected with code duration_unverifiable — upload via POST /files
first (mp3/wav/mp4/mov are parseable). So is a file whose length can only be
estimated from its header (streamed WAV) or an MP3 whose frames can't all be
counted to the end of the file (junk between frames, audio inside a trailing
tag, VBR without a Xing frame, over 25MB) — re-export it as MP4/M4A/WAV or a
constant-bitrate MP3. A fragmented
MP4/M4A (ffmpeg frag_keyframe / -frag_duration, browser MediaRecorder) is
measured fragment by fragment; one too long to read that way is refused the
same way — export it unfragmented (ffmpeg -movflags +faststart). Nothing is
charged on a duration_unverifiable.
The billed file must be on ZenityX storage (https://img.zenityx.xyz/… — the url
POST /files returns, or a generation result): we measure it, and the provider must render the
same bytes. Any other link is refused before we read it — 400
media_url_refused with the field to fix (audio_url / video_url / source_url)
and charged_credits 0. Upload it with POST /files and retry.

### Billing truth per generation
GET /generations[/:id] returns charged_credits / refunded_credits /
net_credits / billing_status (charged|refunded|reserved|not_charged).
FAILED generations are never charged: their net_credits is 0. A
personal-wallet job — image, video, avatar / lip-sync, upscale, voice, music,
Seed Audio — holds its price while it runs (billing_status reserved — the
balance does not drop) and is charged once, when it succeeds; a failed one
releases the hold (billing_status not_charged, refunded_credits 0). A team-pool
job holds its price on the team pool the same way (its member quota counts it at
start, and gets back what a shorter Seed Audio scene, edit or auto-length clip
did not use). Only jobs started before holds were debited at start and refunded
automatically on failure (billing_status refunded) — ignore the legacy
actual_credit_cost field on those failed rows (it records the gross debit that
was already returned). A success can read billing_status settling for a moment while its
final charge lands: poll again.

### Why a generation failed
A failed generation's error_message says what happened and what to do next, in
plain English; error_code and retryable (null unless failed) let you branch:
- provider_unavailable, timeout (retryable: true): the AI model's provider had a
  problem. Not the user's credits, and the job was not charged: retry in a few
  minutes; never offer a top-up for these.
- content_blocked, copyright, real_person: the model's filters declined the
  prompt or reference media. The same input fails again: change the prompt or
  media, or try another model.
- minor_safety: the content may involve children or minors. Never route it to
  another model. If retryable is true (the age check was briefly unavailable)
  or error_message names a fix (upload the reference via POST /files), follow
  error_message on the same model; otherwise do not resend it and tell the user
  it was declined.
- invalid_input: a parameter or reference file does not fit the model;
  error_message names it. Fix it, then submit again.
- rate_limited (retryable: true): wait about a minute. insufficient_credits:
  offer a top-up. team_quota: wait or ask the team owner. cancelled: the user
  stopped it. unknown: retry once at most, then tell the user.

## Billing safety (all generate/upscale endpoints)
Every billable POST (/images /videos /voice /avatar /upscale /music) accepts:
- "idempotency_key": your unique string per creative intent (e.g. a UUID).
  Retrying with the same key + same payload returns the ORIGINAL generation
  (200, "replayed": true) — no second charge. Same key + different payload
  -> 409 idempotency_conflict. ALWAYS set it when you might retry. The
  replay answers before any reference-file check or pricing, so a retry
  never re-measures your files.
- "quote_only": true — FREE price check: the same body you would submit
  returns 200 {"quote_only": true, "estimated_credits", "charged_credits": 0}
  (a prompts[] batch also gives estimated_credits_per_item). Nothing is
  submitted; no idempotency_key needed. Use it whenever you need the price —
  never probe with a tiny max_credits.
- "max_credits": the USER'S budget in CREDITS (not money, not a per-second
  rate) — if the price is above it the call is rejected BEFORE any charge
  (400 over_budget with estimated_credits + hint). Set it only when the user
  gave a budget, or to the quote_only price you just showed them; otherwise
  omit it. On a batch it is the TOTAL. On over_budget, show the user
  estimated_credits and resend only with their OK. Seed Audio (POST /audio) is
  the one exception: max_credits is checked against its hold, and an MP3
  longer than the hold is billed in full at the same rate.

## Errors
JSON: {"error": "<human message>", "code": "<machine code>"}
| HTTP | code                 | meaning / what to do                          |
|------|----------------------|-----------------------------------------------|
| 401  | auth_error           | missing/invalid/revoked key                    |
| 403  | auth_error           | account lacks API access (closed beta)         |
| 400  | insufficient_credits | not enough credits — GET /packages, offer POST /topup (PromptPay QR in chat) |
| 400  | credits_held         | the balance covers it, but the user's jobs still generating hold part of it (held, available, required) — nothing charged; retry when they finish, or offer a top-up |
| 400  | generation_rejected  | generation rejected for another reason (see error text) |
| 503  | provider_unavailable | ZenityX's upstream AI provider is temporarily down — NOT the user's credits, nothing charged; retry after retry_after_sec, never offer a top-up for this |
| 500  | pricing_unavailable  | no price is configured for this model + setting (a ZenityX config problem) — nothing charged; try another resolution/model, don't retry in a loop (a temporary price-check failure is a 503 internal with retry_after_sec — retry that one) |
| 400  | over_budget          | price > your max_credits — nothing charged; estimated_credits is the exact price: ask the user, then resend with max_credits >= it (or without). Check prices with quote_only, not a tiny max_credits |
| 409  | idempotency_conflict | same idempotency_key reused with a DIFFERENT payload — pick a new key |
| 429  | rate_limit           | too many requests — wait retry_after_sec       |
| 429  | team_quota           | team accounts: this member's 5-hour / weekly quota (quota_window "5h" / "week") or the team's weekly cap ("team_week") is used up — nothing charged; wait retry_after_sec when present (absent = the job is bigger than the limit: waiting won't help), or the user asks their team owner / manager to reset or raise it. Never offer a top-up |
| 402  | team_pool_insufficient | team accounts: the team's credit pool can't cover the job — nothing charged; team credits are added by ZenityX for the team owner, POST /topup does not help. Tell the user to ask their team owner |
| 402  | team_pool_held       | team accounts: the pool covers the job, but the team's jobs still generating hold part of it — nothing charged; retry when they finish (new idempotency_key), POST /topup does not help |
| 403  | team_inactive        | team accounts: this account is not an active member of the team the key / session is billed to, or the team is suspended — nothing charged |
| 409  | team_billing         | POST /topup from a caller billed to a team — top-ups are personal; nothing charged |
| 429  | daily_limit_exceeded | this key's daily credit cap reached — nothing charged; it resets at midnight Asia/Bangkok: retry_after_sec / resets_at say when (GET /credits → api_key.remaining_today shows it before you submit) |
| 400  | unknown_model        | bad model_key — call GET /models               |
| 400  | unknown_field        | POST /music got a field it doesn't use — the response lists allowed_fields; put genre/mood/instruments/voice in style. Nothing charged |
| 403  | music_not_open       | Music isn't open for this account yet. Nothing charged |
| 403  | model_not_open       | this model is in a closed trial and not open for this account yet — use another model. Nothing charged |
| 503  | model_unavailable    | this music model is under maintenance — use another music model or retry after retry_after_sec. Nothing charged |
| 400  | invalid_aspect_ratio | this model doesn't take that ratio — response lists allowed_aspect_ratios; each model's list is in GET /models (aspect_ratios) |
| 400  | image_aspect_out_of_range | seedance-2: a reference image is taller/wider than 2:5…5:2 — response names the index; crop it. Nothing charged |
| 400  | ref_audio_duration_out_of_range | reference audio length: seedance-2-5 each clip 2-30s; wan/3-0-video each clip ≥1s and ≤15s in total — response names the index (or the total) + measured seconds. Nothing charged |
| 400  | ref_video_url_refused | a reference_video_urls clip is not a ZenityX file — upload it (POST /files) and retry. Nothing charged |
| 400  | media_url_refused    | /avatar or video /upscale: the per-second-billed file (response `field`: audio_url / video_url / source_url) is not a ZenityX file — upload it (POST /files) and retry. Nothing charged |
| 400  | invalid_quality      | quality is not one of the model's modes (response `allowed_qualities`). POST /avatar: only video-translate has them (speed / precision). POST /images: recraft-vector (normal / pro) and gpt-image-2-5 (flare / sunburst). Nothing charged |
| 400  | media_duration_out_of_range | POST /avatar: the audio/video is longer (billed seconds) or shorter than the model's duration_limits in GET /models — response has field, min_seconds, max_seconds, detected_duration_seconds. Trim it or pick another model. Nothing charged |
| 400  | duration_unverifiable | we couldn't measure the billed file's length (or only estimate it) — re-export as MP4/M4A/WAV/CBR MP3, upload, retry. Nothing charged |
| 400  | invalid_quality_level | quality_level is not one of the model's quality_levels (response `allowed_quality_levels`), or the model has none — omit it. Nothing charged |
| 400  | invalid_background   | background transparent / opaque on a model or size without the option, or transparent with a file type that cannot keep it (response says which) — nothing charged |
| 400  | invalid_output_format | POST /images: this model can't return that file type — response lists allowed_output_formats (one entry = the model has no choice; omit output_format). Nothing charged |
| 400  | invalid_resolution   | this IMAGE model doesn't render that resolution (e.g. nano-banana-2-lite is 1K-only) — response lists allowed_resolutions; each model's list is in GET /models (resolutions) |
| 413/415 | too_large / unsupported_type | upload rejected                     |

## Limits
60 generations / 5 min and 400 / hour per account (a refused attempt does not count). Uploads: 100 files / 5 min
AND 2GB cumulative / day per account (resets midnight UTC = 07:00 Asia/Bangkok;
a 429 carries `reason`: `rate_limit_5min` or `daily_quota_2gb` +
`retry_after_sec` counting down to the actual reset). Upload once and REUSE the
returned url across generations — re-uploading the same file burns quota.
Size caps by media kind on the raw/tokened paths (image 20MB · audio 25MB ·
video 100MB) — the 8MB cap applies ONLY to the JSON base64 path. Failed
generations are never charged (held credits are released, debits refunded).

## Remote MCP (agents)
MCP endpoint: https://mcp.zenityx.com (Streamable HTTP). Two ways to connect:
- OAuth (Claude "Add custom connector", and clients with MCP OAuth): paste
  https://mcp.zenityx.com, log in with the ZenityX Studio account, approve. No key needed.
- API key (Codex/config-based clients): send header x-api-key: zxs_live_...
  (or Authorization: Bearer). Prefer env-backed config over a literal key —
  tools like `codex mcp list --json` print config values unredacted:
      [mcp_servers.zenityx-studio]
      url = "https://mcp.zenityx.com"
      env_http_headers = { "x-api-key" = "ZENITYX_API_KEY" }
  then `source zenityx.env` (the file downloaded when the key was created).
After a tool-schema release (serverInfo.version changes) reconnect / start a
new task — long-lived sessions keep the old tool catalog cached and will not
see newly added parameters like max_credits until they reconnect.

## Rules for agents
- Never invent model_key values — always pick from GET /models.
- Warn the user before expensive video generations; state the price from a
  quote_only call (free) — don't compute it from credit_cost.
- Music: never put artist/band names or lyrics of existing songs in any field;
  ask the user before setting rights_confirmed; deliver both songs of a job.
- If a request times out on your side, check GET /generations before anything
  else — the job usually completed and resubmitting would charge again.
- The API key is a secret. Never print or echo it back in full.
