2. Integrate with an AI agent (Claude Code, Cursor, ChatGPT…)

Two ways, pick one:

Connect the MCP server (Claude Code, Codex, Cursor, Gemini CLI, VS Code…)

The server speaks MCP over Streamable HTTP at https://mp.dvocorp.com/api/mcp and takes the same key as the REST API, in either header:

Authorization: Bearer ca_live_...      # or
X-Api-Key: ca_live_...

The panel's Developer API page (/api) shows every command below with your key already filled in. The key goes into your local agent config — where that agent keeps its other tokens — never into a chat.

# Claude Code
claude mcp add --transport http mediaprocessing https://mp.dvocorp.com/api/mcp \
  --header "Authorization: Bearer $API_KEY"
# Codex (the token is read from an env var; Codex has no header flag)
export MP_API_KEY=ca_live_...
codex mcp add mediaprocessing --url https://mp.dvocorp.com/api/mcp --bearer-token-env-var MP_API_KEY
# Gemini CLI
gemini mcp add --transport http --header "Authorization: Bearer $API_KEY" mediaprocessing https://mp.dvocorp.com/api/mcp
// Cursor (~/.cursor/mcp.json), Windsurf and most other clients
{ "mcpServers": { "mediaprocessing": {
    "url": "https://mp.dvocorp.com/api/mcp",
    "headers": { "Authorization": "Bearer ca_live_..." } } } }
// VS Code (.vscode/mcp.json)
{ "servers": { "mediaprocessing": {
    "type": "http", "url": "https://mp.dvocorp.com/api/mcp",
    "headers": { "Authorization": "Bearer ca_live_..." } } } }
// OpenAI Responses API — your own software, server-side
{ "model": "gpt-5",
  "tools": [{ "type": "mcp", "server_label": "mediaprocessing",
              "server_url": "https://mp.dvocorp.com/api/mcp",
              "headers": { "Authorization": "Bearer ca_live_..." },
              "require_approval": "never" }],
  "input": "compress https://example.com/demo.mp4 for Telegram" }

Then tell the agent what you need — "compress ./demo.mp4 for Telegram and give me the link" — or "add this integration to my bot".

The tools

Tool What it does
ping() Checks the key and the balance. Spends nothing.
list_operations(kind?) The live catalog: every service_slug / operation_key, what media it accepts, input (file or url), credits_per_call, fields (each param's type/min/max) and presets. kind = image | video | audio narrows it.
submit_job(service_slug, operation_key, file_url?, params?, webhook_url?) Starts a job. file_url is the input for file operations; link operations take the link in params.url. Debits at submit, refunds on failure — the same path as POST /jobs/api.
get_job(job_id) Status, output_url once done, error once failed, estimated_cost / final_cost, queue position.
wait_for_job(job_id, timeout_seconds=60) Polls for you, up to 120 s per call; timed_out: true means call again.
list_jobs(limit=20) Your recent jobs, newest first.
presign_upload(filename, content_type) A slot for a file on the agent's machine: PUT the bytes to put_url (the answer includes the curl -T line), then pass url as file_url. Multi-GB is fine; the bytes never pass through the API.
create_upload_link(service_slug, operation_key, params?, callback_url?, metadata?, …) A one-time upload page for a file your end user holds (Telegram bots): the same body as POST /upload-sessions/api, section 7.
get_upload_link(session_id) The link's state and the jobs it produced.

Every call goes through the same code as the REST route it mirrors: the same plan quota, per-key daily cap, in-flight cap, maintenance window, billing and usage journal. The MCP server adds no prices and no limits of its own. Errors come back as tool errors carrying the REST status and detail (402: insufficient credits, 429: … (retry after 60s)).

The server is stateless: every JSON-RPC call is self-contained, which is what lets it sit behind a load balancer with no sticky sessions. A missing or bad key is a plain HTTP 401 (a disabled account 403), not a JSON-RPC error — so claude mcp add tells you right away.

An admin can switch MCP access off per audience (feature mcp, on by default) without touching the REST API.

The prompt (for agents without MCP)

Copy the block below, paste it into your AI coding agent and say "here is the spec, add this integration". It is a complete, self-contained contract — the agent needs nothing else from this page.

Do not put your API key in the prompt. The block deliberately uses an env var. Pasting a live credential into a third-party chat leaks it into that vendor's logs; the agent only needs to know the key's name.

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: <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: <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":"<uuid>","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 '<key>'".
   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: <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":"<session_id>", "upload_url":"https://mp.dvocorp.com/u/<token>",
           "expires_at":"...", "estimated_cost":3,
           "callback_secret":"<shown once, only when the server generated it>"}

   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: <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":<yours, verbatim>,"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":"<url from a>"}}
   c) poll exactly as in step 2.

