Controlling the saved filename on a signed export download
response_disposition renames a download at signing time, RFC 5987 filenames included — but inline is quietly discarded. Measured headers and a Node export flow.
Pass response_disposition when you mint the link. Infrai’s presign call takes it as a request field — {"op":"get","response_disposition":"attachment; filename=\"orders-july-2026.csv\""} — and the vendor returns exactly that header on the download, so the user’s browser saves orders-july-2026.csv no matter how opaque the object key is. You don’t append response-content-disposition to the query string; that breaks the signature.
Worth knowing before you start: every object GET here already comes back as attachment, with an x-amz-force-download: true alongside it. Forcing a download isn’t the problem you have. Naming it is.
What the header does when you leave it alone
export INFRAI_API_KEY=your_infrai_api_key
curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-exportnames-0726/exports/2026-07/e7f21a9c.csv" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300}'
Request the URL that comes back and the response headers read:
HTTP/1.1 200 OK
Content-Type: text/csv
Content-Disposition: attachment
Cache-Control: private, max-age=60
x-amz-force-download: true
Bare attachment, no filename. A browser falls back to the last path segment, so the user gets e7f21a9c.csv in their Downloads folder — technically correct, completely useless three weeks later. That Cache-Control came from the cache_control field set at upload time; it survives to the signed GET, which is handy for short-lived export links you don’t want a proxy caching.
Setting the name at signing time
curl -s -X POST \
"https://api.infrai.cc/v1/storage/object/presign/kb-exportnames-0726/exports/2026-07/e7f21a9c.csv" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"op":"get","expires_seconds":300,"response_disposition":"attachment; filename=\"orders-july-2026.csv\""}'
The download then answers with Content-Disposition: attachment; filename="orders-july-2026.csv", and the x-amz-force-download header disappears — the explicit disposition replaces the default rather than stacking with it. Spaces are fine: filename="Orders July 2026.csv" arrives intact.
Non-ASCII names work too, through the RFC 5987 form. attachment; filename*=UTF-8''Rechnung%20M%C3%BCller.csv comes back byte-for-byte on the response, so a German invoice keeps its umlaut. Percent-encode the value yourself; the API passes the string through rather than interpreting it.
The one that doesn’t stick
What you send as response_disposition | What the download actually sends |
|---|---|
| (omitted) | attachment + x-amz-force-download: true |
attachment; filename="orders-july-2026.csv" | exactly that |
attachment; filename*=UTF-8''Rechnung%20M%C3%BCller.csv | exactly that |
inline; filename="orders-july-2026.csv" | attachment + x-amz-force-download: true |
That last row is the surprise, and it cost us a while to pin down. Ask for inline and the request succeeds, the link works, and your disposition is discarded in full — filename included. There’s no support for previewing an object in a browser tab from a signed URL; the platform forces the save dialog. If your product needs an in-app PDF viewer, proxy the bytes through your own route with GET /v1/storage/object/get/{bucket}/{key} and write your own headers.
The export flow, end to end
import process from "node:process";
const BASE = "https://api.infrai.cc";
const BUCKET = "kb-exportnames-0726";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is not set");
const headers = { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" };
async function call(path, method, body) {
const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const payload = await res.json();
if (!payload.ok) throw new Error(`${path} -> ${payload.error?.code}: ${payload.error?.message}`);
return payload.data;
}
function toCsv(rows) {
const cols = Object.keys(rows[0]);
const escape = (v) => (/[",\n]/.test(String(v)) ? `"${String(v).replaceAll('"', '""')}"` : String(v));
return [cols.join(","), ...rows.map((r) => cols.map((c) => escape(r[c])).join(","))].join("\n") + "\n";
}
export async function publishExport(rows, { month, exportId }) {
const csv = toCsv(rows);
const key = `exports/${month}/${exportId}.csv`;
const stored = await call(`/v1/storage/object/put/${BUCKET}/${key}`, "PUT", {
data_base64: Buffer.from(csv, "utf8").toString("base64"),
content_type: "text/csv",
cache_control: "private, max-age=60",
});
return { key: stored.key, bytes: stored.size_bytes, etag: stored.etag };
}
export async function downloadLink(key, downloadAs, ttlSeconds = 300) {
const safe = downloadAs.replaceAll('"', "").replaceAll("\n", " ");
const ascii = /^[\x20-\x7E]+$/.test(safe);
const disposition = ascii
? `attachment; filename="${safe}"`
: `attachment; filename*=UTF-8''${encodeURIComponent(safe)}`;
const link = await call(`/v1/storage/object/presign/${BUCKET}/${key}`, "POST", {
op: "get",
expires_seconds: ttlSeconds,
response_disposition: disposition,
});
return { url: link.url, expiresAt: link.expires_at };
}
const written = await publishExport(
[{ order_id: 1042, customer: "Acme GmbH", total: "199.00" }],
{ month: "2026-07", exportId: "e7f21a9c" },
);
const link = await downloadLink(written.key, "Orders July 2026.csv");
console.log(written.bytes, "bytes ->", link.expiresAt);
Two details in there are load-bearing. Strip quotes and newlines out of any user-supplied name before it reaches the header, because you’re building a header value by concatenation and a stray " truncates it. And branch on ASCII: the plain filename= form is what every browser handles best, so only reach for filename* when you actually need it.
Lifetime, and what it isn’t
expires_seconds accepts [1..604800] — one second to seven days. Anything outside returns STORAGE_INVALID_TTL with a 400, and a link past its window answers 403.
The signature is the access boundary, not just a clock. Strip the query string off a signed URL and the storage host answers 403 — the object path on its own gets you nothing, and neither does a tampered signature or a HEAD against a URL signed for GET. Expired links come back 403 too, with Request has expired in the body, which is the one 403 you can safely tell a user to retry through.
That said, the link itself is a bearer token for as long as it lives: anyone holding the whole URL is authorised, because the signature doesn’t know who’s presenting it. So the usual hygiene still applies — mint a fresh link per download, keep the TTL to what the click actually needs rather than the seven-day ceiling, derive object keys on the server so they can’t be guessed, and check the user’s session before you mint anything rather than after.
Verification is a free call:
curl -s -X GET \
"https://api.infrai.cc/v1/storage/object/head/kb-exportnames-0726/exports/2026-07/e7f21a9c.csv" \
-H "Authorization: Bearer $INFRAI_API_KEY"
{
"ok": true,
"data": {
"found": true,
"key": "exports/2026-07/e7f21a9c.csv",
"size_bytes": 64,
"etag": "39a463c71c982053cc8ea4dd4ed66fb4",
"content_type": "text/csv",
"last_modified": "2026-07-26T05:35:25Z"
}
}
Cost, and where a specialist wins
Writing the export object costs $0.0001 per call and presign is free, so re-signing the same object under a different filename on every click adds nothing to the bill. Verified 2026-07-27.
The read is the route worth understanding before you design around it. GET /v1/storage/object/get/{bucket}/{key} isn’t priced per call at all — it meters the bytes it returns, at $0.104 per GB.
That’s the figure that decides whether the inline-preview workaround above is cheap or expensive for you: proxying a 40 KB CSV through your own handler is noise, proxying a 200 MB video library is a line item somebody asks about. Rates here drift down over time, so read the live figures with their units instead of quoting this page in a year:
curl -s "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| jq -r '.capabilities[] | select(.id | startswith("storage.object")) | "\(.id) \(.billing.price_usd // "free") \(.billing.unit)"'
On S3 the equivalent knob is ResponseContentDisposition on a GetObject presign, and it accepts inline — so if in-browser preview of stored files is central to your product, that or Cloudflare R2 is the straighter road. What you’d give up is the rest of the account. Every step either side of this one is already on the same key: POST /v1/cron/create to schedule the month-end export, POST /v1/queue/publish to build it off the request path, POST /v1/email/send to deliver the freshly signed link, GET /v1/account/usage to see which tenant the bytes belonged to. No second vendor, no second key rotation, one invoice.