Write-once transaction receipts: what a bucket can't promise you
Ordinary object storage overwrites and deletes on request. What Infrai storage gives you for tamper-evident receipts, and when S3 Object Lock is the honest answer.
No. A bucket that accepts a second PUT to the same key is not write-once, and that includes Infrai’s. If your compliance team is pointing at a retention rule with teeth, you need a store that will refuse a delete for a fixed number of years even when the account owner asks nicely — S3 Object Lock in compliance mode, or an equivalent. Everything else is evidence, not enforcement.
That distinction is the whole answer, so it’s worth being precise about which half Infrai covers. Infrai storage is a fine home for the operational copy of a receipt and for a tamper-evident trail around it: content-addressed keys, a digest chain, and a live event feed that tells you the moment someone deletes something they shouldn’t have. What it doesn’t give you is a lock that can outvote your own credentials.
We tried to break it, and it broke
Two writes to one key, thirty seconds apart.
// overwrite-probe.mjs — Node 22 ESM
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY first");
const BUCKET = "receipts-prod";
const OBJECT_KEY = "receipts/2026/07/txn-88412.json";
async function writeReceipt(document) {
const payload = {
data_base64: Buffer.from(JSON.stringify(document)).toString("base64"),
content_type: "application/json",
metadata: { tenant: "acme", txn: "88412" },
};
const res = await fetch(`${API}/v1/storage/object/put/${BUCKET}/${OBJECT_KEY}`, {
method: "PUT",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || !json.ok) throw new Error(`write failed: ${res.status} ${JSON.stringify(json.error ?? json)}`);
return json.data;
}
const first = await writeReceipt({ amount: 1200, currency: "USD" });
const second = await writeReceipt({ amount: 200000, currency: "USD" });
console.log(first.etag, "->", second.etag);
The second write wins. The etag changes, the size changes, and there’s no version id anywhere in the response because there are no versions — the earlier bytes are gone.
{
"ok": true,
"data": {
"bucket_id": "bkt_d62401165b6d40c9896623",
"key": "receipts/2026/07/txn-88412.json",
"size_bytes": 12,
"etag": "9080e7048bf9b82064ab9b395d5a4d97",
"content_type": "application/json",
"last_modified": "2026-07-26T00:44:12Z"
}
}
Deletion is just as unceremonious, and the ACL doesn’t change it — an ACL governs who reads, never who erases.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X DELETE \
"https://api.infrai.cc/v1/storage/object/delete/receipts-prod/receipts/2026/07/txn-88412.json" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
# {"ok":true,"data":{"bucket":"receipts-prod","key":"receipts/2026/07/txn-88412.json","deleted":true}}
Who can read one, though — that half holds
Retention is the half Infrai can’t promise. Access is the half it can, and with receipt data it’s worth stating precisely rather than assuming.
A private bucket really is private. Take a presigned GET, strip the query string off it, and the storage host answers 403 — the object path on its own isn’t a URL, it’s a name. Tamper with the signature: 403. Sign a GET and send a HEAD against it: 403. Sign correctly over a key that doesn’t exist: an honest 404. The signature is the access boundary, not a convenience wrapper around a file that was reachable anyway.
You can’t opt out of that by accident, either. POST /v1/storage/object/set_acl/{bucket}/{key} refuses public-read outright:
{
"ok": false,
"error": {
"code": "STORAGE_ACL_INVALID",
"http_status": 400,
"message": "unsupported acl 'public-read'",
"hint": "ACL not in storage_acl enum (private/signed-only; public-read refused)"
}
}
For a receipts bucket that refusal is the feature. There’s no one-line mistake, no console checkbox, that turns seven years of transaction data into a public URL. What a signed link still is, though, is a bearer token — whoever holds the whole URL is authorised for as long as it lives, because a signature doesn’t know who’s presenting it. TTLs run from 1 second to 7 days; for receipts, sign per click at the low end of that range, and check the user’s entitlement before you mint the link rather than after.
What a regulator means by WORM
The industry glossaries are consistent on this: write-once-read-many means the medium itself refuses modification for the retention period, and the refusal has to survive an administrator with root. Four properties are usually named — non-rewritable, non-erasable, a retention clock the storage layer enforces, and an audit trail a third party can read.
| Property | Plain object storage | Infrai storage today | S3 Object Lock, compliance mode |
|---|---|---|---|
| Second PUT to the same key rejected | no | no | yes, for the retention period |
| Delete refused by the storage layer | no | no | yes, root included |
| Retention clock enforced server-side | no | lifecycle can only expire, never retain | yes, per object or per bucket |
| Prior versions retrievable | no | no versioning | yes, with versioning on |
| Independent event trail of writes and deletes | depends | yes, bucket/set_notification | yes, CloudTrail |
Read that middle column honestly. Infrai’s lifecycle rules point the wrong way for compliance — POST /v1/storage/bucket/set_lifecycle/{bucket} sets expire_days, which deletes objects on a schedule. That’s the right tool for scratch files and precisely the wrong tool for a seven-year retention obligation.
The part Infrai does well: tamper evidence
If enforcement lives elsewhere, detection can live here, and detection is worth real money during an audit. Subscribe to object events once, from a migration rather than from app boot:
curl -sS -X POST \
"https://api.infrai.cc/v1/storage/bucket/set_notification/receipts-prod" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"events":["object.created","object.deleted","multipart.completed"],"target":{"url":"https://ledger.example.com/storage-events"}}'
Your endpoint then receives a JSON POST per event, with the event type repeated in an X-Infrai-Event header. This is the exact body we saw on the wire:
{
"type": "object.deleted",
"event": "object.deleted",
"account_id": "acct_email_77c768e42148275b",
"bucket_id": "bkt_d62401165b6d40c9896623",
"bucket": "receipts-prod",
"key": "receipts/2026/07/txn-88412.json",
"timestamp": "2026-07-26T00:44:24.219394+00:00",
"subscription_id": "stnf_324394ea6204f09487725fdf"
}
An object.deleted under receipts/ is an incident, full stop. Wire it to a pager, not a dashboard.
One caveat before you build on this, and it’s a real one. The callback carries no HMAC signature — the delivery arrives with an x-infrai-event header and a content type, and nothing that proves it came from Infrai. Treat the event as an untrusted hint rather than a fact: verify, then act. A free GET /v1/storage/object/head/{bucket}/{key} tells you the object’s actual state, and that’s what belongs in your ledger. The handler below does exactly that.
Subscriptions are managed rather than fire-and-forget, which matters if you register them from a deploy script. GET /v1/storage/bucket/notifications/{bucket} lists what’s registered on a bucket and DELETE /v1/storage/bucket/notification/delete/{bucket}/{subscription_id} removes one, so a redeploy can reconcile its own subscriptions instead of stacking up duplicate deliveries. One thing to know before your first call: the target URL passes through an SSRF guard that wants https and a hostname that really resolves in DNS, so a placeholder domain comes back as a 400 WEBHOOK_URL_INVALID rather than being accepted and never firing.
// ledger-webhook.mjs — Node 22 ESM, no framework
import { createServer } from "node:http";
import { createHash } from "node:crypto";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const WATCHED = "receipts-prod";
if (!KEY) throw new Error("set INFRAI_API_KEY first");
async function confirm(bucket, key) {
const res = await fetch(`${API}/v1/storage/object/head/${bucket}/${key}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error(`head failed: HTTP ${res.status}`);
const { data } = await res.json();
return data;
}
createServer((req, res) => {
let raw = "";
req.on("data", (c) => { raw += c; });
req.on("end", async () => {
try {
const evt = JSON.parse(raw);
if (evt.bucket !== WATCHED) return res.writeHead(204).end();
if (evt.type === "object.deleted") {
console.error("ALERT receipt removed:", evt.key, evt.timestamp);
} else {
const meta = await confirm(evt.bucket, evt.key);
const digest = createHash("sha256").update(`${evt.key}:${meta.etag}`).digest("hex");
console.log("chained", evt.key, meta.size_bytes, digest.slice(0, 16));
}
res.writeHead(204).end();
} catch (err) {
console.error("bad event", err.message);
res.writeHead(400).end();
}
});
}).listen(8080);
The digest chain is the cheap half of the work and the half that actually convinces an auditor: hash each receipt, fold it into the previous digest, and publish the running head somewhere you don’t control. Even a store with no locking becomes hard to edit undetected once yesterday’s head is sitting in someone else’s inbox.
What it costs to keep the trail
Storage management is free; only the calls that actually move bytes are metered, and they’re metered on two different axes. Verified 27 July 2026: PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 per call, so writing a receipt is priced by the act of writing it. GET /v1/storage/object/get/{bucket}/{key} is not a per-call charge at all — it meters the bytes it returns, at $0.104 per GB.
That distinction shapes a receipts archive more than any headline rate does. Receipts are small and written constantly, so the write side tracks transaction volume; they’re read rarely and mostly one at a time, by a support agent or an auditor, so the read side sits near nothing until someone exports a full year. Bucket create, head, list, presign, lifecycle and notification calls are all free and rate-limited, which is why the event trail and the integrity sweep cost nothing to run — you can head every object in the bucket nightly and pay for none of it. New accounts get $2 of free credit. Pull today’s figures rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c "import json,sys; [print(c['method'], c['path'], c['billing'].get('price_usd', 'free'), c['billing'].get('unit','')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"
Note that command prints the unit next to the figure. The routes don’t share one, and a rate copied without its unit is how a cost model goes quietly wrong. Rates here drift down and discount campaigns run, so what you read is at least as likely to be lower than what’s printed above.
Where the bytes physically sit is also a question you can get a straight answer to now, which matters the moment compliance says the word residency. region on POST /v1/storage/bucket/create is enforced rather than merely recorded: ask for a region the vendor isn’t provisioned in and the call fails with a 400 that names the region it does serve. You don’t have to take a placement claim on faith — make the call, and either you get the jurisdiction you asked for or the response body tells you which one is actually available. GET /v1/storage/bucket/list then reports the region each existing bucket lives in:
curl -sS "https://api.infrai.cc/v1/storage/bucket/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
Run that before anyone writes a residency clause into a contract. If the region that comes back isn’t the jurisdiction your regulator requires, that settles the question early and cheaply — which is the whole point of asking the API instead of a salesperson.
So what should a fintech actually do
Keep two copies with different jobs. The regulated copy goes into a store that enforces retention — S3 with Object Lock in compliance mode is the default answer, MinIO with object retention if the receipts have to stay inside your own racks, Wasabi if you want immutability without AWS egress pricing.
The operational copy — the one your support team and your reconciliation jobs read all day — can live here, and the rest of that workflow is already on the same key. POST /v1/queue/publish hands each new receipt to the digest-chain worker. POST /v1/cron/create runs the nightly integrity sweep over the free head route. POST /v1/errors/capture records the night a chain link didn’t verify, with the transaction id attached. POST /v1/email/send delivers the auditor’s export. GET /v1/account/usage attributes the storage per tenant when finance asks. No second account, no second vendor, no second invoice to reconcile against the first.
If receipts are the only thing you’ll ever store, stick with the specialist and skip the rest of this page. The argument for consolidating is not that Infrai stores bytes more cheaply; it’s that the second question — queue this, email that, attribute the cost — is already answered on the same account.
The limitation to carry away is small and sharp: Infrai storage does not support object lock, versioning or legal hold, so it cannot be the system of record for a retention mandate. It can be the system that notices.