# Signed URLs

> Upload and download objects with a presigned URL, no API key needed.

Source: https://useprimitives.com/docs/api/signed-urls

Signed URLs serve one object of a `storage` primitive to anyone who holds the URL. See [Storage](/docs/primitives/storage#signed-urls) for the concepts. You create them with [Sign a URL](/docs/api/storage#sign-a-url); this page covers how they're served.

| Method | Path | Description |
| --- | --- | --- |
| `GET` | `/v1/signed/{token}` | Download through a signed URL |
| `HEAD` | `/v1/signed/{token}` | Read headers through a signed URL |
| `PUT` | `/v1/signed/{token}` | Upload through a signed URL |

These routes sit at the top level, outside `/v1/primitives`, and take no `Authorization` header: the token in the path is the credential. Use the `url` that `presign` returned as is; don't build it yourself. Treat it like a short-lived password for that object.

## Failures

A signed URL never reveals whether a store or object exists:

| Status | Code | When |
| --- | --- | --- |
| 400 | `invalid_request` | Cloud only. The token isn't shaped like a signed URL token. Locally, this is `403 forbidden`. |
| 403 | `forbidden` | The signature is bad, the URL has expired or been revoked, the method isn't the one it was signed for, or the store no longer exists (or was forked or moved). Also a `PUT` whose `Content-Type` isn't the signed one. The answer doesn't say which. |
| 404 | `not_found` | A `GET` URL whose object doesn't exist (yet, or any more). |
| 405 | `method_not_allowed` | Cloud only. A method other than `GET`, `HEAD`, `PUT` or a CORS preflight `OPTIONS`. |
| 412 | `precondition_failed` | A `PUT` URL signed with `if_none_match: "*"` and the object exists, or a failed `If-Match`. |
| 413 | `payload_too_large` | The upload is larger than the signed `max_size`, or than 32 MiB. |
| 416 | `range_not_satisfiable` | A `Range` that starts past the end of the object. |
| 423 | `paused` | The store is paused. |
| 429 | `rate_limited` | Cloud only. Too many requests from this IP. See [Rate limiting](#rate-limiting). |
| 429 | `plan_limit` | Cloud only. The store's Starter workspace reached its operations cap, or its storage cap on a `PUT`. The message doesn't name the plan or its limits. |

Errors use the envelope in [Errors](/docs/api/errors).

## Download through a signed URL

`GET /v1/signed/{token}`

For a URL signed with `method: "GET"`. Returns the object's raw bytes exactly as [Read an object](/docs/api/storage#read-an-object) does: `Range` answers `206` with `Content-Range`, a matching `If-None-Match` answers `304`, and the response carries `ETag`, `Content-Type`, `Content-Length`, `Accept-Ranges`, `Last-Modified`, `x-object-meta-*` and the [security headers](/docs/api/storage#security-headers). `?stat=1` isn't supported.

**Headers**

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

`$SIGNED_URL` is the `url` that `presign` returned:

```bash
curl "$SIGNED_URL" -o q3.pdf
```

```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
Access-Control-Allow-Origin: *
```

## Read headers through a signed URL

`HEAD /v1/signed/{token}`

For a URL signed with `method: "GET"`. Returns the same status and headers as the `GET`, but no body.

```bash
curl -I "$SIGNED_URL"
```

```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
```

## Upload through a signed URL

`PUT /v1/signed/{token}`

For a URL signed with `method: "PUT"`. The raw request body is stored under the signed key, as [Put an object](/docs/api/storage#put-an-object) does, and the response is the new ObjectInfo. Send every header in the `headers` that `presign` returned:

- A URL signed with `content_type` only accepts that `Content-Type` (compared without case). A missing or different one is `403 forbidden`.
- A URL signed with `max_size` refuses a larger body with `413 payload_too_large`.
- A URL signed with `if_none_match: "*"` only creates the object. If it exists, the upload is `412 precondition_failed`.

`x-object-meta-*` and `If-Match` apply as on a normal put. In the cloud, the upload is audited as `storage.put` with the actor `signed_url`.

**Headers**

| Parameter | Type | Description |
| --- | --- | --- |
| `Content-Type` | string | Required when the URL was signed with `content_type`, and must match it. Otherwise defaults to `application/octet-stream`. |
| `If-None-Match` | string | Send `*` when `presign` returned it. |
| `If-Match` | string | Upload only if the current ETag matches. |
| `x-object-meta-<name>` | string | Metadata pairs, as on a normal put. |

`$UPLOAD_URL` is the `url` that `presign` returned:

```bash
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: image/png" \
  -H "If-None-Match: *" \
  --data-binary @avatar.png
```

```json
{
  "key": "avatars/user-42.png",
  "size": 48213,
  "etag": "b4d2f6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4",
  "content_type": "image/png",
  "metadata": {},
  "created_at": 1760486500000,
  "updated_at": 1760486500000
}
```

## CORS

In the cloud, signed URLs accept cross-origin requests from any origin, so a web page can upload or download directly:

- Preflight `OPTIONS` requests answer `204` with `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods: GET, HEAD, PUT` and `Access-Control-Max-Age: 600`.
- Allowed request headers are `Content-Type`, `If-Match`, `If-None-Match`, `Range` and `x-object-meta-*`.
- Every response, errors included, carries `Access-Control-Allow-Origin: *` and exposes `ETag`, `Content-Type`, `Content-Length`, `Content-Range`, `Accept-Ranges`, `Last-Modified`, `X-Request-Id` and the response's `x-object-meta-*` headers.

```js
const upload = await fetch(presigned.url, {
  method: presigned.method,
  headers: presigned.headers,
  body: file,
});
```

`primitives dev` doesn't send CORS headers.

## Rate limiting

In the cloud there's no API key to limit by, so signed URLs are rate-limited per client IP: 20 requests per second, with bursts up to 60. Over the limit they answer `429 rate_limited` with a `Retry-After` header in seconds. The local engine doesn't rate-limit. See [Rate limits](/docs/api/limits#rate-limits).

## Operations

In the cloud, each signed request the store serves successfully (a `2xx` answer) counts as one operation of the store's group, like any other call. A signed upload also adds to the workspace's storage. On a Starter workspace at its operations cap, every signed request answers `429 plan_limit`, and at its storage cap, so does every upload; see [Usage and limits](/docs/api/limits#usage-and-limits).

## Example: upload and download

Sign a create-only upload URL for one PNG of at most 5 MB:

```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": "*"
  }' > upload.json
```

```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
}
```

Upload with the returned method and headers. No API key is sent:

```bash
headers=()
while IFS= read -r h; do headers+=(-H "$h"); done \
  < <(jq -r '.headers | to_entries[] | "\(.key): \(.value)"' upload.json)

curl -X "$(jq -r .method upload.json)" "$(jq -r .url upload.json)" \
  "${headers[@]}" \
  --data-binary @avatar.png
```

That sends the same request as writing the headers out:

```bash
curl -X PUT "$(jq -r .url upload.json)" \
  -H "content-type: image/png" \
  -H "if-none-match: *" \
  --data-binary @avatar.png
```

```json
{
  "key": "avatars/user-42.png",
  "size": 48213,
  "etag": "b4d2f6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4",
  "content_type": "image/png",
  "metadata": {},
  "created_at": 1760486500000,
  "updated_at": 1760486500000
}
```

Running the same upload again answers `412 precondition_failed`, because the URL is create-only.

Sign a download URL for the same object, valid for an hour:

```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", "expires_in": "1h"}' > download.json
```

```json
{
  "url": "https://api.useprimitives.com/v1/signed/v1.eyJucyI6IndzXzAxamJ6M…w.Zp2Lx…",
  "method": "GET",
  "headers": {},
  "expires_at": 1760490100000
}
```

Download it, again without an API key:

```bash
curl "$(jq -r .url download.json)" -o avatar.png
```

```http
HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 48213
Accept-Ranges: bytes
ETag: "b4d2f6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4"
Last-Modified: Wed, 15 Oct 2025 00:01:40 GMT
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox
Access-Control-Allow-Origin: *
```

To invalidate both URLs, and every other URL issued for the store, call [Revoke signed URLs](/docs/api/storage#revoke-signed-urls).

## Shapes

| Shape | Fields |
| --- | --- |
| SignedUrl | `{ url, method, headers, expires_at }`, returned by [presign](/docs/api/storage#sign-a-url). `headers` maps lowercase header names to the values the request must send |
| ObjectInfo | `{ key, size, etag, content_type, metadata, created_at, updated_at }`, returned by an upload |
