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

> PUTs pré-assinados para avatares e logos — os bytes não passam pela API.

Duas coisas podem ser enviadas: o avatar de um usuário e o logo de uma organização.
As duas funcionam igual, e nenhuma manda bytes pela API.

<Note>
  Exige storage compatível com S3 (S3, R2, MinIO). Sem ele as duas rotas respondem
  `501 uploads_unavailable` e `GET /v1/session` informa `uploads_available: false`.
</Note>

## O fluxo

<Steps>
  <Step title="Peça uma URL assinada">
    ```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="Faça o PUT direto no bucket">
    ```bash theme={null}
    curl -X PUT "$UPLOAD_URL" \
      -H 'Content-Type: image/png' \
      --data-binary @avatar.png
    ```

    Envie exatamente os headers que voltaram — eles fazem parte da assinatura.
  </Step>

  <Step title="Salve a URL pública">
    ```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>

Salvar é uma chamada separada de propósito. O upload pode falhar no meio, e um
perfil apontando para um objeto escrito pela metade seria pior que um ainda
apontando para a imagem antiga.

## O logo da organização

Idêntico, com duas diferenças: exige `organization:update`, e a URL é salva com
`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 }'
```

## O que é permitido

|         |                                                      |
| ------- | ---------------------------------------------------- |
| Tipos   | `image/jpeg`, `image/png`, `image/webp`, `image/gif` |
| Tamanho | 1 byte a 5 MiB                                       |

Uma allowlist, não uma blocklist — deliberadamente. Esses arquivos são servidos de
volta a partir de um host público, e `text/html` entre eles seria um vetor de XSS
armazenado naquele host.

| Resposta                  | Causa                                         |
| ------------------------- | --------------------------------------------- |
| `400 unsupported_type`    | Não é um dos quatro tipos de imagem           |
| `400 invalid_size`        | `size_bytes` é 0, negativo, ou passa de 5 MiB |
| `501 uploads_unavailable` | O servidor não tem storage configurado        |

O tipo e o tamanho fazem parte da assinatura, então é o **bucket** que os impõe. A
verificação no lado da API existe para te dar um erro legível, não para ser a
imposição.

## Substituindo uma imagem

Cada upload ganha uma chave de objeto aleatória, então substituir uma imagem nunca
sobrescreve o objeto antigo. Uma URL em cache num CDN ou num e-mail não pode começar
a servir a foto de outra pessoa.

O objeto antigo fica para trás. Limpar é um job de manutenção, não parte da
requisição.

## Fazendo isso do navegador

O painel faz exatamente os três passos acima a partir do código no cliente. A URL
pré-assinada é de vida curta e delimitada a um objeto, um content type e um tamanho,
então entregá-la ao navegador não dá nada reutilizável.

```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>
  No painel isso passa pelo proxy `/api/*` do app Next, que anexa o token de sessão a
  partir do cookie httpOnly. O PUT vai direto para o bucket e não carrega credencial
  nenhuma nossa.
</Note>
