Server-side copy: moving objects from a live prefix to an archive

Yes, copies happen inside the storage layer — no download, no re-upload. Here's the paging loop, the concurrency limit and the per-object cost of a batch move.

Yes — POST /v1/storage/object/copy on Infrai does the copy inside the storage layer. You name a source bucket and key and a destination bucket and key; no bytes travel to your server and back, so a 2 GB video costs the same wall-clock time as a 2 KB receipt, near enough. Content type and custom metadata come across with the object, which is what stops an archived PDF turning into application/octet-stream on the other side.

What Infrai doesn’t have is a batch job primitive. There’s no equivalent of S3 Batch Operations that you hand a manifest and walk away from — one call moves one object, so a batch move is a paging loop plus a bounded worker pool that you run. For the tens-of-thousands range that’s about twenty lines; for tens of millions, reach for the AWS feature or rclone instead.

One object, one call

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/storage/object/copy" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "src_bucket": "kb-copy-0726",
    "src_key": "live/tenant-42/doc-1.pdf",
    "dst_bucket": "kb-copy-0726",
    "dst_key": "archive/2026-07/tenant-42/doc-1.pdf"
  }'

The response describes the object that now exists at the destination:

{
  "ok": true,
  "data": {
    "bucket_id": "bkt_c550ed7f46ba452d966506",
    "key": "archive/2026-07/tenant-42/doc-1.pdf",
    "size_bytes": 5,
    "etag": "d5a2550f974e6161e9168810b9922a9e",
    "content_type": "application/pdf",
    "metadata": { "tenant-id": "42" },
    "last_modified": "2026-07-26T00:40:46Z"
  }
}

Same bucket or a different one, both work. A missing source is a clean 404 with STORAGE_OBJECT_NOT_FOUND and the offending key in the message, which makes a failed batch easy to triage.

Enumerate the source prefix

Copying starts with knowing what’s there. Listing is free and pages with a cursor:

curl -sS "https://api.infrai.cc/v1/storage/object/list/kb-copy-0726?prefix=live/tenant-42/&limit=100" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      { "key": "live/tenant-42/doc-1.pdf", "size_bytes": 5, "etag": "d5a2550f974e6161e9168810b9922a9e", "content_type": null, "metadata": null },
      { "key": "live/tenant-42/doc-2.pdf", "size_bytes": 5, "etag": "1ea32cc4344a098d0c5e1fd642ab9384", "content_type": null, "metadata": null },
      { "key": "live/tenant-42/doc-3.pdf", "size_bytes": 5, "etag": "456534dce17c7984a3c78a47757af1b9", "content_type": null, "metadata": null }
    ],
    "next_cursor": null
  }
}

Two things in that payload deserve a mention. content_type and metadata come back as null in listings even when the object definitely has them — the values are there, the listing just doesn’t carry them, so use GET /v1/storage/object/head/{bucket}/{key} when you need them per object. And next_cursor is how you page; feed it back as cursor until it’s null.

The batch runner

Bounded concurrency, resume on failure, and a skip for work already done. Eight in flight is a reasonable starting point — raise it until you start seeing 429s, then back off one notch:

import process from "node:process";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const BUCKET = "kb-copy-0726";
const FROM = "live/tenant-42/";
const TO = "archive/2026-07/tenant-42/";
const CONCURRENCY = 8;
const auth = { Authorization: `Bearer ${KEY}` };
const json = { ...auth, "Content-Type": "application/json" };

async function listAll(prefix) {
  const keys = [];
  let cursor = null;
  do {
    const qs = new URLSearchParams({ prefix, limit: "1000" });
    if (cursor) qs.set("cursor", cursor);
    const res = await fetch(`${API}/v1/storage/object/list/${BUCKET}?${qs}`, { method: "GET", headers: auth });
    if (!res.ok) throw new Error(`list failed: ${res.status} ${await res.text()}`);
    const data = (await res.json()).data;
    for (const o of data.items) keys.push(o.key);
    cursor = data.next_cursor;
  } while (cursor);
  return keys;
}

