# Storage

> Put, read, copy and delete objects with ranges and conditions; presign URLs.

Source: https://useprimitives.com/docs/api/storage

These routes apply to a primitive of type `storage`. See [Storage](/docs/primitives/storage) for the concepts. Calling one of them on a primitive of another type answers `400 wrong_type`.

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/primitives/{id}/objects` | List objects |
| `GET` | `/v1/primitives/{id}/objects/{key}` | Read an object |
| `HEAD` | `/v1/primitives/{id}/objects/{key}` | Read an object's headers |
| `PUT` | `/v1/primitives/{id}/objects/{key}` | Put an object |
| `DELETE` | `/v1/primitives/{id}/objects/{key}` | Delete an object |
| `POST` | `/v1/primitives/{id}/copy` | Copy an object |
| `POST` | `/v1/primitives/{id}/presign` | Sign a URL |
| `POST` | `/v1/primitives/{id}/presign/revoke` | Revoke signed URLs |

`{key}` is the rest of the path, so keys may contain `/`. Percent-encode characters that aren't safe in a URL path. Keys are 1–1,024 bytes of UTF-8, with no control characters, no leading `/` and no `.` or `..` segment; any other key is `400 invalid_request`.

Every route answers `423 paused` while the primitive is paused. Timestamps are milliseconds since the Unix epoch. See [Errors](/docs/api/errors) for the full list of codes.

Object bodies travel as raw bytes, not JSON. Every object has an ETag, the BLAKE3 hash of its content in hex. It's quoted in the `ETag` header and bare in ObjectInfo.

## Security headers

Stored content is untrusted, so every object read, here and through [signed URLs](/docs/api/signed-urls), is sent with:

| Header | Value | Effect |
| --- | --- | --- |
| `X-Content-Type-Options` | `nosniff` | Browsers use the stored `Content-Type` and never guess another. |
| `Content-Security-Policy` | `sandbox` | An uploaded HTML or SVG file can't run scripts as your origin when someone opens it. |

## List objects

`GET /v1/primitives/{id}/objects`

Lists objects in key order, optionally under a prefix. With a `delimiter`, keys that contain it after the prefix are rolled up into `prefixes`, each listed once, and only the rest appear in `data`. `prefixes` is an empty array without a delimiter. `limit` counts objects and prefixes together. Pages hold 100 entries by default and up to 1,000; see [Pagination](/docs/api#pagination).

Key tool: `storage_list`.

**Query parameters**

| Parameter | Type | Description |
| --- | --- | --- |
| `prefix` | string | Only keys that start with this. |
| `delimiter` | string | Roll keys up into folders at this string, usually `/`. 1–64 bytes. |
| `cursor` | string | The `next_cursor` of the previous page. Opaque. |
| `limit` | integer | Page size. Default `100`, max `1000`. |

```bash
curl "https://api.useprimitives.com/v1/primitives/01jbz3k8m5q2w7x9v4n6t0r1ce/objects?prefix=reports/&delimiter=/" \
  -H "Authorization: Bearer $PRIMITIVES_API_KEY"
```

```json
{
  "data": [
    {
      "key": "reports/latest.pdf",
      "size": 482113,
      "etag": "6a1f0c9e3b7d24f58e0a9c1b7d3e5f20a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4",
      "content_type": "application/pdf",
      "metadata": { "run": "r-1234" },
      "created_at": 1760486400000,
      "updated_at": 1760486400000
    }
  ],
  "prefixes": ["reports/2025/", "reports/2026/"],
  "next_cursor": null
}
```

## Read an object

`GET /v1/primitives/{id}/objects/{key}`

Returns the object's raw bytes with its headers. A missing object is `404 not_found`.

- **Range.** A `Range` header with one range, `bytes=0-1023`, `bytes=1024-` or `bytes=-4096`, answers `206 Partial Content` with `Content-Range`. A range that starts past the end answers `416 range_not_satisfiable` with `Content-Range: bytes */<size>`. A malformed or multi-range `Range` is ignored and the whole object comes back with `200`.
- **If-None-Match.** When it matches the current ETag (or is `*`), the answer is `304 Not Modified` with the object's headers and no body.
- **`?stat=1`.** Returns ObjectInfo as JSON instead of the bytes.

Key tool: `storage_read`.

**Query parameters**

| Parameter | Type | Description |
| --- | --- | --- |
| `stat` | flag | Return ObjectInfo as JSON. A flag is on for `1`, `true`, `yes` or an empty value. |

**Headers**

| Parameter | Type | Description |
| --- | --- | --- |
| `Range` | string | One byte range. |
| `If-None-Match` | string | An ETag, a comma-separated list of ETags, or `*`. |

```bash
curl https://api.useprimitives.com/v1/primitives/01jbz3k8m5q2w7x9v4n6t0r1ce/objects/logs/run.jsonl \
  -H "Authorization: Bearer $PRIMITIVES_API_KEY" \
  -H "Range: bytes=0-1023" \
  -o head.jsonl
