Pushing a file to object storage with curl and nothing else
One call with base64, or two calls with a signed URL. Both in full, plus the argv limit that breaks the one-liner at about a megabyte.
Two commands, no SDK, no CLI install, no credentials file. On Infrai a small file goes up in a single PUT to https://api.infrai.cc/v1/storage/object/put/{bucket}/{key} with the bytes base64-encoded in a JSON body, and the Authorization: Bearer header is the only auth ceremony there is. If you’d rather not encode anything, sign a URL first and hand curl the raw file — that’s two calls and it streams.
Which one you want depends almost entirely on size, and the cutoff is lower than you’d guess: the one-liner dies at roughly a megabyte, not because of any server limit but because your shell refuses to build the argument list. That failure and its fix are below.
The one-call version
Make a bucket once, then push:
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/storage/bucket/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"scratch-files","acl":"private","region":"eu-central-1"}'
curl -sS -X PUT \
"https://api.infrai.cc/v1/storage/object/put/scratch-files/notes/inline.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"data_base64\":\"$(base64 -i report.csv | tr -d '\n')\",\"content_type\":\"text/csv\"}"
The response is the object record, and it’s worth reading rather than discarding — etag is the MD5 of what actually landed, so you can compare it against md5sum locally and know the transfer was clean:
{
"ok": true,
"data": {
"bucket_id": "bkt_55ed39fc0ced483bb1bd2f",
"key": "notes/inline.csv",
"size_bytes": 23,
"etag": "822cc15c8c63a3c432a2b77e8dcaf782",
"content_type": "text/csv",
"metadata": null,
"created_at": "2026-07-26T01:05:44.747687Z"
}
}
On Linux the flag is base64 -w0 report.csv; the -i above is the macOS spelling. That’s the only portability wrinkle in the whole thing.
Where the one-liner breaks
Try it with a 6 MB file and the shell stops you before curl even starts:
zsh: argument list too long: curl
Base64 inflates the payload by about a third, so 6 MB of file becomes 8 MB of argument, and every shell has a ceiling on the total size of a command line (256 KB is common on macOS, a couple of megabytes on Linux). Nothing is wrong with your key or the API. Two ways out, both still curl-only.
Put the body in a file and let curl read it with -d @:
python3 -c "import base64,json,sys; \
d=open('big.bin','rb').read(); \
sys.stdout.write(json.dumps({'data_base64': base64.b64encode(d).decode(), 'content_type': 'application/octet-stream'}))" > body.json
curl -sS -X PUT \
"https://api.infrai.cc/v1/storage/object/put/scratch-files/notes/big.bin" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d @body.json
That handled a 6,000,000-byte binary in one call — an 8 MB request body — and returned the object record with the right size_bytes. Bigger than that and you’re wasting bandwidth on encoding overhead you don’t need.
The two-call version, for anything real
Ask for a signed URL, then send the file as-is. No encoding, no temp files, and the bytes never travel through the API host:
UPLOAD_URL=$(curl -sS -X POST \
"https://api.infrai.cc/v1/storage/object/presign/scratch-files/notes/report.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"op":"put","expires_seconds":600,"content_type":"text/csv"}' \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data']['url'])")
curl -sS -T report.csv -H "Content-Type: text/csv" "$UPLOAD_URL"
-T is curl’s upload-file flag and it does the right thing: PUT, correct Content-Length, streamed from disk rather than buffered in memory. The Content-Type header has to match the one you signed with, because it’s part of the signature.
Piping works too, which is the trick that makes this useful in a script. Give -T a dash and curl sends the stream chunked, with no Content-Length at all:
pg_dump --no-owner mydb | gzip -9 | curl -sS -T - \
-H "Content-Type: application/gzip" \
"$UPLOAD_URL"
We ran that shape against a signed slot and it returned 200. No temp file, no knowing the size in advance.
Confirm it arrived
Two free calls. head for one object, list for the prefix:
curl -sS "https://api.infrai.cc/v1/storage/object/head/scratch-files/notes/report.csv" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
curl -sS "https://api.infrai.cc/v1/storage/object/list/scratch-files?prefix=notes/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
A caveat on the second one: GET /v1/storage/object/list/{bucket} returns content_type and metadata as null for every item, even when the object definitely has both. Listing is for keys, sizes and etags; use GET /v1/storage/object/head/{bucket}/{key} when you need the rest.
How the options stack up
| Approach | Install needed | Streams? | Practical ceiling | Calls |
|---|---|---|---|---|
base64 inline, -d "…" | none | no | ~1 MB (argv limit) | 1 |
base64 in a file, -d @body.json | none | no | tens of MB | 1 |
Presign + curl -T | none | yes | large; multipart above ~5 GB | 2 |
aws s3 cp | AWS CLI + credentials file | yes | very large, resumable | n/a |
mc cp (MinIO client) | mc binary + alias config | yes | very large | n/a |
The AWS CLI and MinIO’s mc are better tools for recursive syncs, resumable multi-gigabyte transfers and anything you’d run in a cron job that has to survive a dropped connection. If that’s your job, stick with them — they’re free, they’re mature, and they speak S3 against Amazon S3, MinIO or Backblaze equally well. The curl route wins when you want no install at all: a CI container, a colleague’s laptop, a debugging session at 2am.
What it costs
Bucket creation, signing, head and list are free and rate-limited. Only the write is billed: verified 26 July 2026, PUT /v1/storage/object/put/{bucket}/{key} costs $0.0001 per call, with stored bytes and egress metered separately. New accounts start with $2 of free credit, which is on the order of 20,000 uploads before anything is charged. Check the current numbers rather than trusting a static page:
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','free')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"
Prices here move down over time and discount campaigns run, so the figure you read today may well be lower than the one above.
Two limits worth knowing before you commit. Object keys with metadata attached must use hyphens, not underscores — owner_id breaks the vendor’s signature and comes back as a 503, while owner-id works, which is a trap that costs an hour if you don’t know it. And an oversized single PUT returns STORAGE_OBJECT_TOO_LARGE rather than silently truncating, at which point you want the multipart flow instead.
The reason to keep the file here rather than in a dedicated bucket somewhere else is what sits next to it: the same key that pushed this CSV also runs the cron job that expires it in 30 days, queues the parse, and emails whoever asked for it. One credential, one bill.