> ## Documentation Index
> Fetch the complete documentation index at: https://developers.sults.com.br/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload de arquivos na API v2 do SULTS

> Envie um arquivo para o SULTS, receba o id dele e use o id para anexar o arquivo nos endpoints de escrita da API v2

API v2

Na v2, um arquivo nunca vai dentro do JSON de uma escrita. Você envia o arquivo antes, recebe o **id** do arquivo e passa esse id no endpoint que vai usar o anexo.

## Como funciona o upload de arquivos?

O anexo acontece em duas etapas:

**1. Envie o arquivo**

`POST /api/v2/attachment/upload`, com o arquivo em `multipart/form-data`. A resposta traz o id do arquivo em `id`.

**2. Use o id na escrita**

No corpo JSON do endpoint de escrita, informe o id no campo de anexos, como `attachments` na [importação de avaliação de checklist](https://developers.sults.com.br/v2/api-reference/checklist/importar-avaliacao-de-checklist.md).

Cada chamada de upload recebe **um arquivo** e devolve **um id**. Para anexar vários arquivos, faça um upload por arquivo e junte os ids no array.

## Etapa 1: envie o arquivo

O endpoint de upload é comum a todos os módulos e não pertence a nenhum deles:

| Método | URL |
| - | - |
| POST | `https://api.sults.com.br/api/v2/attachment/upload` |

O corpo é `multipart/form-data`, com o arquivo na parte `file`:

| Parte | Obrigatório | Descrição |
| - | - | - |
| `file` | Sim | O arquivo. Um por requisição. |

A referência, com playground, está em [Enviar arquivo](https://developers.sults.com.br/v2/api-reference/arquivos/enviar-arquivo.md). O token vai no header `Authorization`, puro, como em qualquer chamada da v2. Não informe o `Content-Type` à mão: a biblioteca HTTP monta o `multipart/form-data` com o separador (`boundary`) certo.

```bash cURL theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl -X POST "https://api.sults.com.br/api/v2/attachment/upload" \
  -H "Authorization: SEU_TOKEN" \
  -F "file=@fachada-unidade-centro.jpg"
```

```python Python theme={"theme":{"light":"github-dark","dark":"github-dark"}}
import requests

with open("fachada-unidade-centro.jpg", "rb") as arquivo:
    resposta = requests.post(
        "https://api.sults.com.br/api/v2/attachment/upload",
        headers={"Authorization": "SEU_TOKEN"},
        files={"file": arquivo},
    )
resposta.raise_for_status()

id_arquivo = resposta.json()["id"]
print(id_arquivo)
```

```javascript Node theme={"theme":{"light":"github-dark","dark":"github-dark"}}
import { openAsBlob } from "node:fs";

const formulario = new FormData();
formulario.append(
  "file",
  await openAsBlob("fachada-unidade-centro.jpg"),
  "fachada-unidade-centro.jpg",
);

const resposta = await fetch(
  "https://api.sults.com.br/api/v2/attachment/upload",
  {
    method: "POST",
    headers: { Authorization: "SEU_TOKEN" },
    body: formulario,
  },
);
if (!resposta.ok) throw new Error(`HTTP ${resposta.status}`);

const { id } = await resposta.json();
console.log(id);
```

```php PHP theme={"theme":{"light":"github-dark","dark":"github-dark"}}
<?php
$ch = curl_init("https://api.sults.com.br/api/v2/attachment/upload");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: SEU_TOKEN"]);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
    "file" => new CURLFile("fachada-unidade-centro.jpg"),
]);
$resposta = curl_exec($ch);
curl_close($ch);

$idArquivo = json_decode($resposta, true)["id"];
echo $idArquivo . PHP_EOL;
```

```csharp C# theme={"theme":{"light":"github-dark","dark":"github-dark"}}
using System.IO;
using System.Net.Http;
using System.Text.Json;

var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "SEU_TOKEN");

using var formulario = new MultipartFormDataContent();
await using var arquivo = File.OpenRead("fachada-unidade-centro.jpg");
formulario.Add(new StreamContent(arquivo), "file", "fachada-unidade-centro.jpg");

var resposta = await http.PostAsync(
    "https://api.sults.com.br/api/v2/attachment/upload", formulario);
resposta.EnsureSuccessStatusCode();

using var doc = JsonDocument.Parse(await resposta.Content.ReadAsStringAsync());
Console.WriteLine(doc.RootElement.GetProperty("id").GetString());
```

O upload responde `200 OK`, com o id do arquivo:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  "id": "3f9a6c2e-8b41-4d7a-9e0f-2a5b7c1d9e33"
}
```

Guarde o id: é ele que liga o arquivo enviado ao registro da próxima etapa. Até ser usado ou expirar, uma hora depois do upload, o arquivo fica disponível e ocupa uma das 100 vagas descritas em [Disponibilidade e o limite de 100](#disponibilidade-e-o-limite-de-100).

## Etapa 2: use o id na escrita

Informe os ids no campo de anexos do endpoint de escrita. Hoje, o endpoint que recebe anexos é a [importação de avaliação de checklist](https://developers.sults.com.br/v2/api-reference/checklist/importar-avaliacao-de-checklist.md): cada resposta do array `answer` aceita `attachments`, um array com os ids dos arquivos daquela questão.

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
{
  "templateQuestionId": 366,
  "notApplicable": false,
  "answerTemplateOptionId": [859],
  "attachments": [
    "3f9a6c2e-8b41-4d7a-9e0f-2a5b7c1d9e33",
    "54e12530-0d99-4292-b849-088692932f70"
  ]
}
```

Nesse exemplo, a resposta da questão 366 leva dois arquivos, enviados em dois uploads.

## Disponibilidade e o limite de 100

Todo arquivo enviado nasce **disponível**. Ele deixa de estar disponível quando o id é usado em um endpoint de escrita, como a importação de avaliação, ou quando expira. Os disponíveis são os que aparecem na [listagem de arquivos](https://developers.sults.com.br/v2/api-reference/arquivos/listar-arquivos.md).

### Quanto tempo tenho para usar o arquivo?

**Uma hora** a partir do upload. A listagem mostra o prazo de cada arquivo em `expiresAt`. Depois dele, o arquivo expira: fica indisponível para os endpoints de escrita, deixa de aparecer na listagem e libera a vaga no limite de 100. Para anexar o mesmo conteúdo, faça um upload novo e use o id novo.

Por isso, faça o upload perto da escrita que vai usar o arquivo, e não horas antes.

### Quantos arquivos disponíveis posso ter?

Até **100**. Com 100 arquivos disponíveis, o upload responde erro e não devolve id novo. Para voltar a enviar, use os ids que já tem num endpoint de escrita, remova os disponíveis que não vai usar ou espere algum expirar: arquivo expirado não conta no limite.

Na prática, isso pede duas cautelas:

* **Envie só o que vai usar logo.** Não faça o upload de um lote grande "para depois": cada id sem uso ocupa uma vaga.
* **Limpe o que ficou para trás.** Se uma importação falhou depois dos uploads, os arquivos dela continuam disponíveis e ocupando vaga até expirar. Remova-os, ou reaproveite os ids ao reenviar a importação corrigida dentro do prazo.

### Como listar os arquivos enviados?

`GET /api/v2/attachment/list` traz os arquivos disponíveis para uso nos endpoints de escrita, com o id, o nome, o tamanho em bytes (`fileLength`), a data do upload (`uploadedAt`) e a data de expiração (`expiresAt`, uma hora depois do upload), as duas em UTC. Arquivos já usados ou expirados não aparecem, então a listagem mostra exatamente os que ocupam vaga no limite. Não há filtros. A referência está em [Listar arquivos](https://developers.sults.com.br/v2/api-reference/arquivos/listar-arquivos.md).

```bash theme={"theme":{"light":"github-dark","dark":"github-dark"}}
curl "https://api.sults.com.br/api/v2/attachment/list" \
  -H "Authorization: SEU_TOKEN"
```

A resposta é um array:

```json theme={"theme":{"light":"github-dark","dark":"github-dark"}}
[
  {
    "id": "c810e86e-616d-45b7-8a5e-669319021829",
    "name": "fachada-unidade-centro.jpg",
    "fileLength": 3344244,
    "uploadedAt": "2026-11-05T13:31:12Z",
    "expiresAt": "2026-11-05T14:31:12Z"
  }
]
```

### Como remover um arquivo?

`DELETE /api/v2/attachment/remove`, com o id do arquivo na query string, o mesmo devolvido pelo upload, remove um arquivo disponível e libera a vaga dele. A resposta é o status `200` sem corpo. Só arquivos disponíveis podem ser removidos: os já usados não. A referência está em [Remover arquivo](https://developers.sults.com.br/v2/api-reference/arquivos/remover-arquivo.md).

O exemplo abaixo lista os disponíveis e remove os enviados há mais de 30 minutos, que dificilmente ainda serão usados. Os arquivos recentes ficam, porque podem ser de uma importação em andamento. Entre uma remoção e outra, a pausa de meio segundo respeita o limite de 2 requisições por segundo.

```bash cURL theme={"theme":{"light":"github-dark","dark":"github-dark"}}
# 1. Liste os disponíveis e anote o id dos que não vai usar
curl "https://api.sults.com.br/api/v2/attachment/list" \
  -H "Authorization: SEU_TOKEN"

# 2. Remova cada um pelo id
curl -X DELETE "https://api.sults.com.br/api/v2/attachment/remove?id=c810e86e-616d-45b7-8a5e-669319021829" \
  -H "Authorization: SEU_TOKEN"
```

```python Python theme={"theme":{"light":"github-dark","dark":"github-dark"}}
import time
from datetime import datetime, timedelta, timezone

import requests

BASE = "https://api.sults.com.br/api/v2/attachment"
HEADERS = {"Authorization": "SEU_TOKEN"}

resposta = requests.get(f"{BASE}/list", headers=HEADERS)
resposta.raise_for_status()

limite = datetime.now(timezone.utc) - timedelta(minutes=30)
for arquivo in resposta.json():
    enviado = datetime.fromisoformat(arquivo["uploadedAt"].replace("Z", "+00:00"))
    if enviado < limite:
        time.sleep(0.5)
        remocao = requests.delete(f"{BASE}/remove", headers=HEADERS, params={"id": arquivo["id"]})
        remocao.raise_for_status()
        print(f"Removido: {arquivo['name']} ({arquivo['id']})")
```

```javascript Node theme={"theme":{"light":"github-dark","dark":"github-dark"}}
const BASE = "https://api.sults.com.br/api/v2/attachment";
const headers = { Authorization: "SEU_TOKEN" };
const esperar = (ms) => new Promise((resolver) => setTimeout(resolver, ms));

const resposta = await fetch(`${BASE}/list`, { headers });
if (!resposta.ok) throw new Error(`HTTP ${resposta.status}`);

const limite = Date.now() - 30 * 60 * 1000;
for (const arquivo of await resposta.json()) {
  if (Date.parse(arquivo.uploadedAt) < limite) {
    await esperar(500);
    const remocao = await fetch(`${BASE}/remove?id=${arquivo.id}`, {
      method: "DELETE",
      headers,
    });
    if (!remocao.ok) throw new Error(`HTTP ${remocao.status}`);
    console.log(`Removido: ${arquivo.name} (${arquivo.id})`);
  }
}
```

```php PHP theme={"theme":{"light":"github-dark","dark":"github-dark"}}
<?php
const BASE = "https://api.sults.com.br/api/v2/attachment";

function chamar(string $metodo, string $url): string
{
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $metodo);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: SEU_TOKEN"]);
    $corpo = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new RuntimeException("HTTP $status: $corpo");
    }
    return $corpo;
}

$disponiveis = json_decode(chamar("GET", BASE . "/list"), true);
$limite = time() - 30 * 60;

foreach ($disponiveis as $arquivo) {
    if (strtotime($arquivo["uploadedAt"]) < $limite) {
        usleep(500000);
        chamar("DELETE", BASE . "/remove?id=" . $arquivo["id"]);
        echo "Removido: {$arquivo['name']} ({$arquivo['id']})" . PHP_EOL;
    }
}
```

```csharp C# theme={"theme":{"light":"github-dark","dark":"github-dark"}}
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;

const string Base = "https://api.sults.com.br/api/v2/attachment";

var http = new HttpClient();
http.DefaultRequestHeaders.Add("Authorization", "SEU_TOKEN");

var resposta = await http.GetAsync($"{Base}/list");
resposta.EnsureSuccessStatusCode();

using var doc = JsonDocument.Parse(await resposta.Content.ReadAsStringAsync());
var limite = DateTimeOffset.UtcNow.AddMinutes(-30);

foreach (var arquivo in doc.RootElement.EnumerateArray())
{
    var enviado = arquivo.GetProperty("uploadedAt").GetDateTimeOffset();
    if (enviado < limite)
    {
        await Task.Delay(500);
        var id = arquivo.GetProperty("id").GetString();
        var remocao = await http.DeleteAsync($"{Base}/remove?id={id}");
        remocao.EnsureSuccessStatusCode();
        Console.WriteLine($"Removido: {arquivo.GetProperty("name").GetString()} ({id})");
    }
}
```

## Erros do upload

O upload segue o [contrato de erros da v2](https://developers.sults.com.br/v2/erros.md): `401` para token ausente ou inválido e `429` quando o limite de 2 requisições por segundo, por IP, é excedido. Com 100 arquivos disponíveis, ele também responde erro, até que você use ou remova algum deles, ou até algum expirar.

Cada arquivo é uma requisição, então uma avaliação com muitos anexos consome o limite de requisições rápido: espace os uploads como numa varredura, descrita em [Limites de requisição](https://developers.sults.com.br/geral/limites.md).

## Próximos passos

> [card] [Importar avaliação](https://developers.sults.com.br/v2/api-reference/checklist/importar-avaliacao-de-checklist.md)
>
> O corpo completo da importação, com `attachments` em cada resposta.

> [card] [Listar arquivos](https://developers.sults.com.br/v2/api-reference/arquivos/listar-arquivos.md)
>
> Os arquivos disponíveis, com a data de expiração de cada um.

> [card] [Remover arquivo](https://developers.sults.com.br/v2/api-reference/arquivos/remover-arquivo.md)
>
> Libere vagas no limite de 100 removendo o que não vai usar.

> [card] [Erros](https://developers.sults.com.br/v2/erros.md)
>
> O catálogo de falhas e como tratar cada uma.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.