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 acl: "signed-only" 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}}

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.

PropertyPlain object storageInfrai storage todayS3 Object Lock, compliance mode
Second PUT to the same key rejectednonoyes, for the retention period
Delete refused by the storage layernonoyes, root included
Retention clock enforced server-sidenolifecycle can only expire, never retainyes, per object or per bucket
Prior versions retrievablenono versioningyes, with versioning on
Independent event trail of writes and deletesdependsyes, bucket/set_notificationyes, 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.

Two caveats before you build on this, both of which we hit in testing. The callback carries no HMAC signature, so treat it as an untrusted hint and confirm with a free GET /v1/storage/object/head/{bucket}/{key} before you act on it. And subscriptions are registered per account rather than per bucket — a subscription we created on one bucket received events for every other bucket on the same account, so your handler must filter on the bucket field itself. There’s also no route to list or remove a subscription, which means registering on every deploy quietly gives you duplicate deliveries forever.

// 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 on Infrai is free and the byte-moving calls are metered per call. Verified 26 July 2026: PUT /v1/storage/object/put/{bucket}/{key} is $0.0001 per call and GET /v1/storage/object/get/{bucket}/{key} is $0.0002 — reads run about twice writes. Bucket create, head, list, presign, lifecycle and notification calls are all free (rate-limited), which is why the event trail costs nothing to run. 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')) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.')]"

Rates on this platform drift down and discount campaigns run, so what you read is at least as likely to be lower. And you can confirm the account you’re pointed at with one concrete call:

curl -sS "https://api.infrai.cc/v1/storage/bucket/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

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 on Infrai next to the queue that generated it, the cron job that reconciles it, and the error tracking that catches the failure — one key, one bill, one usage query per tenant.

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.

References

Browse more storage developer guides