# Media Processing API — complete reference > Generated from the project documentation. Machine-readable schema: > https://mp.dvocorp.com/api/openapi.json > Live operation catalog (public, no auth): https://mp.dvocorp.com/api/v1/studio/tools > Human-facing version of this page: https://mp.dvocorp.com/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. 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", "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 } ``` *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. 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) 401 missing/invalid API key 402 not enough credits 404 unknown service or job 413 file too large 415 file type not allowed 422 validation error (e.g. webhook_url pointing at a private address) 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. 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 every available operation with: service_slug, operation_key, label, group, accepts (["image"] or ["video"]), credits_per_call, presets[].params (ready-made settings) and config.fields (each tunable parameter with type/min/max). 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 `service_slug`, `operation_key`, `credits_per_call`, what file kinds it `accepts`, and its ready-made `presets`. `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. 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`. --- # 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. | 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 | | `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`. **Billing:** `credits_per_call` is debited **at submit** and automatically refunded if the job fails or is canceled. **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 # 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":1900,"currency":"USD", # "period_credits":6000,"period_days":30,"priority":5, # "is_locked_price":false,"list_price_cents":1900}] ``` ```bash # 2. start a plan purchase -> open `redirect_url` in a browser to pay curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"plan_id":2}' https://mp.dvocorp.com/api/v1/billing/subscribe # -> {"order_id":"ord_...","redirect_url":"https://nowpayments.io/payment/...", # "status":"waiting","balance":100} ``` ```bash # 3. or buy a one-off credit pack (these never expire) curl -X POST -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"package_id":1}' https://mp.dvocorp.com/api/v1/billing/checkout ``` ```bash # 4. 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"} ``` `GET https://mp.dvocorp.com/api/v1/billing/subscription` returns the active term (`plan_name`, `period_end`, `credits_per_period`, `auto_renew`) or `null`. **How credits behave.** You have **one balance**. Plan credits are granted each paid period and are **void at its end** — an unused remainder is replaced by the new allowance, not added to it. Credits you buy as a pack, and any an administrator grants you, **never expire**. A charge always consumes the soonest-expiring credit first, so nothing you paid extra for is lost to a reset. `expiring` in the balance response is the part that lapses on `expires_at`; it is not a separate wallet. **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. Every confirmed account is topped up with a small number of credits once a day, usable on anything the API offers. 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). 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.** Crypto payments cannot be pulled from a wallet on a schedule, so `auto_renew` is always `false` and nothing is ever charged without you starting it. Buy the next term from `/pricing` before the current one ends. `POST /billing/subscribe` on the plan you already hold simply starts a new term. **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. **Payment states.** `status` follows `waiting → confirming → paid`. Credits land only on `paid`, which is driven by the provider's callback — expect a few minutes for network confirmations after the redirect returns. An **underpaid** invoice becomes `partially_paid` and is **not** credited automatically; 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. --- # 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` names the reason (e.g. `font_size: Input should be less than or equal to 12`); check `service_slug` / `operation_key` / `accepts` against `/studio/tools` | | `401` | missing or invalid API key | check the `X-Api-Key` header | | `402` | not enough credits | top up | | `403` | not allowed | admin-only action | | `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 — e.g. an unsafe `webhook_url` | | `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.