← Back to API docs

Docs

Blob API

Blob stores files — avatars, uploads, generated PDFs, exports, anything that is bytes rather than JSON. Your app uploads them directly to storage with a short-lived signed link, and anything you mark public gets a permanent URL on a subdomain of its own.

The bytes never pass through altengine

An upload is two steps, and only one of them talks to us:

  1. Ask for a link. POST /v1/blob/{instance}/uploads with the exact byte length and content type. You get back a blobkey, an upload_url, and the headers that link requires.
  2. PUT the bytes to that URL. Straight to storage, from wherever the file is — a browser, a phone, a server. Nothing is proxied, so a 2 GB upload is as fast as your connection and costs you no request time.
// 1. ask
const r = await fetch(`${API}/v1/blob/uploads-inst/uploads`, {
  method: "POST",
  headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
  body: JSON.stringify({ name: "receipt.pdf", size: file.size, content_type: file.type, public: false }),
}).then((r) => r.json());

// 2. upload — directly to storage, with EXACTLY the headers it named
await fetch(r.upload_url, { method: "PUT", headers: r.required_headers, body: file });

// there is no step 3 — keep r.blobkey and you are done

Those two steps are the whole upload — there is nothing to call afterwards to finish it. Once the PUT succeeds the file exists, so an upload survives the thing most likely to go wrong: if your page is closed, or the phone loses signal the moment the transfer finishes, the file is still there and still yours.

Send the headers exactly as given. The size and content type are signed into the link, so a mismatch is rejected with a signature error rather than a helpful one. This is deliberate: it means a link issued for a 2 MB image cannot be used to store 2 GB.

The link is where the decision lives, so it usually belongs in a function. Whoever asks for it names the size, and the size is what you store and pay for — so a page that mints its own links is a page that decides your storage bill. Grant a function blob and it gets env.blob, with no API key anywhere:

const up = await env.blob.uploadUrl({ instance: "uploads" }, {
  name: file.name, size: file.size, contentType: file.type,
});                                    // ...after checking who is asking, and for how much
return Response.json(up);              // the browser PUTs to up.upload_url itself

The same client stores a file the function already has (put), reads one back (bytes), and lists, publishes or deletes. A function cannot reach this API over HTTP at all — altengine's own hostnames are refused by a function's outbound allowlist — so the grant is the only route, and there is no key to leak.

A file is readable by id as soon as its upload finishes, so a page that uploads an avatar and shows it straight away needs no waiting and no polling. list is eventually consistent — a file that has just landed can take a moment to appear in it. A link nobody ever uploads to expires, along with any bytes that did arrive.

Files past 5 GB

One signed link is one request, and storage caps a single request at 5 GB. Above that a file goes up in parts: you ask for a plan, open the upload, PUT the parts, and tell storage to glue them together. It comes back down as one ordinary file — nobody who downloads it needs a tool that understands split archives.

// 1. begin — the reply carries the part size, the part count, and a URL that opens the upload
const b = await fetch(`${API}/v1/blob/exports/uploads/multipart`, {
  method: "POST",
  headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
  body: JSON.stringify({ name: "invoices-2026.zip", size: file.size }),
}).then((r) => r.json());

// 2. open it — storage answers with XML naming the UploadId
const xml = await fetch(b.create_url, { method: "POST" }).then((r) => r.text());
const upload_id = /<UploadId>(.*?)<\/UploadId>/.exec(xml)[1];

// 3. sign a window of part URLs — at most 100 per request
const w = await fetch(`${API}/v1/blob/exports/uploads/multipart/urls`, {
  method: "POST",
  headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
  body: JSON.stringify({ id: b.id, upload_id, from: 1, count: b.parts }),
}).then((r) => r.json());

// 4. PUT each part, keeping the ETag it answers with
const done = [];
for (const { part_number, url } of w.part_urls) {
  const at = (part_number - 1) * b.part_size;
  const res = await fetch(url, { method: "PUT", body: file.slice(at, at + b.part_size) });
  done.push(`<Part><PartNumber>${part_number}</PartNumber><ETag>${res.headers.get("etag")}</ETag></Part>`);
}

// 5. finish — keep b.blobkey, exactly as with a single PUT
await fetch(w.complete_url, {
  method: "POST",
  body: `<CompleteMultipartUpload>${done.join("")}</CompleteMultipartUpload>`,
});
Multipart upload limits
LimitValue
When to use itAny file over 5 GB. Below that a single PUT is strictly simpler — one request, nothing to finish, nothing to abort.
Part size64 MB by default. begin tells you the size and the count; a file large enough to need more than 10,000 parts gets bigger parts instead.
Part URLs per request100. Ask for the next window as you go — every URL expires 30 minutes after it is signed.
Largest fileWhatever this instance's maximum file size allows, up to just under 5 TB.

