Skip to main content

Your AI API returned an asset key. Now make the media durable

Generated media is kept about 14 days and uploaded references 1 day. See what expires when, then copy results into storage you control from a callback.

Author
SpicyAPI Engineering
Published
Read
12 min read
Updated
A hand sliding a glowing photo card from a temporary dock with an hourglass into a drive bay beside an editing monitor

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:

  1. Type a short scene into Prompt and pick an Aspect ratio.
  2. Press Run model. The result appears under Output.
  3. Download the image and save it somewhere you control.
  4. 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

WhatHow long it lastsWhat happens at the end
Upload ticket (uploadUrl)About 20 minutes; the PUT and the commit must both finish in that timeAn 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 responseURI is rejected for new tasks and for retries
Result link (output.assets[].url)About 20 minutes, never past the media's own deadlineStorage answers 403; read the task again for a fresh link
Generated image, video or audioAbout 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 task30 daysinput and output are cleared; the task row stays
Task record (ID, model, state, cost, timestamps)Kept indefinitelyStill 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

text
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 URL

The 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 the spicy:// URI as file.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.

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

text
asset_01k...                     database identity
generated/job_.../....mp4        private storage key
https://media.example.com/a/...  controlled delivery URL

Keep 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 modeWhat you observeCorrect recovery
Upload ticket expired before the upload finished, or an uncommitted file used in a taskUpload or commit is rejected, or task creation refuses the referenceRequest a new ticket and upload again; a busy 409/429 on commit instead means retry the same commit
Saved spicy:// URI used the next day400 at task creation, or a retry failsUpload the original again; keep originals in your own storage
Callback delivered twiceTwo workers start the same copyDeduplicate on request_id, and on task ID plus asset index in the queue and at the destination
Callback endpoint is slowRetries arrive while a download runsPersist, enqueue, return 2xx before moving media
Result link expired while queuedSource fetch returns 403Read the task again for a fresh url and retry the stream
Asset shows pending: truekey and url are emptyWait a few seconds and query again
Asset shows unavailable: true, or download-url returns 410Expired, deleted or failed to storeUse your verified private copy; if none exists, tell the user and generate again as a new, billed task
Copy finished but the database write failedPrivate object exists, no ready asset rowVerify the existing destination and retry the metadata write; never delete a valid copy or regenerate
Database says ready but the object is missingBroken page or 404 from your CDNReconcile database rows against the bucket before marking ready
Signed URL appears in logsA temporary read capability is exposedRedact query strings and response bodies; links expire in about 20 minutes but cannot be revoked
Large video buffered in memoryWorker restarts or runs out of memoryStream 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.

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_get for the current output and save the returned file. See the MCP guide.
  • Skill: install npx skills add spicyapi-ai/spicy-skill, then ask $spicyapi to archive that task's result in your chosen directory. See the Skill guide.

Also in 日本語 · 한국어 · Deutsch · Français · Español · Português (Brasil) · Русский · Italiano · Polski

Try it yourself

Pay only for what you run — no plan, no subscription.

Made with MiniMax H3 · ByteDance Seedance 2.0 Fast