spicyapiDocs
Main content

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, PUT the file straight into object storage, then put the returned key into your task's input.
  • 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-url

Prop

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

contentTypeExtension
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
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
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/png" \
  -H "Content-Length: 402118" \
  --data-binary @reference.png

Put 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

JavaScript
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;
}
Python
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-url

Prop

Type

curl
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"

SituationWhat you seeWhat to do
Still being copied from upstream409; the asset shows pending: true in recordInfoWait a few seconds and retry
The copy failed for goodThe asset shows unavailable: true and has no keyWaiting will not help — regenerate
Task has not succeeded yet404, "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.

On this page