Express CSV export: build it, store it, hand back a signed link
A complete Express endpoint that renders rows to CSV, writes the file to S3-compatible storage on Infrai, and returns an expiring download URL.
An export endpoint has three jobs: turn rows into CSV, park the file somewhere durable, and give the caller a link that stops working eventually. The version below does all three in about forty lines of Express, using csv-stringify for the rendering and two Infrai storage routes — PUT /v1/storage/object/put/{bucket}/{key} to write and POST /v1/storage/object/presign/{bucket}/{key} to sign.
The reason to store rather than stream the response directly is retries. A user who refreshes mid-download of a 40 MB CSV re-runs your query; a user who refreshes a signed storage URL re-downloads bytes that already exist. Infrai’s storage surface is S3-compatible underneath, so the object you write here is an ordinary object — nothing proprietary about the file on disk.
npm install express csv-stringify
node --version # v22.x
The whole route
import express from "express";
import { stringify } from "csv-stringify/sync";
import { createHash } from "node:crypto";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY before starting");
const BUCKET = "hub6-report-exports";
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
const app = express();
app.post("/reports/orders/export", express.json(), async (req, res) => {
const tenant = req.session?.tenantId;
if (!tenant) return res.status(401).json({ error: "unauthenticated" });
const rows = await fetchOrders(tenant, req.body?.month ?? "2026-07");
const csv = stringify(rows, {
header: true,
columns: ["order_id", "tenant", "amount_usd", "created_at"],
bom: true,
});
const bytes = Buffer.from(csv, "utf8");
const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 12);
const key = `exports/2026-07/orders-${tenant}.csv`;
const put = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { ...headers, "Idempotency-Key": `orders-export-${tenant}-${digest}` },
body: JSON.stringify({ data_base64: bytes.toString("base64"), content_type: "text/csv" }),
});
const putBody = await put.json();
if (!put.ok) {
console.error("upload failed", put.status, putBody?.error?.code);
return res.status(502).json({ error: "export_upload_failed" });
}
const sign = await fetch(`${API}/v1/storage/object/presign/${BUCKET}/${key}`, {
method: "POST",
headers,
body: JSON.stringify({ op: "get", expires_seconds: 900 }),
});
const signBody = await sign.json();
if (!sign.ok) return res.status(502).json({ error: "link_failed" });
res.json({
rows: rows.length,
bytes: putBody.data.size_bytes,
etag: putBody.data.etag,
download_url: signBody.data.url,
expires_at: signBody.data.expires_at,
});
});
async function fetchOrders(tenant, month) {
const result = await db.query(
"SELECT order_id, $1::text AS tenant, amount_usd, created_at FROM orders WHERE tenant = $1 AND to_char(created_at, 'YYYY-MM') = $2 ORDER BY order_id",
[tenant, month],
);
return result.rows;
}
app.listen(3000);
bom: true is not decoration. Excel on Windows reads a UTF-8 CSV without a byte-order mark as Latin-1 and mangles every accented name in the file, and finance is the department that opens exports in Excel.
The bytes travel as base64
Object content goes up inside the JSON body as data_base64, so a 30 MB CSV becomes a roughly 40 MB request. That’s a trade-off with a real ceiling: base64 costs about 33% overhead, and very large payloads are slow — a 40 MB upload took roughly 75 seconds in our testing. Two practical thresholds follow.
| CSV size | What to do | Why |
|---|---|---|
| Under ~5 MB | Render and upload inline, respond with the link | Sub-second; the user waits once |
| 5–100 MB | Return 202 with a job id, upload in a worker, poll or email the link | Nobody should hold an HTTP connection for 75 s |
| Over 100 MB | Multipart: multipart/create, presign_part, multipart/complete | Single-shot puts get slow and all-or-nothing |
Sending the same request twice is the common failure, not the exotic one. That’s what the Idempotency-Key header is for.
curl -s -X PUT "https://api.infrai.cc/v1/storage/object/put/hub6-report-exports/exports/2026-07/orders-acme.csv" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: orders-export-acme-578c96c68b75" \
-d '{"data_base64":"b3JkZXJfaWQsdGVuYW50LGFtb3VudF91c2QsY3JlYXRlZF9hdAoxMDAxLGFjbWUsNDIuNTAsMjAyNi0wNy0yNgo=","content_type":"text/csv"}'
Replay it with the same bytes and you get the same object back. Replay it with different bytes under the same key and the API answers HTTP 409 IDEMPOTENCY_KEY_CONFLICT — which is exactly the behaviour you want from a key derived from a content hash. Worth flagging: the body-level idempotency_key field did not behave the same way in our testing, so use the header.
Expiring old exports without writing a cleanup job
Reports pile up. Rather than a cron sweep, put the retention rule on the bucket and let the storage layer do it:
curl -s -X POST "https://api.infrai.cc/v1/storage/bucket/set_lifecycle/hub6-report-exports" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"rules":[{"prefix":"exports/","expire_days":7}]}'
The rule list replaces the previous set wholesale, so send every rule you want, not just the new one. expire_days has a floor of 1.
Checking your work
curl -s -X GET "https://api.infrai.cc/v1/storage/object/list/hub6-report-exports?prefix=exports/" \
-H "Authorization: Bearer $INFRAI_API_KEY"
curl -s -X GET "https://api.infrai.cc/v1/storage/object/get/hub6-report-exports/exports/2026-07/orders-acme.csv" \
-H "Authorization: Bearer $INFRAI_API_KEY"
list gives you keys, sizes and etags with a next_cursor for pagination; get returns the object itself as data_base64, which is a handy way to diff what you uploaded against what you meant to upload without leaving the terminal.
Cost, and what’s free
Writes and reads through the API are the billable parts on Infrai: object/put is $0.0001 per call and object/get is $0.0002 per call, verified 2026-07-26, against a $2 starting credit. Presign, list, head, lifecycle and bucket management are free but rate-limited — which means the download link itself costs nothing, however many times a user refreshes the page. Fetch today’s table before you build a budget on that:
curl -s -X GET "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer $INFRAI_API_KEY" \
| jq '.capabilities[] | select(.id | startswith("storage.object")) | {id, price_usd: .billing.price_usd, free: .billing.free}'
Prices here move downward rather than up, so treat a quoted rate as a ceiling. Stored bytes and egress are metered separately from call count.
When the plain AWS SDK is the better answer
If your report generator already runs on EC2 or Lambda with an instance role, @aws-sdk/client-s3 streams a CSV to S3 without ever holding it in memory, and no base64 tax applies. That’s a genuine advantage and the reason to stay put. Cloudflare R2 is the other honest alternative — S3 API, no egress fees, and a browser can upload straight into it because R2 lets you configure CORS, which an Infrai bucket currently doesn’t.
What Infrai buys instead is scope. The export job’s schedule, the queue that runs it, the storage it lands in, the email that delivers the link and the error you capture when the query times out are all reachable with the one token already in this file — one bill, one usage query, and per-tenant cost attribution that doesn’t need a reconciliation script. If storage is genuinely the only thing you need, a specialist bucket is cheaper and you should take it.