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:
- Ask for a link.
POST /v1/blob/{instance}/uploadswith the exact byte length and content type. You get back ablobkey, anupload_url, and the headers that link requires. - 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 doneThose two steps are the whole upload — there is nothing to call afterwards to finish it. Once the PUT succeeds the file exists, even if your page is closed the moment the transfer finishes.
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.
Mint links in a function. Whoever asks for a link names the size, and the size is what you store and pay for. 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 itselfThe 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.
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.
// 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.
// THE CONTENT TYPE IS PART OF THE SIGNATURE: send the same one you passed to begin
// (or application/octet-stream, the default), or the request is rejected as unsigned.
const xml = await fetch(b.create_url, {
method: "POST",
headers: { "content-type": "application/octet-stream" },
}).then((r) => r.text());
const upload_id = /<UploadId>(.*?)<\/UploadId>/.exec(xml)[1];
// 3+4. sign a window of part URLs (at most 100 per request), PUT each part, repeat until
// every part is uploaded. A file with more than 100 parts needs several windows —
// signing one window of b.parts and stopping gives you a TRUNCATED object.
const done = [];
let from = 1;
let complete_url;
while (from <= b.parts) {
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, count: Math.min(100, b.parts - from + 1) }),
}).then((r) => r.json());
complete_url = w.complete_url; // every window carries it, and abort_url, for this reason
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>`);
}
from += w.part_urls.length;
}
// 5. finish — keep b.blobkey, exactly as with a single PUT
await fetch(complete_url, {
method: "POST",
body: `<CompleteMultipartUpload>${done.join("")}</CompleteMultipartUpload>`,
});An object that lands larger than the instance's per-object limit is deleted rather than stored — the size you declare at begin is checked then, and the real size is checked when the upload completes.
| Limit | Value |
|---|---|
| When to use it | Any file over 5 GB. Below that a single PUT is strictly simpler — one request, nothing to finish, nothing to abort. |
| Part size | 64 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 request | 100. Ask for the next window as you go — every URL expires 30 minutes after it is signed. |
| Largest file | Whatever 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.
Reading files back
GET /v1/blob/{instance}/{id} returns the file's metadata plus a download_url good for five minutes (expires_at, epoch ms). 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.
Endpoints and grants
Every call takes an organization API key with a grant on the instance, or a container job's token. An end-user identity token cannot be granted blob; a page that needs an upload link gets it from a function.
| Endpoint | Grant | Does |
|---|---|---|
POST /v1/blob/{instance}/uploads | write | Mint an upload link. {name, size, content_type, public?, meta?}. |
POST /v1/blob/{instance}/uploads/multipart | write | Begin a multipart upload. |
POST /v1/blob/{instance}/uploads/multipart/urls | write | Sign the next window of part URLs. |
GET /v1/blob/{instance} | read | List files, newest first. Not available to a job token. |
GET /v1/blob/{instance}/{id} | read | One file's metadata and a download_url. |
POST /v1/blob/{instance}/{id}/public | write | Publish ({"public": true}) or unpublish ({"public": false}). Returns {blob}. Not available to a job token. |
POST /v1/blob/{instance}/delete | full | Delete up to 200 files: {"ids": [...]}. Returns {deleted}, the number that existed. |
A list takes limit (25 by default, at most 200), prefix (matched against the file's name) and the cursor from the previous page. A null cursor is the last page.
GET /v1/blob/uploads-inst?limit=2&prefix=receipts/
{ "blobs": [
{ "id": "7a1b…", "name": "receipts/1041.pdf", "size": 48213, "content_type": "application/pdf",
"etag": "…", "status": "ready", "public": false, "created": 1791561600000, "updated": 1791561600000,
"meta": {}, "blobkey": "blob:9f3c…:7a1b…", "url": null },
… ],
"cursor": "1791561600000:7a1b…" }url is the file's public URL, or null while it is private.
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}Or from your own name, with a custom domain pointed at this instance.
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.
A public file is served as a download, not as a page. Every response carries:
content-security-policy: default-src 'none'; sandbox
x-content-type-options: nosniff
access-control-allow-origin: *So an HTML file opened from this host renders with no scripts, no stylesheets, no images and no forms. A script, stylesheet or image that another page loads from here still works — provided it was uploaded with the right content_type, since nosniff refuses a script served as anything else. A page that has to run belongs in Static. x-ae-cache is HIT when the edge answered and MISS when the file was read from storage.
Unpublishing and deleting take effect within seconds. Public files are cached at the edge for a long time, 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 keep its copy for up to a year (public objects are served immutable). 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}:{id} — the instance's id, not the name you address it by. 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.
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:
- Publishing — both
public: trueon an upload and flipping a file afterwards. - Listing the store.
- 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.
Install it once with alt, or build it from the repository:
alt install altlimit/altenginealtengine dev # http://127.0.0.1:9191The 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 — 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.
Limits and settings
| Setting | What it does |
|---|---|
| Maximum file size | The 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. |
| Largest single request | 5 GB, and not a setting — that is what one PUT can carry. Past it an upload goes multipart. |
| Public by default | Whether new files are world-readable unless you say otherwise. Off by default. |
| Public subdomain | Where public files are served from. May not contain --. Changing it breaks every public URL already shared. |
| Rate limit | Bounds the API calls that mint links and read metadata: 6,000 a minute per instance by default, and a value you set can only lower it. Over it, a call returns 429 RATE_LIMITED. 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. 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.