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.

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.
   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)
   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:

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.