# Superstak > One API key for AI models, tools and infrastructure. Call hundreds of capabilities (text and image models, video, audio, avatars, web scraping and search, SEO data, social networks, Google Analytics and Search Console, public research), buy and manage domain names, and deploy apps, all through one REST API or one MCP server. Everything is paid from a single prepaid wallet in USD. This file is written for AI agents and developers. It is enough to use the whole platform: read it once, then discover exact schemas and prices live from the catalog. ## Essentials - Base URL: `https://api.superstak.sh` - Authentication: `Authorization: Bearer ` on every request. Keys are created by the account owner in the dashboard (https://app.superstak.sh). Never put a key in a URL or a query string. - MCP server: `https://api.superstak.sh/mcp` (Streamable HTTP), same key in the `Authorization` header. - OpenAI-compatible models: `https://api.superstak.sh/v1` (chat completions, embeddings, model list) with the same key. - Every price is in USD and written `2.34 USD`. Read with your key (`Authorization: Bearer …`), this page and the catalog (`catalog_get`, `/catalog/endpoints/`) show the prices of your account; without a key, the platform's standard prices. - A key only reaches the capability groups its owner enabled. A call outside them returns `403`. ## The one mechanic: discover, call, follow Every capability, whatever it does, works the same way. 1. **Discover.** `GET /catalog/search?q=&offset=0` returns matching capabilities, best matches first, 20 per page: id, name, a one-line summary and the price. Pass `next_offset` for more, and `category` (from `categories`) to narrow. `GET /catalog/endpoints/` returns the exact JSON input schema, method, price and an example. Image and video models: `GET /catalog/endpoints/?model=` returns the schema of that model only. Discovery is free and never executes anything. 2. **Call.** `POST /call/` with the JSON input as the body. Read calls answer directly. Long jobs answer `202` with a call id. 3. **Follow.** `GET /calls/` returns the status, the output and the settled cost. Poll it, every few seconds for short jobs and less often for long ones. The actual cost of a call is returned in the `X-Router-Cost-USD` response header, and in `call_status` for jobs. ```bash curl -s "https://api.superstak.sh/catalog/search?q=scrape%20a%20web%20page" -H "Authorization: Bearer $KEY" curl -s "https://api.superstak.sh/catalog/endpoints/web.scrape" -H "Authorization: Bearer $KEY" curl -s -X POST "https://api.superstak.sh/call/web.scrape" \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"url":"https://example.com"}' ``` ## MCP: connect an assistant Any MCP client (Claude, Cursor, custom agents) can use the platform. Configuration: ```json { "mcpServers": { "superstak": { "type": "http", "url": "https://api.superstak.sh/mcp", "headers": { "Authorization": "Bearer " } } } } ``` The MCP server exposes a few generic tools, which reach every capability of the catalog: - `catalog_search` `{query?: string, offset?: integer, category?: string}`: Find capabilities and models by keyword. Returns ranked one-line summaries with their price, 20 per page (pass next_offset for more). Read the full input schema with catalog_get before calling. Discovery never executes an operation. - `catalog_get` `{endpoint_id: string, model?: string}`: Full description of one operation: every input field explained, method, price and an example. Read it before calling. - `connections_list` `{}`: List only accounts explicitly authorized for this project key. - `call` `{endpoint_id: string, query?: object, body?: any, connection_id?: string, idempotency_key?: string}`: Execute an operation. May spend money or publish/change provider data depending on the selected endpoint. Use an authorized connection_id for personal accounts. - `call_status` `{call_id: string}`: Read the status and settlement of a previous call from this project. Typical MCP flow: `catalog_search` → `catalog_get` → `call` (with `idempotency_key` for paid operations) → `call_status` until the status is final. With MCP, a personal account (social network, Google, Slack) is selected with `connection_id` from `connections_list`. Over HTTP, use the `X-Router-Connection` header. ## Money and safety rules - **Prepaid wallet.** The account owner tops up the wallet. Each call is debited at the price in force when it started. A price is never recomputed afterwards. - **No spending below zero.** When the balance reaches zero, paid calls return `402 insufficient_balance` and apps are suspended until the next top-up. Free reads keep working. - **Idempotency-Key.** Every paid operation requires an `Idempotency-Key` header (MCP: `idempotency_key`). Use a new unique value for each intent, and reuse the same value when you retry the same intent after a network error. The same key with a different body returns `409`. - **Confirmed writes.** Operations that spend money or change something outside (publish, send, buy, deploy, delete) require `"confirmed": true` in the input. Only set it after the user agreed. - **Pending or unknown is not failed.** A `202`, `pending` or `unknown` status means the work may still happen or may already be paid. Never resubmit it. Poll `GET /calls/` (or the operation's own status call) until the status is final. The platform reconciles unknown outcomes automatically. - **Price caps.** Some operations accept a maximum (`max_price_usd`, `max_cost_usd`). The call is refused instead of exceeding it. - **Limits.** Request bodies up to 8 MiB, responses up to 8 MiB. Streaming is not available through `/call` (use `/v1` for streamed model output). ## Capabilities Always read the exact schema and price with `catalog_get` before calling. The families below are a map, not the full list. ### AI models and text - `/v1/*`: OpenAI-compatible chat completions, with fallback between equivalent deployments handled for you. `GET /v1/models` lists the models your key may use. - `text.models` lists the public model aliases enabled for your key. `text.generate` takes `{model, prompt, system?, max_tokens?}`. Always use those aliases, never a vendor prefix. - `ai.judge` answers structured questions about evidence you provide: `{state, questions, model?, images?}`, where each question has a `type` (`choice`, `score` or `noul`), `instructions` and `criteria`. Three decision models: `jev` (default; 10 questions, 10 KB of evidence), `clef` (highest precision) and `clef-flash` (about 40 ms per decision, the cheapest); both `clef` models take 64 questions, 100 KB of evidence and up to 4 `images` (data URLs), which makes them fit for moderating posts, clips and thumbnails. Answers are probabilities, never free text; the price is per input token with `clef` models and per reported cost with `jev` (see the catalog). ### Media - `image.generate`, `image.design`, `video.generate`: search models by name with `catalog_search`, then read one model's schema with `catalog_get(endpoint_id, model)`. Recent models use `{model, parameters}`. Generations are jobs: poll `/calls/`. - `audio.models`, `audio.voices` (needs `model`, returns compatible voices), `audio.generate` (MP3 in `output.audio_base64`). - `avatar.list`, `avatar.voices`, `avatar.generate`, `avatar.animate`: talking avatars and videos. ### Web - `web.search`, `web.images`, `web.extract`: search results and page extracts. - `web.scrape`, `web.screenshot`, `web.structured`, `web.branding`, `web.summarize`, `web.pdf`, `web.map`, `web.search-content`: one page or one site, as Markdown, JSON, screenshot or brand data. - `web.crawl`, `web.batch`, `web.agent`: long jobs. Poll `/calls/`, read pages of results with `web.job-status {call_id, cursor?}`, stop with `web.job-cancel {call_id}`. Work already done stays billed. ### SEO and search data - `seo.*`: search engine results (organic, news, images, shopping), keyword volumes and ideas, backlinks, AI search mentions and answers, on-page analysis, business and merchant data. - Jobs require `confirmed: true` and an `Idempotency-Key`. Poll `seo.job-status {call_id}`, read results with `seo.job-result {call_id, cursor?}`. `status` is about completion, `cost_status` is about settlement. ### Social networks - **Your own accounts.** `social.connect {network, label, capability, plan?, account_of?}` returns a `consent_page`: a console link to hand to the account holder, who signs in, sees which account the link lands in and authorizes the network from there. Then poll `social.connection-status`: once `connected` it also returns `account` (`network`, `link`, `label`, `username`, `display_name`, `platform_user_id`, `followers`, `avatar_url`, null when the network does not say), so you can check the account the holder authorized is the one you meant. An application that embeds the authorization itself calls `social.authorize {connection_id}` instead and opens the `consent_url` it returns. An account carries up to two OAuth links: **Lecture** (`plan: "essential"`, included) and **Publication** (`plan: "advanced"`, a fixed monthly fee per account charged to the wallet at your account's pricing, see `connection_plans` and pass its `monthly_cents`, the platform list price, as `accepted_monthly_cents`). Without `plan`, Publication is used where it covers the network. Pass `account_of` (an existing connection id) to add the second link to the same account. A call made with the account id runs on the Lecture link whenever it covers the operation and falls back to the Publication link otherwise; `connections_list` shows which operations an account can serve. Once connected, use `social.profile`, `social.stats`, `social.posts`, `social.inbox`, `social.messages`, `social.send` and `social.compose` with the connection. A thread read with `social.messages` carries `reply_token` and `reply_until` (ISO, the end of the reply window: 24 hours after the newest incoming message on Instagram, 48 on TikTok, open elsewhere); both are null once the window has closed. Sends require `confirmed: true` and are never retried automatically. When the wallet cannot pay a Publication renewal, that link is suspended (`subscription_suspended`) for a grace period; if the wallet is not topped up before it ends, both links of the account are revoked. - **Publishing.** `social.publish {confirmed: true, text?, media?: [{file}], title?, visibility?, kind?, options?}` posts through the Publication link of a connected account: X, Instagram (feed, carousel, Reel, Story), TikTok (video, photo carousel, Creator Inbox draft), LinkedIn (text, images, video, PDF), YouTube (video or Short: a vertical video of 3 minutes or less) and Reddit. `media[].file` is a key of your storage (`users//…`, for example an output of `video.*`), in the private scope or already served (`video.serve`, `files.serve`): both work. The call answers 202: poll `call_status` until `published` or `failed`; the output carries `url` (the public link of the post), `platform_post_id`, `published_at` and the network's reason on failure (`error`, `error_code`). An `Idempotency-Key` is required and a publish is never retried on its own. `social.post-stats {post_id}` returns the post's counters (`impressions`, `reach`, `views`, `likes`, `comments`, `shares`, `saves`, `clicks`), by the `sp_…` call id or the network's post id; `null` means the network does not report that counter. - **Comments on your posts** (Publication link, included in its fee): `social.comments {post_id | comment_token, limit?, cursor?}` reads the comments of one of your posts (`post_id`: the `sp_…` call id, the connector post id or the network's own post id, a YouTube video id for instance), each with its author, counters, inline replies, `can_reply` / `can_hide` / `can_delete`, `hidden` and a `comment_token`; pass a `comment_token` instead of `post_id` to page the replies of that comment. `social.commented-posts {limit?, cursor?, since?, min_comments?}` lists your posts that received comments. `social.comment-reply {comment_token, text, confirmed: true}` answers a comment publicly, `social.comment-post {post_id, text, confirmed: true}` comments a post, `social.comment-hide {comment_token, hidden?, confirmed: true}` hides or shows a comment (Instagram, TikTok, X), `social.comment-delete {comment_token, confirmed: true}` deletes one. Writes need an `Idempotency-Key` and answer synchronously; reads may lag new comments by up to 10 minutes. Instagram, TikTok, YouTube, X, LinkedIn and Reddit. - **Webhook** (Publication link): `social.webhook {url, events?, confirmed: true}` registers one HTTPS address per account; every comment (`social.comment.received`, with a ready `comment_token`) and private message (`social.message.received`, with a `thread_token`) the network reports is then announced by a signed POST (`X-Superstak-Timestamp`, `X-Superstak-Signature` = HMAC-SHA256 of `timestamp.body` with the `webhook_secret` returned once), retried 1, 5, 30, 120 and 720 minutes later until your endpoint answers 2xx. `{url: null}` removes it. X sends no comment events. - **Public data** (no login): `public..*` for Instagram, TikTok, X, YouTube, LinkedIn, Reddit, Facebook and Linktree: profiles, enriched profiles, posts, post stats, comments, transcripts, trending content, hashtag and creator search, audiences. `public.posts-page {call_id, cursor}` reads more pages of a stored result for free. Missing counters are `null`, never `0`. - **Ads, commerce and reviews:** `ads.*` (ad libraries), `commerce.*` (product pages), `reviews.*` (app store, marketplace and review site collections). ### Google Analytics and Search Console `ga.connect` / `gsc.connect` return an OAuth `consent_url`. Then poll `*.connection-status`, list properties with `*.resources`, pick one with `*.select-resource`, and call the report endpoints with the connection. The selected property is injected server-side. Reads cost `0.00 USD`. ### Communities - Slack: `community.slack.connect` with a bot or user token, then read channels, messages, threads and users, or send messages and reactions (`confirmed: true`). - Telegram public channels: `community.telegram.*` (channel info, posts, comments, search, similar channels). ## Domain names Search, buy, renew and manage domain names. The price is debited from the wallet. 1. `domain.search {query, tlds?}`: availability plus purchase and renewal price per year. Premium domains are not sold. 2. `domain.register {domain, years, contact, max_price_usd, auto_renew?, nameservers?, confirmed: true}` with an `Idempotency-Key`. The domain is registered in the name of `contact`. The phone number uses the `+CC.NUMBER` format, for example `+33.612345678`. `max_price_usd` protects you against a price change. If registration is refused, the amount is refunded. If the answer is `unknown`, poll `domain.order-status {order_id}` and **never order again**. 3. Manage: `domain.list`, `domain.get`, `domain.dns.list`, `domain.dns.set` (replaces all the values of one name and type; MX and SRV values start with the priority, as in `"10 mail.example.com"`), `domain.dns.delete`, `domain.nameservers` (delegate to external name servers), `domain.contact`. 4. Renew: `domain.renew {domain, years, max_price_usd, confirmed: true}`, or leave `auto_renew` on. The wallet is then debited 30 days before expiry. 5. Transfer in a domain held elsewhere: `domain.transfer-quote {domain}` gives the price (a year of renewal is usually included) and what to prepare at the current registrar (unlock the domain, get its authorization code, no registration or transfer in the last 60 days). Then `domain.transfer {domain, auth_code, contact, max_price_usd, auto_renew?, confirmed: true}` with an `Idempotency-Key`. The wallet is debited first; the order stays `in_progress` for several days (`domain.order-status`) and the domain becomes `active` when it arrives. A refusal by the current registrar, or `domain.transfer-cancel {domain, confirmed: true}` while it is still pending, refunds the order. The authorization code is used once and never stored. Reference, generated from the live contract (`paid` = requires an `Idempotency-Key`, `?` = optional): - `domain.search`: `{query: string, tlds?: [string]}` - `domain.register` · paid: `{domain: string, years?: integer, contact: {type?: "individual"|"company"|"association"|"public_body", first_name: string, last_name: string, organization?: string, email: string, phone: string, address: string, city: string, postal_code: string, state?: string, country: string}, max_price_usd: number, auto_renew?: boolean, nameservers?: [string], confirmed: true}` - `domain.transfer-quote`: `{domain: string}` - `domain.transfer` · paid: `{domain: string, auth_code: string, contact: {type?: "individual"|"company"|"association"|"public_body", first_name: string, last_name: string, organization?: string, email: string, phone: string, address: string, city: string, postal_code: string, state?: string, country: string}, max_price_usd: number, auto_renew?: boolean, confirmed: true}` - `domain.transfer-cancel`: `{domain: string, confirmed: true}` - `domain.renew` · paid: `{domain: string, years?: integer, max_price_usd: number, confirmed: true}` - `domain.list`: `{}` - `domain.get`: `{domain: string}` - `domain.order-status`: `{order_id: string}` - `domain.auto-renew`: `{domain: string, enabled: boolean, confirmed: true}` - `domain.dns.list`: `{domain: string}` - `domain.dns.set`: `{domain: string, name: string, type: "A"|"AAAA"|"ALIAS"|"CAA"|"CNAME"|"MX"|"NS"|"SRV"|"TXT", values: [string], ttl?: integer, confirmed: true}` - `domain.dns.delete`: `{domain: string, name: string, type: "A"|"AAAA"|"ALIAS"|"CAA"|"CNAME"|"MX"|"NS"|"SRV"|"TXT", confirmed: true}` - `domain.nameservers`: `{domain: string, nameservers: [string], confirmed: true}` - `domain.contact`: `{domain: string, contact: {type?: "individual"|"company"|"association"|"public_body", first_name: string, last_name: string, organization?: string, email: string, phone: string, address: string, city: string, postal_code: string, state?: string, country: string}, confirmed: true}` ## App hosting Deploy a website or an app in one call. You never pick servers or regions: send the project and get a URL. 1. **Send the project.** - Large projects: `app.upload` returns `{upload_id, url}`, then send a zip, tar or tar.gz with `curl -T project.zip ""` (100 MB max, URL valid 1 hour). - Small projects: skip the upload and pass `files: [{path, content_base64}]` directly to `app.deploy` (8 MiB max). 2. **Deploy.** `app.deploy {upload_id | files, confirmed: true}` with an `Idempotency-Key` creates an app and returns its identifier `app` (12 characters, generated by the platform). The app is served at `https://.superstak.dev`. To publish a new version, call `app.deploy` again with `app`. 3. **Follow.** Static sites go live immediately. Server apps are built in the background: poll `app.get {app}` until `deployment.status` is `live` (or `failed`, with a reason). What you can deploy (detected automatically): - **A built static site:** `index.html` at the root, or in `dist/`, `build/` or `out/`. Single-page apps work as expected. - **An edge function:** a bundled JavaScript module exporting `fetch` (`_worker.js`, or a project with `wrangler.toml` / `wrangler.jsonc` and a built entry point). Bundle it before sending: nothing is built on the edge. - **Anything with a `Dockerfile`:** any language or server. It listens on the port of its `EXPOSE` line (8080 if none), sleeps when idle and wakes on the first request. Choose `memory_mb` (256, 512, 1024 or 2048). Anything else returns `unsupported_project` with what to provide. - **Custom domain:** `app.domain.attach {app, domain, confirmed: true}`. HTTPS is automatic. A domain bought here is configured for you, including the apex. For another domain, the answer lists the DNS records to create, then HTTPS turns on by itself. - **Environment variables:** `app.secrets.set {app, secrets: {NAME: value}}` and `app.secrets.delete {app, keys}`. They are applied to the running app. - **Operate:** `app.list`, `app.deployments`, `app.rollback {app, deployment_id}`, `app.logs` (server apps), `app.delete {app, keep_source, confirmed: true}`: `keep_source: true` leaves the deployed sources in the owner's storage (billed hourly, removable with `files.delete`), `false` deletes them with the app. Ask the owner before choosing. - **Price:** 5.00 USD per app and per month, debited at first go-live and then every month, plus usage (requests, compute time) beyond the included allowance, billed from the wallet. An app whose wallet is empty is suspended and comes back after a top-up. Deleting an app stops billing; the current month is not refunded. Reference, generated from the live contract: - `app.upload`: `{}` - `app.deploy` · paid: `{app?: string, upload_id?: string, files?: [{path: string, content_base64: string}], memory_mb?: 256|512|1024|2048, confirmed: true}` - `app.list`: `{}` - `app.get`: `{app: string}` - `app.deployments`: `{app: string}` - `app.rollback`: `{app: string, deployment_id: string, confirmed: true}` - `app.secrets.set`: `{app: string, secrets: object, confirmed: true}` - `app.secrets.delete`: `{app: string, keys: [string], confirmed: true}` - `app.domain.attach`: `{app: string, domain: string, confirmed: true}` - `app.domain.detach`: `{app: string, domain: string, confirmed: true}` - `app.logs`: `{app: string, limit?: integer}` - `app.delete`: `{app: string, keep_source: boolean, confirmed: true}` ## Video and audio processing Process media files on demand, billed per second of compute and capped per job. 1. **Send the file**, or skip this step and pass a public link (`source_url`, a direct file link or a YouTube link): `video.upload` returns `{upload_id, url, chunked}`. Up to 500 MB, send it in one request: `curl -T video.mp4 ""`. Larger files, up to 5 GB, go in parts with the same link: `POST ` answers `{uploadId}`; `PUT ` for each part (replace `` and `` from 1, parts of 5 MB to 500 MB, the last one any size) answers `{etag}`; `POST ` with `{"parts": [{"partNumber": 1, "etag": "…"}, …]}` finishes it. The link is valid 1 hour. 2. **Start a job** with `source_url` or `upload_id`, an `Idempotency-Key`, and optionally `max_cost_usd`. Every job answers `202` with a call id. 3. **Follow it** with `GET /calls/` (MCP: `call_status`) until `success` or `failure`. The result holds `data`, `files` (each named by its `path` in your storage: read it with `files.link`, or publish the job with `video.serve`) and `usage.compute_seconds`. A YouTube source is downloaded once into your own storage (`uploads/`, reported in `data.source.upload_id`): your next jobs on the same video read that copy instead of downloading again (pass the link again, or `upload_id`). It is billed as private storage like any upload and stays until you delete it with `files.delete`. Files given as `upload_id` are never deleted by a job either: delete them yourself once you are done. What each operation does: - `video.probe`: duration, container, codecs, resolution, frame rate and audio tracks. - `video.ffmpeg`: any ffmpeg processing written as output options in `args` (cut with `-ss`/`-t`, scale, crop, overlay with `-filter_complex`, convert, extract the audio or one frame) into one file of the chosen `format`. Inputs are the source then `inputs` (up to 4), in order. Options that read or write files or reach the network are refused with `invalid_parameters`. - `video.encode`: publication-ready files in one pass: one MP4 per requested size (`renditions`, short side 2160p to 360p, default 1080p/720p/480p, never upscaled), `thumbnails` spread over the duration, a `poster` still, with closed settings (`codec` h264/h265/av1, `crf`, `preset` fast/quality/turbo, `audio` aac/opus/none, `trim`). `package` chooses the output: `mp4` (default), `hls` (an adaptive stream: `hls/master.m3u8`, one playlist and 4-second segments per rendition, ready for any web or mobile player) or `both`. Expect 0.026 USD to 0.052 USD per source minute for three h264 renditions (measured: a 1080p source encodes at about 1.1x real time). - `audio.transcribe`: text, segments and word-level timings, language detected or given; `transcript.json`, `.srt` and `.vtt`. - `video.reframe`: the whole video moved to another canvas (`aspect` 9:16 by default), following the speaker or stacking two speakers (`mode`), with a blurred fit when nobody is found. - `video.captions`: transcribes and burns animated word-by-word subtitles in (`subtitles.style`: highlight, karaoke, pop-scale, fade, typewriter, glow, bounce-in, outline, shake, color-wave, bold), plus `captions.srt`. Pass `transcript` to skip the transcription: `{call_id, clip}` names a clip of one of your finished `video.clips` calls (its `clip_.json`, the exact words and timings its subtitles were drawn from), or `{url}` / `{upload_id}` points at a words file `{words: [{word, start, end}]}` timed on the video you caption (for example the `transcript.json` of `audio.transcribe` run on it). The words are then identical on every rendering, nothing is recognised again and the job runs on a cheaper CPU machine. SRT files are not accepted, they carry no timing per word. - `video.clips`: a long video (podcast, interview, live, YouTube link) into short vertical clips of about `target_seconds`. Without `count` the model keeps every moment worth a clip (up to 20); `count` caps the number: moments chosen by a text model (`model`, any alias from `text.models`; its tokens are billed as a normal model call), speaker framing, animated subtitles, optional `cut_silences`, and a `brief` to steer the choice. Cuts land on sentence boundaries: the edges are pushed out to the punctuation of the transcript. The model is asked for more candidates than will be rendered (about one and a half times `count`), and before any passage is rendered a decision model reads it with the words said just before and after (one or two `ai.judge` calls per candidate, billed like your own, a fraction of a cent): an edge it still finds in the middle of a sentence is widened to the sentence and the passage read again (`review.repaired`); a passage that depends on context the clip does not give, with a flat opening, unusable or off `brief` is not made; of the passages that hold up, the best by score are rendered, up to `count`. Each clip comes with its video, thumbnail and SRT, a title, a hook line, why it was picked and its `review` scores (`cut_start`, `cut_end`, `standalone`, `hook`, `quality` 0 to 4, `on_brief` when a brief was given); the passages turned down are listed in `rejected` with their reason, and `review.skipped` says when no review could be had (the key has no right to `ai.judge`, for example: every candidate is then rendered). `review: false` renders every candidate as picked. Beside each clip come its thumbnail, its SRT and `clip_.json`, the words of the clip with their timings, which `video.captions` takes as `transcript` to render other looks of the same clip. Styling `video.captions` and `video.clips`: - `subtitles`: `style` (one of the 11 animations), `font` (Anton or Inter), `font_size` (20 to 120), `position` (top, center, bottom), `active_color` (the word being spoken), `inactive_color`, `highlight_color`, `max_words_per_group`, or `enabled: false`. Colors are `#RRGGBB`. - `watermark`: a logo, `{url | upload_id, position (top-left, top, top-right, center, bottom-left, bottom, bottom-right), size (% of the width, 5 to 60), opacity (20 to 100), timing}`, or a band along the bottom, `{kind: "band", text, color, text_color, opacity, timing}`. `timing` is permanent, intro, outro or intro_outro (the first and/or last 5 s). Send a logo file with `video.upload` first, or give a public link; a PNG with transparency works best. - `outro`: `{url | upload_id}`, a closing video of 15 s at most, appended to every clip. - `video.clips` only: omit `count` and the model keeps every moment worth a clip (about one per two minutes of source at most, never two picks that repeat each other); give `count` for a fixed maximum. Transcription, reframing, captions and clips run on a GPU; probe, ffmpeg and encode on a CPU. Long sources take minutes: poll every 15 to 30 seconds. Every job takes `keep_source`: `true` (default) keeps the upload, or the YouTube download, in the caller's storage for later jobs (billed hourly as storage), `false` deletes it once the job has succeeded. Ask the owner which they want. Price: one rate per second of compute, shown by `catalog_get`. A job never costs more than its `max_cost_usd` (default per operation in the catalog): it stops at the cap with `cost_cap_reached` and keeps what it produced. Failures are billed for the compute they used. **Serve a video.** The files of a finished job are private: a download link is bought per file with `files.link`. `video.serve {call_id}` publishes them at a fixed public address, delivered worldwide with caching: the answer gives `base_url` and `urls` (one per file, same paths as the job's `files`; for an HLS package, point the player at `hls/master.m3u8`). From then on `GET /calls/` returns those public links. Served files are billed as served storage (per GB and per month, delivery included, the rate is in the catalog), every hour, until `video.unserve {call_id}` takes them back to private storage, or `files.delete {scope: "public", prefix: "video/"}` removes them. Both calls are free and idempotent. Errors specific to processing: `source_unreachable` (link private, gone or blocked), `source_too_long`, `unsupported_media`, `invalid_parameters`, `cost_cap_reached`, `processing_failed`, `video_budget_too_small` (raise `max_cost_usd`). Reference, generated from the live contract: - `video.upload`: `{}` - `video.probe` · paid: `{source_url?: string, keep_source?: boolean, upload_id?: string, max_cost_usd?: number}` - `video.ffmpeg` · paid: `{source_url?: string, keep_source?: boolean, upload_id?: string, max_cost_usd?: number, inputs?: [{source_url?: string, upload_id?: string}], args: [string], format: "mp4"|"mov"|"webm"|"mkv"|"gif"|"mp3"|"m4a"|"wav"|"ogg"|"flac"|"png"|"jpg"|"webp"}` - `video.encode` · paid: `{source_url?: string, keep_source?: boolean, upload_id?: string, max_cost_usd?: number, renditions?: ["2160p"|"1440p"|"1080p"|"720p"|"480p"|"360p"], codec?: "h264"|"h265"|"av1", crf?: integer, preset?: "fast"|"quality"|"turbo", audio?: "aac"|"opus"|"none", thumbnails?: integer, poster?: boolean, trim?: {start?: number, duration?: number}, package?: "mp4"|"hls"|"both"}` - `video.serve`: `{call_id: string}` - `video.unserve`: `{call_id: string}` - `audio.transcribe` · paid: `{source_url?: string, keep_source?: boolean, upload_id?: string, max_cost_usd?: number, language?: string}` - `video.reframe` · paid: `{source_url?: string, keep_source?: boolean, upload_id?: string, max_cost_usd?: number, mode?: "auto"|"face_tracking"|"split"|"center"|"blurred", aspect?: "9:16"|"1:1"|"16:9"}` - `video.captions` · paid: `{source_url?: string, keep_source?: boolean, upload_id?: string, max_cost_usd?: number, language?: string, aspect?: "9:16"|"1:1"|"16:9", subtitles?: {enabled?: boolean, style?: "highlight"|"karaoke"|"pop-scale"|"fade"|"typewriter"|"glow"|"bounce-in"|"outline"|"shake"|"color-wave"|"bold", font?: "Anton"|"Inter", font_size?: integer, position?: "top"|"center"|"bottom", active_color?: string, inactive_color?: string, highlight_color?: string, max_words_per_group?: integer}, watermark?: {kind?: "image", url?: string, upload_id?: string, position?: "top-left"|"top"|"top-right"|"center"|"bottom-left"|"bottom"|"bottom-right", size?: number, opacity?: number, timing?: "permanent"|"intro"|"outro"|"intro_outro"}|{kind: "band", text?: string, color?: string, text_color?: string, opacity?: number, timing?: "permanent"|"intro"|"outro"|"intro_outro"}, outro?: {url?: string, upload_id?: string}, transcript?: {call_id?: string, clip?: integer, url?: string, upload_id?: string}}` - `video.clips` · paid: `{source_url?: string, keep_source?: boolean, upload_id?: string, max_cost_usd?: number, count?: integer, target_seconds?: integer, language?: string, brief?: string, cut_silences?: boolean, mode?: "auto"|"face_tracking"|"split"|"center"|"blurred", aspect?: "9:16"|"1:1"|"16:9", subtitles?: {enabled?: boolean, style?: "highlight"|"karaoke"|"pop-scale"|"fade"|"typewriter"|"glow"|"bounce-in"|"outline"|"shake"|"color-wave"|"bold", font?: "Anton"|"Inter", font_size?: integer, position?: "top"|"center"|"bottom", active_color?: string, inactive_color?: string, highlight_color?: string, max_words_per_group?: integer}, watermark?: {kind?: "image", url?: string, upload_id?: string, position?: "top-left"|"top"|"top-right"|"center"|"bottom-left"|"bottom"|"bottom-right", size?: number, opacity?: number, timing?: "permanent"|"intro"|"outro"|"intro_outro"}|{kind: "band", text?: string, color?: string, text_color?: string, opacity?: number, timing?: "permanent"|"intro"|"outro"|"intro_outro"}, outro?: {url?: string, upload_id?: string}, model?: string, review?: boolean}` ## Your storage Everything the platform keeps for you (uploads, processing outputs, app sources) lives in your own folder. It is measured and billed every hour, at a rate per GB shown in the catalog. Your folder exists in two scopes: `private` (the default: uploads, outputs, sources, read through signed links you buy with `files.link`) and `public` (what `video.serve` or `files.serve` published, read at a fixed address, billed at the served rate). **Give someone a file for a while.** `files.link {path, minutes?, confirmed: true}` (Idempotency-Key required) answers a signed download link to one private file, valid for the duration you choose, 5 minutes to 7 days (`minutes`, default 60), and its `expires_at`. The file stays private and keeps its private storage price; the link costs 0.001 USD whatever its duration, debited when it is created, and the same Idempotency-Key answers the same link again without a second charge. A link cannot be revoked before it expires, except by deleting the file, so size the duration to the need: 20 minutes for a download, a week for a review. For a file that must stay reachable at a fixed address, serve it instead. **Share a file.** `files.upload {name}` answers a signed link valid 1 hour and the `path` the file will have (`files//`): send the file with `curl -T photo.jpg ""` (up to 500 MB in one request, larger files in parts exactly as described for `video.upload`). Then `files.serve {paths: [path], confirmed: true}` moves it to the public scope and answers its fixed address, ready to send to anyone or embed in a page: it is delivered worldwide with caching, no key or expiry. Only files of a known type can be served (images jpg/png/webp/gif, video mp4/mov/webm, audio mp3/m4a/wav, pdf, zip, txt/json/srt/vtt, HLS playlists and segments): the name must carry one of those extensions and the first bytes of the content must match it, otherwise `415 file_content_mismatch`. Served files are billed at the served rate every hour until `files.unserve` takes them back to the private scope (the cache may still answer for up to 24 hours) or `files.delete {scope: "public"}` removes them. The address never changes for a given path, so upload a new file for a new version. Outputs of video jobs are served with `video.serve`, which also updates the job's links. - `files.list {scope?, prefix?, after?}`: your files with their `path` inside your folder, their full `key` (`users//`, what `social.publish` media take), their size (and their public address in the `public` scope), 1000 per page. `files.upload` and `files.serve` answer the same `path` and `key`. A private file carries no link: ask `files.link` for one. - `files.link {path, minutes?, confirmed: true}`: a signed download link to one private file for 5 minutes to 7 days, 0.001 USD per link (see above). - `files.usage {scope?}`: the size of your folder at the last measurement and what it costs per hour, per scope. - `files.upload {name}`, `files.serve {paths | prefix, confirmed: true}`, `files.unserve {paths | prefix, confirmed: true}`: see above. - `files.delete {scope?, paths | prefix, confirmed: true}`: delete files, or a whole folder such as `video/` or `files/`. Billing stops from the next hour. If your balance stays below zero for 30 days, your folder is emptied. - `files.upload`: `{name: string}` - `files.serve`: `{paths?: [string], prefix?: string, confirmed: true}` - `files.unserve`: `{paths?: [string], prefix?: string, confirmed: true}` - `files.link` · paid: `{path: string, minutes?: integer, confirmed: true}` - `files.list`: `{scope?: "private"|"public", prefix?: string, after?: string}` - `files.usage`: `{scope?: "private"|"public"}` - `files.delete`: `{scope?: "private"|"public", paths?: [string], prefix?: string, confirmed: true}` ## Email mailboxes Connect a mailbox with its IMAP (read) and SMTP (send) access and an app password, then read, search, reply, send, draft and file messages through the same `call` mechanic. The platform checks both servers before saving anything, keeps the password sealed, and never returns it. 1. **Connect.** `mail.connect {email, imap: {host, port}, smtp: {host, port}, password, username?, label?, confirmed: true}`. Ports: IMAP 993 or 143, SMTP 465 or 587; TLS is required. The mailbox is granted to the calling project and its id is returned. Connecting is free. Pass the id as the connection of every other `mail.*` call (`X-Router-Connection`, MCP `connection_id`); `connections_list` shows the mailboxes a project may use. 2. **Read.** `mail.folders`, then `mail.search {folder?, query?, from?, to?, subject?, since?, before?, unread_only?, limit?, cursor?}` (newest first, 50 per page) and `mail.read {message, include_attachments?}`. Message ids are opaque and bound to the mailbox and the project; attachments are saved to your storage (`files.list` paths), never returned inline. HTML bodies come back as plain text (`html_text`). 3. **Write.** `mail.send {to, cc?, bcc?, subject, text, html?, attachments?: [{file}], confirmed: true}` and `mail.reply {message, text, reply_all?, confirmed: true}` need an `Idempotency-Key`: the same key returns the same result without sending twice. `mail_send_unconfirmed` means the outcome is unknown; the message may have left, **never resend it**. Each mailbox has a daily sending quota (`mail.connection-status` shows it). `mail.draft` saves a draft without sending; `mail.mark` and `mail.move` file messages. Nothing is ever deleted: moving to the trash is refused. 4. **Prices.** Reads, searches, drafts and filing cost 0.001 USD per call; each send or reply costs 0.003 USD, whatever the number of recipients. A refused send is not charged. Connecting, checking and disconnecting are free. 5. **Receiving addresses.** `mail.inbox-create {label?, webhook_url?, confirmed: true}` with an `Idempotency-Key` gives you an email address of your own on the platform's domain (`-@…`), paid once (0.50 USD); receiving and reading are free and the address stays yours until `mail.inbox-delete`. Each message announced to your `webhook_url` costs 0.001 USD, charged at the first delivery attempt whatever the answer (retries included); while your balance is at or below zero nothing is announced, and those messages are not announced later. Messages sent to it are kept for you: `mail.inbox-messages {inbox, unread_only?, since?, cursor?}` lists them, `mail.inbox-read {inbox, message}` returns the text (HTML as plain text) with attachments already saved in your storage, and `mail.inbox-wait {inbox, since?, timeout_s?}` blocks up to 25 seconds until a message arrives, which is the way to collect a verification code. With `webhook_url` (HTTPS), every message is announced by a signed POST (`X-Superstak-Timestamp`, `X-Superstak-Signature` = HMAC-SHA256 of `timestamp.body` with the `webhook_secret` returned once at creation). You can also write from the address: `mail.inbox-send {inbox, to, subject, text, html?, attachments?, confirmed: true}` and `mail.inbox-reply {inbox, message, text, reply_all?, confirmed: true}` cost 0.005 USD each, need an `Idempotency-Key`, and follow the same never-resend rule as mailbox sends. 6. **New-mail webhook.** `mail.webhook {webhook_url | null, webhook_secret?, rotate_secret?, confirmed: true}` announces every message that arrives in the mailbox's inbox from that moment on: the inbox is checked every two minutes and nothing older is announced. Each message is a signed POST (same headers as above, secret returned once) carrying `{event: "mail.received", connection, folder, message, from, subject, received_at, has_attachments}`; `message` is a token the registering project can pass to `mail.read` for 30 days. Each announced message costs 0.001 USD, charged at the first delivery attempt whatever the answer (retries included). While your balance is at or below zero the mailbox is not watched, and the messages received meanwhile are never announced. `mail.connection-status` shows the webhook, its last check and whether it is paused. - `mail.connect`: `{label?: string, email: string, imap: {host: string, port: integer, secure?: boolean}, smtp: {host: string, port: integer, secure?: boolean}, username?: string, password: string, confirmed: true}` - `mail.update-credentials`: `{password: string, username?: string, imap?: {host: string, port: integer, secure?: boolean}, smtp?: {host: string, port: integer, secure?: boolean}, confirmed: true}` - `mail.connection-status`: `{}` - `mail.webhook`: `{webhook_url: string|null, webhook_secret?: string, rotate_secret?: boolean, confirmed: true}` - `mail.disconnect`: `{confirmed: true}` - `mail.folders`: `{}` - `mail.search`: `{folder?: string, query?: string, from?: string, to?: string, subject?: string, since?: string, before?: string, unread_only?: boolean, limit?: integer, cursor?: string}` - `mail.read`: `{message: string, include_attachments?: boolean}` - `mail.send` · paid: `{to: [string|{email: string, name?: string}], cc?: [string|{email: string, name?: string}], bcc?: [string|{email: string, name?: string}], subject: string, text: string, html?: string, attachments?: [{file: string}], confirmed: true}` - `mail.reply` · paid: `{message: string, text: string, html?: string, reply_all?: boolean, attachments?: [{file: string}], confirmed: true}` - `mail.draft`: `{to?: [string|{email: string, name?: string}], cc?: [string|{email: string, name?: string}], bcc?: [string|{email: string, name?: string}], subject?: string, text?: string, html?: string, attachments?: [{file: string}], confirmed: true}` - `mail.drafts`: `{limit?: integer, cursor?: string}` - `mail.mark`: `{messages: [string], read?: boolean, flagged?: boolean, confirmed: true}` - `mail.move`: `{messages: [string], folder: string, confirmed: true}` - `mail.inbox-create` · paid: `{label?: string, webhook_url?: string, max_price_usd?: number, confirmed: true}` - `mail.inbox-list`: `{}` - `mail.inbox-update`: `{inbox: string, label?: string, webhook_url?: string|null, rotate_webhook_secret?: boolean, confirmed: true}` - `mail.inbox-delete`: `{inbox: string, confirmed: true}` - `mail.inbox-messages`: `{inbox: string, unread_only?: boolean, since?: string, limit?: integer, cursor?: string}` - `mail.inbox-read`: `{inbox: string, message: string, mark_read?: boolean}` - `mail.inbox-send` · paid: `{inbox: string, to: [string|{email: string, name?: string}], cc?: [string|{email: string, name?: string}], bcc?: [string|{email: string, name?: string}], subject: string, text: string, html?: string, attachments?: [{file: string}], confirmed: true}` - `mail.inbox-reply` · paid: `{inbox: string, message: string, text: string, html?: string, reply_all?: boolean, attachments?: [{file: string}], confirmed: true}` - `mail.inbox-wait`: `{inbox: string, since?: string, timeout_s?: integer}` ## Calendars Connect a calendar account (any CalDAV server: iCloud, Fastmail, Nextcloud, Zoho, Posteo and the like, with an app password) and read, create, update, delete and answer events through the same `call` mechanic. The platform checks the account live before saving anything, keeps the password sealed, and never returns it. Microsoft 365 and Google Calendar accounts are not supported yet. 1. **Connect.** `calendar.connect {host, login, password, port?, path?, label?, confirmed: true}`. Port 443, 8443 or 2080 (cPanel hosts), TLS only; `path` is the base path for servers that need one (e.g. `/remote.php/dav`). The account is granted to the calling project, its calendars are discovered, and its id is returned. Connecting is free. Pass the id as the connection of every other `calendar.*` call (`X-Router-Connection`, MCP `connection_id`); `connections_list` shows the accounts a project may use and their calendars. 2. **Read.** `calendar.list {refresh?}` names the calendars (id, name, colour, `writable`, `default`). `calendar.events {calendar?, from, to, timezone?, query?, limit?, cursor?}` returns the occurrences between two instants (recurring events expanded, at most one year per call, 200 per page), sorted by start; `calendar.event {event, timezone?}` returns one event in full, with its next five occurrences when it is a series; `calendar.free-busy {calendars?, from, to, timezone?, slot_minutes?, working_hours?}` returns the busy and free intervals (31 days at most) and, with `slot_minutes`, the free slots that fit, optionally inside `working_hours {start, end, days?, timezone?}`. Times are ISO 8601: give `timezone` (IANA name) to read times without an offset and to get the answers in that zone; all-day events use `YYYY-MM-DD` dates with an exclusive end. Event ids are opaque, bound to the account and the project, and valid 30 days; an occurrence of a series has its own id, plus `recurring_id` for the whole series. 3. **Write.** `calendar.create {title, start, end | all_day, timezone?, calendar?, description?, location?, url?, attendees?, reminders?, recurrence?, transparency?, status?, confirmed: true}`, `calendar.update {event, scope?, …same fields, confirmed: true}`, `calendar.delete {event, scope?, confirmed: true}` and `calendar.respond {event, response, comment?, confirmed: true}` need an `Idempotency-Key`: the same key returns the same result without writing twice. On a series, `scope` is `this` (one occurrence, the id of that occurrence) or `all` (the whole series); the default follows the id you pass. `calendar_conflict` means the event changed since you read it: read it again, then retry with a new key. `calendar_read_only` means that calendar refuses writes. `calendar_write_unconfirmed` means the outcome is unknown: read the event before writing again, **never replay blindly**. Invitations and answers are sent by servers that support scheduling (`reply_sent` says so). 4. **Prices.** Reads (calendars, events, one event, availability) are free; each successful write costs 0.003 USD. A refused write is not charged. Connecting, checking and disconnecting are free. 5. **Change webhook.** `calendar.webhook {webhook_url | null, webhook_secret?, rotate_secret?, calendars?, confirmed: true}` announces every event created, modified or deleted in the chosen calendars from that moment on: the calendars are checked every two minutes and nothing older is announced. Each change is a signed POST (`X-Superstak-Timestamp`, `X-Superstak-Signature` = HMAC-SHA256 of `timestamp.body` with the secret returned once) carrying `{event: "calendar.changed", change: "created" | "updated" | "deleted", connection, calendar: {id, name}, event_id, uid, title, start, end, all_day, status, recurring, changed_at}`; `event_id` (null for a deletion) can be passed to `calendar.event` by the registering project for 30 days. Each announced change costs 0.001 USD, charged at the first delivery attempt whatever the answer (retries included). While your balance is at or below zero the account is not watched, and the changes made meanwhile are never announced. `calendar.connection-status` shows the webhook, its calendars, its last check and whether it is paused. - `calendar.connect`: `{label?: string, host: string, port?: integer, path?: string, login: string, password: string, confirmed: true}` - `calendar.update-credentials`: `{password: string, login?: string, host?: string, port?: integer, path?: string, confirmed: true}` - `calendar.connection-status`: `{}` - `calendar.webhook`: `{webhook_url: string|null, webhook_secret?: string, rotate_secret?: boolean, calendars?: [string], confirmed: true}` - `calendar.disconnect`: `{confirmed: true}` - `calendar.list`: `{refresh?: boolean}` - `calendar.events`: `{calendar?: string, from: string, to: string, timezone?: string, query?: string, limit?: integer, cursor?: string}` - `calendar.event`: `{event: string, timezone?: string}` - `calendar.free-busy`: `{calendars?: [string], from: string, to: string, timezone?: string, slot_minutes?: integer, working_hours?: {start: string, end: string, days?: [integer], timezone?: string}}` - `calendar.create` · paid: `{calendar?: string, title: string, description?: string|null, location?: string|null, url?: string|null, start?: string, end?: string, all_day?: {start: string, end?: string}|null, timezone?: string, attendees?: [{email: string, name?: string, optional?: boolean}], reminders?: [{minutes_before: integer}], recurrence?: string|null, transparency?: "busy"|"free", status?: "confirmed"|"tentative"|"cancelled", confirmed: true}` - `calendar.update` · paid: `{event: string, scope?: "this"|"all", title?: string, description?: string|null, location?: string|null, url?: string|null, start?: string, end?: string, all_day?: {start: string, end?: string}|null, timezone?: string, attendees?: [{email: string, name?: string, optional?: boolean}], reminders?: [{minutes_before: integer}], recurrence?: string|null, transparency?: "busy"|"free", status?: "confirmed"|"tentative"|"cancelled", confirmed: true}` - `calendar.delete` · paid: `{event: string, scope?: "this"|"all", confirmed: true}` - `calendar.respond` · paid: `{event: string, response: "accepted"|"declined"|"tentative", comment?: string, confirmed: true}` ## WhatsApp numbers Link a dedicated WhatsApp number by scanning a QR code, then send and read messages and receive them on your webhook through the same `call` mechanic. This is an unofficial link: read the notice in step 7 before offering it to a user. 1. **Connect.** `whatsapp.connect {label?, webhook_url?, accepted_monthly_cents, confirmed: true}` reserves the first month (2.00 USD per number; pass the platform list price in cents, `prices.monthly_cents` from the catalog document or `whatsapp.connection-status`, as `accepted_monthly_cents`; your account's pricing applies on top of it, like on every price) and returns the connection `id`, `status: "pending_scan"`, `qr` (a PNG data URL) and `qr_page`, a console link that shows the renewed QR and follows the link once the user is signed in as the number's owner. Hand the user `qr_page` when you cannot display an image yourself. WhatsApp › Settings › Linked devices › Link a device. The code is renewed regularly: poll `whatsapp.connection-status` every few seconds and show the latest `qr` until `status` is `connected` (then `phone` and `push_name` are filled); the reservation lapses after 30 minutes without a scan. The number is granted to the calling project; pass its id as the connection of every other `whatsapp.*` call (`X-Router-Connection`, MCP `connection_id`); `connections_list` shows it. `whatsapp.reconnect` gives a fresh QR to a number that was unlinked. 2. **Send.** `whatsapp.send {to, text, quote?, confirmed: true}` and `whatsapp.send-media {to, kind, url | path, caption?, confirmed: true}` need an `Idempotency-Key`: `to` is an international number (`+33612345678`) or a chat id from `whatsapp.chats`; `kind` is image, video, audio or document; `path` is a file of your storage, `url` a public HTTPS file. Accepted is not delivered: the message's `status` moves on in `whatsapp.messages`. The same key returns the same result without sending twice. `whatsapp_send_unconfirmed` means the outcome is unknown: the message may have left, **never resend it**. Sends are paced per number: `whatsapp_pacing_limited:` says how long to wait. 3. **Read.** `whatsapp.chats {limit?, offset?}` lists conversations with their chat ids; `whatsapp.messages {chat?, since?, limit?, cursor?}` returns the messages received and sent since the link (text, sender, direction, delivery status); media received are already saved in your storage under `whatsapp//…` (`whatsapp.media {message}` gives the path, type and size; a download link is bought with `files.link`). `whatsapp.contacts` lists the phone's contacts, `whatsapp.check-number {number}` says whether a number is reachable (sparingly: bulk checks expose the number), `whatsapp.mark-read {chat}` marks a conversation read. `whatsapp.wait {chat?, since?, timeout_s?}` blocks up to 25 seconds until a message arrives. Messages are kept 90 days. 4. **Receive.** With `webhook_url` (HTTPS), every inbound message is announced by a signed POST (`X-Superstak-Timestamp`, `X-Superstak-Signature` = HMAC-SHA256 of `timestamp.body` with the `webhook_secret` returned once), retried a few times; `whatsapp.webhook {webhook_url | null, rotate_secret?}` changes it. Each announced message costs 0.001 USD, charged at the first delivery attempt whatever the answer (retries included); while your balance is at or below zero nothing is announced, and those messages are not announced later. 5. **Prices.** 2.00 USD per number and per month, reserved at connection and renewed from the wallet; 0.01 USD per message sent, text or media. Reads, waits and inbound messages are free, a webhook announcement costs 0.001 USD per message; a refused send is not charged. When the wallet cannot pay a renewal the number is suspended (`subscription_suspended`) for a grace period, then unlinked. `whatsapp.disconnect` unlinks the device and stops the renewals; the current month is not refunded. 6. **Limits.** One phone number per link; a number already linked on the account is refused (`whatsapp_number_already_connected` in `last_error`). Linking capacity is shared: `whatsapp_capacity_reached` means try again later. A number restricted by WhatsApp answers `status: "restricted"` with the expiry; nothing is retried for you. 7. **Notice for the user.** The link is not provided or guaranteed by WhatsApp and can be cut at any time. Use a dedicated number, never a personal or main business number. WhatsApp restricts or bans numbers that send unsolicited messages: only write to people who expect it, and keep a fallback for critical flows. - `whatsapp.connect`: `{label?: string, webhook_url?: string, webhook_secret?: string, accepted_monthly_cents: integer, confirmed: true}` - `whatsapp.connection-status`: `{}` - `whatsapp.reconnect`: `{confirmed: true}` - `whatsapp.webhook`: `{webhook_url: string|null, rotate_secret?: boolean, confirmed: true}` - `whatsapp.disconnect`: `{confirmed: true}` - `whatsapp.send` · paid: `{to: string, text: string, quote?: string, confirmed: true}` - `whatsapp.send-media` · paid: `{to: string, kind: "image"|"video"|"audio"|"document", url?: string, path?: string, caption?: string, filename?: string, voice_note?: boolean, quote?: string, confirmed: true}` - `whatsapp.chats`: `{limit?: integer, offset?: integer}` - `whatsapp.messages`: `{chat?: string, since?: string, limit?: integer, cursor?: string}` - `whatsapp.contacts`: `{limit?: integer, offset?: integer}` - `whatsapp.check-number`: `{number: string}` - `whatsapp.wait`: `{chat?: string, since?: string, timeout_s?: integer}` - `whatsapp.media`: `{message: string}` - `whatsapp.mark-read`: `{chat: string, confirmed: true}` ## Errors Errors are JSON `{"detail": ""}` with a stable code, sometimes followed by `:`. Over MCP, the code is the text of an error result. Common codes: | HTTP | Code | What to do | | --------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | 400 | `idempotency_key_required` | Add an `Idempotency-Key` header (MCP: `idempotency_key`). | | 400 | `invalid_*_parameters:` | Fix the input. Read the schema with `catalog_get`. | | 400 | `unsupported_project` | Send a built site, a bundled edge function or a `Dockerfile`. | | 401 | `invalid_project_key` | Check the `Authorization: Bearer` header. | | 402 | `insufficient_balance` | The wallet is empty: ask the account owner to top up. Do not retry in a loop. | | 403 | `resource_not_allowed`, `connection_not_allowed` | This key has no access to that capability or account. | | 404 | `endpoint_not_found`, `call_not_found`, `app_not_found`, `domain_not_found` | Check the id. Other accounts' resources look missing too. | | 409 | `idempotency_key_reused`, `idempotency_conflict` | Same key with a different body: use a new key for a new intent. | | 409 | `price_above_max` | The price rose above your cap: search again and confirm the new price with the user. | | 409 | `order_in_progress` | An order for this domain is already running: poll it. | | 413 | `request_too_large` | Use `app.upload` or `video.upload` for files, or send less data. | | 429 / 502 / 503 | `*_busy`, `*_unavailable` | Temporary. Retry later with the **same** `Idempotency-Key`. | ## Rules for AI agents 1. Search the catalog before assuming a capability exists, and read its schema before calling it. 2. Tell the user the price before any paid call, and set `confirmed: true` only after they agree. 3. Send one `Idempotency-Key` per intent. Retry with the same key, never with a new one. 4. Never resubmit a `pending`, `unknown` or `202` call: poll it. 5. Stop on `402 insufficient_balance` and tell the user to top up. 6. Use model aliases and capability ids exactly as the catalog returns them. 7. For a nice app address, buy a domain (`domain.search`, then `domain.register`) and attach it with `app.domain.attach`.