A successful generation can still become a broken image in your product if you save its temporary delivery URL instead of copying the file. Signed links expire in minutes; the underlying API output is retained for about two weeks.
A media API hands you the same file under several identities: an input reference, a generated-output key, and a signed URL that can fetch the bytes for a few minutes. Each one expires on its own clock, and none of them is a permanent address. This guide gives the exact windows on SpicyAPI, then shows how to copy valuable results into storage you control. The retention controls can shorten these windows.
Try it in the Playground first
Open the Z-Image text-to-image page and sign in:
- Type a short scene into Prompt and pick an Aspect ratio.
- Press Run model. The result appears under Output.
- Download the image and save it somewhere you control.
- Reopen your saved file later. It opens without the page or its temporary result link, which is what the rest of this guide automates for production.
Key takeaways
- Copy on completion, in a worker. Keep large downloads out of the callback request.
- Keep signed links private. Possessing one grants temporary access to the file.
- Expose your own asset ID and delivery URL. A task record can outlive its media.
What expires when
| What | How long it lasts | What happens at the end |
|---|---|---|
Upload ticket (uploadUrl) | About 20 minutes; the PUT and the commit must both finish in that time | An expired-ticket response requires a new ticket; a busy commit can also return 409 |
Committed reference (spicy://f/fil_...) | 1 day from commit, until the expiresAt in the commit response | URI is rejected for new tasks and for retries |
Result link (output.assets[].url) | About 20 minutes, never past the media's own deadline | Storage answers 403; read the task again for a fresh link |
| Generated image, video or audio | About 14 days from completion (shorter if you set it shorter) | Deleted; no link can be issued (410 from download-url) |
| Prompt text and result file paths on the task | 30 days | input and output are cleared; the task row stays |
| Task record (ID, model, state, cost, timestamps) | Kept indefinitely | Still queryable, with no content |
Short-film media made inside a Spicy Studio project has its own longer window of about 60 days; API, SDK and Playground generations use the 14-day cap above. Generation can also be shortened per request or per account, covered in the retention controls article. You can only go shorter than these caps, never longer.
The 14 days run from completion, not from submission. Before the task finishes the timestamp is provisional and is re-based on completion.
"About" is deliberate. Storage cleanup runs as a lifecycle rule and can lag the deadline by up to a day, but that lag is not extra time for you: once the window closes, no download link is issued.
One file, four representations
local reference image
│
├─ POST /api/v1/common/upload-url → upload ticket (about 20 min)
├─ PUT to the returned uploadUrl → temporary object
├─ POST /api/v1/files/{fileId}/commit → spicy://f/fil_... (usable 1 day)
├─ POST /api/v1/jobs/createTask → task
└─ terminal callback or recordInfo
└─ output.assets[] → key + url + expiresAt (about 20 min)
└─ stream bytes to your storage → your asset ID and delivery URLThe SDKs, the CLI and the Playground run the upload steps for you, so you rarely hand-write them. Each representation has one job. The ticket authorizes a direct write within its validity window; committing freezes the accepted bytes. The spicy:// URI lets a task refer to a committed, account-scoped input for a day. The output key identifies a generated result. The url temporarily authorizes a read. Your asset ID is the only long-lived identity your product should expose.
Uploading the reference to SpicyAPI is free; you pay for the generation task that uses it. Refreshing a link to an existing output does not create another generation charge. If the output is lost and you choose to generate again, that new task has its own model charge. Budget separately for your durable bucket's storage, requests and transfer under your storage provider's plan.
Inputs: upload, then plan for the 1-day expiry
Keep the original reference image, video or audio in your own storage. Upload a local input through the SDK, CLI or Playground and reuse its committed URI while it remains valid. Upload it again when needed after expiry; the temporary URI is not your archive.
Three ways to hand a model an input:
- Public HTTPS address. Works where the model's field accepts a URL, as long as it returns the file itself. A cloud-drive, album or social link returns a web page, and the task fails with
invalid_asset; download the file and upload it instead. - Small inline image. JPEG, PNG, WebP or GIF as a base64 data URI, up to 1 MiB each.
- Upload. For local files. Images (JPEG, PNG, WebP, GIF) up to 10 MiB; WAV, MP3, WebM, MP4 or MOV audio and video up to 90 MiB and 600 seconds. A model can accept less, so check the field's limit on its page. In the TypeScript SDK,
await client.uploadFile("./reference.png")returns thespicy://URI asfile.uri.
Never expose the SpicyAPI key in a browser; have your server issue upload tickets. The media documentation covers browser integration and the full limits.
Outputs: what a finished result looks like
A completed task and a retrievable file are different states:
- Ready: copy the file into your storage and record its task ID, format and size.
- Pending: keep the same task and check again; do not purchase another generation to obtain the file.
- Unavailable: the platform copy is gone and waiting will not bring it back. Use your own saved copy; otherwise generate again, which is billed normally. Only an initial storage failure with no delivered files is uncharged.
Temporary links grant access to their holder. Keep them out of public pages and analytics; obtain a current link when the copy worker is ready.
The retention boundary
A task record, output key or refreshed download link does not preserve the bytes. Copy them before the media deadline.
When the window closes, recordInfo reports contentState: "expired", and input and output are cleared at 30 days at the latest. If you destroyed the task's content yourself, the state is purged. The task state expired is a different thing: it means the generation timed out and produced nothing. Do not show both as the same label in your UI, because one calls for a retry and the other cannot be recovered by retrying.
Set the copy deadline well before the cutoff. Copy on the first success event, then run a reconciliation job that lists succeeded tasks with no durable asset and alerts while the result still has days left. Day 14 is recovery margin, not a schedule. If an account or request setting shortens retention, outputsExpireAt can be much sooner than 14 days, so alert from that field and never from a constant. One account setting can also reach back: shortening the prompt-text clock deletes the results of already finished tasks earlier than the outputsExpireAt they were given. Download what you want to keep before changing account settings, and expect the reconciliation job to report such tasks as lost rather than as pipeline bugs.
A callback pipeline that survives retries and large videos
For an application, verify the completion event, persist it and queue the copy before responding. Make the copy idempotent so a repeated event does not create another gallery item. Alert on uncopied results before their accepted expiry.
This standalone worker saves an existing task's files in a private local directory. Install spicyapi, set SPICY_API_KEY, TASK_ID and optionally ARCHIVE_DIR, then run it. It creates no generation. Store that directory on persistent storage and include it in your backup plan; an ephemeral server disk is not a durable archive.
import hashlib
import json
import os
from pathlib import Path
import tempfile
import urllib.request
from spicyapi import SpicyClient, output_assets
client = SpicyClient()
task_id = os.environ["TASK_ID"]
root = Path(os.environ.get("ARCHIVE_DIR", "media-archive"))
root.mkdir(parents=True, exist_ok=True, mode=0o700)
task = client.get_task(task_id)
if task["state"] != "succeeded":
raise RuntimeError("Resume the existing task before archiving it.")
for index, asset in enumerate(output_assets(task)):
if asset.get("pending") or asset.get("unavailable") or not asset.get("url"):
raise RuntimeError("Asset is not ready; retain the task ID.")
request = urllib.request.Request(asset["url"], headers={"User-Agent": "SpicyAPI-Blog-Example/1.0"})
temporary = None
try:
with urllib.request.urlopen(request, timeout=300) as response:
mime = response.headers.get_content_type()
suffix = {"image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp",
"video/mp4": ".mp4", "video/webm": ".webm", "audio/mpeg": ".mp3", "audio/wav": ".wav"}.get(mime)
if response.status != 200 or suffix is None:
raise RuntimeError("Unexpected response format; do not guess an extension.")
digest, size = hashlib.sha256(), 0
with tempfile.NamedTemporaryFile(dir=root, delete=False) as output:
temporary = Path(output.name)
while chunk := response.read(1024 * 1024):
output.write(chunk)
digest.update(chunk)
size += len(chunk)
if not size or (asset.get("bytes") and size != int(asset["bytes"])):
raise RuntimeError("Empty or incomplete transfer.")
filename = root / (digest.hexdigest() + suffix)
temporary.replace(filename)
record = {"taskId": task_id, "index": index, "file": filename.name,
"mime": mime, "bytes": size, "sha256": digest.hexdigest()}
(root / (digest.hexdigest() + ".json")).write_text(json.dumps(record, indent=2))
print(filename)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)For object-store delivery, stream to a private staging object and expose it only after a complete transfer. The media and webhook documentation cover result refresh and authenticated events. Keep polling as a reconciliation path for missed callbacks.
Your durable asset should hide the generation path
Once copied, the object your product serves has its own identity:
asset_01k... database identity
generated/job_.../....mp4 private storage key
https://media.example.com/a/... controlled delivery URLKeep the bucket private and deliver through your own CDN, an authenticated media endpoint or your own short-lived links. Do not reuse a SpicyAPI signed URL as your public URL. This keeps temporary links and storage details out of HTML, logs and exports, lets you change access rules or domains without rewriting task history, and makes deleting a user's asset an operation on your own record. Keep provenance privately: source task ID, original asset key, content type, size, copy time and destination key.
Failure modes and the recovery action
| Failure mode | What you observe | Correct recovery |
|---|---|---|
| Upload ticket expired before the upload finished, or an uncommitted file used in a task | Upload or commit is rejected, or task creation refuses the reference | Request a new ticket and upload again; a busy 409/429 on commit instead means retry the same commit |
Saved spicy:// URI used the next day | 400 at task creation, or a retry fails | Upload the original again; keep originals in your own storage |
| Callback delivered twice | Two workers start the same copy | Deduplicate on request_id, and on task ID plus asset index in the queue and at the destination |
| Callback endpoint is slow | Retries arrive while a download runs | Persist, enqueue, return 2xx before moving media |
| Result link expired while queued | Source fetch returns 403 | Read the task again for a fresh url and retry the stream |
Asset shows pending: true | key and url are empty | Wait a few seconds and query again |
Asset shows unavailable: true, or download-url returns 410 | Expired, deleted or failed to store | Use your verified private copy; if none exists, tell the user and generate again as a new, billed task |
| Copy finished but the database write failed | Private object exists, no ready asset row | Verify the existing destination and retry the metadata write; never delete a valid copy or regenerate |
| Database says ready but the object is missing | Broken page or 404 from your CDN | Reconcile database rows against the bucket before marking ready |
| Signed URL appears in logs | A temporary read capability is exposed | Redact query strings and response bodies; links expire in about 20 minutes but cannot be revoked |
| Large video buffered in memory | Worker restarts or runs out of memory | Stream with bounded concurrency and backpressure |
Production launch checklist
- Recover before expiry: alert on missing copies using each task's accepted deadline.
- Keep ownership explicit: serve private assets only to their owner and collect results before rotating API keys.
- Delete across your systems: include your bucket, delivery cache and backups in the deletion policy.
FAQ
Can I save the URL returned in output.assets[].url?
Only for the copy itself. Save your own asset record, and take a fresh link when you need to move bytes.
Do I still need download-url?
Only to refresh a link by taskId and key within the retention window; ready assets already carry url and expiresAt.
Does refreshing a link extend how long the media is kept?
No. A new link only renews read access; the object is still deleted at retention.outputsExpireAt.
Can I pass a public image URL to a model instead of uploading?
Yes, where the model's schema allows a URL in that field, as long as the address returns the file itself and stays reachable while the task runs. Use the upload flow for local files, larger inputs or anything not publicly hosted.
I need the same reference image next week. What do I do?
Upload it again from your own copy when you need it next week. A committed reference works for 1 day only, but can serve multiple tasks while valid unless it is deleted sooner.
Should I copy the result inside the callback request?
No. A large transfer inside the request overruns the 15-second callback timeout and triggers duplicate deliveries; a queued worker can retry safely.
What if I do not want to keep a result at all?
Then do not copy it, and let it age out, or shorten retention or destroy a task's content on demand. Those controls are covered in AI media uploads, retention and delivery and the Retention docs. Destroying content never changes what you were charged.
Let an Agent handle the call
Use an Agent to recover an existing output rather than create it again.
- CLI:
npx @spicyapi/cli tasks get "$TASK_ID"retrieves the task. See the CLI guide. - MCP: ask
spicyapi_task_getfor the current output and save the returned file. See the MCP guide. - Skill: install
npx skills add spicyapi-ai/spicy-skill, then ask$spicyapito archive that task's result in your chosen directory. See the Skill guide.







