Skip to Content

Files

Upload files (images, PDFs, documents, and video) and reference them in multimodal batch requests.

POST /files Create an upload session POST /files/{file_id}/complete Finalize and validate an upload POST /files/{file_id}/parts Refresh presigned upload grants GET /files List files GET /files/{file_id} Get file metadata GET /files/{file_id}/content Get a short-lived download URL PATCH /files/{file_id} Update TTL / metadata DELETE /files/{file_id} Delete a file

Authentication

Every route requires the X-API-Key header with a valid project API key. Files are scoped to the project that uploaded them — a file_id from another project always returns 404.

-H "X-API-Key: convoy_sk_your_key_here"

How uploads work

Uploads are presigned-only: the API never receives file bytes. Instead, POST /files returns a short-lived, scoped grant that you use to write the bytes directly to storage (S3), then you call complete to validate the upload:

  1. Create a sessionPOST /files with the file’s name, MIME type, exact size, and SHA-256 digest. The response contains a presigned grant: a POST policy for files up to 8 MB, or multipart part URLs for larger files. If the project already has a ready file with identical content, the response instead returns that file (deduplicated: true, upload: null) — skip steps 2–3 and reference its id directly.
  2. Upload directly to storage using the presigned grant. S3 itself enforces the exact size and SHA-256 you declared — different bytes are rejected at the storage layer.
  3. FinalizePOST /files/{file_id}/complete. The server re-verifies size and checksum, sniffs magic bytes against the allowlist, validates the file structure, and waits for a malware-scan verdict. On success the file becomes ready (200). While the scan verdict is still pending you get 202 with status: "validating" — poll until the file is ready before referencing it.
  4. Reference the file_id in /cargo/load content blocks.

Plan availability (Convoy Cloud): file uploads are available on the Starter and Pro plans. On the Free plan, POST /files returns 403 files_not_allowed_for_plan. Self-hosted Enterprise deployments are not plan-gated; Files limits come from your license entitlements.

Supported file types and limits

KindMedia typesMax size
Imageimage/jpeg, image/png, image/gif, image/webp20 MB
Documentapplication/pdf (≤ 100 pages), text/plain, text/csv, text/markdown50 MB
Videovideo/mp4, video/quicktime, video/webm, video/x-matroska (≤ 30 min)1 GB

Other limits:

LimitValue
Single-shot (presigned POST) threshold8 MB — larger files use multipart
Multipart part size16 MB
Presigned grant expiry15 minutes (refresh via POST /files/{id}/parts)
Default TTL once ready30 days (ttl_seconds: 1 hour – 1 year)
Metadata size4 KB (serialized JSON)
Concurrent open upload sessions per project20

Declared media types must be on the allowlist, and the actual bytes are verified against it at complete — a declared/actual mismatch fails validation.

File lifecycle

StatusMeaning
uploadingSession open — presigned grant issued, bytes not yet finalized
validatingcomplete called — server-side checks and/or malware scan pending
readyValidated and clean — referenceable in /cargo/load
failedValidation or scan failed (see failure_reason); the object is deleted
expiredTTL elapsed or deleted — no longer referenceable

Sessions stuck in uploading for more than 24 hours are aborted automatically.

Create an upload session

POST /files

Request Body

