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 fileAuthentication
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:
- Create a session —
POST /fileswith 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 areadyfile with identical content, the response instead returns that file (deduplicated: true,upload: null) — skip steps 2–3 and reference itsiddirectly. - 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.
- Finalize —
POST /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 becomesready(200). While the scan verdict is still pending you get202withstatus: "validating"— poll until the file isreadybefore referencing it. - Reference the
file_idin/cargo/loadcontent 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
| Kind | Media types | Max size |
|---|---|---|
| Image | image/jpeg, image/png, image/gif, image/webp | 20 MB |
| Document | application/pdf (≤ 100 pages), text/plain, text/csv, text/markdown | 50 MB |
| Video | video/mp4, video/quicktime, video/webm, video/x-matroska (≤ 30 min) | 1 GB |
Other limits:
| Limit | Value |
|---|---|
| Single-shot (presigned POST) threshold | 8 MB — larger files use multipart |
| Multipart part size | 16 MB |
| Presigned grant expiry | 15 minutes (refresh via POST /files/{id}/parts) |
| Default TTL once ready | 30 days (ttl_seconds: 1 hour – 1 year) |
| Metadata size | 4 KB (serialized JSON) |
| Concurrent open upload sessions per project | 20 |
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
| Status | Meaning |
|---|---|
uploading | Session open — presigned grant issued, bytes not yet finalized |
validating | complete called — server-side checks and/or malware scan pending |
ready | Validated and clean — referenceable in /cargo/load |
failed | Validation or scan failed (see failure_reason); the object is deleted |
expired | TTL elapsed or deleted — no longer referenceable |
Sessions stuck in uploading for more than 24 hours are aborted
automatically.
Create an upload session
POST /filesRequest Body
{
"filename": "chart.png",
"media_type": "image/png",
"size_bytes": 48213,
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"ttl_seconds": 2592000,
"metadata": {"source": "reporting-pipeline"}
}| Field | Type | Required | Description |
|---|---|---|---|
filename | string | Yes | Original filename (display only, ≤ 512 chars) |
media_type | string | Yes | MIME type — must be on the allowlist |
size_bytes | integer | Yes | Exact size in bytes of the file to upload |
sha256 | string | Yes | Hex SHA-256 digest of the exact bytes — enforced by S3 |
ttl_seconds | integer | No | Time-to-live once ready (3600–31536000; default 30 days) |
metadata | object | No | User-supplied tags (JSON, ≤ 4 KB) |
Compute the digest locally before calling:
sha256sum chart.png # Linux
shasum -a 256 chart.png # macOSResponse — 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
| Status | Error | Description |
|---|---|---|
403 | files_not_allowed_for_plan | Free plan (Convoy Cloud) — upgrade to enable uploads |
403 | quota_exceeded | Storage or file-count quota exceeded |
409 | too_many_upload_sessions | Too many concurrent open sessions (limit 20) |
413 | file_too_large | Declared size exceeds the per-kind cap |
415 | unsupported_media_type | Media type not on the allowlist |
422 | invalid_request | Malformed field (bad sha256, ttl out of range, …) |
Finalize an upload
POST /files/{file_id}/completeRuns 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
200—status: "ready"; the file can now be referenced.202—status: "validating"; the malware-scan verdict is still pending. PollGET /files/{file_id}or callcompleteagain.409—invalid_state; the file is not in an uploadable state.422— validation or the malware scan failed. The error body containserrorandmessage(e.g.{"error": "magic_byte_mismatch", "message": "…"}), and the stored object is deleted. The file record keeps afailure_reasonfield (visible viaGET /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}/partsPresigned 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 param | Description |
|---|---|
status | Filter by status (uploading, validating, ready, failed, expired) |
kind | Filter by kind (image, document, video) |
limit | Page size, 1–200 (default 50) |
cursor | Opaque 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}/contentReturns 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
expiredand deletes the stored object. - Returns
409 file_in_usewhile in-flight batches reference the file — passforce=trueto 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.