```

Returns `206` with the requested range:

```http
HTTP/1.1 206 Partial Content
Content-Type: application/x-ndjson
Content-Length: 1024
Content-Range: bytes 0-1023/88412
Accept-Ranges: bytes
ETag: "3c9b1e7f0a2d4c6e8f1a3b5d7e9f0c2a4b6d8e0f1a3c5e7a9b1d3f5a7c9e1b3d"
Last-Modified: Wed, 15 Oct 2025 00:00:00 GMT
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox
x-object-meta-run: r-1234
```

With `?stat=1`:

```bash
curl "https://api.useprimitives.com/v1/primitives/01jbz3k8m5q2w7x9v4n6t0r1ce/objects/logs/run.jsonl?stat=1" \
  -H "Authorization: Bearer $PRIMITIVES_API_KEY"
```

```json
{
  "key": "logs/run.jsonl",
  "size": 88412,
  "etag": "3c9b1e7f0a2d4c6e8f1a3b5d7e9f0c2a4b6d8e0f1a3c5e7a9b1d3f5a7c9e1b3d",
  "content_type": "application/x-ndjson",
  "metadata": { "run": "r-1234" },
  "created_at": 1760486400000,
  "updated_at": 1760486400000
}
```

## Read an object's headers

`HEAD /v1/primitives/{id}/objects/{key}`

Returns the same status codes and headers as [Read an object](#read-an-object), but no body. `Content-Length` is the length the matching `GET` would return. Takes `Range`, `If-None-Match` and `?stat=1`.

Key tool: `storage_read`.

```bash
curl -I https://api.useprimitives.com/v1/primitives/01jbz3k8m5q2w7x9v4n6t0r1ce/objects/reports/latest.pdf \
  -H "Authorization: Bearer $PRIMITIVES_API_KEY"
```

```http
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 482113
Accept-Ranges: bytes
ETag: "6a1f0c9e3b7d24f58e0a9c1b7d3e5f20a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4"
Last-Modified: Wed, 15 Oct 2025 00:00:00 GMT
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox
x-object-meta-run: r-1234
```

## Put an object

`PUT /v1/primitives/{id}/objects/{key}`

Creates or replaces an object and returns its ObjectInfo. The raw request body is the object, up to 32 MiB (`413 payload_too_large`). A put always writes the whole object and replaces its content type and metadata; there are no appends or partial writes. `created_at` is kept when an object is replaced.

A failed `If-Match` or `If-None-Match` answers `412 precondition_failed` and writes nothing. On a Starter workspace at its storage cap, it answers `429 plan_limit`; see [Usage and limits](/docs/api/limits#usage-and-limits).

Key tool: `storage_write`.

**Headers**

| Parameter | Type | Description |
| --- | --- | --- |
| `Content-Type` | string | Stored with the object and sent back on reads. Up to 256 bytes of printable ASCII. Default `application/octet-stream`. |
| `x-object-meta-<name>` | string | One metadata pair per header. Names are 1–64 characters of `a-z`, `0-9`, `_` and `-`; values are printable ASCII. Up to 32 pairs and 8 KiB in total. |
| `If-Match` | string | Write only if the current ETag matches. Fails if the object doesn't exist. |
| `If-None-Match` | string | `*` writes only if the object doesn't exist. An ETag writes only if the current one differs. |

```bash
curl -X PUT https://api.useprimitives.com/v1/primitives/01jbz3k8m5q2w7x9v4n6t0r1ce/objects/reports/2026/q3.pdf \
  -H "Authorization: Bearer $PRIMITIVES_API_KEY" \
  -H "Content-Type: application/pdf" \
  -H "x-object-meta-run: r-1234" \
  -H "If-None-Match: *" \
  --data-binary @q3.pdf
```

```json
{
  "key": "reports/2026/q3.pdf",
  "size": 482113,
  "etag": "6a1f0c9e3b7d24f58e0a9c1b7d3e5f20a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4",
  "content_type": "application/pdf",
  "metadata": { "run": "r-1234" },
  "created_at": 1760486400000,
  "updated_at": 1760486400000
}
```

## Delete an object

`DELETE /v1/primitives/{id}/objects/{key}`

Deletes an object. Returns `204` even if the object doesn't exist. With `If-Match`, the delete happens only if the current ETag matches; otherwise, or if the object doesn't exist, it answers `412 precondition_failed`.

Key tool: `storage_write`.

**Headers**

| Parameter | Type | Description |
| --- | --- | --- |
| `If-Match` | string | Delete only if the current ETag matches. |

```bash
curl -X DELETE https://api.useprimitives.com/v1/primitives/01jbz3k8m5q2w7x9v4n6t0r1ce/objects/reports/2026/q3.pdf \
  -H "Authorization: Bearer $PRIMITIVES_API_KEY" \
  -H 'If-Match: "6a1f0c9e3b7d24f58e0a9c1b7d3e5f20a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4"'
