Archiving a generated video before its URL expires
Download URLs are time-limited and jobs carry a retention window. The copy-to-your-own-bucket step, and why the URL should never reach your database.
A finished Infrai video job gives you two things that both expire: a video_url on the job record and a fresh signed link from GET /v1/video/download_url/{id}, which returns an expires_at alongside it. The job itself carries retention_days. If the clip matters beyond that window, copy it into your own bucket — PUT /v1/storage/object/put/{bucket}/{key} on the same key — and store your path, not theirs.
The failure this prevents is the classic one: a URL saved in a database that stops working, in a row nobody can regenerate.
Get a download link
curl -sS "https://api.infrai.cc/v1/video/download_url/vid_2fVc8nRqLmT4xBzY" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"job_id": "vid_2fVc8nRqLmT4xBzY",
"url": "https://vendor-cdn.example/renders/2fVc8nRqLmT4xBzY.mp4?sig=...",
"expires_at": "2026-09-21T04:30:00Z",
"size_bytes": 4831204,
"content_type": "video/mp4"
}
}
Four useful fields. expires_at is why this is a call rather than a stored value — ask for a fresh link when you need one. size_bytes lets you check you have room and set the right expectations for a mobile client. And content_type is what you should pass through when you re-upload, rather than guessing from an extension.
GET /v1/video/get/{id} carries retention_days on the job record, which is the longer clock: after it, the job’s artefact is gone whether or not you fetched it.
Copy it to your own bucket
import base64
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
BUCKET = os.environ.get("ARCHIVE_BUCKET", "video-archive")
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}"})
def download_link(job_id: str) -> dict:
resp = SESSION.get(f"{API}/v1/video/download_url/{job_id}", timeout=30)
resp.raise_for_status()
return resp.json()["data"]
def archive(job_id: str, key: str | None = None) -> dict:
"""Fetch the render and write it into our own bucket. The vendor URL is
transient; the bucket path is the one we keep. Never store the vendor URL as
if it were permanent — that row will be dead within the hour."""
link = download_link(job_id)
media = requests.get(link["url"], timeout=180)
media.raise_for_status()
object_key = key or f"renders/{job_id}.mp4"
put = SESSION.put(
f"{API}/v1/storage/object/put/{BUCKET}/{object_key}",
json={
"data_base64": base64.b64encode(media.content).decode("ascii"),
"content_type": link.get("content_type", "video/mp4"),
"metadata": {"job_id": job_id},
"storage_class": "durable",
},
headers={"Content-Type": "application/json"},
timeout=300,
)
put.raise_for_status()
return {"bucket": BUCKET, "key": object_key, "bytes": len(media.content),
"source_expired_at": link["expires_at"]}
if __name__ == "__main__":
print(archive(os.environ["JOB_ID"]))
Two details worth copying. storage_class: "durable" says this object isn’t ephemeral — the tier decides whether it’s swept, and a render you archived into the wrong tier is a render that disappears. And metadata.job_id keeps the link back to the generation, which is what makes the archive auditable later.
Do it as part of the job, not as a cleanup
The instinct is a nightly sweep that archives yesterday’s renders. It’s the wrong shape, because expires_at on a download link is measured in an hour or so and a sweep that runs once a day will be asking for links that have long gone.
Archive on completion instead. If you’re using webhook_url on the generate call, the handler that receives “done” is exactly the right place:
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const BUCKET = process.env.ARCHIVE_BUCKET ?? "video-archive";
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
export async function archiveOnComplete(jobId) {
const linkRes = await fetch(`${API}/v1/video/download_url/${encodeURIComponent(jobId)}`, { headers });
if (!linkRes.ok) throw new Error(`no download link: ${linkRes.status}`);
const { data: link } = await linkRes.json();
const media = await fetch(link.url);
if (!media.ok) throw new Error(`fetch failed: ${media.status}`);
const bytes = Buffer.from(await media.arrayBuffer());
const key = `renders/${jobId}.mp4`;
const put = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers,
body: JSON.stringify({
data_base64: bytes.toString("base64"),
content_type: link.content_type ?? "video/mp4",
metadata: { job_id: jobId },
storage_class: "durable",
}),
});
if (!put.ok) throw new Error(`archive failed: ${put.status}`);
// Store the bucket and key. Storing link.url is the bug this function exists
// to prevent.
return { bucket: BUCKET, key };
}
Serving it afterwards
Once it’s in your bucket, generate a signed link when a user asks rather than storing one: POST /v1/storage/object/presign with op: "get" gives you a time-limited URL on demand. Same pattern, same reasoning — links are minted, not stored.
| What to store | Where |
|---|---|
job_id | your database |
| bucket and key | your database |
| a signed URL | nowhere — mint it per request |
the vendor’s video_url | nowhere |
Limitations
There’s no server-side copy from a generation straight into storage, so the bytes travel through your process — for a five-megabyte clip that’s fine, and for a long batch it means your archive worker needs bandwidth and memory proportional to what it’s moving. Stream to a temporary file rather than holding it all in memory if your clips get large.
Archived objects also accrue standing storage rent by occupied gigabyte, so an archive is a decision to keep paying rather than a free save. That’s the honest trade against letting retention_days do its job: keep what you’ll use, delete the rest with DELETE /v1/video/delete/{id} and a storage lifecycle you actually chose.
Going direct to a model provider sometimes gets you longer default retention or a CDN they operate — Runway hosts renders for its own clients, and Kling’s own console keeps them visible — so if long-lived hosted delivery is what you actually want rather than an archive you control, that’s worth checking before you build the copy step at all. What you get here is that the render, the archive, the signed link and the bill are all one credential and one GET /v1/account/usage — no second vendor between the job and the file, and generation’s per-second rate is live in GET /v1/discovery/video.generate (verified 2026-09-21), drifting downward as vendor contracts improve.