Media
Presigned direct uploads for reference images, and short-lived download links for artefacts.
Media travels on two paths, neither of which goes through our origin:
- Reference uploads — take an upload ticket,
PUTthe file straight into object storage, then put the returnedkeyinto your task'sinput. - Artefact downloads — trade a task ID for a short-lived download link.
Both endpoints live under /api/v1/common/.
1. Direct reference upload
Image-to-image and image-to-video models need a reference image. Uploading takes three steps.
Request a ticket
POST /api/v1/common/upload-urlProp
Type
There is no filename field, and that is deliberate
Object keys are always generated server-side. Letting the caller pick a path would let anyone mint an upload URL pointing at someone else's object and overwrite it.
Accepted types (images only):
contentType | Extension |
|---|---|
image/jpeg | .jpg |
image/png | .png |
image/webp | .webp |
image/gif | .gif |
The default per-file ceiling is 10 MiB; the authoritative value is maxBytes in the response.
curl -X POST https://api.spicyapi.ai/api/v1/common/upload-url \
-H "Authorization: Bearer $SPICY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"contentType": "image/png", "bytes": 402118}'{
"code": 200,
"msg": "success",
"data": {
"key": "inputs/usr_01k3m8x9q2z4v7n5p6r8s0t1w3/2026/08/28/a91f4c2e5b7d16809e3f2a4c8d0b61e7.png",
"uploadUrl": "https://<account-id>.r2.cloudflarestorage.com/spicy-production-inputs/inputs/…?X-Amz-Signature=…",
"method": "PUT",
"headers": {
"Content-Type": "image/png",
"Content-Length": "402118"
},
"expiresAt": "2026-08-28T09:32:11Z",
"maxBytes": 10485760
}
}PUT the file
PUT the bytes to uploadUrl and send every header from headers exactly as given.
Drop one header and the signature no longer matches
Both Content-Type and Content-Length are signed. Change a byte of either and object storage rejects the upload — with a 403 that comes from the storage layer rather than from us, which makes it look baffling.
Most HTTP clients set Content-Length for you, but the browser fetch API sometimes does not, which is why we list it explicitly.
Do not send an Authorization header on this request. The presigned URL is the credential; an extra auth header makes some storage implementations reject it.
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/png" \
-H "Content-Length: 402118" \
--data-binary @reference.pngPut the key in your input
Use the key from step one — not uploadUrl — as the value of the reference field:
{
"model": "kie/minimax-h3-spicy",
"input": {
"endpoint": "image-to-video",
"prompt": "slow dolly in, rain on the window",
"first_frame_url": "inputs/usr_01k3m8x9q2z4v7n5p6r8s0t1w3/2026/08/28/a91f4c2e5b7d16809e3f2a4c8d0b61e7.png",
"duration": 5
}
}When the task is dispatched we swap that key for a fetchable URL the upstream model can read — the bucket is private, so the key means nothing to it. The swap happens at dispatch, not at creation: signed URLs expire, and a task may queue, retry, or wait hours for its turn.
A key that does not belong to you is rejected at creation with 400.
Full example
import { readFile } from 'node:fs/promises';
const BASE = 'https://api.spicyapi.ai/api/v1';
const AUTH = { Authorization: `Bearer ${process.env.SPICY_API_KEY}` };
async function uploadReference(path, contentType) {
const file = await readFile(path);
// 1. Get a ticket
const ticketRes = await fetch(`${BASE}/common/upload-url`, {
method: 'POST',
headers: { ...AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ contentType, bytes: file.byteLength }),
});
const ticket = await ticketRes.json();
if (ticket.code !== 200) throw new Error(`${ticket.code} ${ticket.msg}`);
// 2. Upload directly. No Authorization here; pass the headers through as-is
const put = await fetch(ticket.data.uploadUrl, {
method: ticket.data.method,
headers: ticket.data.headers,
body: file,
});
if (!put.ok) throw new Error(`upload failed: ${put.status}`);
// 3. Hand the key back so the caller can put it in `input`
return ticket.data.key;
}import os, requests
BASE = "https://api.spicyapi.ai/api/v1"
AUTH = {"Authorization": f"Bearer {os.environ['SPICY_API_KEY']}"}
def upload_reference(path: str, content_type: str) -> str:
with open(path, "rb") as f:
data = f.read()
# 1. Get a ticket
ticket = requests.post(
f"{BASE}/common/upload-url",
headers={**AUTH, "Content-Type": "application/json"},
json={"contentType": content_type, "bytes": len(data)},
).json()
if ticket["code"] != 200:
raise RuntimeError(f'{ticket["code"]} {ticket["msg"]}')
# 2. Upload directly. No Authorization; pass the headers through as-is
put = requests.put(ticket["data"]["uploadUrl"], headers=ticket["data"]["headers"], data=data)
put.raise_for_status()
# 3. Hand the key back so the caller can put it in `input`
return ticket["data"]["key"]An extra rate limit
On top of the general open-API budget, upload-url has its own allowance of 60 requests per minute per account.
The cost here is asymmetric: signing a ticket is pure local computation for us, but each ticket is a write authorisation that no longer passes through us. A human picking files cannot go that fast, and a batch script uploading a few dozen references still fits comfortably.
2. Artefact download
POST /api/v1/common/download-urlProp
Type
curl -X POST https://api.spicyapi.ai/api/v1/common/download-url \
-H "Authorization: Bearer $SPICY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png"
}'{
"code": 200,
"msg": "success",
"data": {
"key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
"url": "https://<account-id>.r2.cloudflarestorage.com/spicy-production-results/tasks/…?X-Amz-Signature=…",
"expiresAt": "2026-08-28T09:32:11Z"
}
}For a task with several artefacts, call once per key.
20 minutes, and unrevocable once issued
A signed URL is a self-attesting string: object storage will not ask again who is holding it. Therefore
- Do not store it in your database, and do not put it in a long-lived cache you serve to browsers. Sign one when you need it — signing is local computation and effectively free.
- Download anything you want to keep. Artefacts live about 14 days on our side either way; see Data retention.
Two kinds of "not yet"
| Situation | What you see | What to do |
|---|---|---|
| Still being copied from upstream | 409; the asset shows pending: true in recordInfo | Wait a few seconds and retry |
| The copy failed for good | The asset shows unavailable: true and has no key | Waiting will not help — regenerate |
| Task has not succeeded yet | 404, "no downloadable artefact for this task" | Wait for state: "succeeded" |
A task that is not yours, or a key that does not belong to the task, both return 404. We do not distinguish "does not exist" from "belongs to someone else", because that difference can be used to enumerate other people's task IDs.
Retention of uploads
Reference images are kept for 7 days, shorter than artefacts. Input material has no purpose once the task has run, and every extra day is extra exposure. If you reuse the same reference repeatedly, keep your own copy.