{ "filename": "chart.png", "media_type": "image/png", "size_bytes": 48213, "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "ttl_seconds": 2592000, "metadata": {"source": "reporting-pipeline"} }
FieldTypeRequiredDescription
filenamestringYesOriginal filename (display only, ≤ 512 chars)
media_typestringYesMIME type — must be on the allowlist
size_bytesintegerYesExact size in bytes of the file to upload
sha256stringYesHex SHA-256 digest of the exact bytes — enforced by S3
ttl_secondsintegerNoTime-to-live once ready (3600–31536000; default 30 days)
metadataobjectNoUser-supplied tags (JSON, ≤ 4 KB)

Compute the digest locally before calling:

sha256sum chart.png # Linux shasum -a 256 chart.png # macOS

Response — small file (presigned POST)

{ "id": "file_0123456789abcdef0123456789abcdef", "status": "uploading", "deduplicated": false, "upload": { "type": "post", "url": "https://your-bucket.s3.amazonaws.com/", "fields": { "key": "files/…", "Content-Type": "image/png", "x-amz-checksum-sha256": "…", "policy": "…", "x-amz-signature": "…" }, "expires_at": "2026-01-01T00:15:00Z" } }

Upload by POSTing a multipart/form-data body to upload.url with every returned field, plus the file as the final file field:

curl -X POST "https://your-bucket.s3.amazonaws.com/" \ -F "key=files/…" \ -F "Content-Type=image/png" \ -F "x-amz-checksum-sha256=…" \ -F "policy=…" \ -F "x-amz-signature=…" \ -F "file=@chart.png"

(Include each key/value from upload.fields as its own -F form field — the exact set varies by deployment. The file field must come last.)

Response — large file (presigned multipart)

Files larger than 8 MB get multipart part URLs instead:

{ "id": "file_0123456789abcdef0123456789abcdef", "status": "uploading", "deduplicated": false, "upload": { "type": "multipart", "part_size_bytes": 16777216, "parts": [ {"part_number": 1, "url": "https://…"}, {"part_number": 2, "url": "https://…"} ], "expires_at": "2026-01-01T00:15:00Z" } }

Split the file into part_size_bytes chunks (the last part may be smaller) and PUT each chunk to its URL, sending the part’s base64 SHA-256 in the x-amz-checksum-sha256 header. Record the ETag and checksum from each response — complete requires them.

Response — deduplicated

When the project already has a ready file with identical content (same sha256 + size + media type), no upload is needed:

{ "id": "file_fedcba9876543210fedcba9876543210", "status": "ready", "deduplicated": true, "upload": null }

Skip the upload and reference the returned id directly.

Errors

StatusErrorDescription
403files_not_allowed_for_planFree plan (Convoy Cloud) — upgrade to enable uploads
403quota_exceededStorage or file-count quota exceeded
409too_many_upload_sessionsToo many concurrent open sessions (limit 20)
413file_too_largeDeclared size exceeds the per-kind cap
415unsupported_media_typeMedia type not on the allowlist
422invalid_requestMalformed field (bad sha256, ttl out of range, …)

Finalize an upload

POST /files/{file_id}/complete

Runs server-side validation (size/checksum re-check, magic-byte sniff, structure checks) and waits for the malware-scan verdict.

Request Body

Single-shot uploads send an empty body ({}). Multipart uploads must list every part:

{ "parts": [ {"part_number": 1, "etag": "\"abc…\"", "checksum_sha256": "base64…"}, {"part_number": 2, "etag": "\"def…\"", "checksum_sha256": "base64…"} ] }

Response

  • 200status: "ready"; the file can now be referenced.
  • 202status: "validating"; the malware-scan verdict is still pending. Poll GET /files/{file_id} or call complete again.
  • 409invalid_state; the file is not in an uploadable state.
  • 422 — validation or the malware scan failed. The error body contains error and message (e.g. {"error": "magic_byte_mismatch", "message": "…"}), and the stored object is deleted. The file record keeps a failure_reason field (visible via GET /files/{file_id}) recording why it failed.

Only ready files can be referenced in /cargo/load — a 202 (validating) file is not yet usable.

{ "id": "file_0123456789abcdef0123456789abcdef", "status": "ready", "kind": "image", "filename": "chart.png", "media_type": "image/png", "size_bytes": 48213, "sha256": "9f86d081…", "scan_status": "clean", "failure_reason": null, "ttl_seconds": 2592000, "expires_at": "2026-01-31T00:00:00Z", "pinned": false, "active_ref_count": 0, "metadata": {"source": "reporting-pipeline"}, "created_at": "2026-01-01T00:00:00Z" }

Refresh presigned grants

POST /files/{file_id}/parts

Presigned grants expire after 15 minutes. While a session is still uploading, this re-issues them with identical signed conditions — uploading different bytes requires a new file.

{"part_numbers": [3, 7]}

Omit part_numbers (or send {}) to refresh every part; for single-shot sessions the presigned POST is re-issued. Returns 409 invalid_state once the session is no longer uploading.

List files

GET /files?status=ready&kind=image&limit=50&cursor=…
Query paramDescription
statusFilter by status (uploading, validating, ready, failed, expired)
kindFilter by kind (image, document, video)
limitPage size, 1–200 (default 50)
cursorOpaque keyset cursor from a previous response
{ "files": [ { "id": "file_…", "status": "ready", "…": "…" } ], "next_cursor": "…" }

next_cursor is null when there are no older files.

Get file metadata

GET /files/{file_id}

Returns the same shape as the complete response, including pinned and active_ref_count (in-flight batch references).

Download a file

GET /files/{file_id}/content

Returns a presigned GET URL for a ready file — scoped to the one object, short-lived (5 minutes), with Content-Disposition: attachment. Files that are not ready return 409.

{ "id": "file_0123456789abcdef0123456789abcdef", "download_url": "https://…", "expires_at": "2026-01-01T00:05:00Z", "filename": "chart.png", "media_type": "image/png", "size_bytes": 48213 }

Update TTL / metadata

PATCH /files/{file_id}
{ "ttl_seconds": 604800, "metadata": {"source": "reporting-pipeline", "reviewed": true} }

Both fields are optional; metadata replaces the existing tags.

Delete a file

DELETE /files/{file_id}?force=false
  • While uploading/validating: aborts the session.
  • Otherwise: marks the file expired and deletes the stored object.
  • Returns 409 file_in_use while in-flight batches reference the file — pass force=true to delete anyway (those batches may fail).

End-to-end example

Upload a small PNG and reference it in a batch request:

# 1. Hash the file SHA=$(shasum -a 256 chart.png | cut -d' ' -f1) SIZE=$(stat -f%z chart.png) # stat -c%s on Linux # 2. Create the upload session RESP=$(curl -s -X POST https://api.cnvy.ai/files \ -H "Content-Type: application/json" \ -H "X-API-Key: convoy_sk_your_key_here" \ -d "{\"filename\": \"chart.png\", \"media_type\": \"image/png\", \"size_bytes\": $SIZE, \"sha256\": \"$SHA\"}") FILE_ID=$(echo "$RESP" | jq -r '.id') # 3. Upload directly to storage (all fields from .upload.fields, file last) URL=$(echo "$RESP" | jq -r '.upload.url') curl -X POST "$URL" \ $(echo "$RESP" | jq -r '.upload.fields | to_entries[] | "-F \(.key)=\(.value)"') \ -F "file=@chart.png" # 4. Finalize (repeat while it returns 202/status=validating) curl -s -X POST "https://api.cnvy.ai/files/$FILE_ID/complete" \ -H "X-API-Key: convoy_sk_your_key_here" # 5. Reference the file in a batch request curl -X POST https://api.cnvy.ai/cargo/load \ -H "Content-Type: application/json" \ -H "X-API-Key: convoy_sk_your_key_here" \ -d "{ \"params\": { \"model\": \"claude-3-haiku\", \"max_tokens\": 500, \"messages\": [{ \"role\": \"user\", \"content\": [ {\"type\": \"text\", \"text\": \"Describe this chart\"}, {\"type\": \"image\", \"source\": {\"file_id\": \"$FILE_ID\"}} ] }] }, \"callback_url\": \"https://example.com/callback\" }"

Python

import hashlib import time from pathlib import Path import httpx API = "https://api.cnvy.ai" HEADERS = {"X-API-Key": "convoy_sk_your_key_here"} path = Path("chart.png") data = path.read_bytes() # 1. Create the upload session resp = httpx.post( f"{API}/files", headers=HEADERS, json={ "filename": path.name, "media_type": "image/png", "size_bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(), }, ).raise_for_status().json() file_id = resp["id"] if not resp["deduplicated"]: # 2. Upload directly to storage upload = resp["upload"] httpx.post( upload["url"], data=upload["fields"], files={"file": (path.name, data, "image/png")}, ).raise_for_status() # 3. Finalize — poll while the malware scan is pending (202) while True: r = httpx.post(f"{API}/files/{file_id}/complete", headers=HEADERS) r.raise_for_status() if r.json()["status"] == "ready": break time.sleep(3) # 4. Reference the file in a batch request httpx.post( f"{API}/cargo/load", headers=HEADERS, json={ "params": { "model": "claude-3-haiku", "max_tokens": 500, "messages": [{ "role": "user", "content": [ {"type": "text", "text": "Describe this chart"}, {"type": "image", "source": {"file_id": file_id}}, ], }], }, "callback_url": "https://example.com/callback", }, ).raise_for_status()

See Load Cargo — Multimodal Content Blocks for content-block shapes, model capability requirements (Vision, Documents, Video badges), and per-request reference limits.

Last updated on