ERRORS — the body is {"detail": ...}: a string, or a list of field errors for 422.
   400 bad request — unknown operation, invalid file URL, wrong media kind, OR a
       param the operation refused. A bad param NAMES the field and refunds any charge:
       "could not start the job — quality: Input should be less than or equal to 100 (charge refunded)"
   401 missing/invalid API key ("missing credentials")   402 not enough credits
   404 unknown service or job       413 file too large      415 file type not allowed
   422 validation error — pydantic detail array; e.g. webhook_url pointing at a
       private address, or ?limit outside 1..200 on GET /jobs
   429 rate limited — HONOR the Retry-After response header (seconds)
   5xx retry with exponential backoff

BILLING: ONE tariff, the same for every operation, measured in processing time. 2 credits per
file are debited at submit, then 1 more for each further STARTED minute of processing beyond the
first, never more than 30 for one file. So <=60s costs 2, 61-120s costs 3, 121-180s costs 4. The
operation you address does not change the price, and neither does a `params.operations` pipeline.
Credits are refunded automatically if the job fails or is canceled, and a failed or canceled job
is never charged for time. An input longer than your tier's length limit is refused and refunded
in full (10 / 30 / 180 min for anonymous / free / paid). Read the live numbers from
GET /api/v1/public/config -> billing.time_tariff_*. GET https://mp.dvocorp.com/api/v1/me/ping ->
{"ok":true,"plan":"free","credits":98} is a cheap credential + balance check.

DISCOVERY — never hardcode the operation list:

   GET https://mp.dvocorp.com/api/v1/studio/tools     (public, no auth)
   Returns a JSON array; every entry is one operation with: operation_id, service_slug,
   operation_key, label{en,uk,ru}, group, accepts (["image"] or ["video"]),
   credits_per_call, config.fields[] (each tunable param with key/type/min/max) and
   config.presets[] (ready-made settings, each {label, params}). NOTE: the top-level
   presets[] carries only {index, label} — the actual ready-made params are under
   config.presets[].params. Fetch this once and derive your operation list + validation
   from it.

WHAT TO BUILD — a small typed client. These are DIFFERENT paths; do not collapse
them into one submit():
  submit(file, serviceSlug, operationKey, params) -> jobId
      multipart POST /jobs/upload. Files up to ~100 MB that your process holds.
  submitLarge(pathOrStream, serviceSlug, operationKey, params) -> jobId
      presign (or multipart create/sign-part/complete) -> PUT the bytes ->
      POST /jobs/api with params.file_url. Multi-GB files your process holds.
  createUploadLink({operation, params, metadata, ttl, maxFileSizeBytes,
                    allowedMimeTypes, callbackUrl, deleteAfterProcessing})
      -> {uploadUrl, sessionId, expiresAt}
      For files your END USER holds (Telegram bots). You never touch the bytes.
  waitForResult(jobId) -> outputUrl          poll GET /jobs/{id}
  getSession(sessionId) -> {status, jobs, metadata}
  verifyCallback(rawBody, signatureHeader, secret) -> boolean
      HMAC-SHA256 over the RAW body; use a constant-time compare.
  listOperations() from /studio/tools
Also: retry on 429/5xx honoring Retry-After; persist your own request_id ->
session_id / job_id so a retried command never charges twice; keep the
credential in the environment. Ask me before adding a dependency.

Getting the operation list into the prompt

The block above deliberately tells the agent to discover operations at runtime instead of pasting 40 of them. If your agent has no network access, fetch the catalog yourself and hand it over as a file:

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.