# 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: > > - **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. **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, credits refunded └──► canceled you canceled it, credits 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`; **credits were refunded** | | `canceled` | yes | you canceled it; credits 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…) 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: credits are debited at submit and refunded automatically if the job fails or is canceled. The price is the `credits_per_call` of the operation you address — and, if you send a `params.operations` pipeline, of the dearest operation in it, because that list is what actually runs. `transcribe`, `subtitles` and `auto_edit` are additionally metered by the LENGTH of the input: the first 10 minutes are included in that price, every further 10 (or part of 10) costs another whole `credits_per_call`, and an input longer than your tier's limit is refused and refunded in full. 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. If you pass your own `params.operations` pipeline, that list replaces the addressed operation upstream — so the job is priced at the **dearest operation in the pipeline** (never below the addressed one). Addressing a cheap operation while shipping an expensive recipe costs the expensive one. ### Speech-to-text is also priced by length `transcribe`, `subtitles` and `auto_edit` run speech recognition, which takes roughly as long as the audio itself — so for those three, and only those three, `credits_per_call` buys a **block of ten minutes** of input: | input length | you pay | `subtitles` (6 cr) | |---|---|---| | up to 10 min | 1 × `credits_per_call` | 6 | | 10–20 min | 2 × | 12 | | 20–30 min | 3 × | 18 | | each further 10 min (or part) | +1 × | +6 | Nobody knows how long your file is until the worker opens it, so the flow is: * **at submit** you are charged one `credits_per_call`, exactly as the catalogue says, and `estimated_cost` reflects that; * **when the worker reports the length**, the difference is debited and `final_cost` becomes the real total. Shorter than ten minutes changes nothing — the call price is the minimum and is never refunded down. `final_cost` can therefore appear on a job that is still `processing` (the length is settled as soon as the media is probed, not when the output is ready). **Length ceiling.** An input longer than your tier allows is **refused, not truncated**: the job ends `failed`, every credit it took is refunded, and `error` names the length, the limit and your tier. | tier | max input per ASR job | |---|---| | anonymous (no sign-in) | 10 min | | free account | 30 min | | paid plans | 180 min | Split a longer recording yourself (`trim` is a remux — it is cheap and lossless) and submit the parts. **Bulk works the same way.** A `/studio/batch` of speech-to-text files is metered **per file** — each one is charged its own ten-minute blocks when the service reports its length — and the tier 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 | Credits | 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` | | `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 | — | ### 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 | |---|---| | **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 | Credits | 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. Text comes from your own file (`subtitles_url` or inline `subtitles_text`), or — if you give neither — from automatic speech recognition of the audio track. > These operations are **priced by input length** (10-minute blocks) and refuse > an input longer than your tier's limit — 10 / 30 / 180 min for anonymous / > free / paid. See Speech-to-text is also priced by > length. | 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` | your own SRT / VTT / ASS | — | `subtitles_text` must contain timestamps — plain text is rejected. ```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" } } ``` ## 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. ## 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:** `credits_per_call` is debited **at submit** and automatically refunded if the job fails or is canceled. The three speech-to-text operations (`transcribe`, `subtitles`, `auto_edit`) are metered by input length on top of that, and capped per tier — see *Speech-to-text is also priced by length* 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 credits → `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 credits, 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 credits**. 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 credits the signup bonus 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 credits 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`). Credit 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. ## Plans & credits 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. ```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, # "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. ```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"}' 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 credit pack (no monthly reset on these) curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"package_id":1,"provider":"liqpay"}' https://mp.dvocorp.com/api/v1/billing/checkout ``` ```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} ``` **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 credits behave.** You have **one balance**, and up to four kinds of credit can be sitting in it: | Credit | 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 credits | `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 charge always consumes the soonest-expiring credit first.** Credit 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 credits 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 credits 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 credits 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 credits 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 credit, 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 credits have to come from a plan or a pack. **Renewals are manual.** No payment method is kept on file, so `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 simply 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 already ended. **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`. Credits 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. --- # 9. Errors | 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 credits | 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 | | `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 | honor the `Retry-After` header (seconds) | | `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 | the raw spec | 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.