async function copyOne(srcKey) {
  const dstKey = TO + srcKey.slice(FROM.length);
  const already = await fetch(`${API}/v1/storage/object/head/${BUCKET}/${dstKey}`, { method: "GET", headers: auth });
  if (already.ok && (await already.json()).data.found) return { dstKey, skipped: true };

  const payload = { src_bucket: BUCKET, src_key: srcKey, dst_bucket: BUCKET, dst_key: dstKey };
  const res = await fetch(`${API}/v1/storage/object/copy`, { method: "POST", headers: json, body: JSON.stringify(payload) });
  if (!res.ok) throw new Error(`copy ${srcKey}: HTTP ${res.status} ${await res.text()}`);
  return { dstKey, skipped: false };
}

const keys = await listAll(FROM);
console.log(`${keys.length} object(s) to move`);

let done = 0;
const queue = [...keys];
const workers = Array.from({ length: CONCURRENCY }, async () => {
  for (let key = queue.pop(); key !== undefined; key = queue.pop()) {
    try {
      const r = await copyOne(key);
      done += 1;
      if (done % 100 === 0 || r.skipped) console.log(`${done}/${keys.length} ${r.skipped ? "skip" : "copy"} ${r.dstKey}`);
    } catch (err) {
      console.error(String(err.message));
    }
  }
});
await Promise.all(workers);
console.log(`finished: ${done}/${keys.length}`);

The head check before each copy is what makes the script re-runnable. Head calls are free, so the second run of an interrupted job costs nothing for everything it skips — that asymmetry is worth designing around whenever a job might die halfway.

Copy is not move

There’s no rename and no move verb; a move is a copy followed by a delete, and you should only run the delete once the destination has verified. Check it:

curl -sS "https://api.infrai.cc/v1/storage/object/head/kb-copy-0726/archive/2026-07/tenant-42/doc-1.pdf" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Then clear the source side in batches of up to 1,000 keys, which reports per-key results instead of failing the whole call:

curl -sS -X POST "https://api.infrai.cc/v1/storage/object/delete_batch/kb-copy-0726" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"keys": ["live/tenant-42/doc-2.pdf", "live/tenant-42/doc-3.pdf"]}'

If the archive tier is meant to age out on its own, a lifecycle rule on the destination prefix is a better answer than a second script — and if you’d rather never move anything, POST /v1/storage/bucket/set_lifecycle/{bucket} can expire the live prefix directly.

What to use instead, and when

JobBest toolReason
Thousands of objects inside one accountPOST /v1/storage/object/copy in a worker poolServer-side, no egress, free listings to drive it
Tens of millions of objectsS3 Batch OperationsA managed job with retries and a completion report
Between two providers (S3 ⇄ R2 ⇄ MinIO)rcloneHandles credentials, checksums and resumption across vendors
Same-day expiry rather than relocationLifecycle ruleNo script to own at all

Limitations worth knowing before you start

The copy is account-local: source and destination both live under your own credential, so this is not a migration path from another provider’s bucket — for that you’d be better off with rclone or the provider’s own transfer service. There’s no object versioning, so copying onto an existing destination key overwrites it with no undo. Listings cap out per page and you must page with the cursor rather than assuming one call sees everything. And a copy is charged as a call even when the object is tiny.

What a batch move costs

Structure first: listing, head and delete are free and rate-limited; copy is the only billable call in this workflow, per call rather than per byte. Verified 26 July 2026, a copy was $0.0001 — so moving 50,000 objects is about $5 regardless of whether they’re thumbnails or videos, and the free head-check that makes the job resumable adds nothing. Rates drift downward over time, so take today’s from the API rather than this paragraph:

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', 0)) for c in json.load(sys.stdin)['capabilities'] if c['id'].startswith('storage.object')]"

New accounts carry $2 of trial credit, which is roughly 20,000 copies — enough to rehearse the whole reorganisation on a copy of the data first. And because the archive job, the schedule that triggers it and the error record when a key fails all sit on the same credential, the follow-up work doesn’t become a second integration.

References

Browse more storage developer guides