```

Returns `204` with no body.

## Copy an object

`POST /v1/primitives/{id}/copy`

Copies an object to another key in the same store, with its content type and metadata, without moving the bytes through the caller. Returns the copy's ObjectInfo. A destination that exists is replaced unless `if_none_match` says otherwise.

Errors: `404 not_found` if `from` doesn't exist; `400 invalid_request` if `from` and `to` are the same; `412 precondition_failed` if `if_none_match` fails. On a Starter workspace at its storage cap, it answers `429 plan_limit`; see [Usage and limits](/docs/api/limits#usage-and-limits).

Key tool: `storage_write`.

**Body**

| Parameter | Type | Description |
| --- | --- | --- |
| `from` | string | Required. The source key. |
| `to` | string | Required. The destination key. |
| `if_none_match` | string | `"*"` refuses to overwrite an existing destination. An ETag, or a comma-separated list of ETags, refuses only when the destination's current ETag matches. |

```bash
curl -X POST https://api.useprimitives.com/v1/primitives/01jbz3k8m5q2w7x9v4n6t0r1ce/copy \
  -H "Authorization: Bearer $PRIMITIVES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"from": "reports/2026/q3.pdf", "to": "reports/latest.pdf"}'
```

```json
{
  "key": "reports/latest.pdf",
  "size": 482113,
  "etag": "6a1f0c9e3b7d24f58e0a9c1b7d3e5f20a4c6e8b0d2f4a6c8e0b2d4f6a8c0e2b4",
  "content_type": "application/pdf",
  "metadata": { "run": "r-1234" },
  "created_at": 1760490000000,
  "updated_at": 1760490000000
}
```

## Sign a URL

`POST /v1/primitives/{id}/presign`

Signs a URL that lets anyone holding it make one kind of request on one object, without a token, until it expires. A `GET` URL downloads the object; a `PUT` URL uploads to the key. The object doesn't have to exist when you sign. Returns `{ url, method, headers, expires_at }`: send the request with exactly that method and those headers. See [Signed URLs](/docs/api/signed-urls) for how they're served.

`content_type`, `max_size` and `if_none_match` only apply to `PUT` URLs; passing them for a `GET` URL is `400 invalid_request`. So is an `expires_in` outside 1 second to 7 days, a `max_size` over 32 MiB, or an `if_none_match` other than `"*"`.

Key tool: `storage_sign`.

**Body**

| Parameter | Type | Description |
| --- | --- | --- |
| `key` | string | Required. The object's key. |
| `method` | string | `"GET"` or `"PUT"`. Default `"GET"`. |
| `expires_in` | duration | Lifetime, from 1 second to 7 days. Default `"15m"`. See [Durations](/docs/api#durations). |
| `content_type` | string | `PUT` only. The upload must send exactly this `Content-Type`. |
| `max_size` | integer | `PUT` only. The largest body accepted, in bytes. At most 33,554,432 (32 MiB). |
| `if_none_match` | string | `PUT` only. `"*"` makes the URL create-only: it never overwrites an existing object. |

```bash
curl -X POST https://api.useprimitives.com/v1/primitives/01jbz3k8m5q2w7x9v4n6t0r1ce/presign \
  -H "Authorization: Bearer $PRIMITIVES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "avatars/user-42.png",
    "method": "PUT",
    "content_type": "image/png",
    "max_size": 5000000,
    "expires_in": "10m",
    "if_none_match": "*"
  }'
```

```json
{
  "url": "https://api.useprimitives.com/v1/signed/v1.eyJucyI6IndzXzAxamJ6M…Q.k3f9Tq…",
  "method": "PUT",
  "headers": {
    "content-type": "image/png",
    "if-none-match": "*"
  },
  "expires_at": 1760487000000
}
```

`headers` is empty for a `GET` URL. `expires_at` is in milliseconds since the epoch.

## Revoke signed URLs

`POST /v1/primitives/{id}/presign/revoke`

Invalidates every signed URL issued for this store so far. URLs can't be revoked one by one. URLs signed afterwards work as usual, and restoring a snapshot doesn't bring revoked URLs back. Returns `{ epoch }`, the store's new revocation epoch, which goes up by one each time.

Key tool: `storage_sign`.

```bash
curl -X POST https://api.useprimitives.com/v1/primitives/01jbz3k8m5q2w7x9v4n6t0r1ce/presign/revoke \
  -H "Authorization: Bearer $PRIMITIVES_API_KEY"
```

```json
{ "epoch": 1 }
```

## Shapes

| Shape | Fields |
| --- | --- |
| ObjectInfo | `{ key, size, etag, content_type, metadata, created_at, updated_at }`. `size` is in bytes. `etag` is the BLAKE3 hex of the content, unquoted. `metadata` is an object of string pairs |
| ObjectPage | `{ data: ObjectInfo[], prefixes: string[], next_cursor }`. Each prefix ends with the delimiter. `next_cursor` is `null` on the last page |
| SignedUrl | `{ url, method, headers, expires_at }`. `method` is `"GET"` or `"PUT"`. `headers` maps lowercase header names to the values the request must send |

Scoped API keys need the tool named in each section on their allowlist; see [Tool allowlists](/docs/api/authentication#tool-allowlists). For creating, snapshotting, forking and pausing the store itself, see [Primitives](/docs/api/primitives). See [Limits](/docs/api/limits) for how operations are counted.
