API

Backend

Blobs

Blobs store opaque bytes under their sha256 and serve them through the gateway. A row never stores the bytes themselves. It stores an ordinary string attribute such as image_blob: "blob://products/<sha256>".

Use blobs for media, source files or other binary payloads that rows point at but that should not ride the document write path.

Routes

RouteMethodBehavior
PUT /v1/namespaces/{ns}/blobsPUTStore raw bytes by sha256 and return a blob:// reference.
GET /v1/namespaces/{ns}/blobs/{sha256}GETServe the bytes by sha256.
POST /v1/namespaces/{ns}/blobs/move_to_s3POSTMove a store-held blob set to S3. See Move a blob set to S3.

Where the bytes live

Blobs go to the S3-compatible object store set by S3_BUCKET, or to the namespace’s store when it has a native binary type. The routes and the blob:// reference are the same whichever backend holds the bytes.

LAYER_BLOB_STORAGE on the gateway chooses between them for stores that could hold the bytes themselves:

LAYER_BLOB_STORAGEBlobs a native store could hold
auto (default)S3 on turbopuffer namespaces when S3_BUCKET is set; the store otherwise.
s3S3 whenever S3_BUCKET is set.
storeThe store, up to its per-value cap.

turbopuffer namespaces default to S3 because turbopuffer bills each blob read as a query, by the size of the whole blob namespace rather than the blob returned. Reading many blobs from a large blob namespace multiplies that cost. An S3 read is billed per request and by the bytes it returns.

Blobs larger than the store’s per-value cap, and every blob on a store without native bytes, go to S3 under every setting.

StoreBackendLargest blob in the store
turbopufferA {ns}__hevlayer_blobs namespace, one row per sha256 with a bytes attribute6 MiB
Postgres (pgvector)The layer_pgvector.blobs table, one bytea row per sha25610 MiB (the gateway cap)
Stores without native bytesS3None; every blob goes to S3

The gateway accepts blobs up to 10 MiB. turbopuffer caps a bytes value at 8 MiB of base64 on the wire, which is 6 MiB of blob. A blob between 6 MiB and 10 MiB on turbopuffer goes to S3.

With no S3_BUCKET, a blob that does not fit in the store is rejected rather than dropped:

CaseStatuserror
Blob over 10 MiB413payload_too_large
Blob over the store’s cap, no S3413blob_exceeds_store_cap
Store without native bytes, no S3422object_store_not_configured

A turbopuffer blob namespace appears in namespace listings next to the namespace it serves, and deleting a namespace does not delete its blobs. The per-store values are in the store capability matrix.

Move a blob set to S3

A namespace that switched to S3 still has the blobs it wrote to its turbopuffer blob namespace. Reads find them there until they move. A move copies the blob namespace to S3 in pages of up to 10,000 blobs, one turbopuffer query per page and never one per blob:

curl -X POST "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs/move_to_s3" \
  -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"max_pages": 1}'
{
  "namespace": "products",
  "done": false,
  "cursor": "7d1a54127b222502f5b79b5fb0803061...",
  "pages": 1,
  "moved": 9987,
  "already_in_s3": 13,
  "mismatched": 0,
  "moved_bytes": 2147483648,
  "scanned_bytes": 2150215680,
  "page_size": 10000,
  "billable_bytes_queried": 10737418240,
  "billable_bytes_returned": 2863311530,
  "updated_at": "2026-10-13T18:04:11Z",
  "run": {
    "pages": 1,
    "moved": 9987,
    "already_in_s3": 13,
    "mismatched": 0,
    "moved_bytes": 2147483648,
    "scanned_bytes": 2150215680,
    "billable_bytes_queried": 10737418240,
    "billable_bytes_returned": 2863311530
  }
}

Each call moves up to max_pages pages (default 1) and returns. Call it again until done is true. The top-level counters are totals for the whole move; run is what this call did. billable_bytes_queried and billable_bytes_returned are what turbopuffer billed for the move’s queries.

FieldDefaultMeaning
page_sizeSized by bytesBlobs per turbopuffer query, 1 to 10,000. Unset, the first page is 100 blobs and later pages hold about 256 MiB of blobs at the mean size seen so far, up to 10,000. The gateway holds a whole page in memory.
max_pages1Pages this call moves before returning.
restartfalseStart again from the first blob, even after the move finished.

A move is safe to rerun or interrupt. Progress is saved in the object store after every page, so the next call starts after the last page saved. A blob already in S3 is not rewritten. A row whose bytes do not hash to its sha256 is counted in mismatched and not copied. The move is done when a page comes back empty, so the last call makes one query that finds nothing. Once done is true, further calls return the totals without querying turbopuffer.

A finished move changes reads. Before it finishes, a blob missing from S3 is looked up in the turbopuffer blob namespace. After it finishes, S3 is the only backend, and a blob missing from S3 is a 404 with no turbopuffer query. The move leaves the turbopuffer blob namespace in place. Delete it yourself once reads are served from S3.

A blob written to the store after a move finished (because the namespace switched back to store, or a gateway had not picked up the switch yet) reopens the move: S3 misses ask the store again, and the next call copies the new blob. Deleting a namespace, or branching onto its name, clears its move.

The namespace’s blob storage must already resolve to S3: the route returns 409 when it resolves to the store, and 422 object_store_not_configured without S3_BUCKET.

Store

with open("image.jpg", "rb") as f:
    stored = await client.put_blob("products", f.read())

print(stored.ref)
body, _ := os.ReadFile("image.jpg")
stored, err := client.PutBlob(ctx, "products", body, nil)
import fs from "node:fs/promises";

const bytes = await fs.readFile("image.jpg");
const stored = await client.putBlob("products", bytes);
curl -X PUT "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs" \
  -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @image.jpg

Response:

{
  "ref": "blob://products/9f86d081884c7d659a2feaa0c55ad015...",
  "sha256": "9f86d081884c7d659a2feaa0c55ad015...",
  "size": 48213
}

The same bytes always return the same reference. The route rejects empty bodies and bodies over 10 MiB.

Write the returned ref as a normal row attribute:

{
  "id": "B0123",
  "vector": [0.1, 0.2],
  "image_blob": "blob://products/9f86d081884c7d659a2feaa0c55ad015..."
}

The removed document blobs payload shape is still rejected. Binary bytes do not traverse /v2/namespaces/{ns} writes.

Fetch

image = await client.get_blob("products", stored.sha256)
image, err := client.GetBlob(ctx, "products", stored.Sha256)
const image = await client.getBlob("products", stored.sha256);
curl "$LAYER_GATEWAY_URL/v1/namespaces/products/blobs/$SHA256" \
  -H "Authorization: Bearer $LAYER_GATEWAY_API_KEY" \
  -o image.jpg

Successful responses include immutable cache headers:

Cache-Control: public, max-age=31536000, immutable
ETag: "<sha256>"

The gateway sniffs common image types (jpeg, png, gif, webp) for Content-Type; otherwise it returns application/octet-stream.

Reads

A namespace that keeps blobs in S3 reads S3 first, then its store for blobs not yet moved. A namespace that keeps blobs in its store reads the store first, then S3, so blobs written to S3 before the store held bytes still serve.

esc