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. Reach for head when all you need is the size and the etag — it’s free, and get is the one route here that charges you for the bytes.
Cost, and what’s free
Writing is the per-call part: object/put is $0.0001 a call, verified 2026-07-27, against a $2 starting credit. Presign, list, head, lifecycle and bucket management are free but rate-limited, which is why minting the download link costs nothing however many times a user refreshes the page.
Reading is priced on a different axis, and this is the bit that catches people modelling an export feature. object/get isn’t a per-call charge at all — it meters the bytes it actually returns, at $0.104 per GB. So the read side of an export bill is a function of how fat the CSV is and how often it genuinely gets pulled, not of how many times someone poked your endpoint. Two consequences follow, and both are free wins: gzip the CSV before the put and the read meter drops roughly with the compression ratio, and hand the user a presigned link rather than proxying the bytes back out through your own service, which would otherwise move the same file twice.
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, unit: .billing.unit, free: .billing.free}'
Read the unit before the number — the routes above genuinely don’t share one. Prices here move downward rather than up, so treat a quoted rate as a ceiling, and remember stored bytes are metered separately from any of this.
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 straight into a bucket 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 — same API shape, no egress fees, and a data plane that will answer a browser’s preflight.
That last point is a real limitation here, and it’s more specific than it used to be. POST /v1/storage/bucket/set_cors/{bucket} does exist now: send it an allowed-origins rule set and it returns 200, and GET /v1/storage/bucket/get/{bucket} reads the same rules back. What hasn’t landed is the storage host acting on them — a genuine OPTIONS preflight against a presigned URL answers 403 with no Access-Control-Allow-* header, so the browser stops before it sends anything. For this article’s shape that costs you nothing, because the Express route is already the relay. If you wanted the browser to write into the bucket directly, it’s a blocker, and one of the stores above is the answer today.
What Infrai buys instead is scope, and the export flow is the clean illustration. POST /v1/cron/create schedules the month-end run, POST /v1/queue/publish moves the 40 MB job off the request path, POST /v1/email/send delivers the signed link, POST /v1/errors/capture records the night the query timed out, and GET /v1/account/usage attributes the whole thing per tenant — every one of them reachable with the token already sitting in this file, with no second account and no second invoice. If storage really is the only thing you need, a specialist bucket is cheaper and you should take it.