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_dispositionWhat 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.csvexactly 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 a clock, not a lock. Strip the query string from a signed URL and the underlying object still returns 200, even with the object ACL set to signed-only, so the disposition trick controls presentation rather than access. Mint a fresh link per download, keep object keys random, and never email the raw vendor URL.

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 and presign is free, so re-signing the same object with a different filename on every download is effectively free — verified 2026-07-26. Rates here drift down over time, so read the live figure 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")"'

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: the cron job that builds the export, the queue that runs it, and the email carrying the link all sit behind this same key, with one bill at the end of the month.

References

Browse more storage developer guides