Expiring one-week CSV exports without writing a cleanup job

A prefix-scoped lifecycle rule deletes week-old report exports for you. The setup call, the key layout it needs, and what users see when a link goes stale.

You don’t need a cleanup job. Point a lifecycle rule at the prefix your exports live under, give it an age in days, and Infrai’s storage layer removes them on its own — no scheduler of yours, no worker that has to be alive at 3 a.m., nothing to page you when it isn’t. Setting the rule is one free call, and reading it back to confirm is another.

That’s the whole answer for age-based cleanup, and for weekly report exports age is the only rule you need. The rest of this page is about the parts that bite afterwards: where the files have to sit for a rule to find them, what a user sees when they click a week-old link, and the two cases where you do end up writing a job.

The rule

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/kb-csvexp-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"rules": [{"prefix": "exports/", "expire_days": 7}, {"prefix": "scratch/", "expire_days": 1}]}'

Read it back — it’s free, and a rule you never verified is a rule you’re guessing about:

curl -sS "https://api.infrai.cc/v1/storage/bucket/get/kb-csvexp-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "bucket_id": "bkt_bbb89dba7036472db2c1c9",
    "name": "kb-csvexp-0726",
    "region": "ap-singapore",
    "acl": "private",
    "lifecycle_rules": [
      { "prefix": "exports/", "expire_days": 7 },
      { "prefix": "scratch/", "expire_days": 1 }
    ]
  }
}

Two constraints to design around. expire_days has to be at least 1 — submit a zero and you get STORAGE_INVALID_LIFECYCLE_RULES with the offending rule index, so “delete this immediately after download” isn’t expressible as a rule. And the array you send replaces the bucket’s entire policy; there’s no merge. Keep the full set in one config file and post all of it every time.

Key layout is the API

A rule matches a key prefix and nothing else — no tags, no content type, no per-object TTL. So the retention policy you want has to be visible in the way you name things:

kb-csvexp-0726/
  exports/2026-07/tenant-42/usage.csv        ← 7-day rule
  exports/2026-07/tenant-57/billing.csv      ← 7-day rule
  scratch/job-88131/partial.csv              ← 1-day rule
  templates/monthly-summary.csv              ← no rule, kept

Put the retention class first in the path, then the date, then the tenant. Get that backwards — tenant-42/exports/... — and a single rule can’t express “everyone’s exports expire in a week” any more.

The report job writes the object and mints a link in the same request. Nothing here knows about expiry; that’s the point:

import express from "express";
import process from "node:process";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const BUCKET = "kb-csvexp-0726";
const app = express();

function toCsv(rows) {
  const head = "id,email,total";
  const body = rows.map((r) => `${r.id},${r.email},${r.total.toFixed(2)}`).join("\n");
  return `${head}\n${body}\n`;
}

app.post("/reports/:tenant/export", async (req, res) => {
  const tenant = req.params.tenant.replace(/[^a-z0-9-]/gi, "");
  const rows = [{ id: 1, email: "a@example.com", total: 10 }, { id: 2, email: "b@example.com", total: 22.5 }];
  const month = new Date().toISOString().slice(0, 7);
  const key = `exports/${month}/${tenant}/usage-${crypto.randomUUID()}.csv`;

  const payload = {
    data_base64: Buffer.from(toCsv(rows), "utf8").toString("base64"),
    content_type: "text/csv",
    metadata: { "tenant-id": tenant, "report-month": month },
  };

  const stored = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!stored.ok) {
    console.error("store failed", stored.status, await stored.text());
    return res.status(502).json({ error: "export failed" });
  }

  const linkRequest = { op: "get", expires_seconds: 900 };
  const link = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(linkRequest),
  });
  if (!link.ok) return res.status(502).json({ error: "link unavailable" });

  const { url, expires_at } = (await link.json()).data;
  res.json({ download_url: url, link_expires_at: expires_at, file_expires_in_days: 7 });
});

app.listen(3000, () => console.log("listening on :3000"));

Note the random component in the key. That isn’t decoration — a presigned link stops working when its clock runs out, but the underlying object path is guessable if you build it from a tenant id and a date, and treating the signature as your access control is the mistake people make here. Unguessable, server-derived keys are the part that actually protects one tenant’s export from another’s.

What day eight looks like

Your users will click stale links; plan the response rather than letting it 404 raw. A free head call tells you which case you’re in:

curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-csvexp-0726/exports/2026-07/tenant-42/usage.csv" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Gone objects answer honestly instead of erroring:

{
  "ok": true,
  "data": {
    "found": false,
    "status": "not_found",
    "key": "exports/2026-07/tenant-42/gone.csv"
  }
}

So the download route checks found first and, when it’s false, re-runs the report rather than showing a broken link. Users mostly don’t notice a regenerated file; they very much notice an error page.

Worth being precise about the timing: expire_days is an age, evaluated by the storage layer on its own sweep rather than to the second. Treat 7 days as “at least 7 days, probably a bit more” — if a legal promise depends on the exact hour, run your own deletion.

When you still need a job

Retention ruleLifecycle ruleOwn job
Delete anything older than N daysYes, free, nothing to runUnnecessary
Keep the newest 10 exports per tenantNot expressibleList by prefix, sort, POST /v1/storage/object/delete_batch/{bucket}
Different windows per plan tierOnly if each tier has its own prefixStraightforward with a scheduled sweep
Delete on first downloadNo — the floor is one dayDelete after the response is streamed

The count-based and per-plan cases are where POST /v1/cron/create earns its place, and the sweep it runs uses the same key and the same free listing calls. That’s the practical form of the consolidation argument: the storage, the schedule and the error capture when the sweep fails aren’t three vendors.

Limitations, plainly

There’s no per-object TTL — the granularity is the prefix, so an export that needs to live for a year has to be written somewhere the 7-day rule doesn’t match. There’s no versioning, which means expiry is permanent and a mistyped prefix is unrecoverable; test rules on a throwaway bucket first. Objects in these buckets can be fetched by anyone holding the full storage URL, so short expiry plus random keys is the defence, not the signature itself. And if your requirement is retention that survives a compromised credential, S3 Object Lock or Cloudflare R2’s bucket-level controls are a different class of tool and you’d be better off there.

What it costs

The whole cleanup story is free: set_lifecycle, bucket/get, object/head, object/list and deletes are free and rate-limited, and lifecycle deletions aren’t charged per object either. You pay for the write that creates the export — verified 26 July 2026 at $0.0001 per object/put call — and for the bytes while they exist, which is precisely what a one-week window is minimising. Storage rates keep drifting downward, so read the live figures:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; [print(c['id'], c['billing'].get('price_usd', 0)) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"

New accounts get $2 in trial credit, which is around 20,000 exports before the write side shows up on a bill at all.

References

Browse more storage developer guides