# Media Processing API — complete reference > Base URL: https://mp.dvocorp.com > Auth: one header on every request — `X-Api-Key: ca_live_...` (no OAuth, no cookies). > Two-request integration: (1) POST https://mp.dvocorp.com/api/v1/jobs/upload — multipart with > `file` + `service_slug` + `operation_key` + `params` (a JSON string) -> 201 {"id","status"}; > (2) GET https://mp.dvocorp.com/api/v1/jobs/{id} -> poll until `status` is "done", then read `result.output_url`. > Operation catalog (public, no auth): https://mp.dvocorp.com/api/v1/studio/tools — the source of > truth for every `service_slug`/`operation_key`, accepted media, credit cost and > params. Do not hardcode an operation list; read it from here. > Machine-readable OpenAPI schema: https://mp.dvocorp.com/api/openapi.json > Human-facing interactive docs: https://mp.dvocorp.com/docs/api # Media Processing API Process images and video over a plain REST API. **A minimal integration is two kinds of request:** create a job (send the file with what you want done to it), then read its status until the result is ready. No SDK, no scripts. Everything else — the operation catalog, big-file uploads, webhooks, upload links for your end users — is optional and layers on top of those two. - **Base URL:** `https://mp.dvocorp.com` (written as `https://mp.dvocorp.com` below) - **Auth:** one header — `X-Api-Key: ca_live_…` - **Errors** carry a `detail`: a string for most failures, or a list of field errors for `422` validation failures. - **Interactive version:** the **Developer API** page in your panel (`/api`) shows these same requests filled in with your real key, and hands you a ready Postman collection. > ### For AI tools & agents (ChatGPT / Claude / Cursor / n8n) > > Everything you need to bootstrap an integration: > > - **MCP server (the fastest way):** `https://mp.dvocorp.com/api/mcp` — > Streamable HTTP, authenticated with the same API key as everything else. > Claude Code, Codex, Cursor, Gemini CLI, VS Code and the OpenAI Responses > API connect with one command and get the operations as tools. See > section 2. > - **Machine-readable docs:** `GET https://mp.dvocorp.com/llms.txt` (short) and > `GET https://mp.dvocorp.com/llms-full.txt` (this whole document as plain > text) — both public, no auth. Feed one to your model as the spec. > - **Live operation list:** `GET https://mp.dvocorp.com/api/v1/studio/tools` — > public, no auth, JSON array. Every operation with `service_slug`, > `operation_key`, `accepts` (`["image"]`/`["video"]`), `credits_per_call`, > and `config.fields[]` (each param's `type`/`min`/`max`). Never hardcode the > operation list — read it from here. > - **The whole API is two requests:** `POST /api/v1/jobs/upload` (send the file > + what to do), then `GET /api/v1/jobs/{id}` (poll until terminal). One auth > header, `X-Api-Key`. A ready-to-paste agent contract is in > section 2. Everything you can do is the *same two routes* — what changes is only the `service_slug` / `operation_key` / `params` you send. Pick those from the operations catalog. --- # 1. Quickstart — 2 requests ### Request 1 — send the file and what to do with it One multipart call: the file plus the operation. Returns a job. ```bash curl -s https://mp.dvocorp.com/api/v1/jobs/upload \ -H 'X-Api-Key: ca_live_...' \ -F 'file=@/path/to/photo.jpg' \ -F 'service_slug=photoconvert' \ -F 'operation_key=compress' \ -F 'params={"quality": 70, "format": "jpeg"}' ``` ```json { "id": "6f9c1c9e-2b40-4a71-9c3e-1d5a8f0b7e42", "status": "processing", "operation_key": "compress", "estimated_cost": 1, "result": null, "error": null } ``` Copy the `id`. **Form fields:** | Field | Required | What it is | |---|---|---| | `file` | yes | the file itself | | `service_slug` | yes | `photoconvert` (images) or `clipconvert` (video) | | `operation_key` | — | what to do — see the catalog | | `params` | — | a **JSON object as a string** — the operation's settings | | `webhook_url` | — | public URL we POST the finished job to (then you can skip request 2) | | `high_priority` | — | `true` — honored on premium plans, silently ignored otherwise | `file_url` is filled in from the uploaded file — if you put one in `params` it is ignored. > ### ⚠️ The one mistake everybody makes: `params` is a **string** > > `params` is a multipart form field, so its value must be JSON **already > serialised to text**. Passing an object makes your HTTP client stringify it as > `[object Object]` (JS) or `{'quality': 70}` with single quotes (Python) — the > server then rejects it with `400 params must be a JSON object`. > > ```js > // ✅ correct > form.append("params", JSON.stringify({ quality: 70, format: "jpeg" })); > > // ❌ wrong — sent as [object Object] > form.append("params", { quality: 70, format: "jpeg" }); > ``` > > ```python > # ✅ correct > data = {"service_slug": "photoconvert", "params": json.dumps({"quality": 70})} > > # ❌ wrong — Python dict repr uses single quotes, which is not JSON > data = {"service_slug": "photoconvert", "params": {"quality": 70}} > ``` > > In curl it is already a string, so `-F 'params={"quality":70}'` is fine. > ### Which operations exist, and which files they accept > > `service_slug` and `operation_key` are a **pair**. Each key is registered under > one service and accepts one media kind: `photoconvert` + `compress` for images, > `clipconvert` + `mute` for video. Use a key from the wrong service and you get > > ```json > { "detail": "unknown operation 'mute'" } > ``` > > with `400` — even though `mute` is a real operation, just not on > `photoconvert`. The catalog lists every valid pair, and > `GET https://mp.dvocorp.com/api/v1/studio/tools` returns the same list with an `accepts` field > (`["image"]` / `["video"]`) so you can validate before submitting. Each entry's > `config.keywords` is the search vocabulary for that tool — synonyms and intents > in en/uk/ru ("smaller", "сжать", "зменшити" → compress) — the same list the > studio's search box uses, so a client that offers its own tool picker can search > the way the studio does. `config.popular: true` marks the tiles behind the > studio's "Popular" filter. **Which upload path do I use?** ``` file ≤ 100 MB → POST /api/v1/jobs/upload (this page, one call) file > 100 MB → POST /api/v1/uploads/presign (or /uploads/multipart/*) up to 3 GB PUT the bytes to the returned put_url POST /api/v1/jobs/api with params.file_url file is on your → POST /api/v1/upload-sessions/api END USER's device send them the returned upload_url (Telegram bots) (see section 7) ``` Exact numbers: the CDN caps a proxied request body at **~100 MB**, so anything larger must go straight to storage. The platform cap is **`MAX_UPLOAD_GB` = 3 GB**; multipart uses **64 MB** parts, up to 10 000 of them. Upload links live **1 hour by default, 24 h maximum**. See Big files. ### Request 2 — check the status and grab the result ```bash curl -s https://mp.dvocorp.com/api/v1/jobs/6f9c1c9e-2b40-4a71-9c3e-1d5a8f0b7e42 \ -H 'X-Api-Key: ca_live_...' ``` Repeat every 2–3 seconds until `status` stops being `queued` or `processing`. **Job lifecycle** — three of the five states are terminal: ``` queued ──► processing ──┬──► done result.output_url is ready ├──► failed error explains why, bonuses refunded └──► canceled you canceled it, bonuses refunded ``` | `status` | Terminal? | Meaning | |---|---|---| | `queued` | no | accepted, not started yet — keep polling | | `processing` | no | running — keep polling | | `done` | yes | finished — the file is at `result.output_url` | | `failed` | yes | see `error`; **bonuses were refunded** | | `canceled` | yes | you canceled it; bonuses refunded | The response shape is the same in every state; only `status`, `result` and `error` change. All four, in full: *Still working — `result` and `error` are both null:* ```json { "id": "6f9c1c9e-2b40-4a71-9c3e-1d5a8f0b7e42", "status": "processing", "service_id": 3, "operation_key": "compress", "params": { "quality": 70, "file_url": "https://.../photo.jpg" }, "result": null, "error": null, "estimated_cost": 1, "final_cost": null, "created_at": "2026-07-22T12:00:00Z", "finished_at": null } ``` The `id`, `status`, `result` and `error` fields are the only ones an integration needs; parse defensively and ignore any field you don't recognise. On `done`, `result` also nests the worker's own report at `result.body` (`output_url`, `output_format`, `output_size_bytes`, and for video `encode_mode`) — `result.output_url` is the copy you want. Two more fields appear on the single-job reads (`POST /jobs`, `POST /jobs/api`, `POST /jobs/upload`, `GET /jobs/{id}`, `POST /jobs/{id}/repeat`, `POST /jobs/{id}/cancel`): * `queue_position` — your place in line while `status` is `queued`, where 1 is next to be handed to a worker. `null` for every other status, and `null` on the `GET /jobs` listing. It is the real release order — higher tiers go first, then by when each wait ends — so it can go **up** if paid work arrives behind you. * `input_duration_sec` — the length of the input media in whole seconds, as probed by the worker. `null` until it is reported, and always `null` for operations that are not priced by length. This is the number a speech-to-text bill is computed from. *Done — take `result.output_url`:* ```json { "id": "6f9c1c9e-2b40-4a71-9c3e-1d5a8f0b7e42", "status": "done", "operation_key": "compress", "result": { "output_url": "https://.../out/abc123/photo.jpg" }, "error": null, "estimated_cost": 1, "final_cost": 1, "finished_at": "2026-07-22T12:00:04Z" } ``` *Failed — read `error`; you were **not** charged:* ```json { "id": "6f9c1c9e-2b40-4a71-9c3e-1d5a8f0b7e42", "status": "failed", "result": null, "error": "could not start job: unsupported input format", "estimated_cost": 1, "final_cost": null, "finished_at": "2026-07-22T12:00:02Z" } ``` *Canceled — via `POST /api/v1/jobs/{id}/cancel`:* ```json { "id": "6f9c1c9e-2b40-4a71-9c3e-1d5a8f0b7e42", "status": "canceled", "result": null, "error": null, "final_cost": null, "finished_at": "2026-07-22T12:00:03Z" } ``` Treat any status you do not recognise as non-terminal and keep polling — new states would only ever be added between `queued` and the terminal three. `result.output_url` is a plain public URL — download it directly: ```bash curl -L -o result.jpg "https://.../out/abc123/photo.jpg" ``` In Postman: paste the URL, **Send**, then **Save Response → Save to a file**. > **`queued` can last a while.** Start time is not guaranteed: depending on your > plan a job may be held before processing begins (lower tiers wait, and can > also wait for higher-priority work to finish). Nothing is wrong — keep > polling, or use a webhook. **Never resubmit a `queued` job**: the second > submit is a new job and is charged again. **That's the whole API.** Everything below is reference: which operations exist, what parameters each one takes, and the extra routes for big files. ### Changing what happens — only `params` changes Same two requests, different settings. Crop a photo to a square: ```bash curl -s https://mp.dvocorp.com/api/v1/jobs/upload -H 'X-Api-Key: ca_live_...' \ -F 'file=@/path/to/photo.jpg' \ -F 'service_slug=photoconvert' -F 'operation_key=crop' \ -F 'params={"crop_aspect": "1:1"}' ``` Burn auto-captions onto a video: ```bash curl -s https://mp.dvocorp.com/api/v1/jobs/upload -H 'X-Api-Key: ca_live_...' \ -F 'file=@/path/to/clip.mp4' \ -F 'service_slug=clipconvert' -F 'operation_key=subtitles' \ -F 'params={"style": "karaoke", "position": "top"}' ``` ### Check your key (optional) ```bash curl -s https://mp.dvocorp.com/api/v1/me/ping -H 'X-Api-Key: ca_live_...' # -> {"ok":true,"token_label":"my-app","plan":"free","credits":98} ``` ### Skip polling with a webhook Add `-F 'webhook_url=https://my-app.example.com/hooks/job-done'` to request 1 and we POST the finished job object there. Must be a public `http(s)` URL — private and internal addresses are rejected with `422`. --- # 2. Integrate with an AI agent (Claude Code, Cursor, ChatGPT…) Two ways, pick one: - **Connect the MCP server** — one command, and the agent drives the API through tools (below). Use this when the agent *itself* should process files for you, or should build and test an integration against a live account. - **Paste the prompt** (further down) — a self-contained spec of the REST API for agents that cannot connect to an MCP server, or when you want plain REST code with no runtime dependency. ## Connect the MCP server (Claude Code, Codex, Cursor, Gemini CLI, VS Code…) The server speaks MCP over Streamable HTTP at **`https://mp.dvocorp.com/api/mcp`** and takes the same key as the REST API, in either header: ``` Authorization: Bearer ca_live_... # or X-Api-Key: ca_live_... ``` The panel's **Developer API** page (`/api`) shows every command below with your key already filled in. The key goes into your **local** agent config — where that agent keeps its other tokens — never into a chat. ```bash # Claude Code claude mcp add --transport http mediaprocessing https://mp.dvocorp.com/api/mcp \ --header "Authorization: Bearer $API_KEY" ``` ```bash # Codex (the token is read from an env var; Codex has no header flag) export MP_API_KEY=ca_live_... codex mcp add mediaprocessing --url https://mp.dvocorp.com/api/mcp --bearer-token-env-var MP_API_KEY ``` ```bash # Gemini CLI gemini mcp add --transport http --header "Authorization: Bearer $API_KEY" mediaprocessing https://mp.dvocorp.com/api/mcp ``` ```json // Cursor (~/.cursor/mcp.json), Windsurf and most other clients { "mcpServers": { "mediaprocessing": { "url": "https://mp.dvocorp.com/api/mcp", "headers": { "Authorization": "Bearer ca_live_..." } } } } ``` ```json // VS Code (.vscode/mcp.json) { "servers": { "mediaprocessing": { "type": "http", "url": "https://mp.dvocorp.com/api/mcp", "headers": { "Authorization": "Bearer ca_live_..." } } } } ``` ```json // OpenAI Responses API — your own software, server-side { "model": "gpt-5", "tools": [{ "type": "mcp", "server_label": "mediaprocessing", "server_url": "https://mp.dvocorp.com/api/mcp", "headers": { "Authorization": "Bearer ca_live_..." }, "require_approval": "never" }], "input": "compress https://example.com/demo.mp4 for Telegram" } ``` Then tell the agent what you need — *"compress ./demo.mp4 for Telegram and give me the link"* — or *"add this integration to my bot"*. ### The tools | Tool | What it does | |---|---| | `ping()` | Checks the key and the balance. Spends nothing. | | `list_operations(kind?)` | The live catalog: every `service_slug` / `operation_key`, what media it `accepts`, `input` (`file` or `url`), `credits_per_call`, `fields` (each param's type/min/max) and `presets`. `kind` = `image` \| `video` \| `audio` narrows it. | | `submit_job(service_slug, operation_key, file_url?, params?, webhook_url?)` | Starts a job. `file_url` is the input for file operations; link operations take the link in `params.url`. Debits at submit, refunds on failure — the same path as `POST /jobs/api`. | | `get_job(job_id)` | Status, `output_url` once done, `error` once failed, `estimated_cost` / `final_cost`, queue position. | | `wait_for_job(job_id, timeout_seconds=60)` | Polls for you, up to 120 s per call; `timed_out: true` means call again. | | `list_jobs(limit=20)` | Your recent jobs, newest first. | | `presign_upload(filename, content_type)` | A slot for a file on the agent's machine: `PUT` the bytes to `put_url` (the answer includes the `curl -T` line), then pass `url` as `file_url`. Multi-GB is fine; the bytes never pass through the API. | | `create_upload_link(service_slug, operation_key, params?, callback_url?, metadata?, …)` | A one-time upload page for a file your **end user** holds (Telegram bots): the same body as `POST /upload-sessions/api`, section 7. | | `get_upload_link(session_id)` | The link's state and the jobs it produced. | Every call goes through the **same code as the REST route it mirrors**: the same plan quota, per-key daily cap, in-flight cap, maintenance window, billing and usage journal. The MCP server adds no prices and no limits of its own. Errors come back as tool errors carrying the REST status and `detail` (`402: insufficient credits`, `429: … (retry after 60s)`). The server is **stateless**: every JSON-RPC call is self-contained, which is what lets it sit behind a load balancer with no sticky sessions. A missing or bad key is a plain HTTP `401` (a disabled account `403`), not a JSON-RPC error — so `claude mcp add` tells you right away. > An admin can switch MCP access off per audience (feature `mcp`, on by > default) without touching the REST API. ## The prompt (for agents without MCP) Copy the block below, paste it into your AI coding agent and say **"here is the spec, add this integration"**. It is a complete, self-contained contract — the agent needs nothing else from this page. > **Do not put your API key in the prompt.** The block deliberately uses an env > var. Pasting a live credential into a third-party chat leaks it into that > vendor's logs; the agent only needs to know the key's *name*. ```text You are integrating the Media Processing API (image & video processing) into this project. BASE URL: https://mp.dvocorp.com AUTH: every request sends the header X-Api-Key: The key looks like ca_live_... . Read it from the MP_API_KEY env var. Never hardcode it, never log it, never commit it. THE WHOLE API IS TWO REQUESTS. 1) SUBMIT — one multipart POST carrying the file AND what to do with it: POST https://mp.dvocorp.com/api/v1/jobs/upload header: X-Api-Key: multipart/form-data fields: file (required) the binary file service_slug (required) "photoconvert" for images, "clipconvert" for video operation_key (optional) what to do — see DISCOVERY params (optional) the operation's settings as a JSON STRING, e.g. {"quality":70} webhook_url (optional) public http(s) URL; the finished job is POSTed there high_priority (optional) "true" — honored on premium plans, silently ignored otherwise -> 201 {"id":"","status":"processing","estimated_cost":1,"result":null,"error":null} THE #1 INTEGRATION BUG — params is a STRING, not an object. It is a multipart form field, so serialise it yourself: JS: form.append("params", JSON.stringify({quality: 70})) correct form.append("params", {quality: 70}) WRONG, sends [object Object] Python: data={"params": json.dumps({"quality": 70})} correct data={"params": {"quality": 70}} WRONG, dict repr is not JSON The server answers 400 "params must be a JSON object" when you get this wrong. service_slug and operation_key are a PAIR, and each operation accepts one media kind — check "accepts" in the catalog before submitting. A video operation on an image is a 400 whose detail names BOTH kinds, e.g. "this operation works on video files — the file you sent looks like image" An unknown operation_key for that service is 400 "unknown operation ''". Do NOT put file_url in params: the server injects it from the uploaded file and it always wins. Do NOT set Content-Type yourself; let the HTTP client set the multipart boundary. 2) POLL — until the status is terminal: GET https://mp.dvocorp.com/api/v1/jobs/{id} (same X-Api-Key header) status "queued" | "processing" -> keep polling, every 2-3 seconds status "done" -> result.output_url is a plain public URL; download it directly status "failed" -> read `error`; the credits were refunded automatically status "canceled" -> credits refunded If you set webhook_url, skip polling entirely: the same job object is POSTed there. TELEGRAM BOTS AND ANY "MY END USER HAS THE FILE" INTEGRATION: A Telegram bot CANNOT relay big media: the Bot API lets a bot download only ~20 MB via getFile and send only ~50 MB. That limit cannot be worked around from the bot side — do not try to stream the file through your bot. Mint a one-time upload link instead and let the user's browser upload straight to storage, bypassing both Telegram's servers and ours. POST https://mp.dvocorp.com/api/v1/upload-sessions/api header: X-Api-Key: { "service_slug": "clipconvert", "operation_key": "compress", "params": {"level": "medium"}, "expires_in_seconds": 3600, "max_file_size_bytes": 2147483648, "allowed_mime_types": ["video/mp4", "video/quicktime"], "callback_url": "https://my-bot.example.com/hooks/mp", "delete_after_processing": true, "metadata": {"telegram_user_id": "123", "chat_id": "456", "request_id": "abc"} } -> 201 {"id":"", "upload_url":"https://mp.dvocorp.com/u/", "expires_at":"...", "estimated_cost":3, "callback_secret":""} Send upload_url to the user and stop. The page it opens already does presigned multipart upload with per-part retry, a progress bar and cancel — you do not build an uploader. Fields that matter for a bot: metadata opaque JSON, echoed back VERBATIM in every callback. This is how you map a finished file back to a chat: put telegram_user_id / chat_id / your request_id here. expires_in_seconds 60..604800, default 1h, server-capped at 24h. max_files 1..20 (default 1); every file runs every operation. operations[] up to 10 {service_slug, operation_key, params} — one job per entry on the same file. Mutually exclusive with the single service_slug/operation_key/params. bundle_zip true -> the owner can pull one .zip of everything. max_file_size_bytes your own cap; the platform cap (3 GB) still applies. allowed_mime_types anything else is rejected with 415. delete_after_processing true -> the uploaded SOURCE is deleted as soon as processing succeeds. Unknown operation (404) and insufficient credits (402) are reported at CREATE time — before the user ever opens the link. Handle them there. ONE-TIME AND EXPIRING BY DESIGN: a second upload to the same link gets 409, an expired link gets 410. Both are final — mint a new session. On your side, store request_id -> session_id so a retried bot command does not mint a second link and charge twice. RESULTS FROM AN UPLOAD LINK — webhook or poll: With callback_url set, these events are POSTed to it: upload.completed the file landed and was verified processing.completed all linked jobs finished, at least one succeeded processing.failed they all failed Headers: X-BigLoader-Event: X-BigLoader-Signature: hex HMAC-SHA256(callback_secret, RAW body) Verify the HMAC over the RAW request body BEFORE parsing JSON. Delivery is best-effort, so keep polling as a fallback. Body: {"event","session_id","status","file":{...},"job_id","job_ids", "metadata":,"jobs":[{"id","operation_key","status", "output_url","error"}],"result":{"output_url"}} GET https://mp.dvocorp.com/api/v1/upload-sessions/{session_id} status + jobs + results created -> uploading -> uploaded -> processing -> done (plus failed, expired) — coarse upload progress comes from here GET https://mp.dvocorp.com/api/v1/upload-sessions your recent sessions GET https://mp.dvocorp.com/api/v1/upload-sessions/{id}/archive .zip of all uploads DELETE https://mp.dvocorp.com/api/v1/upload-sessions/{id} deletes the files and cancels + refunds any in-flight job SENDING THE RESULT BACK: result.output_url is a plain public URL. If the processed file exceeds Telegram's ~50 MB send limit, send the URL as a message instead of uploading the file. A round video note (operation "circle" with format "note", <=60s) only renders round when a bot calls sendVideoNote with an uploaded file. FILES OVER ~100 MB THAT YOUR OWN SERVER HOLDS (the CDN caps request bodies) — three steps instead of one: a) POST https://mp.dvocorp.com/api/v1/uploads (multipart, field `file`) -> {"url": "..."} for multi-GB: POST https://mp.dvocorp.com/api/v1/uploads/presign -> PUT the bytes to put_url b) POST https://mp.dvocorp.com/api/v1/jobs/api (JSON body, not multipart): {"service_slug":"...","operation_key":"...","params":{"file_url":""}} c) poll exactly as in step 2. ERRORS — the body is {"detail": ...}: a string, or a list of field errors for 422. 400 bad request — unknown operation, invalid file URL, wrong media kind, OR a param the operation refused. A bad param NAMES the field and refunds any charge: "could not start the job — quality: Input should be less than or equal to 100 (charge refunded)" 401 missing/invalid API key ("missing credentials") 402 not enough credits 404 unknown service or job 413 file too large 415 file type not allowed 422 validation error — pydantic detail array; e.g. webhook_url pointing at a private address, or ?limit outside 1..200 on GET /jobs 429 rate limited — HONOR the Retry-After response header (seconds) 5xx retry with exponential backoff BILLING: ONE tariff, the same for every operation, measured in processing time. 2 credits per file are debited at submit, then 1 more for each further STARTED minute of processing beyond the first, never more than 30 for one file. So <=60s costs 2, 61-120s costs 3, 121-180s costs 4. The operation you address does not change the price, and neither does a `params.operations` pipeline. Credits are refunded automatically if the job fails or is canceled, and a failed or canceled job is never charged for time. An input longer than your tier's length limit is refused and refunded in full (10 / 30 / 180 min for anonymous / free / paid). Read the live numbers from GET /api/v1/public/config -> billing.time_tariff_*. GET https://mp.dvocorp.com/api/v1/me/ping -> {"ok":true,"plan":"free","credits":98} is a cheap credential + balance check. DISCOVERY — never hardcode the operation list: GET https://mp.dvocorp.com/api/v1/studio/tools (public, no auth) Returns a JSON array; every entry is one operation with: operation_id, service_slug, operation_key, label{en,uk,ru}, group, accepts (["image"] or ["video"]), credits_per_call, config.fields[] (each tunable param with key/type/min/max) and config.presets[] (ready-made settings, each {label, params}). NOTE: the top-level presets[] carries only {index, label} — the actual ready-made params are under config.presets[].params. Fetch this once and derive your operation list + validation from it. WHAT TO BUILD — a small typed client. These are DIFFERENT paths; do not collapse them into one submit(): submit(file, serviceSlug, operationKey, params) -> jobId multipart POST /jobs/upload. Files up to ~100 MB that your process holds. submitLarge(pathOrStream, serviceSlug, operationKey, params) -> jobId presign (or multipart create/sign-part/complete) -> PUT the bytes -> POST /jobs/api with params.file_url. Multi-GB files your process holds. createUploadLink({operation, params, metadata, ttl, maxFileSizeBytes, allowedMimeTypes, callbackUrl, deleteAfterProcessing}) -> {uploadUrl, sessionId, expiresAt} For files your END USER holds (Telegram bots). You never touch the bytes. waitForResult(jobId) -> outputUrl poll GET /jobs/{id} getSession(sessionId) -> {status, jobs, metadata} verifyCallback(rawBody, signatureHeader, secret) -> boolean HMAC-SHA256 over the RAW body; use a constant-time compare. listOperations() from /studio/tools Also: retry on 429/5xx honoring Retry-After; persist your own request_id -> session_id / job_id so a retried command never charges twice; keep the credential in the environment. Ask me before adding a dependency. ``` ## Getting the operation list into the prompt The block above deliberately tells the agent to *discover* operations at runtime instead of pasting 40 of them. If your agent has no network access, fetch the catalog yourself and hand it over as a file: ```bash curl -s https://mp.dvocorp.com/api/v1/studio/tools > operations.json ``` Then: *"use operations.json as the operation catalog"*. The panel's **Developer API** page (`/api`) also has a **Copy AI prompt** button that embeds a current snapshot of the catalog for you. ## Checking what the agent built Ask it to prove the integration works against a real job: ``` Run the integration end to end: submit a small test image with service_slug=photoconvert, operation_key=compress, params={"quality":70}, poll until done, download result.output_url, and show me the job id, the final status and the output file size. ``` If that produces a downloaded file, the integration is correct. --- # 3. Operations catalog Every operation is addressed by a **`service_slug` + `operation_key`** pair and takes its input as `params.file_url`. **The live, always-current list is one public request** (no key needed) — this is the same data the panel's catalog renders from: ```bash curl -s https://mp.dvocorp.com/api/v1/studio/tools ``` Each entry gives you `operation_id`, `service_slug`, `operation_key`, `credits_per_call`, what file kinds it `accepts`, its tunable parameters (`config.fields[]`, each with `key` / `type` / `min` / `max`), and its ready-made settings (`config.presets[]`, each a `{label, params}` — the top-level `presets[]` list carries only `{index, label}`, so read the param values from `config.presets[].params`). `credits_per_call` is what one job costs **at submit**, and it is the same on every tool because there is one tariff. A `params.operations` pipeline does not change it either: addressing a cheap operation while shipping an expensive recipe costs exactly the same as addressing the expensive one. ### The tariff: you pay for processing time One price, every operation — photo, video, audio, subtitles, batch, API, studio: | processing time | you pay | |---|---| | up to 1 min | **2** | | 1–2 min | 3 | | 2–3 min | 4 | | each further started minute | +1 | | **however long it runs** | **never more than 30** | A *started* minute is a whole minute: 61 seconds is two minutes. Processing time is measured on the worker from the moment it picks your file up — **the queue wait is not billed**. Nobody knows how long your file will take until a worker has run it, so the flow is: * **at submit** you are charged the base (2), and `estimated_cost` reflects that; * **when the job finishes**, the difference is debited and `final_cost` becomes the real total. Faster than a minute changes nothing — the base is the minimum and is never refunded down. A job that ends `failed` or `canceled` is **never charged for time** and every bonus it took is refunded. If we cannot measure the time at all, you pay the base and nothing more. Don't hardcode these numbers — `GET /api/v1/public/config` returns them under `billing`: `time_tariff_base_credits`, `time_tariff_per_extra_minute_credits`, `time_tariff_max_credits`, `time_tariff_enabled`. ### The input-length ceiling The 30-bonus cap bounds your bill; it does not bound our workers. So an input longer than your tier allows is **refused, not truncated**: the job ends `failed`, every bonus it took is refunded, and `error` names the length, the limit and your tier. | tier | max input length, any operation | |---|---| | anonymous (no sign-in) | 10 min | | free account | 30 min | | paid plans | 180 min | This applies to **every operation**, not just speech-to-text, and only to media that reports a duration (images are never affected). Live values are on `GET /api/v1/public/config` under `billing.input_max_minutes`. Split a longer recording yourself (`trim` is a remux — it is cheap and lossless) and submit the parts. **Bulk works the same way, per file.** A `/studio/batch` is charged the base per file at submit and each file's own processing time afterwards, and the length ceiling applies per file too. A file longer than your tier allows **stops the batch**: the remaining files are canceled, the batch ends `failed` with the filename in `error`, and everything not already delivered is refunded, the over-long file included. Files delivered before it stay charged. Some jobs — compress, resize, convert, watermark, add/strip metadata — exist on **both** services, once for video and once for images. Those entries share a `config.family` value. The web studio uses it to show a single button and picks the operation from the file you dropped; **over the API you address the two operations separately**, as usual, by `service_slug` + `operation_key`. The tables below are the shipped baseline — an admin can add or retune operations, so treat `/studio/tools` as the source of truth. ## ClipConvert — video (`"service_slug": "clipconvert"`) Every operation accepts **video**. | `operation_key` | What it does | Bonuses | Main params | |---|---|---|---| | `compress` | Shrink for web | 3 | `level` `light\|medium\|strong`, `crf` 0–51, `preset` `ultrafast…slow` | | `resize` | Change resolution | 3 | `width` / `height` 16–7680, `mode` `fit\|pad` | | `convert` | Change container | 3 | `format` `mp4\|webm\|mov\|mkv`; `vcodec` `libx264\|libx265\|libvpx-vp9\|copy`, `acodec` `aac\|libmp3lame\|libopus\|copy` (omit → the container's default) | | `social` | Social presets | 3 | `preset` `square\|vertical\|widescreen`, `mode` `crop\|pad`, `focus_x`/`focus_y` 0–1 (where the crop sits: 0 = left/top edge, 0.5 centre) | | `trim` | Cut a segment | 2 | `start` (s), `duration` (s), `precise` (default `true`) | | `fps` | Change frame rate | 2 | `fps` 1–120 | | `faststart` | Web-optimize (instant play) | 1 | — | | `mute` | Remove the audio track | 1 | — | | `speed` | Speed up / slow down | 3 | `factor` 0.1–10 | | `rotate` | Rotate | 2 | `mode` `90cw\|90ccw\|180` | | `flip` | Mirror | 2 | `axis` `h\|v` | | `gif` | Video → GIF | 3 | `start`, `duration` 1–60, `fps` 1–30, `width` 16–1920 | | `thumbnail` | Grab a frame | 1 | `at` (s), `smart`, `format` `jpg\|png\|webp` (`jpeg` is accepted as an alias for `jpg`), `width` 16–3840 | | `cover` | Cover image | 1 | builder-driven | | `extract_audio` | Rip the audio | 2 | `format` `mp3\|aac` | | `enhance_voice` | Clean up voice | 3 | `level` `light\|medium\|strong`; `denoise`, `deess`, `compress`, `loudnorm` | | `add_audio` | Voiceover / background music | 3 | `tracks[]`, `original_volume` — see Add audio | | `subtitles` | Burn / embed subtitles | 6 | see Subtitles | | `transcribe` | Speech → SRT/VTT/TXT | 5 | `format`, `language`, `task`, `quality` `fast\|balanced\|accurate` | | `auto_edit` | Cut pauses & filler words | 7 | `language`, `max_pause` 0.2–5, `keep_pause` 0–2, `padding` 0–1, `remove_fillers`, `remove_retakes` | | `circle` | Round video / Telegram note | 3 | see Circle | | `blur_bg` | Fit to canvas, blurred bg | 3 | see Fit to canvas | | `watermark` | Overlay a watermark | 3 | builder-driven | | `device_meta` | Tag as shot on a device | 2 | `preset` (device key), `creation_time` — writes container/QuickTime tags, not EXIF | | `strip_metadata` | Remove metadata | 1 | — | ### From a link (input is a URL, not a file) These operations take **no uploaded file**. The input is a public video link in `params.url`, so they are submitted with the plain JSON `POST /api/v1/jobs` instead of the multipart upload — everything else (polling, the result URL, billing) is identical. | `operation_key` | What it does | Bonuses | Main params | Ships | |---|---|---|---|---| | `link_info` | Read a link's public metadata, engagement stats and comments | 2 | `url`, `include[]`, `comment_limit` 1–500, `format` `json\|csv` | **on** | | `download_video` | Download the video | 2 | `url`, `quality` `best\|2160p\|1440p\|1080p\|720p\|480p\|360p`, `start`, `duration` | off | | `download_audio` | Link → MP3 / M4A / Opus / WAV | 2 | `url`, `audio_format`, `start`, `duration` | off | | `download_thumbnail` | The platform's own cover image | 1 | `url`, `thumbnail_format` `jpg\|png\|webp` | off | | `link_transcribe` | Video link → text (fetch + Whisper) | 5 | `url`, `language`, `transcript_format` `txt\|srt\|vtt\|json`, `task` | off | | `link_captions` | Video link → video with burned-in captions | 6 | `url`, `language`, `style` `karaoke\|boxed\|clean\|yellow`, `position` | off | **"Ships off" means exactly that.** Every operation that TRANSFERS media from a platform is present in the code and dormant: platform terms of service forbid the transfer, and payment providers' acceptable-use policies name it. Turning one on takes two deliberate acts — an admin enables the operation, and the operator sets `MEDIA_FETCH_ENABLED=true` on the video service. Until then those operations answer with a plain "switched off on this server". Reading a link's public metadata (`link_info`) moves no media and is on by default. ```bash # one request, no upload — the link IS the input curl -s https://mp.dvocorp.com/api/v1/jobs/api \ -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \ -d '{"service_slug":"clipconvert","operation_key":"link_info", "params":{"url":"https://www.youtube.com/watch?v=VIDEO_ID", "include":["info","stats","analytics"],"format":"json"}}' # -> {"id":"...","status":"queued"} then poll /api/v1/jobs/ as usual ``` `include[]` picks what the report contains — any of `info` (title, description, channel, tags, upload date), `stats` (views, likes, comments, subscribers), `analytics` (engagement rate, views/day, likes and comments per 1k views, views per subscriber), `chapters`, `subtitles` (which caption languages exist), `thumbnails`, `formats` (available qualities), `comments`. `format: "csv"` flattens the comments — or, with no comments requested, the stats — into one spreadsheet-ready table. Three limits worth knowing before you build against these: - **One video per job.** A playlist or channel link is refused rather than silently expanded into hundreds of charged jobs. - **Live streams are refused.** A stream has no length, so no cap could bind on one. - **Length.** A download-only job accepts sources up to `MEDIA_MAX_DURATION_SEC` (default 60 min). A job that also processes the video (`link_transcribe`, `link_captions`) obeys the ordinary input cap `MAX_DURATION_SEC` (default 5 min) — the same ceiling an uploaded file gets, because the expensive part is the same work. - **Comments are personal data.** They are returned to you and never retained beyond the job's own 24-hour artifact. What you do with them afterwards is your controller obligation, not ours. ### Which video operations re-encode (and which don't) An operation that changes no pixels is a **remux**: the picture is copied packet for packet instead of being re-encoded. It comes back in seconds instead of minutes, and it is bit-identical — no quality loss, no bitrate change. | Behaviour | Operations | |---|---| | **No encoder at all** | `link_info` (reads a page), `download_video` / `download_audio` / `download_thumbnail` (a transfer, at most a mux) | | **Remux** — nothing is re-encoded | `faststart`, `mute`, `strip_metadata`, `device_meta`, `trim` from `0` (or with `precise: false`), `convert` when the codecs already fit the new container (e.g. `.mov` H.264 → `.mp4`), `subtitles` with `mode: "embed"`, `extract_audio` when the source track already is that format (AAC → `aac`) | | **Sound only** — the picture is copied | `enhance_voice`, `add_audio` | | **Full re-encode** — required | `compress`, `resize`, `social`, `fps`, `rotate`, `flip`, `speed`, `watermark`, `blur_bg`, `circle`, `gif`, `subtitles` with `mode: "burn"`, `auto_edit`, `convert` to a different codec (anything → `webm`) | Two consequences worth knowing: - **`trim` accuracy.** `precise: true` (the default) re-encodes so the cut lands exactly on `start`. `precise: false` cuts on the nearest keyframe at or before `start` and copies — near-instant, but the clip can begin up to a few seconds early. A trim from `0` always takes the fast path; the flag only matters for a cut from mid-video. - **Resolution.** A re-encode is capped at 1080p. A remux is not — the video is passed through exactly as you sent it, 4K included. Ask for a smaller file explicitly with `resize` or `compress`. The finished job reports which path actually ran at `result.body.encode_mode` — one of `remux`, `video_copy`, `audio_copy`, `encode`. ## PhotoConvert — images (`"service_slug": "photoconvert"`) Every operation accepts **image**. | `operation_key` | What it does | Bonuses | Main params | |---|---|---|---| | `resize` | Change dimensions | 1 | `width` / `height` 16–20000, `resize_mode` `fit\|fill\|exact`; scale by percentage needs **both** `percent` and `resize_mode: "scale"` | | `compress` | Shrink | 1 | `quality` 1–100, `format` `jpeg\|webp\|avif\|png`, `max_kb` (re-encodes down to fit) | | `convert` | Change format | 1 | `format` `webp\|avif\|jpeg\|png`, `quality`, `max_kb` | | `crop` | Crop to aspect | 1 | `crop_aspect` `1:1\|4:5\|5:4\|4:3\|3:4\|3:2\|2:3\|16:9\|9:16\|21:9`, `gravity` `center\|top\|bottom\|left\|right` | | `circle` | Round avatar | 1 | `diameter`, `zoom`, `offset_x/y`, `bg_color` (omit → transparent PNG) | | `transform` | Rotate / flip | 1 | `rotate` 0–359 counter-clockwise (90/180/270 lossless, other angles fill the corners with `bg_color`), `flip` `h\|v` | | `enhance` | Filters | 1 | `autocontrast`, `brightness` / `contrast` / `saturation` / `sharpness` 0–4 (`1` = unchanged), `blur` 0–100 px, `grayscale` | | `social` | Social sizes | 1 | any `width`/`height` 16–20000, `resize_mode` (presets use `fill`) | | `web_optimize` | Optimize for web | 1 | `format`, `quality`, `max_kb`, strips metadata | | `strip_meta` | Remove EXIF / GPS | 1 | `strip_metadata`, `strip_gps`; GPS **only** → `{"metadata_mode": "merge", "strip_gps": true}` | | `device_meta` | Write camera EXIF | 2 | `preset` (see below), `metadata_mode: "set"` | | `watermark` | Overlay a watermark | 1 | `text` *or* `logo_url`, `position` (9-grid `tl…br`), `opacity` `0.05`–`1`, `size` (% of width), `color` | | `edit` | Everything in one pass | 2 | composes a full pipeline | `device_meta` presets: `iphone`, `iphone_15`, `iphone_15_pro_max`, `iphone_16`, `iphone_16_pro`, `iphone_16_pro_max`, `samsung_galaxy_s24`, `samsung_galaxy_s24_ultra`, `samsung_galaxy_s25_ultra`, `google_pixel_8_pro`, `google_pixel_9_pro`, `xiaomi_14_ultra`, `oneplus_12`, `sony_xperia_1_vi`, `huawei_p60_pro`, `sony_a7_iv`, `canon_eos_r5`, `canon_eos_r6_mark_ii`, `nikon_z8`, `fujifilm_x_t5`. > **Send the params.** An image operation whose params say nothing to do > (`watermark` with no text and no logo, `edit` with no pipeline) is **rejected** > with an error — it is never accepted, billed, and answered with your own file > back. > **There is no separate audio service.** Audio work is done by the ClipConvert > operations `extract_audio`, `mute`, `enhance_voice` and `transcribe` — and the > input is a **video file**, not an audio file. These operations read the audio > track out of a video container; handing them a bare `.mp3`/`.wav`/`.aac` is > refused with `400 this operation works on video files — the file you sent > looks like audio`. To process a standalone audio file, first wrap it in a > video container (e.g. a still image + the audio track) and submit that. --- # 4. Recipes The route never changes — only the three form fields do. Each recipe below is the same `POST https://mp.dvocorp.com/api/v1/jobs/upload` with a different `service_slug` / `operation_key` / `params`. | Goal | `service_slug` | `operation_key` | `params` | |---|---|---|---| | Compress a photo to 70% JPEG | `photoconvert` | `compress` | `{"quality": 70, "format": "jpeg"}` | | Photo to WebP | `photoconvert` | `convert` | `{"format": "webp", "quality": 85}` | | Square crop | `photoconvert` | `crop` | `{"crop_aspect": "1:1"}` | | Round avatar, transparent | `photoconvert` | `circle` | `{"diameter": 512}` | | Strip EXIF + GPS | `photoconvert` | `strip_meta` | `{"strip_metadata": true, "strip_gps": true}` | | Instagram portrait size | `photoconvert` | `social` | `{"width": 1080, "height": 1350, "resize_mode": "fill"}` | | Shrink a video | `clipconvert` | `compress` | `{"level": "medium"}` | | Exactly 1280×720, letterboxed | `clipconvert` | `resize` | `{"width": 1280, "height": 720, "mode": "pad"}` | | First 30 seconds | `clipconvert` | `trim` | `{"start": 0, "duration": 30}` | | Video → GIF | `clipconvert` | `gif` | `{"start": 0, "duration": 5, "fps": 15, "width": 480}` | | Rip the audio | `clipconvert` | `extract_audio` | `{"format": "mp3"}` | | Burn auto-captions | `clipconvert` | `subtitles` | `{"style": "karaoke", "position": "top"}` | | Speech → SRT file | `clipconvert` | `transcribe` | `{"format": "srt"}` | | Telegram video note | `clipconvert` | `circle` | `{"format": "note", "diameter": 512, "duration": 60}` | | Landscape → TikTok canvas | `clipconvert` | `blur_bg` | `{"width": 1080, "height": 1920, "bg_mode": "blur"}` | Written out in full, one of them: ```bash curl -s https://mp.dvocorp.com/api/v1/jobs/upload \ -H 'X-Api-Key: ca_live_...' \ -F 'file=@/path/to/clip.mp4' \ -F 'service_slug=clipconvert' \ -F 'operation_key=resize' \ -F 'params={"width": 1280, "height": 720, "mode": "pad"}' ``` The sections below document every parameter of the operations that have more than a couple. ## Subtitles & transcription **`subtitles`** puts captions on the video. The text comes from **one** of: - `subtitles_text` — your own text: a plain script (no timestamps), or a whole SRT / VTT / ASS file; - `captions` — lines with your own timing, as JSON; - `subtitles_url` — a link to an SRT / VTT / ASS file; - none of them — automatic speech recognition of the audio track. > Speech recognition takes roughly as long as the audio, so these are the > operations where the per-minute half of the tariff actually bites — and they > are the most likely to hit the per-tier input-length limit (10 / 30 / 180 min > for anonymous / free / paid). See The tariff: you pay for processing > time. | Param | Values | Default | |---|---|---| | `mode` | `burn` (baked in) / `embed` (soft track, mp4/mov/mkv only) | `burn` | | `language` | `en`, `uk`, `ru`, … | auto-detect | | `task` | `transcribe` / `translate` (→ English captions) | `transcribe` | | `style` | `boxed` / `clean` / `yellow` / `karaoke` (word-by-word) | `boxed` | | `position` | `bottom` / `middle` / `top` | `bottom` | | `font_size` | 2–12 (% of frame height) | `5` | | `subtitles_url` / `subtitles_text` / `captions` | your own captions — see Your own text | — | | `shift_sec` | -600…600 — move every caption earlier (−) or later (+) | `0` | | `font` | `dejavu_sans`, `dejavu_serif`, `liberation_sans`, `liberation_serif`, `roboto`, `roboto_condensed`, `open_sans`, `montserrat`, `comfortaa`, `mono` (all with Cyrillic) | `dejavu_sans` | | `text_color` / `outline_color` | `#RRGGBB` | the style's | | `background_color` / `background_opacity` | `#RRGGBB` / 0–1 — the plate of `boxed` | the style's | | `bold` / `italic` / `uppercase` | `true` / `false` | the style's / `false` / `false` | | `align` | `left` / `center` / `right` | `center` | | `margin_v` | 0–45 — distance from the edge, % of frame height (`bottom` / `top`) | the style's | | `animation` | how each caption arrives: `fade`, `pop`, `slide` (up), `typewriter` (letter by letter), `words` (word by word — follows the speech when it was recognised) | `none` | | `animation_rate` | 0.2–5 — speed of the effect: 1 normal, 2 twice as fast, 0.5 half. `animation_speed` `slow`/`normal`/`fast` = 0.5 / 1 / 2 | `1` | Font, colours and placement apply to `burn`. An `embed` soft track is drawn by the player, which picks its own look. ### Your own text **Plain text, spread over the video.** One caption per line; a line too long for one caption is split. The lines follow one another over the whole video (or `text_start`…`text_end`, in seconds), longer lines staying on screen longer. No sound needed — this is how a silent video, a slideshow or a music clip gets captions. ```bash curl -s https://mp.dvocorp.com/api/v1/jobs/upload \ -H 'X-Api-Key: ca_live_...' \ -F 'file=@/path/to/clip.mp4' \ -F 'service_slug=clipconvert' \ -F 'operation_key=subtitles' \ -F 'params={"subtitles_text": "Spring collection\nAvailable now\nLink in bio", "font": "montserrat", "bold": true}' ``` **Plain text, synced to the speech.** Add `"text_timing": "speech"`: the speech is recognised and each of your lines appears when it is said. The text stays exactly yours (spelling, names, punctuation) — recognition only supplies the timing, so this is billed and queued like automatic captions. With no speech to match (no audio track, or a script that is not what is said) the lines are spread instead; `result.text_timing` says which happened (`speech` / `spread`). ```json { "subtitles_text": "Hi, I'm Olena from Kyiv.\nToday — three tips.", "text_timing": "speech", "style": "karaoke" } ``` **Lines with your own timing.** `captions` is a list of `{start, end, text, position?}`; times are seconds (`2.5`) or clock strings (`"0:02.5"`, `"00:00:02,500"`). `position` puts one line somewhere else — a title at the top, for example. Up to 5000 lines, 500 characters each. ```json { "captions": [ { "start": 0, "end": 2.5, "text": "BIG NEWS", "position": "top" }, { "start": "0:03", "end": "0:06.5", "text": "We open a second store" } ], "text_color": "#FFD400", "background_color": "#000000", "background_opacity": 0.6 } ``` **Arrival animation.** One preset for the whole track, burned in: ```json { "subtitles_text": "Big news today.\nRead on.", "animation": "typewriter", "animation_speed": "fast" } ``` At rate 1: `fade` 150 ms in/out, `pop` scales up with a small bounce, `slide` rises into place, `typewriter` reveals letters at 40 ms each, `words` reveals words at 250 ms — divided by `animation_rate`, and both reveals are capped so the whole line is on screen for most of its time. With automatic captions, `words` follows the actual word timings. The `karaoke` style keeps its own word highlight; `typewriter` and `words` are ignored on it. **A subtitle file.** SRT / VTT / ASS as `subtitles_text` (the file's content) or `subtitles_url`; its cues are kept as they are. `shift_sec` fixes a file that runs early or late. Give only one of `subtitles_text`, `captions`, `subtitles_url` — two at once is a `422`. Every finished `subtitles` job returns the captions it burned as plain SRT in `result.subtitles_srt`. ```json { "service_slug": "clipconvert", "operation_key": "subtitles", "params": { "file_url": "https://.../clip.mp4", "style": "karaoke", "position": "top" } } ``` **`transcribe`** returns a subtitle/text file instead of a video — `format`: `srt` (default), `vtt`, `ass`, `txt`, `json`. ```json { "service_slug": "clipconvert", "operation_key": "transcribe", "params": { "file_url": "https://.../interview.mp4", "format": "srt", "task": "translate" } } ``` **Audio files.** `transcribe` also takes an audio-only file — MP3, WAV, M4A, OGG/Opus, FLAC, voice notes — uploaded exactly like a video. Every other operation (including `subtitles`) needs a video stream and refuses an audio-only file with `400`. ```bash curl -s https://mp.dvocorp.com/api/v1/jobs/upload \ -H 'X-Api-Key: ca_live_...' \ -F 'file=@/path/to/podcast.mp3' \ -F 'service_slug=clipconvert' \ -F 'operation_key=transcribe' \ -F 'params={"format": "txt"}' ``` **Several formats in one job.** `formats` (1–5 of `srt`, `vtt`, `ass`, `txt`, `json`) replaces `format`. With more than one, the result is a ZIP holding `transcript.srt`, `transcript.vtt`, … and its `result.files` lists them. ```json { "formats": ["srt", "vtt", "txt"] } ``` **JSON with word timings.** `format: "json"` returns `language`, `duration_sec`, the full `text`, and `segments[]` — each with `start`, `end`, `text` and `words[]` (`start`, `end`, `word`). **Caption layout and vocabulary** — on both `subtitles` and `transcribe`, for captions produced by speech recognition: | Param | Values | Default | |---|---|---| | `max_chars_per_line` | 16–80 | `42` | | `max_lines` | 1–3 lines per caption | `2` | | `max_cue_duration` | 1–10 s | `5` | | `min_cue_duration` | 0.3–3 s (not above `max_cue_duration`) | `1` | | `max_cps` | 8–30 characters per second — fast speech stays on screen longer | off | | `vocabulary` | up to 100 terms (names, brands, jargon) recognition should spell your way | — | | `initial_prompt` | up to 600 chars of context text; affects the start of the recording | — | ```json { "format": "srt", "language": "en", "max_chars_per_line": 32, "max_lines": 1, "max_cps": 17, "vocabulary": ["Media Processing", "ClipConvert", "Kubernetes"] } ``` ## Auto-edit & voice cleanup **`auto_edit`** is an ASR-driven rough cut: drops dead air, tightens pauses longer than `max_pause` (default `0.6`s) down to `keep_pause` (`0.3`s) of natural ambience, cuts standalone filler sounds («эээ», "um" — extend with `filler_words`), and with `remove_retakes: true` drops a sentence that is immediately re-spoken almost verbatim, keeping the last take. **`enhance_voice`** cleans phone audio in the same encode pass: noise reduction, de-esser, gentle compression, loudness normalization to −16 LUFS. ### Add audio (voiceover / music) **`add_audio`** lays one or more audio tracks over the video — a voiceover, a music bed, sound effects — mixing them with the video's own soundtrack. Up to **8 tracks** in one pass. The result always keeps the **video's** duration: a 3-minute music bed under a 10-second clip is cut at 10 seconds, and a track shorter than the video simply stops (or repeats, with `loop`). Per track: | Field | Meaning | |---|---| | `audio_url` | The track to mix in (fetched server-side, same SSRF rules as a watermark logo) | | `volume` | `0`–`4`, `1` = as-is. A bed under speech usually wants `0.15`–`0.3` | | `offset_sec` | Where it starts on the video timeline | | `start_sec` | Skip this far *into* the track before using it | | `loop` | Repeat until the video ends — for beds shorter than the clip | | `fade_in_sec` / `fade_out_sec` | Fades; the fade-out is anchored to the end of the video | Plus `original_volume` (`0`–`4`, default `1`) for the video's own audio — **`0` replaces the soundtrack entirely**. On a silent video the original leg is simply skipped. ```json { "service_slug": "clipconvert", "operation_key": "add_audio", "params": { "file_url": "https://.../talk.mp4", "operations": "[{\"type\":\"add_audio\",\"original_volume\":0.25,\"tracks\":[{\"audio_url\":\"https://.../voice.mp3\",\"volume\":1},{\"audio_url\":\"https://.../music.mp3\",\"volume\":0.2,\"loop\":true,\"fade_out_sec\":2}]}]" } } ``` It composes with the rest of the pipeline in one pass (resize, compress, subtitles, watermark…). The one exception is **`speed`** — both rewrite the audio graph, so combining them is rejected with a clear error; run them as two jobs instead. Both run inside the pipeline, so they compose with everything else in **one job**: ```json { "service_slug": "clipconvert", "operation_key": "auto_edit", "params": { "file_url": "https://.../talk.mp4", "operations": "[{\"type\":\"auto_edit\"},{\"type\":\"enhance_voice\"},{\"type\":\"subtitles\",\"style\":\"karaoke\"}]" } } ``` The finished job's `result` carries `auto_edit: { removed_sec, kept_sec, cuts }` so you can show "cut 47 seconds of pauses" to your user. > Note the `operations` value is a **JSON string**, not a nested object. **Review before cutting.** `plan_only: true` returns the proposed cuts as a JSON file instead of a video — speech is recognised, nothing is rendered: ```json { "service_slug": "clipconvert", "operation_key": "auto_edit", "params": { "file_url": "https://.../talk.mp4", "plan_only": true, "max_pause": 0.8 } } ``` ```json { "duration_sec": 92.4, "language": "en", "removed_sec": 11.2, "keep": [{ "start": 0.0, "end": 14.9 }, { "start": 15.6, "end": 40.2 }], "cuts": [{ "start": 14.9, "end": 15.6, "kind": "pause", "text": "" }, { "start": 40.2, "end": 40.9, "kind": "filler", "text": "um" }], "words": [{ "start": 0.3, "end": 0.6, "word": "Hi" }] } ``` `kind` is `silence` (dead air at the start or end), `pause` (a long gap between phrases, tightened), `filler` (a hesitation sound) or `retake` (a repeated take). Keep the ones that are not noise — a deliberate beat, birdsong between sentences — and send the rest back as `cuts`: ```json { "operation_key": "auto_edit", "params": { "cuts": [[14.9, 15.6], [40.2, 40.9]] } } ``` With `cuts` nothing is recognised again: one re-encode, billed as plain video work, and the result's `auto_edit.windows` counts the windows removed. This is exactly what the studio's "Preview cuts" editor does. ## Circle video & Telegram video note `format` decides what you get: | `format` | Result | |---|---| | `note` | **Square, cover-cropped, filled** h264/aac clip, ≤60 s. This is the real **Telegram video note** format — Telegram renders it round only when a bot sends it *as a video note*. Use `diameter: 512`. | | `mp4` | **Round** video baked onto a solid `bg_color` (square file, colored corners). For sites and overlays — *not* Telegram notes. | | `webm` | Round with real transparency. | | `gif` | Animated, round on `bg_color`, silent. | Other params: `diameter` 64–1080, `zoom` 1–3, `offset_x` / `offset_y` −1..1, `bg_color`, `start`, `duration`, `mute`, `fps`, `crf`, `loop`. ```json { "service_slug": "clipconvert", "operation_key": "circle", "params": { "file_url": "https://.../clip.mp4", "format": "note", "diameter": 512, "duration": 60, "mute": false } } ``` ## Fit to canvas (`blur_bg`) Landscape source → vertical TikTok / Reels / Shorts canvas: ```json { "service_slug": "clipconvert", "operation_key": "blur_bg", "params": { "file_url": "https://.../wide.mp4", "width": 1080, "height": 1920, "bg_mode": "blur", "scale": 0.9, "pos_y": 0.5 } } ``` `bg_mode`: `blur` / `dark_blur` / `color` / `mirror` / `stretch`. `scale` and `pos_y` are 0–1 fractions of the canvas. Also: `bg_color`, `blur`, `dim`, `padding`, `radius`. --- # 5. Big files (> 100 MB) `POST /jobs/upload` sends the file through the CDN, which caps request bodies at ~100 MB. Anything larger goes **straight to storage** first, and then you submit the job by URL instead of by file — two steps instead of one: ```bash # 1. get the file into storage (one of the two ways below) → you get a `url` # 2. submit it by URL: curl -s https://mp.dvocorp.com/api/v1/jobs/api \ -H 'X-Api-Key: ca_live_...' -H 'Content-Type: application/json' \ -d '{"service_slug":"clipconvert","operation_key":"compress", "params":{"file_url":"","level":"medium"}}' ``` Then poll `GET /jobs/{id}` exactly as before. Both upload methods return `{ url, name, content_type, kind }`; `url` is what goes in `params.file_url`. **Option A — single presigned PUT** (simplest): ```bash curl -s https://mp.dvocorp.com/api/v1/uploads/presign \ -H 'X-Api-Key: ca_live_...' -H 'Content-Type: application/json' \ -d '{"filename":"clip.mp4","content_type":"video/mp4"}' # -> {"put_url":"https://...signed...","url":"https://.../clip.mp4", ...} curl -X PUT "" -H 'Content-Type: video/mp4' --data-binary @clip.mp4 ``` **Option B — multipart** (adds per-part retry and parallelism; what the web UI uses). Hard cap: `MAX_UPLOAD_GB`, default **3 GB**. Part size 64 MB, up to 10 000 parts. ```bash # 1. create curl -s https://mp.dvocorp.com/api/v1/uploads/multipart/create \ -H 'X-Api-Key: ca_live_...' -H 'Content-Type: application/json' \ -d '{"filename":"movie.mp4","size":2147483648,"content_type":"video/mp4"}' # -> {"key":"tmp//movie.mp4","upload_id":"...","part_size":67108864} # 2. sign one URL per part (1..10000) curl -s https://mp.dvocorp.com/api/v1/uploads/multipart/sign-part \ -H 'X-Api-Key: ca_live_...' -H 'Content-Type: application/json' \ -d '{"key":"tmp//movie.mp4","upload_id":"...","part_number":1}' # 3. PUT each slice and KEEP the ETag response header curl -si -X PUT "" --data-binary @part-0001.bin | grep -i etag # 4. complete curl -s https://mp.dvocorp.com/api/v1/uploads/multipart/complete \ -H 'X-Api-Key: ca_live_...' -H 'Content-Type: application/json' \ -d '{"key":"tmp//movie.mp4","upload_id":"...","content_type":"video/mp4", "parts":[{"part_number":1,"etag":"\"\""}]}' # on failure — free the stored parts (204) curl -s -X POST https://mp.dvocorp.com/api/v1/uploads/multipart/abort \ -H 'X-Api-Key: ca_live_...' -H 'Content-Type: application/json' \ -d '{"key":"tmp//movie.mp4","upload_id":"..."}' ``` Both presigned modes need S3/R2 storage. On local-disk deployments `multipart/create` returns `501` — fall back to `POST /uploads`. > **R2 bucket CORS** (needed for browser uploads): allow `PUT` and `GET` from > your site origins and expose `ETag` — > `AllowedMethods: ["PUT","GET"]`, `AllowedHeaders: ["*"]`, > `ExposeHeaders: ["ETag"]`. Without `ExposeHeaders` the browser cannot read > each part's ETag and the upload aborts. --- # 6. Managing jobs | Request | What it does | |---|---| | `POST https://mp.dvocorp.com/api/v1/jobs/upload` | **submit with the file** (the main one) — multipart | | `POST https://mp.dvocorp.com/api/v1/jobs/api` | submit by URL — JSON, for files already in storage | | `GET https://mp.dvocorp.com/api/v1/jobs/{job_id}` | status of one job | | `GET https://mp.dvocorp.com/api/v1/jobs?limit=50&offset=0` | your recent jobs — a JSON **array**, newest first | | `POST https://mp.dvocorp.com/api/v1/jobs/{job_id}/repeat` | run it again, same params — billed as a fresh submit, returns a **new** job | | `POST https://mp.dvocorp.com/api/v1/jobs/{job_id}/cancel` | cancel and refund | All take `X-Api-Key`. **Listing bounds.** `GET /jobs` takes `limit` (**1..200**, default 50) and `offset` (≥ 0). A `limit` outside that range — including `0` or a negative — is a `422` with a pydantic detail array (`"Input should be less than or equal to 200"` / `"…greater than or equal to 1"`), not a clamp. The response is a bare JSON array of job objects (same shape as `GET /jobs/{id}`), newest first. **Billing:** the tariff base is debited **at submit** and automatically refunded if the job fails or is canceled. Every job is then charged for its processing time when it finishes — 1 more bonus per further started minute, capped at 30 per file — and a failed or canceled job is never charged for time. See *The tariff: you pay for processing time* in section 3. **Priority:** `"high_priority": true` is honored only on premium plans; on other plans it is silently ignored and the job runs at normal priority. --- # 7. Upload links (BigLoader) — let *your* users upload Telegram bots and many integrations can't relay big files. An upload link is a short-lived, one-time URL you send to an end user: they open it in any browser, the file goes **straight to storage**, and — if you pinned an operation — it is processed automatically and billed exactly like a normal `/jobs/api` submit. Prefer clicking? The panel's **Upload links** page (`/upload-links`) does the same thing and lets you download both the source and the result. ### Create a link ```bash curl -s https://mp.dvocorp.com/api/v1/upload-sessions/api \ -H 'X-Api-Key: ca_live_...' -H 'Content-Type: application/json' \ -d '{ "service_slug": "clipconvert", "operation_key": "compress", "params": { "level": "medium" }, "expires_in_seconds": 3600, "max_file_size_bytes": 2147483648, "allowed_mime_types": ["video/mp4", "video/quicktime"], "callback_url": "https://my-bot.example.com/hooks/bigloader", "delete_after_processing": true, "metadata": { "telegram_user_id": "123456" } }' ``` ```json { "id": "", "upload_url": "https://mp.dvocorp.com/u/", "expires_at": "2026-07-22T13:00:00Z", "status": "created", "estimated_cost": 3, "callback_secret": "" } ``` Send `upload_url` to your user. That's it. **Options:** - `service_slug` / `operation_key` / `params` are exactly the fields `POST /jobs/api` takes — the uploaded file is injected as `params.file_url`. **Omit them** to just receive a file with no processing. - `max_files` (1–20, default 1) — accept several files through one link; every file is fanned out to the configured operations. With `bundle_zip: true` the owner can pull everything as one archive from `GET https://mp.dvocorp.com/api/v1/upload-sessions/{id}/archive` (works regardless of the flag). - `operations` (max 10) — several processings of the same file, **one job per entry**, each billed separately. Mutually exclusive with the single triple: ```json { "operations": [ { "service_slug": "clipconvert", "operation_key": "compress", "params": { "level": "medium" } }, { "service_slug": "clipconvert", "operation_key": "thumbnail", "params": { "at": 3 } } ] } ``` - An operation whose media kind doesn't match a file (a photo hitting a video-only op) is **skipped with a recorded reason** in `files[].skipped` — never an error, never charged. - Unknown operation → `404`; not enough bonuses → `402`. You find out **at create time**, before anyone uploads. - The token is stored hashed server-side and appears only inside `upload_url`. ### Get the result Poll: ```bash curl -s https://mp.dvocorp.com/api/v1/upload-sessions/ -H 'X-Api-Key: ca_live_...' ``` ```json { "id": "...", "status": "processing", "file": { "filename": "movie.mp4", "size_bytes": 2147483648, "url": "https://.../movie.mp4" }, "uploaded_ip": "203.0.113.7", "jobs": [ { "id": "...", "operation_key": "compress", "status": "processing" } ], "estimated_cost": 3, "error": null } ``` Statuses: `created → uploading → uploaded → processing → done`, plus `failed` and `expired`. `done` means every job finished and at least one succeeded — the per-job statuses tell the rest. Results are at `jobs[].result.output_url`. `GET https://mp.dvocorp.com/api/v1/upload-sessions` lists your recent sessions; `DELETE https://mp.dvocorp.com/api/v1/upload-sessions/{id}` removes the files and cancels and refunds any in-flight jobs. Or set `callback_url` and receive webhooks: - `upload.completed` — right after the file lands and is verified - `processing.completed` / `processing.failed` — when **all** linked jobs finish Each callback carries: ``` X-BigLoader-Event: processing.completed X-BigLoader-Signature: HMAC_SHA256(callback_secret, raw_request_body) # hex ``` Verify by recomputing the HMAC over the **raw body**. Delivery is best-effort — polling always works as a fallback. ### Limits - **Lifetime:** `expires_in_seconds`, default 1 h, capped at 24 h. - **One upload per link** unless `max_files` says otherwise — a second attempt gets `409`. - **Size:** the lower of `MAX_UPLOAD_GB` (default 3 GB) and your `max_file_size_bytes`. The stored size is verified after upload; oversized objects are deleted and rejected with `413`. - **Storage:** sources live under the temp prefix and are cleaned by bucket lifecycle rules (~24 h), or immediately after successful processing when `delete_after_processing: true`. Env knobs: `UPLOAD_BRIDGE_TTL_SEC`, `UPLOAD_BRIDGE_MAX_TTL_SEC`, `UPLOAD_BRIDGE_IP_RPM`, `UPLOAD_BRIDGE_PAGE_BASE_URL`. --- # 8. API keys Create keys on the panel's **Developer API** page (`/api`). The plaintext is shown **once** — store it. Each key has an optional per-day cap (`max_per_day`; `0` = server default). | Action | Where | |---|---| | Create / list / pause / delete | panel `/api` page (session-authenticated) | | Verify a key + read balance | `GET https://mp.dvocorp.com/api/v1/me/ping` with the key | - **Pausing** a key is reversible — the same plaintext works again after you re-enable it. **Deleting** is permanent; pause instead if you might need it. - A key revoked by an administrator can't be re-enabled or deleted by you. - You **cannot mint a key with a key** — key management needs a panel session. ## Signing in to the panel (to get that session) Two doors, **one account**: email + password, or **Continue with Google**. A Google sign-in creates the ordinary account (same starter bonuses, same plans, same keys); signing in with Google using an address that already has an account takes you into *that* account rather than making a second one. | Action | Endpoint | |---|---| | Password sign-in | `POST https://mp.dvocorp.com/api/v1/auth/login` `{email, password, captcha_token?}` | | Start a Google sign-in | `POST https://mp.dvocorp.com/api/v1/auth/google/start` `{next, mode}` → `{authorization_url}` | | Finish it (browser returns with `?code=&state=`) | `POST https://mp.dvocorp.com/api/v1/auth/google/callback` `{code, state}` → `{access_token, created}` | | Set / change your own password | `POST https://mp.dvocorp.com/api/v1/auth/password` `{current_password?, new_password}` | | Attach / detach Google | `POST` / `DELETE https://mp.dvocorp.com/api/v1/auth/google/link` | | Register with an email | `POST https://mp.dvocorp.com/api/v1/auth/register` `{email, password, full_name?, captcha_token?}` | | Is this address usable? | `POST https://mp.dvocorp.com/api/v1/auth/email/check` `{email}` → `{acceptable, verdict}` | | Send / resend the confirmation code | `POST https://mp.dvocorp.com/api/v1/auth/email/send-code` | | Confirm with the code | `POST https://mp.dvocorp.com/api/v1/auth/email/verify` `{code}` → `{email_verified, granted_credits}` | ### Confirming your email Registration works with any mailbox, not just Gmail. The account is created and **fully usable immediately** — studio, API keys, uploads, everything. What waits for confirmation is the **welcome bonuses**. Nothing is emailed by `/auth/register`. The 6-digit code is sent when you ask for it (`/auth/email/send-code`, which is what the banner's **Confirm** button calls) — a message sent during signup is one that ages out unread. Entering the code adds the welcome bonuses to your balance and subscribes you to job notifications and product news. `/auth/email/status` reports `code_pending` (one is already in the inbox, so don't send another) and `resend_available_in` (seconds until the next send is allowed). Signing in with Google skips this — Google already verified the address, so the bonuses are granted right away. Because open signup is the one endpoint that costs us money per request, it is guarded: a Cloudflare Turnstile captcha (when configured — **sign-in carries the same check**, since that is where leaked-password lists get tried), a per-IP rate limit, an address check (shape + does the domain accept mail + no disposable inboxes), and hard limits on how often a code can be sent or guessed. `verdict` from `/auth/email/check` is one of `ok | invalid | no_domain | disposable | unknown` (`unknown` = our DNS could not answer, and the address is still accepted). The captcha's public site key, whether confirmation is available and what it pays are all in `GET https://mp.dvocorp.com/api/v1/public/config` (`turnstile_site_key`, `email_verification_enabled`, `verify_bonus_credits`). - These are **browser flows** — the SPA drives them. Nothing here accepts (or needs) an API key, and API keys are unaffected by how you signed in. - `current_password` is required only if you already have one. An account created through Google sets its first password without it — which is how you keep access if Google is ever unavailable or you move to another Google account. An administrator can also issue a password from the panel. - Detaching Google is refused while it is your only way in. - The button appears only when the deployment is configured for it (`GET https://mp.dvocorp.com/api/v1/public/config` → `google_auth_enabled`). Bonus history lives at `GET https://mp.dvocorp.com/api/v1/billing/ledger?limit=&offset=&entry_type=` (panel session; `entry_type`: `purchase | debit | refund | signup_bonus | adjust | subscription | expire`) — the same data the `/ledger` page shows. Your **orders** are a different list, at `GET https://mp.dvocorp.com/api/v1/billing/payments?limit=&offset=` (panel session), newest first. The ledger records every movement of your balance; this records what you paid for. They are not interchangeable: a plan term is **one** order followed by a monthly grant, so reading receipts off the ledger would print twelve invoices for one charge. Each row carries `order_id` (the number to quote to support), `kind` (`package | subscription`), `item_name`, `credits` (on a plan order, the whole term, not the monthly allowance), `months`, `extends_term`, `amount_cents` / `currency`, `actually_paid_cents`, `status` and `credited_at`. Orders that never completed are returned too — if you paid and saw nothing, the `order_id` is here. ## Plans & bonuses Everything below needs a **panel session** (the `access_token` from `/auth/login`), not an API key — buying is a browser flow. To read your balance with a key, use `GET https://mp.dvocorp.com/api/v1/me/ping` instead. > **Naming.** The panel calls the unit **bonuses**. Nothing is renamed in the > API: it is still `credits` on a balance, `credits_per_call` on an operation, > `period_credits` on a plan, and the 402 body still reads > `not enough credits`. Same numbers, same field names — only the word a human > reads changed. ```bash # 0. the public price list — no account, no key, never cached curl https://mp.dvocorp.com/api/v1/public/pricing # -> {"packages":[{"id":1,"name":"Starter","credits":500,"price_cents":500, # "charge_cents":500,"reference_cents":null,"badge":null, # "is_popular":false,"currency":"USD"}],"plans":[…],"tax_included":false} ``` **The response is the price list; the line above is only its shape.** Packs and plans are edited in the admin panel, so any figure written into this document is stale the moment someone changes one — read the amounts from the call, never from here. Prices are pre-tax: sales tax or VAT is added at checkout by the reseller, based on where the buyer is. The endpoint reads the same rows the checkout charges from, so the quoted figure and the charged figure cannot drift. ```bash # 1. what you can buy — prices are already personalised for YOUR account curl -H "Authorization: Bearer $JWT" https://mp.dvocorp.com/api/v1/billing/plans # -> [{"id":2,"slug":"pro","name":"Pro","price_cents":2900,"currency":"USD", # "period_credits":6000,"period_days":30,"priority":5, # "is_locked_price":false,"list_price_cents":2900, # "terms":[{"months":1,"credits_per_month":6000,"credits_total":6000, # "monthly_price_cents":2900,"undiscounted_cents":2900, # "charge_cents":2900,"discount_pct":0,"currency":"USD"}]}] # Shape, not a quote — the amounts come from the call, not from this page. ``` `price_cents` and `period_credits` are **per month**. `terms` is the list of term lengths you may buy in one payment; it always has at least the one-month entry, and `charge_cents` on the entry you pick is exactly what you will be charged. Do not compute a multi-month price yourself — `undiscounted_cents` is the struck-through reference, `charge_cents` is the bill, and `discount_pct` is derived from the two. `terms` also appears on the anonymous `GET /public/pricing` plans, at list price. ### Promotions: what the extra fields on a pack or a plan mean Both `GET /public/pricing` and `GET /billing/plans` carry these. All of them are resolved by the server; **none of them is something you compute.** | Field | On | Means | |---|---|---| | `charge_cents` | pack, plan | The amount that will actually be taken. On a plan this is the **monthly** figure with the tier's promo applied; `price_cents` beside it is the undiscounted monthly rate | | `reference_cents` | pack, plan | The struck-through "before" price, or `null` | | `badge` | pack, plan | A short **key**, not a sentence — `recommended`, `new`, `limited`, or absent. Render your own wording for it | | `is_popular` | pack, plan | This row is the most bought over the trailing window. **Computed** from paid orders; nobody can set it | | `promo_ends_at` | pack, plan | When the discount stops, if it has an end. A date, ISO-8601 | **`reference_cents` has three states and they are not the same thing.** * **A number** — the lowest price this product was genuinely on sale at over the last 30 days, and never above your own undiscounted rate. Strike it through, and derive any percentage **from it**. * **`null`** — show **no reduction at all**: no strike-through, no percentage, no "on sale" wording. Either the product has no price history to anchor to, or the current price is not actually below what it has recently sold at. Do **not** fall back to `price_cents` in this case. * **Absent** — the row does not carry an anchor (a term total, a bundle half, a payment record). Those have their own reference, described below. Two reasons that matters if you are rendering our prices anywhere: the percentage has to be calculated **from the 30-day reference** rather than from the last regular price (CJEU C-330/23, Aldi Süd, 26 Sep 2024), and a "reduced" price that equals or exceeds the prior one may not be presented as a reduction at all (same judgment, para. 27). **A multi-month term uses a different reference and the two must not be mixed on one line.** A term is struck through against `undiscounted_cents` — your own monthly rate × the months — because it is a *tied* offer ("this rate when you buy three months"), not a price that fell over time. `reference_cents` belongs to the monthly price only. **Discounts compose by multiplication.** A 20% plan promo and a 25% twelve-month term is **40% off, not 45%**. You never have to do that arithmetic — `charge_cents` and `discount_pct` are both already computed — but if you are reconciling our numbers against your own, that is the rule. ```bash # 2. how you can pay — one entry means there is no choice to make curl -H "Authorization: Bearer $JWT" https://mp.dvocorp.com/api/v1/billing/providers # -> [{"name":"liqpay","label":"LiqPay","kind":"card","is_default":true}, # {"name":"coinbase","label":"Coinbase Commerce","kind":"crypto","is_default":false}] ``` ```bash # 3. start a plan purchase -> open `redirect_url` in a browser to pay # "provider" is optional: omit it for the default rail above # "months" is optional and defaults to 1 — one of the `months` values in # `terms` above; anything else is refused with 400 curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"plan_id":2,"months":6,"provider":"coinbase", "accept_documents":true,"accept_immediate":true,"consent_locale":"en"}' \ https://mp.dvocorp.com/api/v1/billing/subscribe # -> {"order_id":"ord_...","redirect_url":"https://commerce.coinbase.com/charges/...", # "status":"waiting","provider":"coinbase","balance":100} ``` **One endpoint, two outcomes, and the server picks.** Post it with no term running and you open a new one. Post it for the **same plan** while a term is running and you **extend** that term — the months are added to the end and nothing about the month you are already in changes. Post it for a **different plan** while a term is running and you get **`409`**: there is no proration here, and refusing is the only answer that cannot destroy time you already paid for. Contact support to change tier; they can refund and re-sell. ```bash # 4. or buy a one-off bonus pack (no monthly reset on these) # on this deployment a pack is an ADD-ON: you need a running plan term to # buy one — see below, and check `purchasable` before you post curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"package_id":1,"provider":"liqpay", "accept_documents":true,"accept_immediate":true,"consent_locale":"en"}' \ https://mp.dvocorp.com/api/v1/billing/checkout ``` ```bash # 4b. or buy BOTH IN ONE ORDER — a term and a pack, one charge, the pack # discounted for being bought together. Quote it first, then post the same # `package_id` to /subscribe; there is no second checkout and no second # payment. curl -H "Authorization: Bearer $JWT" \ "https://mp.dvocorp.com/api/v1/billing/bundle-quote?plan_id=2&package_id=1&months=6" # -> {"plan_id":2,"package_id":1,"months":6, # "term":{"months":6,"credits_per_month":6000,"credits_total":36000, # "monthly_price_cents":2900,"undiscounted_cents":17400, # "charge_cents":14700,"discount_pct":15,"currency":"USD"}, # "pack_credits":500,"pack_list_cents":500,"pack_charge_cents":425, # "pack_discount_pct":15,"total_cents":15125,"currency":"USD"} # Shape, not a quote — the amounts come from the call, not from this page. curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"plan_id":2,"months":6,"package_id":1, "accept_documents":true,"accept_immediate":true,"consent_locale":"en"}' \ https://mp.dvocorp.com/api/v1/billing/subscribe # -> {"order_id":"ord_...","status":"waiting","provider":"liqpay","balance":100} ``` **One order, one charge, two line items.** `total_cents` is what the rail takes and it is the only figure you should show a customer — do not add `term.charge_cents` and `pack_charge_cents` yourself, and do not apply a percentage to anything. The quote comes from the same function the checkout charges from, so the number you were quoted and the number you are debited cannot differ. **The discount applies to the PACK only.** The term keeps whatever its own length already earns it (`term.discount_pct`, from the term ladder) and the bundle percentage comes off the pack (`pack_discount_pct`). The two compose; neither is applied twice. `pack_list_cents` is what the same pack costs bought on its own — a real price you could be charged — which is why it is the honest thing to strike through. **It is a conditional offer**: post `POST /billing/checkout` for that pack alone and you are charged `pack_list_cents`, so any UI showing the lower figure has to say what the condition is. **A bundle is not refused by the pack rule.** The rule below asks for a running term; in a bundle the term is in the same order, which is exactly the case it has to allow — so an account with no subscription at all can buy one. A pack on its own is still refused. **Settlement delivers both, exactly once.** One payment opens (or extends) the term and grants the pack in the same transaction. The pack's bonuses land in full and carry `expires_at: null` — they never expire, and buying them beside a term changed the price and nothing else. The term's allowance still arrives one month at a time and each month's remainder still lapses when the next lands; a combined balance figure must never blur the two. `GET /billing/payments` returns the pack half as `bundle_item_name` / `bundle_credits` / `bundle_amount_cents` / `bundle_discount_pct`, separate from `credits` and `amount_cents`, for the same reason. `GET /public/config` → `billing.bundle_pack_discount_pct` is the **rule** (the percentage, published for anyone); every pack row on `GET /billing/packages` and `GET /public/pricing` also carries **`bundle_charge_cents`**, so a price list can show the bundled figure before a plan is chosen without computing anything. `0` means the owner has switched the incentive off — a pack can still be added to a term, at its ordinary price. **TWO CONSENTS, AND BOTH ARE REQUIRED.** Every purchase call carries two booleans, and a checkout missing either is refused with `400` naming the one that is missing. They are separate fields because one tick cannot lawfully carry both: express consent to immediate performance takes a positive act of its own, and accepting the general terms and conditions does not supply it (European Commission Notice 2021/C 525/01, §5.7 on the Consumer Rights Directive). The single `accept_terms` field the old contract used is **gone**, not aliased — a synonym that satisfied both would rebuild the defect silently. | field | what it means | | --- | --- | | `accept_documents` | the buyer accepts the Terms of Service and the Refund Policy | | `accept_immediate` | the buyer expressly asks you to begin providing the service at once, and acknowledges that bonuses already spent on completed jobs cannot be handed back | | `consent_locale` | optional; which translation of those two sentences was on screen (`en`, `uk`, `ru`). Stored on the order so the confirmation can quote them back in the language they were read in. Anything else is stored as "unrecorded" | | `terms_version` | optional and **ignored**. The server stamps its own published version — a value you supply identifies nothing | Send `true` for either one **only** when a person actually ticked a box that said so, unticked, in front of them. `accept_immediate` does not waive the right of withdrawal and must never be presented as if it did: buying a unit-denominated balance is not the supply of digital content, so unspent bonuses stay refundable. ``` 400 {"detail":"tick every box before paying — still missing: accept_immediate (your request that we begin providing the service immediately)"} ``` **You do not have to build the receipt.** When an order is delivered — the moment the bonuses land — we email the buyer a confirmation of the concluded contract: the order id and date, the line items (both halves of a bundle, and for a plan the term length and the fact that the allowance arrives monthly), the amount and currency, the two consent sentences they ticked quoted verbatim with the documents version, their right of withdrawal and how to exercise it, and links to the Terms, the Refund Policy and their invoices. It is sent once per order, in the language recorded on the order (English otherwise), and it is not sent again on a repeated provider callback. **A pack may need a plan.** When the operator has switched packs to add-ons, `POST /billing/checkout` refuses an account with no active subscription: ``` 409 {"detail":{"code":"subscription_required", "message":"a one-off pack needs an active subscription — start a monthly plan first"}} ``` The `detail` is an object, not a string, so `code` is the field to branch on — it is what separates this from a failed payment. Nothing is opened by the refusal: there is no order to poll and nothing to cancel. Buy a term with `POST /billing/subscribe` (plans are never gated — that is the way in) and the packs open up. Two ways to know before you post, and both come from the server: * `GET /billing/packages` — every row carries **`purchasable`**, the answer for *your* account with the rule, the rails and the billing audience already resolved. The rows are still listed when it is `false`; only the checkout is closed. * `GET /public/config` → `billing.packs_require_subscription` — the **rule**, published for anyone (no auth). It says what the deployment requires, not whether you personally qualify. **This gates buying, never spending.** Packs you have already bought keep `expires_at: null` and stay spendable for ever — after a term lapses, after it is cancelled, and whatever this setting is later changed to. Only the plan's own monthly allowance is swept at the end of a month; see the table below. ```bash # 5. your balance, and how much of it renews curl -H "Authorization: Bearer $JWT" https://mp.dvocorp.com/api/v1/billing/balance # -> {"balance":6100,"expiring":6000,"expires_at":"2026-09-03T10:00:00Z"} ``` ```bash # 6. the term you hold — or null curl -H "Authorization: Bearer $JWT" https://mp.dvocorp.com/api/v1/billing/subscription # -> {"plan_name":"Pro","period_start":"2026-08-29T10:00:00Z", # "period_end":"2027-02-25T10:00:00Z","credits_per_month":6000, # "months_total":6,"months_granted":1, # "next_grant_at":"2026-09-28T10:00:00Z","can_extend":true, # "auto_renew":false,"renewal_status":"off","renews_at":null, # "renewal_amount_cents":0,"renewal_months":0, # "cancel_at_period_end":false,"grace_until":null,"can_cancel":false} ``` **Two dates, and they mean different things.** `next_grant_at` is when your next monthly allowance lands (and when the current one is void); `period_end` is when access ends. On a multi-month term they are far apart — a countdown that shows one of them is wrong about the other. Both are server timestamps; compute from them, not from a client clock plus a duration. `next_grant_at` is `null` when every allowance has been delivered and the term is simply running out. `credits_per_period` is the old name for `credits_per_month` and carries the same number; it will be dropped a release from now. **How bonuses behave.** You have **one balance**, and up to four kinds of bonus can be sitting in it: | Bonus | Ledger `entry_type` | Expires | |---|---|---| | Daily free allowance | `daily_free` | ~24h after it lands | | Welcome grant (for confirming your email) | `signup_bonus` | **90 days** after it is paid | | Plan bonuses | `subscription` | **at the end of each month of the term** | | Packs you buy, and anything an administrator grants you | `purchase`, `adjust` | **never**, while your account is open — a lapsed or cancelled plan does not touch them | **A charge always consumes the soonest-expiring bonus first.** A bonus that never expires is therefore always spent last: nothing you paid for is lost to a reset, and nothing you were given is left to evaporate while you still had a use for it. Where two kinds lapse at the same moment, they are spent in the order of the table above. Note that a monthly plan runs out before a 90-day welcome grant, so on a monthly term the plan bonuses go first — the rule is the expiry date, not the row order. **A plan pays out one monthly allowance at a time.** Buying three, six or twelve months buys that many allowances — not one large balance. Each month's allowance is void the moment the next one lands: the remainder is *replaced*, not added to it. The last one is void when the term ends. So a 12-month Pro customer holds 6 000 bonuses at a time, twelve times over; never 72 000 at once. `expiring` in the balance response is the part that lapses on `expires_at`; it is not a separate wallet. On a multi-month term `expires_at` is the next monthly boundary, not the end of the term — `GET /billing/subscription` has both dates. **The free tier is a daily allowance, not a plan.** There is no free plan to sign up for and no feature is switched off without one. An eligible account is topped up with a small number of bonuses once a day, usable on anything the API offers, and the FIRST allowance lands the moment the account is created — you never wait on a scheduled pass to make your first call. Whether an account with an unconfirmed address is eligible is a setting, so if your balance is zero right after signing up, confirm your address. Like plan bonuses they are refreshed rather than accumulated — yesterday's unused allowance is replaced by today's, not added to it, so the daily figure is also the most you can hold. Confirming your email grants a larger one-off amount on top (`granted_credits` in the confirm response), and that welcome grant lapses 90 days after it is paid — it is a promotional bonus, not a purchase. Ledger entries are typed `daily_free`, and the top-up stops while a paid plan is active, since a plan already grants its own allowance. Practical consequence for API clients: the free tier is fine for steady low-volume work and cannot do bursts. If you need to process a batch in one go, the bonuses have to come from a plan or a pack. **Renewals are manual unless the service says otherwise, and the shipped answer is that they are.** `GET /public/config` → `billing.recurring_available` is the one field that says which product you are talking to, and everything you build about renewal must read it rather than assume: * **`recurring_available: false`** — the default, and what this service does today. No payment method is kept on file, `auto_renew` is always `false` and nothing is ever charged without you starting it, on a 12-month term too. Length is not a mandate: the term ends when the months you bought run out, the tier drops and the last allowance is swept. Buy the next term from `/pricing` before the current one ends. `POST /billing/subscribe` on the plan you already hold extends the term you are in, or starts a new one if it has ended. `POST /billing/subscription/cancel` answers `409 not_recurring`, because there is nothing to cancel. * **`recurring_available: true`** — automatic renewal can be bought. See the section below. ### Automatic renewal Only where `billing.recurring_available` is `true`. Send `auto_renew: true` **together with `accept_recurring: true`** on `POST /billing/subscribe` and the sale becomes a subscription: the payment provider keeps the card — we never see it and never store it — and charges again when the term ends, so the customer never has to come back. Omit both and you get the ordinary fixed term; they default to `false` and always will. **`accept_recurring` is a THIRD consent tick, and it is required.** The two universal ones (`accept_documents`, `accept_immediate`) do not cover it: Cal. B&P §17602(a)(1) wants the consumer's affirmative consent to the automatic renewal terms *specifically*, and consent to the transaction as a whole is not that. Send `auto_renew: true` without it and the order is refused with **`400`** naming the field, and nothing is opened — never quietly downgraded to a fixed term. It is ignored entirely when `auto_renew` is false. If you are building your own checkout, the box you show for it has to state, on the same screen and before the tick: **what will be charged, how often, when the first renewal falls, and that it can be cancelled at any time from the account**. Ours is unticked, always. ```bash # a renewing subscription: three ticks, one order curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"plan_id":2,"months":1, "accept_documents":true,"accept_immediate":true, "auto_renew":true,"accept_recurring":true,"consent_locale":"en"}' \ https://mp.dvocorp.com/api/v1/billing/subscribe ``` It is **refused with `409 {"code": "recurring_unavailable"}`**, never silently downgraded to a fixed term, when the service does not sell subscriptions, when the chosen rail cannot hold a mandate (any crypto rail), when the order is a bundle (a pack cannot be bought over and over for ever), and when a paid term is already running (buying the same plan then *extends* that term). **The charge repeats once per term; the credits still arrive monthly.** This is the one thing to get right. A 12-month subscription is billed **once a year** and delivers **twelve monthly allowances** inside that year, each lapsing when the next lands — exactly like a 12-month term bought outright. A renewal buys another twelve. `GET /billing/subscription` carries both clocks and they are usually far apart: | Field | Means | |---|---| | `renews_at` | when the next **charge** falls due (the provider's own schedule), `null` when none is expected | | `next_grant_at` | when the next monthly **allowance** lands | | `renewal_months` | the charge cadence in months — `12` is one charge a year | | `renewal_amount_cents` / `renewal_currency` | what will be charged, frozen at the moment you subscribed | | `renewal_status` | `off` · `active` · `past_due` · `paused` · `cancelling` · `ended` | | `cancel_at_period_end` | a cancellation is scheduled; the paid period is still yours | | `grace_until` | a renewal charge failed — when access stops if it never succeeds | | `can_cancel` | whether the cancel endpoint will answer right now | **Your price does not change under you.** `renewal_amount_cents` is fixed when the subscription is created and a later change to the price list reaches new customers only. ```bash # stop the next charge. The period you have paid for is untouched. curl -X POST -H "Authorization: Bearer $JWT" \ https://mp.dvocorp.com/api/v1/billing/subscription/cancel # -> {"auto_renew":false,"renewal_status":"cancelling", # "cancel_at_period_end":true,"renews_at":null, # "period_end":"2027-02-25T10:00:00Z","months_total":6,"can_cancel":false} ``` **Cancelling is never a refund and never a clawback.** `period_end` does not move, every month you paid for is still delivered on its own boundary, and the term closes on the day it was always going to close. Only the mandate ends. `409 {"code": "not_recurring"}` means there was nothing renewing to stop. **The customer can always do it themselves, from the browser.** The control lives on their own account page at **`/profile#subscription`** — the same medium they subscribed in, behind no menu and no support form, one click from the balance chip in the header of every page. Read `can_cancel` before you render a control of your own: it asks the same three questions the endpoint raises on, so a button offered on it will never answer `409`. ### What we email a subscriber Three letters, sent automatically. Each one states the **amount**, the **date**, and carries a link straight to the cancel control — you do not have to build any of them, and you should not send your own copies. | When | What it says | |---|---| | Before a renewal charge | what will be taken, on what date, at what cadence, and how to stop it first | | A renewal charge failed | that nothing was taken and nothing granted, how long the account keeps working (`grace_until`), and that the card is fixed with the payment provider | | A cancellation | an acknowledgement, and the date access actually ends (`period_end`, which does not move) | **How much warning before a charge.** Two operator settings, picked by the charge cadence, not by the grant cadence: | Cycle | Setting | Default | |---|---|---| | 12 months or more | `subscriptions_renewal_notice_days_annual` | **30 days** | | shorter than a year | `subscriptions_renewal_notice_days_short` | **7 days** | The annual default sits in the middle of the 15–45 day window Cal. B&P §17602(b) and NY GBL §527-a(d) both require for terms of a year or more. The short one is a courtesy no statute requires — a charge nobody remembers agreeing to becomes a chargeback either way — and both are clamped to the cycle itself, so a window longer than the term can never make the notice arrive as a receipt for the charge that just happened. A mail failure is never a billing failure: the letters are queued on the subscription row and retried, and nothing about a charge, a grant or a cancellation waits on one. **If a renewal charge fails** — an expired card, a decline — the payment provider retries on its own schedule and `renewal_status` becomes `past_due`. Access continues until `grace_until`; **no new allowance is granted in the meantime**, because no money arrived to pay for one, so the tier, the priority and the balance you are still holding stay and nothing new is added. A charge that lands clears the grace and delivers the cycle at once. If none ever does, the term closes on the ordinary schedule and the last allowance is swept. **Refunds stop future months and never take back delivered ones.** If a multi-month order is refunded, the months not yet handed out are withdrawn and the term ends at the close of the month you are in. Allowances already granted stay in your balance — nothing is clawed back. **Your price does not go up.** The price and allowance you first pay for are locked to your account for that plan. If the list price later rises, you keep yours (`is_locked_price: true`, with `list_price_cents` showing what new customers pay). If it falls below yours, you get the lower one. **Paying.** `redirect_url` is a hosted checkout page — open it in a browser and finish the payment there. Cards go through LiqPay, crypto through Coinbase Commerce, and both can be open at the same time: `GET /billing/providers` is the list you may pick from, `provider` on the checkout call is your pick, and the response echoes the rail the order was opened on. Omit `provider` and you get the default (`is_default`). A rail that is closed is **refused** (`400`), never silently swapped for another one — nobody's card gets charged because the crypto rail went down. Nothing else in the flow changes with the rail, and an order already opened still settles even if that rail is switched off while you are paying. `GET /billing/providers` returns `[]` for the same reasons the price lists do: no rail is open, or payments are not enabled for your account. **Prices are quoted in USD and may be charged in another currency.** A card is charged in the merchant account's settlement currency (UAH for the Ukrainian merchant) at the rate shown on the checkout page, so the figure there can differ from `price_cents`. Your order, your ledger and your receipts stay in the currency the price was quoted in. **Payment states.** `status` follows `waiting → confirming → paid`. Bonuses land only on `paid`, which is driven by the provider's callback — expect it a moment after the redirect returns, not instantly. A declined card leaves the order `waiting`, not `failed`, precisely so you can retry the same order with another card. An **underpaid** invoice becomes `partially_paid` and is **not** credited automatically (a card cannot underpay; a crypto transfer can); contact support with the `order_id`. `expired` and `failed` grant nothing and charge nothing. **You never lose a payment to a lost callback.** If the provider's callback never reaches us, open payments are polled independently and settled from the provider's own record, so a paid order is credited either way — occasionally a couple of minutes later than usual. Poll `GET /billing/balance` rather than assuming the redirect meant delivery. If an order is still `waiting` 48 hours after payment, that is the point to contact support with the `order_id`. **Plans and packs are hidden until payments are live.** `GET /billing/plans` and `GET /billing/packages` return `[]` when no payment provider is configured, so you will never be quoted a price you cannot actually pay. Payments are also gated **per account**: on an account that isn't cleared for billing yet, starting a purchase (`POST /billing/subscribe` or `POST /billing/checkout`) is refused with `403 {"detail":"payments are not enabled for this account yet"}`. Reading your balance (`GET /billing/balance`, or `GET /me/ping` with a key) always works, and so does spending it. **Packs can additionally require a plan** (`billing.packs_require_subscription` on `GET /public/config`). That is a rule about opening an order, not about the balance: bonuses already bought never expire and stay spendable whatever happens to a subscription. ### Getting money back Three endpoints, and like the rest of this section they take a **panel session** Bearer token, not an API key — withdrawing from a contract is an act of the person who concluded it. | Endpoint | What it does | |---|---| | `GET /api/v1/billing/payments/{order_id}/refund-quote` | what this order would return right now, and why it would not | | `POST /api/v1/billing/payments/{order_id}/refund` | withdraw from the contract, or ask for a refund under the policy | | `GET /api/v1/billing/refunds` | your own requests and where each one stands | **Take the quote first.** It is a read — it commits nobody to anything — and it is the only place the arithmetic is visible: ```bash curl -s -H "Authorization: Bearer $TOKEN" \ https://mp.dvocorp.com/api/v1/billing/payments/ord_XXXX/refund-quote ``` ```json { "order_id": "ord_XXXX", "currency": "USD", "amount_cents": 1200, "paid_cents": 2000, "already_refunded_cents": 0, "credits_sold": 1000, "credits_returnable": 600, "credits_spent": 400, "reason": "withdrawal", "deadline": "2026-09-19T10:04:11Z", "withdrawal_deadline": "2026-09-19T10:04:11Z", "blocked": "", "eligible": true, "payout_manual": false, "open_refund_id": null } ``` Then confirm: ```bash curl -s -X POST -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"note":"bought the wrong pack"}' \ https://mp.dvocorp.com/api/v1/billing/payments/ord_XXXX/refund ``` **What comes back is what you did not spend.** Bonuses you already spent paid for jobs that completed and the processing behind them has already run, so they are not refundable — and they are never clawed back off your balance either. The unspent part is returned in full, at the price you paid for it, and the money is that same fraction of the charge. `credits_spent` on the quote is that figure, printed rather than implied. **Bonuses are spent soonest-expiring first**, so a purchased pack (which never expires) is the last thing touched. Burning a free daily allowance does not reduce what a pack is worth back. **A plan term also returns the months it has not delivered.** Those were never granted, so nothing comes off the balance for them; the money for them goes back and the term stops accruing. **`reason` says which right you are exercising.** `"withdrawal"` is the statutory right to withdraw from a distance contract — 14 days — and is what the control in the web app is labelled *"withdraw from contract here"* for. `"policy"` is our own Refund Policy, which promises unused bonuses back for 30 days and is the broader of the two. Both are admin-set; read the dates off the quote rather than counting days yourself. **`blocked` is machine-readable and `amount_cents` is then 0.** `not_settled` (the order never completed), `window_closed`, `nothing_left` (every bonus from this order was spent — the request is answered, and the answer is nothing), `already_open` (a request is with us), `fully_refunded`. **`payout_manual: true` means a person sends the money.** Card orders are refunded over the provider's API. A crypto order cannot be: a blockchain can only be debited by the wallet's owner, so the refund is approved, the unspent bonuses come off the balance, and the transfer is made by hand. You get an email when it goes out. **You get the confirmation in writing.** Asking sends an acknowledgement to your account email, and the outcome is emailed too — that is your record of the request, not the page you asked from. A refund never returns more than the order charged, and a retried request never pays twice. --- # 9. Errors > **Anonymous runs are deleted on delivery.** A job submitted without a key or a > token (the open web studio) keeps its source and its result only until the > caller says they have the file: `POST /api/v1/jobs/{id}/delivered` deletes > both immediately. An authenticated job answers `purged: false` and is > untouched — its files live until the storage lifecycle expires them. | Status | Meaning | What to do | |---|---|---| | `400` | bad request — unknown operation, invalid file URL, a parameter the operation refused, or a file of the wrong kind | the `detail` is a string that names the reason. A bad param names the field and refunds the charge: `could not start the job — quality: Input should be less than or equal to 100 (charge refunded)`. Wrong kind names both: `this operation works on video files — the file you sent looks like image`. Unknown key: `unknown operation 'mute'`. Malformed `params`: `params must be a JSON object: …`. Check `service_slug` / `operation_key` / `accepts` against `/studio/tools` | | `401` | missing or invalid API key | `{"detail":"missing API key"}` when the header is absent, `{"detail":"invalid API key"}` when it is wrong — check `X-Api-Key` (or `Authorization: Bearer ca_live_…`, which is accepted identically). Billing endpoints instead want a panel Bearer token and answer `{"detail":"missing bearer token"}` | | `402` | not enough bonuses | top up | | `403` | not allowed | an admin-only action, **or** a payment on an account where billing isn't switched on yet: `POST /billing/checkout` can return `{"detail":"payments are not enabled for this account yet"}` | | `404` | not found | service, job, or session id is wrong | | `409` | conflict | job already finished, or upload link already used. Billing uses it for account-state refusals, all with an object `detail`: buying a **different** plan while a term runs, `{"code":"subscription_required"}` from `POST /billing/checkout` when packs are add-ons and you hold no plan, and a refund request the order cannot take — `{"code":"nothing_left"}`, `window_closed`, `already_open`, `fully_refunded`, `not_settled`, or `refunds_disabled` when self-service refunds are switched off (write to support instead) | | `410` | upload link expired | mint a new one | | `413` | file too large | over `MAX_UPLOAD_GB` or your declared limit | | `415` | file type not allowed | check `allowed_mime_types` on the link | | `422` | validation error | pydantic detail array (`detail[].loc` / `.msg`) — e.g. an unsafe `webhook_url` (`host '127.0.0.1' resolves to a private/reserved address`), or `GET /jobs?limit=` outside 1..200 | | `429` | rate limited, or too many jobs in flight | honor the `Retry-After` header (seconds). `detail` says which: the per-minute / per-day counters, or your plan's in-flight cap (open jobs plus undelivered batch files); the cap clears the moment a job ends | | `502` / `503` | upstream error / temporarily unavailable | retry with exponential backoff | ## Maintenance windows During planned maintenance every *submitting* endpoint (`/studio/run`, `/studio/batch`, `POST /jobs*`, and starting a bridge upload) answers `503` with a machine-readable detail: ```json { "detail": { "code": "maintenance", "message": "Back around 18:00 CET" } } ``` Reads keep working, so a job submitted before the window can still be polled and its result downloaded. Treat `code: "maintenance"` as "retry later, don't resubmit in a loop" — the message is written for humans and can be shown as-is. Check ahead of time without submitting anything: ```bash curl -s https://mp.dvocorp.com/api/v1/public/config # -> {"maintenance":false,"maintenance_message":"", "support":{...}, ...} ``` ## Reaching a human `POST /api/v1/public/feedback` sends a message straight to our team chat. Send your API key with it (or use the form in the web app — anonymous calls from scripts are refused with `401`, same rule as the free studio): ```bash curl -s -X POST https://mp.dvocorp.com/api/v1/public/feedback \ -H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' \ -d '{"message":"circle op fails on 4K input","contact":"me@example.com","error":"job failed: upstream 500"}' # -> {"ok":true} ``` `message` is required (10–2000 chars); `contact`, `page` and `error` are optional context. This mailbox is a human, not a webhook, so it is tightly limited: | Limit | Anonymous (web form) | Signed in | |---|---|---| | Gap between messages | 15 min per IP | 2 min per account | | Per day | 5 per IP | 20 per account | | Per network / hour | 10 per /24 (or /64) | — | Plus a global hourly ceiling, and content rules: at most 2 links, no exact duplicate of a message you already sent that day. Answers are `400` (message rejected — too short, too many links, duplicate), `429` (`Retry-After` tells you the wait in seconds), or `503` (feedback delivery is not configured — use the contacts from `/public/config` instead). ## Rate limits - **Plan limits** — per-minute (`rpm`) and per-day budgets for job submission and upload-link creation. - **Account limits** — your account can carry its own per-minute/per-day budget set by the operator, which replaces the plan's. It is counted per *account*, not per key: the studio, `POST /jobs/*` and the `/r/` gateway all draw on the same counters, so extra keys don't buy extra budget. - **Per-key daily cap** — each key's `max_per_day`, shared between `POST /jobs/upload`, `POST /jobs/api` and the `/r/` gateway. - **Anonymous traffic** (no API key) — separate, stricter per-IP limits. - **No per-IP limit for authenticated callers** — once you send a key (or are signed in), every guard counts against your *account*, never your address. A shared office IP, a NAT, a mobile carrier CGNAT or several parallel uploads from one machine never spend each other's budget, and the anonymous abuse block can never apply to you. All of them return `429` with `Retry-After`. ## Queue delay Limits decide *whether* a request is accepted; the plan also decides *when the accepted work starts*. On a throttled plan a submit still returns `201` and is charged normally, but the job stays `queued` for a configured hold — and may additionally wait until higher-priority work has finished, up to a fixed cap. There is no separate status or error for this, and nothing to handle: poll `GET /api/v1/jobs/{id}` (or wait for the webhook) until `done` / `failed`, and size your client timeouts for minutes rather than seconds. If you need guaranteed fast starts, ask about a higher tier. --- # 10. Appendix
Ready-made scripts (bash / Node / Python) You don't need these — the two requests above are the whole API. These just wrap them in a polling loop. **bash** ```bash #!/usr/bin/env bash set -euo pipefail BASE="https://mp.dvocorp.com" API_KEY="ca_live_..." JOB=$(curl -s "https://mp.dvocorp.com/api/v1/jobs/upload" -H "X-Api-Key: $API_KEY" \ -F 'file=@photo.jpg' -F 'service_slug=photoconvert' \ -F 'operation_key=compress' -F 'params={"quality":70}') JOB_ID=$(printf '%s' "$JOB" | jq -r .id) STATUS=processing until [ "$STATUS" = done ] || [ "$STATUS" = failed ] || [ "$STATUS" = canceled ]; do sleep 2 RESP=$(curl -s "https://mp.dvocorp.com/api/v1/jobs/$JOB_ID" -H "X-Api-Key: $API_KEY") STATUS=$(printf '%s' "$RESP" | jq -r .status) done printf '%s' "$RESP" | jq -r '.result.output_url // .error' ``` **Node 18+** — save as `run.mjs`, then `node run.mjs` ```js import { readFile } from "node:fs/promises"; const BASE = "https://mp.dvocorp.com"; const API_KEY = process.env.MP_API_KEY; const form = new FormData(); form.append("file", new Blob([await readFile("photo.jpg")]), "photo.jpg"); form.append("service_slug", "photoconvert"); form.append("operation_key", "compress"); form.append("params", JSON.stringify({ quality: 70 })); const job = await fetch(`${BASE}/api/v1/jobs/upload`, { method: "POST", headers: { "X-Api-Key": API_KEY }, body: form, }).then(r => r.json()); let st = job; while (st.status === "queued" || st.status === "processing") { await new Promise(r => setTimeout(r, 2000)); st = await fetch(`${BASE}/api/v1/jobs/${job.id}`, { headers: { "X-Api-Key": API_KEY }, }).then(r => r.json()); } if (st.status !== "done") throw new Error(`job ${st.status}: ${st.error}`); console.log(st.result.output_url); ``` **Python 3** ```python import time, requests BASE = "https://mp.dvocorp.com" H = {"X-Api-Key": "ca_live_..."} job = requests.post( f"{BASE}/api/v1/jobs/upload", headers=H, files={"file": open("photo.jpg", "rb")}, data={ "service_slug": "photoconvert", "operation_key": "compress", "params": '{"quality": 70}', }, ).json() st = job while st["status"] in ("queued", "processing"): time.sleep(2) st = requests.get(f"{BASE}/api/v1/jobs/{job['id']}", headers=H).json() assert st["status"] == "done", f"job {st['status']}: {st.get('error')}" print(st["result"]["output_url"]) ```
Studio endpoints (operation_id-addressed alternative) `POST https://mp.dvocorp.com/api/v1/studio/run` and `POST https://mp.dvocorp.com/api/v1/studio/batch` accept an API key and are what the web studio uses. They address operations by **numeric `operation_id`** (read it from `GET /studio/tools`) rather than by `service_slug` + `operation_key`, and they **only accept files uploaded through this site** — an external `file_url` is rejected with `400`. For most integrations `POST /jobs/upload` is the better entry point. Use `/studio/batch` when you want one call to fan out over many files and give you a single `result_zip_url`: ```bash curl -s https://mp.dvocorp.com/api/v1/studio/batch \ -H 'X-Api-Key: ca_live_...' -H 'Content-Type: application/json' \ -d '{"operation_id": 42, "file_urls": ["https://.../a.jpg", "https://.../b.jpg"], "naming": "sequential", "params": {"quality": 70}}' ``` `naming`: `keep` / `sequential` / `random`. Poll `GET https://mp.dvocorp.com/api/v1/studio/batches/{batch_id}` for `status`, `done`, `failed` and `result_zip_url`. `POST https://mp.dvocorp.com/api/v1/studio/batches/{batch_id}/cancel` stops a running batch and refunds the items that never ran (`409` if it already finished). `POST https://mp.dvocorp.com/api/v1/studio/batches/{batch_id}/repeat` re-runs the same sources with the same operation and returns a **new** batch, billed as a fresh submit. Both take `X-Api-Key`. `sources` mirrors your submitted URLs in the same order. **Video** batches also carry per-file results as they finish, so you can preview or fetch any single output without unpacking the ZIP: `params._items` is a list of `{idx, name, url}` (idx = position in your `file_urls`). Image batches do not return `_items` — take `result_zip_url`. For video pipelines that recognise speech, items of small batches (≤12 files) additionally include `srt` — the captions of exactly that video, ready for an editor without a second transcription pass.
Telegram video-note delivery (first-party panel feature) Telegram only produces a round кружок when a **bot** calls `sendVideoNote` with an uploaded file — a downloaded square mp4 sent normally stays square. So the studio hands the user a deep link into one of our delivery bots, and the bot re-uploads the note. This is a panel feature, **not** an API-key operation: - `POST https://mp.dvocorp.com/api/v1/tg/deliver` — body `{ result_url }` or `{ job_id }`, plus optional `{ bot_id }`. Returns `{ deep_link, token, bot_id }`; `503` when no active bot exists. - `POST https://mp.dvocorp.com/api/v1/tg/webhook/{bot_id}` — the per-bot Telegram update sink. Not for clients. - `GET https://mp.dvocorp.com/api/v1/public/config` exposes `telegram_bots: [{id, name, username}]` so the SPA can show the button. **Admin setup:** create a bot with @BotFather, then in the admin panel go to **Services → Telegram bots → Add bot** (name, `@username`, token, mark **Active**). On save the panel registers the webhook at `{TELEGRAM_WEBHOOK_BASE or APP_BASE_URL}/api/v1/tg/webhook/` — the host must be publicly reachable by Telegram. Bots are DB-managed; the feature is on as soon as one active bot with a token exists.
Other public endpoints | Request | Auth | Returns | |---|---|---| | `GET https://mp.dvocorp.com/api/v1/health` | none | liveness | | `GET https://mp.dvocorp.com/api/v1/studio/tools` | none | the operations catalog | | `GET https://mp.dvocorp.com/api/v1/public/config` | none | `{anon_enabled, anon_allow_heavy, anon_allow_batch, registration_enabled, studio_tiles_v2, telegram_bots, telegram_enabled, marketing}` — these govern the **no-login web studio** only; API-key access is unaffected. `studio_tiles_v2` is presentation only (`true` = the icon tile grid, `false` = the classic text tiles; admin-switchable under **Settings**) and never changes what an operation does. `marketing` carries the web app's own ad-counter ids (`ga4_id`, `google_ads_id`, `google_ads_signup_label`, `google_ads_purchase_label`, `meta_pixel_id`, `consent_required`) — browser-only, irrelevant to API clients, and empty unless the owner configured a counter | | `GET https://mp.dvocorp.com/api/v1/uploads/download?url=…` | none | streams an allow-listed storage URL with an attachment header (rate limited per account, or per IP when called anonymously) — a browser convenience, not needed for API clients | | `GET https://mp.dvocorp.com/api/docs` | none | interactive OpenAPI docs | | `GET https://mp.dvocorp.com/api/openapi.json` | none | OpenAPI 3.1 spec of the public API — every endpoint callable with an API key (`ApiKeyAuth`, header `X-Api-Key`), plus the unauthenticated catalog, pricing and config; the web app's session-only endpoints are not in it | Usage analytics are recorded automatically for the admin dashboards; there is no API-key analytics endpoint. See docs/ANALYTICS.md.
--- > **Maintainer note — new feature → docs in the same change.** Every new > user-facing operation or API endpoint MUST ship in the same change with: > (a) an update to this file, (b) the client-panel Developer API page > (`frontend/src/pages/ApiPage.tsx`, route `/api`) — automatically via the > studio tools registry when possible (register the op with studio > labels/groups/presets; the catalog renders from `GET /api/v1/studio/tools`) > or a manual section update, and (c) en/uk/ru strings in > `frontend/src/i18n/translations.ts`. PRs without docs are incomplete.