> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Uploads

> Presigned PUTs for avatars and logos — the bytes never touch the API.

Two things can be uploaded: a user's avatar and an organization's logo. Both work
the same way, and neither sends bytes through the API.

<Note>
  Requires S3-compatible storage (S3, R2, MinIO). Without it both routes answer
  `501 uploads_unavailable` and `GET /v1/session` reports `uploads_available: false`.
</Note>

## The flow

<Steps>
  <Step title="Ask for a signed URL">
    ```bash theme={null}
    curl -s $API/v1/account/avatar/upload-url \
      -H "Authorization: Bearer $UK_SESSION" \
      -H 'Content-Type: application/json' \
      -d '{ "content_type": "image/png", "size_bytes": 184320 }'
    ```

    ```json theme={null}
    {
      "upload_url": "https://bucket.example.com/avatars/…?X-Amz-Signature=…",
      "headers": { "Content-Type": "image/png" },
      "public_url": "https://cdn.example.com/avatars/…"
    }
    ```
  </Step>

  <Step title="PUT the file straight to the bucket">
    ```bash theme={null}
    curl -X PUT "$UPLOAD_URL" \
      -H 'Content-Type: image/png' \
      --data-binary @avatar.png
    ```

    Send exactly the headers that came back — they are part of the signature.
  </Step>

  <Step title="Save the public URL">
    ```bash theme={null}
    curl -s -X PATCH $API/v1/account \
      -H "Authorization: Bearer $UK_SESSION" \
      -H 'Content-Type: application/json' \
      -d '{ "avatar_url": "https://cdn.example.com/avatars/…" }'
    ```
  </Step>
</Steps>

Saving is a separate call on purpose. The upload can fail halfway, and a profile
pointing at a half-written object would be worse than one still pointing at the old
image.

## The organization logo

Identical, with two differences: it needs `organization:update`, and the URL is
saved with `PATCH /v1/organization`.

```bash theme={null}
curl -s $API/v1/organization/logo/upload-url \
  -H "Authorization: Bearer $UK_SESSION" \
  -H "X-Organization-Id: org_4b1e…" \
  -H 'Content-Type: application/json' \
  -d '{ "content_type": "image/webp", "size_bytes": 24576 }'
```

## What is allowed

|       |                                                      |
| ----- | ---------------------------------------------------- |
| Types | `image/jpeg`, `image/png`, `image/webp`, `image/gif` |
| Size  | 1 byte to 5 MiB                                      |

An allowlist, not a blocklist — deliberately. These files are served back from a
public host, and `text/html` among them would be a stored-XSS vector on that host.

| Response                  | Cause                                      |
| ------------------------- | ------------------------------------------ |
| `400 unsupported_type`    | Not one of the four image types            |
| `400 invalid_size`        | `size_bytes` is 0, negative, or over 5 MiB |
| `501 uploads_unavailable` | The server has no storage configured       |

Content type and size are part of the signature, so the **bucket** enforces them.
The check on the API side is there to give you a readable error, not to be the
enforcement.

## Replacing an image

Every upload gets a random object key, so replacing an image never overwrites the
old object. A URL cached in a CDN or an email cannot start serving somebody else's
picture.

The old object is left behind. Pruning is a housekeeping job, not part of the
request.

## Doing it from the browser

The panel does exactly the three steps above from client code. The presigned URL is
short-lived and scoped to one object, one content type and one size, so handing it
to the browser gives away nothing reusable.

```ts theme={null}
const signed = await fetch("/api/v1/account/avatar/upload-url", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ content_type: file.type, size_bytes: file.size }),
}).then((r) => r.json());

await fetch(signed.upload_url, {
  method: "PUT",
  headers: signed.headers,
  body: file,
});

await fetch("/api/v1/account", {
  method: "PATCH",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ avatar_url: signed.public_url }),
});
```

<Note>
  In the panel these go through the Next app's `/api/*` proxy, which attaches the
  session token from the httpOnly cookie. The PUT goes straight to the bucket and
  carries no credential of ours at all.
</Note>