DELETE the abort URL if you give up. Parts uploaded for an upload nobody completed stay in storage and are billed, and no listing shows them. The abort URL comes back with every window of part URLs rather than on request, so you are holding one before you need it.

Reading files back

GET /v1/blob/{instance}/{id} returns the file's metadata plus a download_url good for a few minutes. Like the upload link it is a bearer capability — anyone holding it can read the file until it expires — so treat it as a redirect target, not something to store.

Public files get a permanent URL

Mark a file public and it is served, forever, from your instance's own subdomain:

https://{your-subdomain}-blob.altengine.app/{filename}/{id}

The filename is there for people, search engines and the browser's save dialog — the id is what identifies the file, so requesting it under a different name simply redirects to the canonical URL. Responses are cached at the edge, and bandwidth is free: serving a file that goes viral costs you the reads, not the traffic — and after the first request in a region, not even those. Range requests work, so audio and video seek properly.

A private file is not reachable on that host at all. It answers exactly as it would for a file that does not exist, so the URL cannot be used to discover what you have.

Unpublishing and deleting take effect within seconds. Public files are cached at the edge for a long time — that is what makes them fast and free to serve — and making one private, or deleting it, clears those copies rather than waiting for them to expire.

What that cannot reach is a copy somebody already has: a browser may hold the file for a few hours, and anything downloaded or shared onward is simply gone from your control. Ids are unguessable, so an unpublished file is not something anyone can go looking for — but a URL that has been public should be treated as having been seen.

Referring to a file from your data

Every file has a blobkey — an opaque string like blob:{instance}:{id}. Store it in a datastore document like any other string:

{ "title": "Invoice 1041", "pdf": "blob:9f3c…:7a1b…" }

It filters, sorts, indexes and joins exactly as a string does, because it is one. There is no special field type to learn, and nothing to migrate if you later change how you serve the file.

A container job, with no key of its own

Name this instance as a container instance's blob store and every job it launches starts with AE_BLOB_URL and AE_BLOB_TOKEN in its environment. The job then uses this API exactly as anything else does — ask for a link, PUT the bytes to storage.

That token reaches this store and no other, puts and gets, and expires with the job. Three things it is refused, each a 403 naming what was refused rather than a quiet downgrade:

  • Publishingboth public: true on an upload and flipping a file afterwards. Everything else a job does ends when its machine does; a public URL does not.
  • Listing the store. A job is told which files to work on; it does not enumerate what else is in there.
  • Deleting. That needs full, which a job token cannot carry.

Running locally

The same CLI that runs the other services locally serves blob too. The flow is identical — ask for a link, PUT to it, and the file is there.

altengine dev                      # http://127.0.0.1:9191

The size and content type you declared are enforced on the upload locally exactly as they are hosted, so a PUT that production will refuse is refused here. Public files are served from /blob/{instance}/{filename}/{id} rather than a subdomain, since there is no wildcard DNS on your laptop — read the url off the file rather than assembling one and the same code works in both places.

Uploads persist across restarts when the emulator is run with a data directory, so a blobkey written into a local datastore document still resolves tomorrow.

Multipart works locally too, with the same calls and the same replies, so the path a multi-gigabyte export will take is one you can walk through with a test file.

Limits and settings

Blob instance settings
SettingWhat it does
Maximum file sizeThe largest file this instance will store. 100 MB by default; raise it as far as just under 5 TB. Checked when an upload is minted, so it is a real ceiling.
Largest single request5 GB, and not a setting — that is what one PUT can carry. Past it an upload goes multipart.
Public by defaultWhether new files are world-readable unless you say otherwise. Off by default.
Public subdomainWhere public files are served from. Changing it breaks every public URL already shared.
Rate limitBounds the API calls that mint links and read metadata. Serving public files is not affected by it.

What it costs

Two things: the bytes you keep, and the operations you perform. Storage is averaged over the month, so a file uploaded on the 20th costs a third of a month, not a whole one. Operations are three lines — a small flat fee per upload link used, writes and lists as the expensive one, reads as the cheap one. See pricing for the numbers.

Bandwidth is not metered at all. That is the difference from S3-style pricing and the main reason to keep files here rather than next to them. A public file served from cache is not billed as a read either.

From an AI agent

The MCP server exposes blob directly: an agent can list files, store a small one inline (blob_put — handy for a generated config or a README), mint an upload link for anything larger, publish or unpublish, and delete. See the MCP page for how to connect one.