# ZOOOP > ZOOOP is an AI-native creative platform and filmmaking workstation. Generate > images, video, audio, speech, and music from every top AI model in one > browser-based workspace — through focused generator pages, reusable recipe > templates, or an infinite collaborative canvas. Credit-based and > pay-as-you-go; credits never expire and there is no subscription required. This file follows the `llmstxt.org` convention so language-model agents and AI search engines can understand what ZOOOP offers without crawling HTML. Website: · Model catalog: · Pricing: ## What is ZOOOP ZOOOP brings every leading generative-AI model under one roof so creators don't have to juggle a dozen separate tools and subscriptions. There are three ways to create: - **Generator pages** — a focused page per task (image, video, speech, music…) for when you just need one result. - **Recipe templates & tools** — one-click presets built on top of the models; creators can publish their own and earn from others' usage. - **Generative canvas** — an infinite pan/zoom workspace for storyboarding and chaining shots together, with real-time multiplayer collaboration. ## Capabilities - **Image**: text-to-image, image-to-image, image editing & inpainting, SVG / vector graphics, upscaling, background removal. - **Video**: text-to-video, image-to-video, motion control, first/last-frame interpolation, video extension, video-to-video editing, lip sync, and automatic sound effects & background music. - **Audio**: text-to-speech, voice cloning, sound-effect generation, and music generation. ## Supported models ZOOOP aggregates 70+ models from leading providers. A representative selection: - **Image**: Flux 2 (Pro / Flash), Nano Banana (Pro / 2), Seedream, GPT Image, Recraft (including vector), Grok Imagine. - **Video**: Google Veo 3 / 3.1, Kling (v2.6 / v3), ByteDance Seedance, MiniMax Hailuo, Alibaba Wan, Luma Ray 2, Pika, Vidu, LTX. - **Speech & audio**: ElevenLabs (multilingual v3, sound effects), MiniMax Speech, Qwen TTS, Gemini TTS, Google Lyria, MiniMax Music. The full, always-current catalog — with a dedicated page per model — lives at . ## Pricing - Pay-as-you-go credits. **Credits never expire**, and individuals need no monthly subscription. Free daily login credits are granted on check-in. - The cost of each generation depends on the model and the output (resolution / duration). - Teams get a shared credit pool with per-creator seats billed monthly. Current rates: . ## Why ZOOOP - **Every top model in one place** — switch models without switching tools or paying for multiple subscriptions. - **No subscription, no expiry** — buy credits and use them whenever you want. - **Two modes** — a focused generator when you need a single shot, an infinite canvas when you're building a whole sequence. - **Creator economy** — publish recipe templates and earn from others' usage. - **Runs in the browser** — no GPU and nothing to install. ## For developers — REST API ZOOOP also exposes a public REST API for image / video / audio generation. Authenticate with a Personal Access Token; per-call charges hit your existing credit balance. - [API reference](https://github.com/zooopai/skill-zooop/blob/main/references/api-docs.md) — full v1 endpoint specification - [llms-full.txt](/llms-full.txt) — every doc concatenated for single-fetch ingest ## Skill bundle (Claude Code / agent integration) The canonical agent skill bundle lives on GitHub at — `SKILL.md`, helper shell scripts, and install / contribution docs. Install it for any agent with: ``` npx skills add zooopai/skill-zooop ``` Add `-g` to install user-wide, or `-a claude-code -a codex` to target specific agents. Claude Code users can equivalently clone the bundle straight into their personal skills directory: ``` git clone https://github.com/zooopai/skill-zooop.git ~/.claude/skills/zooop ``` Other agents: clone the repo anywhere and point them at `SKILL.md`, or fetch . Human-readable install guide: . ## Quick start 1. Visit and create a PAT (`zpk_live_…`). Bind it to a project — all PAT-submitted tasks + uploads land there. 2. (Optional) Self-check budget + project before doing real work: `GET https://api.zooop.ai/v1/me` 3. List models for the task type: `GET https://api.zooop.ai/v1/models?type=image&subtype=default` 4. (Optional) Upload a local file the model will consume: `POST https://api.zooop.ai/v1/uploads` (raw body + `Content-Type`) 5. Submit a task: `POST https://api.zooop.ai/v1/tasks` with `{interfaceId, versionId, params}` 6. Poll until terminal: `GET https://api.zooop.ai/v1/tasks/{id}` All requests carry `Authorization: Bearer $ZOOOP_API_KEY`. ## Public categories (`type` × `subType`) The 12 (type, subType) pairs the API currently exposes match the homepage sidebar one-for-one: ``` image / default text-to-image / image-to-image image / edit-image image editing with mask video / text-ref text-to-video video / motion-control image-to-video with motion direction video / first-last-frame generate video between two keyframes video / audio-lipsync sync video to audio video / extend-video extend an existing video video / video-edit edit / restyle existing video audio / text-to-speech TTS audio / voice-clone voice cloning from sample audio / sound-effect text-to-SFX audio / music text-to-music ``` --- # SKILL.md (Agent integration guide) --- name: zooop description: | Generate or edit images / videos / audio via the ZOOOP AI platform — t2i, i2i, t2v, i2v, lipsync, upscale, remove-background, TTS, voice-clone, sound-effect, music, plus ZOOOP's published one-click templates (superhero poster, future baby, action figure, …) run by slug. Use whenever the user asks to generate, create, make, edit, or transform any of those media types, or names a ZOOOP template / pastes a zooop.ai/templates link. --- # ZOOOP AI generation Public REST API host: `https://api.zooop.ai` (override via `$ZOOOP_API_HOST`). Auth: `Authorization: Bearer $ZOOOP_API_KEY` on every request. `scripts/{upload,quote,submit,poll,download,ai-tools,templates,describe}.sh` ship with this skill. Inline `curl` recipes below work the same way if the bundle isn't cloned. Full request / response / error reference: [`references/api-docs.md`](./references/api-docs.md) — read it whenever this guide doesn't cover a field. ## API key — setup **The token must NEVER appear in the agent conversation.** Token in chat risks leaking into training corpora, telemetry, or shared transcripts — once leaked it spends the user's credits until revoked. Always have the **user** set the env var themselves, in their own terminal. Agent: check `$ZOOOP_API_KEY` (or OS-equivalent). If empty, give the user these instructions **verbatim** — do not ask them to paste the token back: 1. Get a token. **Personal** (spends your own credits): visit [https://zooop.ai/user#apiKeys](https://zooop.ai/user#apiKeys) → **Create token** → pick project (immutable) → **set a daily credit cap** → copy token (shown ONCE). **Team** (spends the team's shared credits): a team owner/admin creates it in the team admin → API Keys tab. Everything below works identically. 2. In their **own terminal** (NOT this chat), set the env var: - macOS / Linux / WSL / Git Bash — `echo 'export ZOOOP_API_KEY=zpk_live_…' >> ~/.zshrc && source ~/.zshrc` - Windows PowerShell — `[Environment]::SetEnvironmentVariable('ZOOOP_API_KEY','zpk_live_…','User')` - Windows cmd — `setx ZOOOP_API_KEY "zpk_live_…"` 3. **Restart the agent** so the new env var is inherited. Tasks and uploads land under the token's bound project. `GET /v1/me` shows the active wallet and balance (`user.creditBalance` personal, `team.creditBalance` team). ## Three paths: raw model, AI tool, template **Default to raw models.** The other two are narrow, deliberate surfaces: - **AI tools** cover capabilities raw text-to-image / text-to-video models CAN'T replicate — chiefly precise background removal and image / video upscaling. For everything else (style transfer, edits, age changes, character animations, etc.) a raw model is more flexible AND cheaper. - **Templates** are the site's one-click looks (superhero poster, future baby, action figure, …). Reach for one when the user names that specific look, or asks for "that viral effect" — the prompt behind it is tuned and hard to reproduce blind. Don't route generic requests through a template: its prompt is fixed and you cannot steer it. | Path | When | How | | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | **Raw model** (`interfaceId` + `versionId`) | Default. Text-driven generation, prompt-driven edits, anything where you'd write a prompt | `GET /v1/models?type=…&subtype=…` → submit with `interfaceId` + `versionId` + `params` | | **AI tool** (`aiTool` slug) | Specialized non-prompt-driven capabilities only — currently background removal + image/video upscale. Browse `GET /v1/ai-tools` to see what's actually exposed | `GET /v1/ai-tools` → submit with `aiTool: ` + the tool's `params[]` | | **Template** (`template` slug) | The user wants a specific published look ("超英海报", "future baby", "手办"), or hands you a `zooop.ai/templates/…` link. Browse `GET /v1/templates` | `GET /v1/templates` → submit with `template: ` + the template's `params[]` | `/v1/ai-tools` is a short curated list. If your task isn't on it, don't force it through — use a raw model with a tailored prompt. ```bash bash scripts/ai-tools.sh [image|video] # browse catalog, optionally filtered bash scripts/ai-tools.sh -s # one tool's full param schema bash scripts/templates.sh [image|video] # browse every published template bash scripts/templates.sh -s # one template's full param schema ``` Then reuse the raw-path `submit.sh` / `quote.sh`, swapping ` ` for `--ai-tool ` or `--template `: ```bash bash scripts/submit.sh --ai-tool background-removal '{"image_url":"https://storage.zooop.ai/…"}' bash scripts/submit.sh --template superhero-movie-poster \ '{"image_urls":["https://storage.zooop.ai/…"],"hero_name":"DAFU"}' ``` ### Working with templates - **The slug is the URL.** `https://zooop.ai/templates/image/superhero-movie-poster` → `template: "superhero-movie-poster"`. If the user pastes a template link, take the slug straight from it and skip the catalog browse. - **Only send what the template exposes.** `GET /v1/templates/{slug}` lists exactly those params. Anything the template fixes (`prompt`, `quality`, …) is absent from that list, and sending it back is a 400 (`Param "prompt" is fixed by template`). A template you can't steer is working as designed — if the user wants a different look, use a raw model. - **Template-specific inputs are ordinary params.** e.g. `hero_name` on the superhero poster: pass it in `params` like any other id and it gets woven into the fixed prompt server-side. Omit it and its `default` applies. - Both official and community templates are callable, and cost is quoted the same way (`quote.sh --template …`). ## How model selection works (raw path) Pick a (type, subType) pair by **what the user wants to do**, not by what inputs they happen to provide. Slug names like `motion-control` / `text-ref` aren't self-explanatory — read the descriptions, don't guess from the name. | type | subType | What it's for | | ----- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | image | default | Generate a new image, **or edit an existing one from a plain text description** (text-to-image, img-to-img, style transfer, prompt-driven edits — GPT Image 2 / Nanobanana handle "把背景换成…" / "去掉左边的人" without any mask) | | image | edit-image | Targeted region edit driven by a **black-and-white mask or hand-drawn annotation** the user supplies. Only pick this when the user actually provides (or asks to draw) a mask/region — never for plain text-described edits | | image | image-svg | Generate **scalable vector graphics** (`.svg`) — logos, icons, flat illustrations, infographics — from a text prompt, or vectorize a raster image. Recraft Text/Image-to-Vector models; output is editable vector paths, NOT a raster PNG. Pick this only when the user explicitly wants vector / SVG output | | video | text-ref | Generate a video from a prompt. Some models also accept reference images **for style** (referenced as `@Image1` / `@Image2` in the prompt) — these guide look, NOT motion | | video | first-last-frame | **Animate a still image** — provide one image as the start frame and the model generates motion outward. Optional end frame interpolates between two keyframes | | video | motion-control | Make an image move using motion **copied from a reference video** (e.g. the character in the image performs the action / dance in the reference clip) | | video | audio-lipsync | Make a character image lip-sync to a given audio track | | video | extend-video | Continue an existing video — append more frames after its last one | | video | video-edit | Restyle or re-edit an existing video clip | | audio | text-to-speech | TTS in a named voice | | audio | voice-clone | Speak text using a voice cloned from a reference audio sample | | audio | sound-effect | One-shot sound effect from a text description | | audio | music | Music track from a text description | For the per-model required fields (which image / video / audio params, and which are optional), read `params[].required` on the model returned by `/v1/models`. Don't infer from the subType — different models in the same subType can have different param requirements. ### Disambiguation — pick subType by intent The hard cases are video subTypes (and image edits), where the user's **goal** decides, not which media they happen to hand you: | User says… | Pick | Why | | ---------------------------------------------------------------------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | "把这张图的背景换成海边" / "remove the person on the left" / "edit this photo to…" (image + text only, no mask) | **image/default** | Plain-text edits go through `default` with GPT Image 2 or Nanobanana — they take a reference image + edit prompt natively. `edit-image` is the wrong pick because it requires a mask the user didn't provide. | | "我画了个 mask / 涂了一块区域,只改这里" | **image/edit-image** | The user actually has a mask or hand-drawn region. That's the only time `edit-image` is right. | | "做个矢量图 / SVG" / "我要可缩放可编辑的 logo 矢量文件" / "as an SVG / vector" | **image/image-svg** | The deciding signal is **vector / SVG**, not the word "logo" or "icon" — those alone usually mean a normal raster image (use `default`). Only route here when the user wants an editable, infinitely scalable vector file. | | "让这张图动起来" / "animate this poster" | **video/first-last-frame** | Goal: bring a still image to life. The image is a *start frame*. | | "用这张图当风格参考生成视频" / "use this image as a style reference" | **video/text-ref** | Goal: generate a new scene; image only shapes look. Reference image goes into the model's `image_urls` / `reference_image_urls` param and is cited as `@Image1` in the prompt. | | "让这个角色跳这段舞" / "make this character do this dance" | **video/motion-control** | Goal: transplant motion from a reference video onto an image. | | "让这个人对口型说这段话" / "lip-sync this audio to my photo" | **video/audio-lipsync** | Goal: drive an image's mouth from audio. | | "续写这段视频" / "extend this clip" | **video/extend-video** | Goal: more frames after the last one. | | "把这段视频换风格" / "restyle this video" | **video/video-edit** | Goal: change look/feel of existing video. | | Pure prompt → video, no input image | **video/text-ref** | Goal: generate from text alone. | Each model has one or more `versions` (e.g. `standard` / `pro` / `fast`) with a coarse `typicalPrice` summary like `{ typicalCredits: 8, unit: "second", note: "~40 credits for 5s @ 720p" }`. That's enough to explain ballpark cost to the user. For an **exact** quote, call `POST /v1/quote` (see "Quote before submit"). Final cost is enforced server-side at submit time and echoed in `creditsCharged`. ## Standard workflow 1. **(Optional, first call only)** `GET /v1/me` — remaining daily budget, wallet balance (personal or team), bound project name, rate-limit numbers. 2. **Discover models** for the matching subtype: ```bash curl -fsS "$ZOOOP_API_HOST/v1/models?type=video&subtype=motion-control" \ -H "Authorization: Bearer $ZOOOP_API_KEY" ``` Match the user's hint (e.g. "用 seedance2") against `name` / `brand.name`. No hint → follow "Default model selection" below. If the user named a published look instead of a model, browse `GET /v1/templates` and submit `template: `. 3. **(Optional) Upload local files.** If the user gave a path on disk: ```bash bash scripts/upload.sh /path/to/file.png # → prints the storage URL to feed into params. ``` Wraps `POST /v1/uploads`. Image / audio return sync; video is processed asynchronously — the script polls until ready (5–30s typical). Rejected content → exits non-zero. 4. **Quote the task** — exact credits + ETA, no side effects: ```bash bash scripts/quote.sh '' # → { "credits": 8, "estimatedSeconds": 18, "breakdown": {...} } ``` Safe to repeat. See "Quote before submit" for the confirmation policy. 5. **Submit the task**: ```bash bash scripts/submit.sh '' # → { "taskId": "...", "status": "queued", "modelId": "...", "versionId": "..." } ``` `submit.sh` attaches an `Idempotency-Key` (default `sha256(body)`), so a submit that times out is safe to recover by **re-running the identical command** — see "Idempotency". Never tweak the params on a timeout retry to "avoid a duplicate"; that changes the key and creates a real duplicate. 6. **Poll until terminal** (`succeeded` / `failed` / `cancelled`): ```bash bash scripts/poll.sh # → { "status": "succeeded", "outputs": [{"url": "..."}], "creditsCharged": 4 } ``` 7. **Show the URL, then offer download.** Proactively ask *"Want me to download it to a local file?"* (unless the user already said they only want the URL) — on yes, run: ## Quote before submit Before every `POST /v1/tasks`, call `POST /v1/quote` with the **same body** to get exact credits + P50 ETA. No charge, no DB row, no capacity hold — safe to call as often as you like. ```bash curl -fsS -X POST "$ZOOOP_API_HOST/v1/quote" \ -H "Authorization: Bearer $ZOOOP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"interfaceId":"...","versionId":"...","params":{...}}' # → { "credits": 8, "estimatedSeconds": 18, "modelName": "Kling V3", # "breakdown": { "pricingKey": "720p", "base": 8, "multiplier": 1, ... } } ``` After the quote, print **one compact summary line** before submitting — just the agent's choice + cost, so the user can sanity-check it. Don't echo the prompt back; they just typed it and re-reading it is noise. ``` → Kling V3 · 720p · 8 credits · ~18s ``` **Confirmation policy** (don't confirm on every task — annoying): | Situation | Behaviour | | ------------------------------------------------ | ---------------------------------------------------------- | | Default — single task, no opt-in/out | Print summary line, submit. No "should I continue?". | | User said "不用问我" / "just go" / "make N variants" | Submit silently, no extra confirmation. | | User said "ask me before spending" / "贵的让我确认" | Confirm before submitting. | | Batch (≥ 3 tasks OR total ≥ 100 credits) | Confirm once for the whole batch unless already opted out. | | `estimatedSeconds == null` | Say "ETA unknown" — don't invent a number. | If the quote returns 400 / 404, fix the payload and re-quote — never submit a request that just failed to quote. ## Default model selection When the user's request is vague and does NOT name a model / brand / quality tier, pick the curated defaults below. ### Images (`type=image, subtype=default`) - Default model: **GPT Image 2.0** with `quality: "low"`, `resolution: "2k"`, `aspect_ratio: "1:1"`. ~80% of cases are fine at `low`; `medium` handles ~95% of needs. Also strong for prompt-driven edits. - **Quality-sounding words inside the user's prompt are prompt-words, not param hints.** Phrases like "highest quality", "最高质量", "4k", "ultra-realistic", "cinematic", "电影级" appear as decorative descriptors in almost every prompt — they do NOT mean the user is asking you to bump the `quality` param. Keep `quality: "low"`. - Bump to `quality: "medium"` when the request is non-trivial (detailed composition, important visual fidelity) or when a `low` result didn't satisfy. - `quality: "high"` only for genuinely complex requests (intricate detail, fine typography, multi-subject composition) OR when the user explicitly passes `params.quality: "high"`. - Switch to a different image model only when the user explicitly asks (e.g. "use Flux" / "用 Seedream"). Don't switch on a quality complaint. ### Videos (`type=video, subtype=text-ref`) | User signal | Pick | | --------------------------------------- | ------------------------------------------------------------------------------------------------------- | | "highest quality" / "best" / "电影级" | **Seedance 2** | | Balanced (default — no explicit signal) | **Kling O3** → fallback to **Kling V3** → **Happy Horse** → **Grok Imagine** (pick first one available) | | "cheap" / "fastest" / "性价比" / "便宜" | **Grok Imagine** | Match by `name` (case-insensitive substring) or `brand.name` against `/v1/models`. ### Other categories For every other (type, subType), use `**models[0]`** from `/v1/models` — it's pre-sorted, so the first row is the recommended default. ## Reference image → prompt (`POST /v1/describe-image`) Turn a user-supplied reference image into a structured, ready-to-feed prompt. **Call this only when** you need to extract a specific dimension from the image (subject / style / lighting / palette / …), OR the user explicitly asks to reverse-engineer a prompt from a picture. Otherwise **skip it** — most image / video models accept a reference image directly through their own param (e.g. `image_urls` / `reference_image_urls`), so feed the picture straight into the submit without a describe round-trip (saves a credit and a step). ```bash bash scripts/describe.sh [language] # language: optional locale code (zh / ja / fr / es / …); omitted → English # → { "credits": 1, "overallDescription": "A vibrant ...", "subject": "...", # "composition": "...", "style": "...", "lighting": "...", "palette": "...", # "mood": "...", "camera": "..." } ``` - Input MUST be a ZOOOP CDN URL (returned by `/v1/uploads`). Foreign URLs are rejected — pipe local files through `upload.sh` first. - Costs 1 credit per call. - Optional `language` sets the output language — every field (including `overallDescription`) comes back in it. Pass the locale the user is working in so they can read the result; unknown / omitted → English. - `overallDescription` is the canonical one-paragraph prompt; other fields are best-effort and may be empty when the model can't tell. ## Endpoints | Method | Path | What | | ------ | ------------------------ | ------------------------------------------------------- | | GET | `/v1/me` | Self-introspection | | GET | `/v1/models` | List public models by `type` × `subtype` | | GET | `/v1/ai-tools` | List curated AI tools (optional `?type=image|video`) | | GET | `/v1/ai-tools/{slug}` | Full param schema for one tool | | GET | `/v1/templates` | List published templates (optional `?type=image|video`) | | GET | `/v1/templates/{slug}` | Full param schema for one template | | POST | `/v1/quote` | Price a task (`interfaceId` OR `aiTool` OR `template`) | | POST | `/v1/tasks` | Submit a generation (`interfaceId` OR `aiTool` OR `template`) | | GET | `/v1/tasks/{id}` | Poll status / outputs | | POST | `/v1/uploads` | Upload a file (raw body + `Content-Type`) | | GET | `/v1/uploads/{uploadId}` | Poll async (video) upload status | | POST | `/v1/describe-image` | Reference image → structured prompt (1 credit) | Full request / response shapes live in `references/api-docs.md`. ## Reading a model's params schema `/models` returns `params: InterfaceParam[]` per model. Each entry carries `id` (the key to set in `params`), `type`, `required`, `options`, `constraints`, `default`. Media-URL types (`image_url`, `video_url`, `audio_url`, `first_frame`, `last_frame`, `mask_url`, `voice`, …) accept only ZOOOP-hosted URLs — pipe foreign URLs / local paths through `upload.sh` first. Full type list and behaviour: `references/api-docs.md`. ## Uploads — single-step raw-body ```bash curl -X POST "$ZOOOP_API_HOST/v1/uploads" \ -H "Authorization: Bearer $ZOOOP_API_KEY" \ -H "Content-Type: image/png" \ --data-binary "@$HOME/Desktop/cat.png" ``` `Content-Type` drives how the file is processed AND the resulting file extension — pass the file's REAL mime, not a guess. Image / audio return sync (`{ "status": "ready", "url": ..., "size": ..., "contentType": ... }`). Video returns `{ "status": "processing", "uploadId": ..., "pollUrl": ... }` — then poll `GET /v1/uploads/{uploadId}` until `ready` / `blocked` / `errored`. Allowed mimes, size cap (100 MB), and full poll-status shapes: see `references/api-docs.md` → `POST /v1/uploads`. ## Error response shape OpenAI-style envelope: ```json { "error": { "message": "...", "type": "invalid_request_error", "code": "missing_required_params", "params": [...] } } ``` - `type` — broad family: `invalid_request_error` / `authentication_error` / `permission_error` / `rate_limit_error` / `api_error`. Branch on `code`. - `param` — set when one field is at fault. - `missing_required_params` carries a structured `params` array with `id`, `type`, `default`, `options`, `constraints` — self-correct in one round-trip without re-fetching the model. Most common codes (full table in `references/api-docs.md`): | HTTP | code | Meaning | | ---- | -------------------------- | ------------------------------------------------ | | 400 | `missing_required_params` | Required model params absent — see `params` hint | | 400 | `invalid_payload` | Bad body shape | | 401 | `invalid_token` | Token missing / revoked / expired | | 402 | `token_daily_cap_exceeded` | This task's cost would breach the daily cap | | 404 | `unknown_model` | Bogus `interfaceId` or disabled model | | 422 | `moderation_blocked` | Text or image violates content policy | | 422 | `token_project_unbound` | Bound project deleted — revoke + recreate token | | 429 | `rate_limited` | Honor `Retry-After` | | 503 | various | No enabled provider — try another model | ## Transient errors Up to 3 attempts with exponential backoff (1s → 3s → 9s), honor `Retry-After`. Retry only: connection errors with no HTTP status, `5xx`, `429`. Don't retry other `4xx`, `451 moderation_blocked`, or `422 token_project_unbound`. Full retry table + outage-diagnostic curls: `references/api-docs.md` → "Transient errors". **Submit timed out with no response (curl exit 28 / outer kill)?** The task may already exist — `submit.sh` likely created it before the connection stalled. Recover by **re-running the exact same `submit.sh` command** (the `Idempotency-Key` returns the original `taskId`), or reconcile with `GET /v1/tasks/{id}` if you captured an id. Never submit a fresh, modified request — that double-charges. ## Idempotency (automatic on submit) `POST /v1/tasks` is the only retryable **write**, and a slow-but-successful submit (task created + credits charged) can still time out before the response reaches you. So `submit.sh` always sends an `Idempotency-Key` (default `sha256(body)`). The server caches it 24h; a repeat submit with the same `(token, key)` returns the original `taskId` plus `"idempotent": true` instead of creating — and charging for — a duplicate. Stripe semantics. Rules: - **Retry a timed-out submit by re-running the identical command.** Same key → same task → no double charge. - **Don't add "uniqueness" to params to dodge duplicates** — that's backwards; it changes the hash and produces a genuine duplicate. - To intentionally launch N tasks from identical params, set a distinct `ZOOOP_IDEMPOTENCY_KEY` per submission. - Calling the raw endpoint yourself? Set the `Idempotency-Key` header manually — the dedup only works if you do. ## Rate limits Per-PAT, 60s sliding window. Headline: `tasks 60/min`, `quote 120/min`, `uploads 30/min`, `me / upload-poll / ai-tools / templates 120/min`, `describe-image 10/min`. `/models` and `/tasks/{id}` are unbounded. `429` carries `Retry-After`. Full table: `references/api-docs.md` → "Limits". ## What this skill does NOT do - It does **not** manage projects, canvases, or organizations — single-shot generation only. PAT's bound project is fixed at creation time. - It does **not** stream progress — poll instead. - It does **not** auto-download outputs; always **ask** first (step 7) and pick the save extension from response `content-type`. - It does **not** allow uploads > 100 MB via the API — direct the user to the Web UI for those. --- # API Reference # ZOOOP Public REST API (v1) ## Quick start ```bash # 1. Create a Personal Access Token at https://zooop.ai/user#apiKeys export ZOOOP_API_KEY=zpk_live_... # (Quote every URL: zsh treats `?` as a glob and `&` as a job separator.) # 2. Discover models for the kind of task you want curl -fsS -H "Authorization: Bearer $ZOOOP_API_KEY" \ "https://api.zooop.ai/v1/models?type=image&subtype=default" # 3. Submit a task with one of the model ids + a version id curl -fsS -X POST -H "Authorization: Bearer $ZOOOP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"interfaceId":"","versionId":"standard","params":{"prompt":"a red panda"}}' \ "https://api.zooop.ai/v1/tasks" # 4. Poll for the result curl -fsS -H "Authorization: Bearer $ZOOOP_API_KEY" \ "https://api.zooop.ai/v1/tasks/" ``` ## Authentication All endpoints require `Authorization: Bearer `. Tokens look like `zpk_live_<32 chars>` and are created at . The plaintext is shown ONCE at creation; afterwards only the leading prefix is visible in the settings UI. To rotate, create a new token and revoke the old one (revocation is immediate). A token is either **personal** (created at the URL above) or **team** (issued by a team owner/admin in the team admin → API Keys tab). Credits are debited from that token's wallet — the user's personal balance for a personal token, or the team's shared credit pool for a team token. The endpoints are identical; `GET /v1/me` reports which wallet funds the token. **Project binding**: every PAT is bound to one project at creation time and cannot be re-bound. Tasks submitted via the PAT land under that project; uploaded files do too. If the bound project is later deleted, all submits return 422 `token_project_unbound` — the user must revoke + recreate. See `GET /v1/me` for the bound project info. ## Error envelope All errors follow an OpenAI-style envelope so SDKs that parse that shape work out of the box: ```json { "error": { "message": "Missing required parameters: aspect_ratio, resolution", "type": "invalid_request_error", "code": "missing_required_params", "params": [ { "id": "aspect_ratio", "type": "aspect_ratio", "required": true, "default": "1:1", "options": [{"value": "1:1", "label": "1:1"}, {"value": "16:9", "label": "16:9"}] }, { "id": "resolution", "type": "resolution", "required": true, "default": "1024", "options": [{"value": "1024", "label": "1024 × 1024"}, ...] } ] } } ``` - `type` — broad family: `invalid_request_error`, `authentication_error`, `permission_error`, `rate_limit_error`, `api_error`. - `code` — stable machine-readable reason. Branch on this in client code. - `param` — set when the failure points at a single body / query field. - Free-form extras (`validCategories`, `spent`, `cap`, `params`, `labels`, …) live under `error` too and are stable for any documented `code`. - For `missing_required_params`, the `params` array carries structured hints per missing field (`id` / `type` / `required` / `default` / `options` / `constraints`) — agents can self-correct in one round-trip without re-fetching the model. Per-endpoint error tables below. ## Endpoints ### `GET /v1/me` PAT self-introspection. Returns everything the calling agent needs to plan its requests without trial-and-error 429s / 402s — bound project, daily credit headroom, account balance, current rate-limit numbers. ```bash curl -fsS -H "Authorization: Bearer $ZOOOP_API_KEY" \ "https://api.zooop.ai/v1/me" ``` Response: ```json { "key": { "id": "uuid", "prefix": "zpk_live_aBcD…", "label": "claude-code-mac", "createdAt": "2026-05-15T10:00:00.000Z", "expiresAt": null, "dailyCreditCap": 1000, "creditsSpentToday": 42, "creditsRemainingToday": 958 }, "project": { "id": "uuid", "name": "My Project" }, "user": { "creditBalance": 84495 }, "limits": { "tasksPerMin": 60, "uploadsPerMin": 30, "uploadPollPerMin": 120 } } ``` Field notes: - `key.dailyCreditCap` is `null` when no cap is set; `creditsRemainingToday` is also `null` in that case. - `key.expiresAt` is `null` when the token never expires. Warn the user to renew before this date. - `project.id` / `project.name` are both `null` (with `project.bound: false`) when the bound project was deleted — submits will fail with `token_project_unbound` until the token is rotated. - **Wallet** — exactly one key is present: `user: { creditBalance }` for a **personal** token, or `team: { id, creditBalance }` for a **team** token (the team's shared pool; a team token never exposes its creator's personal balance). This is the wallet that funds tasks — even with `creditsRemainingToday > 0`, an empty wallet still blocks expensive submits. - `limits.*` reflect the current per-PAT rate-limit numbers. Honor them. All PATs grant full access to every `/v1/*` endpoint. Privacy: deliberately omits `user.id / email / name` (PAT bearer doesn't need user identity — the token itself is the identity) and never enumerates other PATs of the same user. ### `GET /v1/models?type=&subtype=` Lists the enabled model interfaces that match one `(type, subType)` pair. Both query params are **required** — calling without them returns 400 plus the full set of valid `(type, subType)` pairs so the caller can self-correct. Exposure rule: a `(type, subType)` pair is exposed via the public API iff the corresponding generator tool is currently in the homepage sidebar. The single source of truth is `src/lib/seo/tool-pages-seo.ts` (`inSidebar: true`). Response shape: ```json { "models": [ { "id": "uuid", "name": "Flux Pro", "type": "image", "subType": "default", "description": "Photorealistic text-to-image model …", "brand": { "id": "flux", "name": "Black Forest Labs", "logo": "https://..." }, "promptRequired": true, "promptMaxLength": 2000, "params": [ { "id": "prompt", "type": "long_text", "required": true, "maxLength": 2000 }, { "id": "aspect_ratio", "type": "aspect_ratio", "required": false, "enumValues": ["1:1","16:9","9:16"] } ], "versions": [ { "id": "standard", "name": "Standard", "typicalPrice": { "typicalCredits": 4, "unit": "image", "note": "1024×1024, 1:1" } }, { "id": "pro", "name": "Pro", "typicalPrice": { "typicalCredits": 8, "unit": "image", "note": "1024×1024, 1:1" } } ], "tags": ["HOT"] } ] } ``` ### Public categories | type | subType | Tool | | ------ | ------------------ | ------------------------------- | | image | default | Image generator | | image | edit-image | Image editor | | image | image-svg | SVG generator (text/image-to-vector) | | video | text-ref | Video generator | | video | motion-control | Motion control (image-to-video) | | video | first-last-frame | First / last frame video | | video | audio-lipsync | Lip sync | | video | extend-video | Extend video | | video | video-edit | Video editor | | audio | text-to-speech | Text-to-speech | | audio | voice-clone | Voice clone | | audio | sound-effect | Sound effect | | audio | music | Music generation | `typicalPrice.unit` values: - `image` — credits per generated image (total at default dimensions) - `second` — credits per second (output for duration-defaulted models, OR per second of input for extend / lipsync-style models) - `frame` — credits per frame of input video - `1k_chars` — credits per 1000 characters (TTS; all per-character pricing is normalized to this unit so the number is always a relatable integer / one-decimal value rather than a fractional per-char rate) - `call` — credits per call (flat, no dimension scaling) For flat units (`image`, `call`) `typicalCredits` is an integer and the `minPrice` floor is already applied. For rate units (`second`, `frame`, `1k_chars`) it may be fractional (e.g. `50.5`) to preserve fidelity for sub-credit rates. `typicalPrice` is a coarse ballpark only — call `POST /v1/quote` (below) with the exact payload you'd submit to get the authoritative number. ### `GET /v1/ai-tools[?type=image|video]` List active AI tools — admin-curated recipes that pre-bind a model + version + tuned params for a specific outcome (background removal, age modification, style transfer, video upscale, etc.). Agents submit by stable `slug` and supply only the recipe's visible inputs instead of configuring the raw underlying interface. Optional `?type=image|video` filters by media type. Other values return 400 `invalid_type`. There is **no** `subtype` query — the recipe's `subType` field is admin free-form ("特效", "动作模仿") and not a filter axis. Exposure rule: a tool appears iff (a) `recipe.isActive = true`, (b) the recipe carries the `api` tag (admin opt-in — fail-closed by default), and (c) its underlying interface is still enabled. This is a deliberately narrow surface: most /tools/* recipes are prompt-wrappers around generic text-to-image models that an agent could replicate cheaper by calling the raw model directly. Only the tools that genuinely add capability beyond `/v1/models` (background removal, upscalers, etc.) are tagged. Response: ```json { "aiTools": [ { "slug": "background-removal", "name": "Background Removal", "mediaType": "image", "subType": "", "summary": "Remove the background and keep only the subject.", "coverUrl": "https://storage.zooop.ai/...", "tags": ["NEW"], "params": [ { "id": "image_url", "type": "image_url", "required": true, "displayName": "" } ], "typicalPrice": { "typicalCredits": 2, "unit": "image" } }, { "slug": "style-transfer", "name": "Style Transfer", "mediaType": "image", "params": [ { "id": "image_url", "type": "image_url", "required": true }, { "id": "target_style", "type": "enum", "required": true, "options": [ { "value": "impressionism", "label": "Impressionism" }, { "value": "cubism", "label": "Cubism" } ] }, { "id": "aspect_ratio.ratio", "type": "aspect_ratio", "required": true, "options": ["1:1","16:9","9:16","4:3","3:4"] } ], "typicalPrice": { "typicalCredits": 6, "unit": "image" } } ] } ``` The `params[]` shape mirrors `/v1/models` exactly — same `id` / `type` / `required` / `options` / `default` / `constraints` semantics. Voice-type params on TTS-style tools expand options to `{value, label, previewUrl?}` identical to `/v1/models`. Fields the agent doesn't need (mapping internals, preset overrides, hidden / fixed params) are stripped. ### `GET /v1/ai-tools/{slug}` Fetch one AI tool by stable slug. Same shape as a single entry from the list endpoint. Returns 404 `unknown_ai_tool` for unknown / inactive slugs and for slugs whose interface is no longer enabled. ```bash curl -fsS -H "Authorization: Bearer $ZOOOP_API_KEY" \ "https://api.zooop.ai/v1/ai-tools/background-removal" ``` ### `GET /v1/templates[?type=image|video]` List the gallery templates published at `/templates/{slug}` — a tuned prompt plus a bound model + version, run by stable `slug`. Where an AI tool adds a capability the raw models lack, a template packages a *look*: superhero poster, future baby, action figure, and so on. You supply only the inputs it leaves open (usually a photo and a word or two); the prompt itself is baked in and cannot be overridden. Optional `?type=image|video` filters by media type. Other values return 400 `invalid_type`. No `subtype` query — same reason as `/v1/ai-tools`. Exposure rule: every template with `status = approved` + `isActive` + an enabled interface, **official and community alike** — the same set the public gallery runs. Unlike `/v1/ai-tools` there is no per-row opt-in tag, because a template IS the user-facing product rather than an internal wrapper. Running a community template attributes its creator exactly as the website does. Response: ```json { "templates": [ { "slug": "superhero-movie-poster", "name": "Superhero Movie Poster", "mediaType": "image", "subType": "", "summary": "One photo becomes a blockbuster teaser poster.", "coverUrl": "https://storage.zooop.ai/global/website/templates/superhero-movie-poster-cover.webp", "tags": ["poster", "movie"], "params": [ { "id": "aspect_ratio", "type": "aspect_ratio", "default": "2:3", "options": ["1:1", "2:3", "3:2", "9:16"], "required": true }, { "id": "image_urls", "type": "image_urls", "displayName": "Your photo", "constraints": { "maxCount": 1 } }, { "id": "hero_name", "type": "text", "displayName": "Poster title", "default": "NIGHTFALL", "constraints": { "maxLength": 28 } } ], "official": true, "typicalPrice": { "typicalCredits": 5, "unit": "image" } } ] } ``` `params[]` has the same shape as `/v1/models` and `/v1/ai-tools`. Two things to notice on templates specifically: - Params the template fixes (`prompt`, `quality`, …) are **absent** — sending one back returns 400 `invalid_payload` (`Param "prompt" is fixed by template`). Don't try to steer a template by prompt; pick a different template or call the raw model. - Template-specific inputs (e.g. `hero_name`) are ordinary ids you submit like any other param. Their values get interpolated into the fixed prompt server-side; leaving one out falls back to its `default`. ### `GET /v1/templates/{slug}` Fetch one template by stable slug — the same slug as the website URL `https://zooop.ai/templates/image/{slug}`. Same shape as a single list entry. Returns 404 `unknown_template` for unknown / draft / pending / rejected / inactive slugs and for slugs whose interface is no longer enabled. ```bash curl -fsS -H "Authorization: Bearer $ZOOOP_API_KEY" \ "https://api.zooop.ai/v1/templates/superhero-movie-poster" ``` ### `POST /v1/quote` Price a hypothetical task without submitting. Same request body as `POST /v1/tasks` (so an agent can reuse one payload for both calls). No credits are charged, no DB row is created, no capacity is reserved — safe to repeat freely. Body — pick one shape: ```json // (a) Raw model { "interfaceId": "", "versionId": "standard", "params": { "prompt": "…", "duration": 5 } } // (b) AI tool (curated recipe) { "aiTool": "", "params": { "image_url": "https://storage.zooop.ai/…" } } // (c) Template (gallery preset) { "template": "", "params": { "image_urls": ["https://storage.zooop.ai/…"], "hero_name": "DAFU" } } ``` Body — **one of three shapes**, mutually exclusive. Sending more than one of `interfaceId` / `aiTool` / `template` returns 400 `invalid_payload`. Response: ```json { "credits": 8, "estimatedSeconds": 18, "modelId": "uuid", "versionId": "standard", "modelName": "Kling V3", "breakdown": { "pricingKey": "720p", "base": 8, "multiplier": 1, "surcharges": 0, "basePrice": 0, "minPriceApplied": false } } ``` - `credits` — authoritative cost for this exact payload. The `POST /v1/tasks` call WILL charge this number, provided input media durations don't change between quote and submit (media duration is part of the pricing basis). - `estimatedSeconds` — P50 of the last 10 successful generations on this model. `null` when the model has no completion history yet. - `breakdown` — itemised cost so the agent can explain "why" to the user: `final = max(minPrice, ceil(base × multiplier + surcharges + basePrice))`. Errors mirror `POST /v1/tasks` for the same payload-validation paths (`invalid_payload`, `missing_required_params`, `unknown_model`, `model_not_public`). Quote also has its own `rate_limited` 429 (default 120 / min per token). ### `POST /v1/tasks` Submit one generation task. Three body shapes are accepted — mutually exclusive. Body — **raw model path**: ```json { "interfaceId": "", "versionId": "standard", "params": { "prompt": "…", "image_url": "…", "duration": 5 } } ``` Body — **AI tool path** (curated recipe): ```json { "aiTool": "", "params": { "image_url": "https://storage.zooop.ai/…" } } ``` Body — **template path** (gallery preset): ```json { "template": "", "params": { "image_urls": ["https://storage.zooop.ai/…"], "hero_name": "DAFU" } } ``` On the AI-tool path the server resolves slug → recipe → underlying interfaceId + versionId, merges your `params` with the recipe's fixedParams / defaultParams / customParams, validates the merged shape, and applies the recipe's pricing override. The keys allowed in `params` come from the tool's `params[]` spec returned by `/v1/ai-tools/{slug}`. The template path works the same way — slug → template → interfaceId + versionId, your `params` merged over the template's fixed / default / custom params, priced off the bound interface version plus the template's credit markup. Allowed keys come from `/v1/templates/{slug}`. The resulting task carries the template attribution, so a community creator is credited exactly as they would be from the website. On the raw path, the keys come from the model's `params[]` spec returned by `/v1/models`. Param values are validated server-side via the same path the web `/api/ai/execute` route uses. **Header** `Idempotency-Key: <1–256 chars>` — repeat submissions with the same `(token, key)` within 24 hours return the original `taskId` plus `"idempotent": true` instead of creating a duplicate. Stripe semantics. Strongly recommended on every submit: a slow-but-successful request (task created + credits charged) can still time out before the response arrives, and a blind retry would double-charge. `scripts/submit.sh` sets the header automatically (default `sha256(body)`); if you call this endpoint directly, send your own stable key and reuse it verbatim on retry. The header is optional only in the sense that omitting it is allowed — but then duplicate protection is off. Response: ```json { "taskId": "uuid", "status": "queued", "modelId": "...", "versionId": "..." } ``` Errors: | HTTP | code | Cause | | ---- | -------------------------- | ------------------------------------------- | | 400 | `invalid_payload` | Missing required input, bad shape, an attempt to set a param the tool / template fixes, OR more than one of `interfaceId` / `aiTool` / `template` set | | 400 | `missing_required_params` | Model `required: true` params absent; `error.params` lists each with type / options / default | | 400 | `invalid_idempotency_key` | `Idempotency-Key` header present but empty or > 256 chars | | 401 | `missing_token` / `invalid_token` | Missing or revoked / expired PAT | | 402 | `arrears` | Account in arrears | | 402 | `token_daily_cap_exceeded` | This task's cost would breach the token's daily cap (post-pricing precise check) | | 403 | `model_not_public` | Raw-path model exists but isn't on the public API surface (does not apply to AI-tool / template paths) | | 403 | `account_banned` | Token's owner is banned | | 404 | `unknown_model` | Bogus `interfaceId` or disabled model | | 404 | `unknown_ai_tool` | Bogus `aiTool` slug, inactive recipe, or underlying interface disabled | | 404 | `unknown_template` | Bogus `template` slug, template not approved / inactive, or underlying interface disabled | | 422 | `moderation_blocked` | Text or image violates content policy | | 422 | `token_project_unbound` | PAT's bound project was deleted — revoke + recreate | | 429 | `rate_limited` | Per-token rate-limit ceiling (default 60 req/min) | | 429 | `token_daily_cap_reached` | Token's `dailyCreditCap` already met before this call (coarse pre-check) | | 503 | various | No enabled provider — pick a different model | ### `GET /v1/tasks/{id}` Returns the current state of a task. Path-id is the `taskId` from submit. ```json { "taskId": "uuid", "status": "succeeded", "outputs": [{ "url": "https://storage.zooop.ai/cdn-cgi/image/format=png,quality=95/.../uuid.webp", "thumbnailUrl": "https://storage.zooop.ai/cdn-cgi/image/format=png,quality=95/.../uuid-thumb.webp" }], "error": null, "creditsCharged": 4, "createdAt": "2026-05-14T01:23:45.000Z", "completedAt": "2026-05-14T01:24:12.000Z" } ``` Public `status` values: `queued | running | succeeded | failed | cancelled`. Internal granular states (`pending`, `processing`, `uploading`, `moderating`) are collapsed into `running` since they're not actionable for agents. **Image output URLs** are served via Cloudflare Image Transformations (`cdn-cgi/image/format=png,quality=95`). The `format=png` is a soft hint that Cloudflare honours selectively: - Source has **alpha** (background-removal, transparent stickers) → response is **`image/png`** with transparency preserved. - Source is fully **opaque** (typical t2i / i2i output) → Cloudflare downgrades to **`image/jpeg`** to keep delivery size small. This is Cloudflare's standard behaviour and there is no URL switch to override. Either way the response `content-type` reflects the actual format. Trust the header, not the URL pathname (which still ends in `.webp` — that's the source-object key, not the delivered format). When saving locally, pick the extension from `content-type`: ```bash URL="https://storage.zooop.ai/cdn-cgi/image/format=png,quality=95/.../uuid.webp" CT=$(curl -sI "$URL" | awk 'tolower($1)=="content-type:" {print $2}' | tr -d '\r') EXT=$(case "$CT" in image/png) echo png;; image/jpeg) echo jpg;; *) echo bin;; esac) curl -fL -o "out.$EXT" "$URL" ``` Video / audio URLs pass through unchanged. ### `POST /v1/uploads` Upload a local file in a single request. The file content is the raw HTTP body (NOT multipart). `Content-Type` selects the media type. Returns a public URL ready to feed into a subsequent `/tasks` submission. ```bash curl -X POST \ -H "Authorization: Bearer $ZOOOP_API_KEY" \ -H "Content-Type: image/png" \ --data-binary @cat.png \ https://api.zooop.ai/v1/uploads ``` The upload lands under the PAT's bound project (same path as Web UI uploads), so it shows up in the "Uploads" tab of the in-app history picker and counts toward your storage quota. **Image / audio — synchronous response** (typically < 1s): ```json { "status": "ready", "url": "https://storage.zooop.ai///.png", "size": 123456, "contentType": "image/png" } ``` **Video — asynchronous**. Returns `202` with an upload id: ```json { "status": "processing", "uploadId": "", "pollUrl": "/v1/uploads/" } ``` Poll `GET /v1/uploads/{uploadId}` until you get a terminal status. Recommended cadence: 5s first poll, then 10–20s. Most clips resolve in under a minute; the workflow has a 30-minute deadline. **Image rejected by content policy**: ```json { "error": { "message": "Uploaded content violates content policy", "type": "invalid_request_error", "code": "moderation_blocked", "labels": "porn" } } ``` HTTP 451. **Allowed Content-Type values**: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `audio/mpeg`, `audio/wav`, `audio/webm`, `audio/ogg`, `video/mp4`, `video/webm`, `video/quicktime`. Off-list values return 400 with the allowlist in the response. **Size cap**: 100 MB (Cloudflare request-body limit). If you need to upload something larger, use the Web UI then pass that URL to `/tasks`. ### `GET /v1/uploads/{uploadId}` Poll the status of an async (video) upload returned by `POST /v1/uploads`. ```bash curl -H "Authorization: Bearer $ZOOOP_API_KEY" \ https://api.zooop.ai/v1/uploads/ ``` Response shapes: ```json { "status": "processing" } # 202, still moderating { "status": "ready", "url": "...", "contentType": "..." } # 200, ready to use { "status": "blocked", "labels": "porn", "error": "..." } # 200, rejected { "status": "errored", "reason": "..." } # 200, workflow gave up ``` Ownership: the upload id can only be polled by the PAT that created it. Other tokens (even from the same user account) get 404 `unknown_upload`. ### `POST /v1/describe-image` Analyse a reference image and return a structured prompt suitable for feeding back into a generator (`subject` / `composition` / `style` / `lighting` / `palette` / `mood` / `camera`, plus a single flowing `overallDescription` paragraph). Body: ```json { "imageUrl": "https://storage.zooop.ai///.png", "language": "zh" } ``` - `imageUrl` is **required** and MUST be a ZOOOP CDN URL (i.e. one returned by `POST /v1/uploads`). Foreign URLs are rejected — this avoids SSRF and guarantees the input has already been validated. - `language` is **optional** — a locale code (`zh`, `zh-TW`, `ja`, `fr`, `es`, `de`, `pt`, `ru`, … ) that sets the output language. Every field, including `overallDescription`, is written in it; pass the locale the user is working in so the result is readable to them. Unknown / omitted → English. Response: ```json { "credits": 1, "subject": "A young woman with light skin in her 20s", "composition": "Centered portrait, eye-level framing", "style": "Soft photographic realism with film grain", "lighting": "Warm window light from the left, gentle shadows", "palette": "Muted earth tones with desaturated greens", "mood": "Contemplative and serene", "camera": "Shot at 50mm with shallow depth of field", "overallDescription": "A young woman with light skin in her 20s, ..." } ``` - `overallDescription` is the canonical "ready-to-use" field — drop it straight into a generator's `prompt` param. The other dimension fields are best-effort and may be empty strings depending on the active vision model. Treat `overallDescription` as the only field with strong stability. - `credits: 1` is charged on success (failures are refunded — see the error table below). - `camera` may be `null` for compositionally minimal images. Errors: | HTTP | code | Cause | | ---- | ----------------------- | -------------------------------------------------------- | | 400 | `invalid_payload` | `imageUrl` missing or not on a ZOOOP CDN host | | 402 | `insufficient_credits` | Balance below 1 credit (no refund issued — nothing was charged) | | 422 | `policy_violation` | Vision model refused the image on content policy (refund issued) | | 429 | `rate_limited` | Default 10 / min per PAT | | 429 | `upstream_rate_limited` | Upstream LLM rate-limited us (refund issued — retry with backoff) | | 503 | `vision_unavailable` | Vision service unreachable (refund issued) | ## Limits - **Rate limit** (per PAT, 60-second sliding window): - `POST /v1/tasks` — 60 req/min - `POST /v1/quote` — 120 req/min - `POST /v1/uploads` — 30 req/min - `GET /v1/uploads/{id}` — 120 req/min - `GET /v1/me` — 120 req/min - `GET /v1/ai-tools` and `GET /v1/ai-tools/{slug}` — 120 req/min - `GET /v1/templates` and `GET /v1/templates/{slug}` — 120 req/min (own bucket) - `POST /v1/describe-image` — 10 req/min - `GET /v1/models`, `GET /v1/tasks/{id}` — unbounded - 429 responses carry `Retry-After` (seconds). - **Tokens per user**: max 10 active. Revoke an old one to create a new one. - **Daily credit cap (optional)**: per token; UTC-midnight rollover. Two enforcement points — coarse pre-check (`token_daily_cap_reached`, 429) and precise post-pricing check (`token_daily_cap_exceeded`, 402, prevents over-cap submission even when current spend < cap). - **Concurrency**: `maxConcurrency` (personal token → the user's, default 3; team token → the team's shared pool) caps how many tasks run in parallel — excess queues in FIFO order and drains as earlier tasks complete. Rate-limit and concurrency are orthogonal: you can submit 60/min but only a few run at once. - **Content policy**: prompts are checked before any credits are charged; uploaded images are checked synchronously on `POST /v1/uploads`, uploaded videos asynchronously via the poll route. Rejections surface as `moderation_blocked`. - **Upload size**: 100 MB hard ceiling per file (Cloudflare request-body limit). Larger files: use the Web UI and pass the URL. ## Out of scope - **Workspace management**: a token's scope (personal vs team) and bound project are fixed at creation in the web UI — the API can't switch them. A team token is issued by a team owner/admin and spends the team's shared credits; otherwise the endpoints are identical to a personal token. - **Project management**: tokens are bound to one project at creation. To switch projects, revoke and create a new token. - **Canvases / story timeline**: the API exposes single-shot generation only. - **Streaming progress**: poll instead. - **Listing tasks / uploads**: no `/tasks?since=` or `/uploads?since=` endpoints — track task IDs client-side. ## Transient errors — what to retry, how to diagnose A single failed request often has nothing to do with the API. Retry the transient class with exponential backoff before declaring an outage. **Retry (transient, usually self-heals):** - TCP / TLS errors with no HTTP status — `ECONNRESET`, `EAI_AGAIN`, curl exit codes `6` (DNS), `7` (connect), `35` / `52` / `56` (TLS / recv). - HTTP `502` / `503` / `504`. - HTTP `429` — honor `Retry-After`. - Cloudflare `520`–`526`. **Do NOT retry:** - Other `4xx` (auth / validation — retry won't help). - `451 moderation_blocked` — same bytes will be rejected again. - `422 token_project_unbound` — bound project deleted; user must rotate the token. **Pattern:** up to 3 attempts, exponential backoff 1s → 3s → 9s, honor `Retry-After`. If all 3 fail, surface the error. **Special case — `POST /v1/tasks` that times out with no response body** (curl exit `28`, or an outer command-timeout hard-kill with no exit code): the submit path runs cold-start + media probing + workflow dispatch synchronously, so the task is often already created and charged by the time the connection stalls — most visible on the *first* submit of a session. Always submit with an `Idempotency-Key` (the bundled `submit.sh` does this by default). Then a timeout is safe to retry **with the same key + same body** — the server returns the original `taskId` (`"idempotent": true`) rather than duplicating. If you have a candidate id, `GET /v1/tasks/{id}` to reconcile. Do not re-submit a modified request after a timeout. **Diagnostic before declaring outage:** ```bash curl -fsS -o /dev/null -w "%{http_code}\n" https://zooop.ai curl -fsS -o /dev/null -w "%{http_code}\n" https://api.zooop.ai/llms.txt ``` If `zooop.ai` is up but `api.zooop.ai` keeps failing, only THEN report an outage — otherwise it's almost always local network or transient.