# API de integração para imobiliárias

A API permite que o CRM da sua imobiliária publique e mantenha os anúncios na
Tudonuma automaticamente — sem redigitar nada no painel. Ela também avisa o
seu sistema (webhooks) quando um anúncio é aprovado, recusado ou alterado.

- **Base URL:** `https://tudonuma.com/api/v1`
- **Formato:** JSON (UTF-8) na entrada e na saída. Campos em `snake_case`.
- **Especificação OpenAPI:** [`/api/v1/openapi.json`](/api/v1/openapi.json)
- **Documentação interativa:** [`tudonuma.com/integracao`](https://tudonuma.com/integracao) (exemplos em cURL, Node.js, PHP e Python, validador de payload e teste de assinatura; `/api/v1/docs` no navegador leva até lá)
- **Esta página em Markdown:** [`/api/v1/docs.md`](/api/v1/docs.md)
- **Para agentes de IA:** [`/llms.txt`](/llms.txt) (o índice) e [`/llms-full.txt`](/llms-full.txt) (esta documentação num arquivo só). Em [`tudonuma.com/integracao#agentes`](https://tudonuma.com/integracao#agentes) há um prompt pronto para o Claude Code, o Cursor, o Codex ou o ChatGPT, e a mensagem pronta para o fornecedor do CRM.

---

## 1. Autenticação

Crie uma chave em **Painel da imobiliária → Integrações → Chaves de API**.
A chave completa é mostrada **uma única vez**; guardamos apenas um hash.

Envie a chave em todas as chamadas:

```http
Authorization: Bearer mk_9f3c1a...
```

(`X-Api-Key: mk_...` também é aceito.)

Cada chave tem **escopos**: `listings:read`, `listings:write`, `webhooks:manage`.
Uma chave revogada no painel deixa de funcionar imediatamente.

Limite: **600 chamadas por minuto por chave**. Para sincronizar a carteira
inteira use `/listings/batch`.

Erros vêm sempre neste formato, com o status HTTP correspondente:

```json
{ "error": "invalid_price", "message": "price (reais) or price_cents must be positive" }
```

| Status | Significado |
|---|---|
| 400 | Dados inválidos (`error` diz qual campo). |
| 401 | Chave ausente, desconhecida ou revogada. |
| 403 | A chave não tem o escopo necessário. |
| 404 | Anúncio não encontrado para este `external_id`. |
| 409 | Ação não aplicável (ex.: reativar um anúncio recusado pela moderação). |
| 413 | Corpo acima de 2 MB (divida o lote). |
| 415 | Faltou `Content-Type: application/json`. |
| 422 | JSON com tipo errado num campo (ex.: `"bedrooms": "2"` em texto, `area_m2` com decimal). |
| 429 | Limite de chamadas excedido. |
| 500 | Falha do nosso lado (`internal_error`); tente de novo. |

413, 415 e 422 vêm em texto simples, sem o corpo `{error, message}`.

---

## 2. O modelo: `external_id`

Todo anúncio é identificado pelo **seu** código — o `external_id` que o CRM
já usa (ex.: `REF-1042`). `PUT /listings/{external_id}` **cria** o anúncio na
primeira chamada e **atualiza** nas seguintes. Rodar a sincronização toda
noite não gera duplicatas.

`external_id`: 1–100 caracteres, letras, dígitos e `- _ . :`.

---

## 3. Anúncios

### `GET /me`

Quem sou eu: imobiliária, escopos da chave, e o modo de moderação da
plataforma (`post` = publica na hora e a equipe revisa depois; `pre` = fica
em análise até ser aprovado).

### `GET /listings?page=1&status=all`

Lista os anúncios publicados por esta chave/conta. 100 por página.
`status`: `active`, `paused`, `rejected`, `pending_review` ou `all` (padrão,
tudo menos excluídos).

### `GET /listings/{external_id}`

Um anúncio.

### `PUT /listings/{external_id}` — criar ou atualizar

```http
PUT /api/v1/listings/REF-1042
Content-Type: application/json
Authorization: Bearer mk_...
```

```json
{
  "title": "Apartamento 2 quartos com sacada na Velha",
  "operation": "aluguel",
  "type": "apartamento",
  "address": "Rua João Pessoa, 1250, ap. 302",
  "neighborhood": "Velha",
  "city": "Blumenau",
  "uf": "SC",
  "lat": -26.9245,
  "lng": -49.0912,
  "area_m2": 72,
  "bedrooms": 2,
  "bathrooms": 1,
  "parking": 1,
  "floor": 3,
  "building_floors": 8,
  "price": 2100.00,
  "condo": 380.00,
  "iptu": 60.00,
  "description": "Apartamento arejado, sol da manhã...",
  "photos": [
    "https://cdn.suaimobiliaria.com.br/imoveis/1042/01.jpg",
    "https://cdn.suaimobiliaria.com.br/imoveis/1042/02.jpg"
  ],
  "furnished": "partial",
  "pet_friendly": true,
  "internet": false,
  "amenities": ["piscina", "academia", "portaria-24h"],
  "tags": ["sacada", "churrasqueira"],
  "contact_phone": "47999990000",
  "show_whatsapp": true,
  "show_phone": true,
  "show_chat": true,
  "matricula": "45.123",
  "inscricao": "01.02.003.0456.001",
  "owner_type": "agency",
  "available": true
}
```

Campos:

| Campo | Tipo | Obrigatório | Observações |
|---|---|---|---|
| `title` | string | sim | 3–200 caracteres. |
| `operation` | `aluguel` \| `venda` | sim | |
| `type` | string | sim | `apartamento`, `studio`, `casa`, `loft`, `coliving`, `cobertura`, `consultorio`, `galpao`, `comercial`, `terreno`, `terreno_comercial`, `loja`, `sala`, `predio`, `rural`. |
| `address` | string | sim | Rua, número, complemento. |
| `city`, `uf` | string | sim | `uf` com 2 letras. Cidades fora do cadastro são criadas. |
| `neighborhood` | string | não | Bairro pelo nome; é casado com o cadastro da cidade. |
| `lat`, `lng` | number | não | Sem coordenadas o anúncio não aparece no mapa. |
| `area_m2`, `bedrooms`, `bathrooms`, `parking` | integer | não | |
| `floor`, `building_floors` | integer | não | `floor` 0 = térreo. |
| `price` / `price_cents` | number / integer | sim | Em reais ou centavos; centavos vence se ambos vierem. |
| `condo`, `iptu` (ou `_cents`) | number | não | Mensais. |
| `description` | string | não | Até 8.000 caracteres. |
| `photos` | string[] | não | URLs públicas `http(s)://`. Até 16 (as excedentes são descartadas com aviso); JPG, PNG ou WebP, até 10 MB e 12000×12000 px. Baixamos e servimos as fotos. Redirecionamentos não são seguidos. Uma URL que falhar é pulada e aparece em `warnings`. Um anúncio sem nenhuma foto é salvo, mas não entra no catálogo público. |
| `furnished` | bool ou `no`/`partial`/`full` | não | |
| `pet_friendly`, `internet` | bool | não | |
| `amenities` | string[] | não | Áreas comuns. Viram filtro no site: `piscina`, `academia`, `churrasqueira`, `salao-festas`, `espaco-kids`, `pet-area`, `coworking`, `portaria-24h`, `sauna`, `mercadinho`. Outros valores são guardados sem filtro. Até 40 itens de até 40 caracteres (o mesmo vale para `tags`). |
| `tags` | string[] | não | Características livres do imóvel (`sacada`, `vista-rio`…). |
| `contact_phone` | string | não | Dígitos, com DDD. |
| `show_whatsapp`, `show_phone`, `show_chat` | bool | não | Padrão `true`. |
| `matricula`, `inscricao` | string | não | Matrícula (cartório) e inscrição imobiliária (IPTU). Anúncios com esses dados entram no filtro “com matrícula”. |
| `owner_type` | `owner` \| `agency` | não | Padrão `agency`. |
| `available` | bool | não | `false` pausa (fica oculto, não é excluído). |

Numa **atualização**, envie o registro **completo**. Mantêm o valor anterior
quando omitidos: `title`, `operation`, `type`, `address`, `city`, `uf`,
`neighborhood`, `lat`/`lng`, `area_m2`, `bedrooms`, `bathrooms`, `parking`,
`price`, `condo`, `iptu`, `description`, `photos` e `tags`. Os demais
(`floor`, `building_floors`, `furnished`, `pet_friendly`, `internet`,
`amenities`, `contact_phone`, `show_*`, `matricula`, `inscricao`,
`owner_type`) voltam ao padrão se não vierem. `photos` e `tags`, quando
enviados, substituem a lista inteira.

Cada URL de foto é baixada **uma vez** e lembrada: se a imagem mudar, use uma
URL nova.

Resposta `201 Created` (novo) ou `200 OK` (atualizado): o anúncio como ficou,
mais `created` e `warnings`.

```json
{
  "id": 18231,
  "public_id": "aa8fde4f9382",
  "external_id": "REF-1042",
  "url": "https://tudonuma.com/imovel/apartamento-aluguel-velha-blumenau-aa8fde4f9382",
  "status": "active",
  "moderation": "pending",
  "moderation_note": "",
  "created": true,
  "warnings": []
}
```

`status` é o ciclo de vida (`active`, `paused`, `pending_review`, `rejected`);
`moderation` é o parecer da equipe (`pending`, `approved`, `rejected`). Um
anúncio recusado traz o motivo em `moderation_note`; corrija e envie o `PUT`
de novo — isso o reenvia para análise.

### `POST /listings/batch`

Até 200 anúncios numa chamada (corpo até 2 MB). Cada item é julgado
separadamente quanto ao conteúdo; um JSON com tipo errado em qualquer item
recusa a chamada inteira (422). As fotos novas são baixadas durante a chamada:
com muitas fotos novas, prefira lotes de ~50 para não passar de 60 s.

```json
{ "listings": [ { "external_id": "REF-1042", "title": "...", "...": "..." }, { "external_id": "REF-1043", "...": "..." } ] }
```

```json
{ "ok": 2, "failed": 0, "results": [ { "external_id": "REF-1042", "ok": true, "id": 18231, "created": false, "warnings": [] } ] }
```

### `POST /listings/{external_id}/status`

`{ "status": "paused" }` ou `{ "status": "active" }`. Não reativa um anúncio
retido pela moderação (409). Não dispara webhook.

### `DELETE /listings/{external_id}`

Remove o anúncio (exclusão lógica; o histórico fica).

---

## 4. Webhooks

Cadastre uma URL `https://` em **Painel → Integrações → Webhooks** (ou via
`POST /webhooks`). Receberá um `POST` JSON a cada evento:

| Evento | Quando |
|---|---|
| `listing.created` | Um `PUT` criou o anúncio (só via API). |
| `listing.updated` | Um `PUT` atualizou (só via API). |
| `listing.deleted` | Um `DELETE` (só via API). |
| `listing.approved` | A moderação aprovou um anúncio da imobiliária (API ou painel). |
| `listing.rejected` | A moderação recusou — `moderation_note` diz por quê. |
| `ping` | Teste disparado pelo painel. |

Corpo:

```json
{
  "event": "listing.rejected",
  "created_at": "2026-09-07T14:03:11Z",
  "data": { "listing": { "id": 18231, "external_id": "REF-1042", "status": "rejected", "moderation": "rejected", "moderation_note": "Fotos pertencem a outro imóvel.", "...": "..." } }
}
```

Cabeçalhos:

- `X-Tudonuma-Event` — o nome do evento.
- `X-Tudonuma-Delivery` — id único da entrega (use para deduplicar).
- `X-Tudonuma-Signature` — `sha256=<hex>`, HMAC-SHA256 do corpo **bruto** com o
  `secret` mostrado ao criar o webhook. Verifique antes de confiar no payload.

Responda `2xx` em até 10 segundos. Outra resposta ou timeout é tentada de
novo com espera crescente (1 min, 5 min, 30 min, 2 h, 12 h), até 6 tentativas;
depois disso a entrega é abandonada. Redirecionamentos (3xx) não são seguidos
e contam como falha: cadastre a URL final. Até 10 webhooks ativos por
imobiliária, URL `https://` pública de até 500 caracteres.
O painel mostra as entregas e o último status de cada webhook.

Verificação em Node.js:

```js
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}
```

Gerenciamento por API (escopo `webhooks:manage`):

- `GET /webhooks` — lista.
- `POST /webhooks` — `{ "url": "https://...", "events": ["listing.approved", "listing.rejected"] }`. `events` vazio = todos. Resposta inclui `secret` (uma única vez).
- `DELETE /webhooks/{id}`.

---

## 5. Exemplo completo (curl)

```bash
KEY="mk_..."
curl -s -X PUT https://tudonuma.com/api/v1/listings/REF-1042 \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"title":"Casa 3 quartos no Garcia","operation":"venda","type":"casa","address":"Rua Amazonas, 310","city":"Blumenau","uf":"SC","neighborhood":"Garcia","area_m2":140,"bedrooms":3,"bathrooms":2,"parking":2,"price":650000,"photos":["https://exemplo.com.br/f1.jpg"]}'

curl -s https://tudonuma.com/api/v1/listings?status=all -H "Authorization: Bearer $KEY"

curl -s -X POST https://tudonuma.com/api/v1/listings/REF-1042/status \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"status":"paused"}'
```

---

## 6. Boas práticas

- Sincronize **incrementalmente**: envie só o que mudou desde a última execução, e a carteira inteira uma vez por dia com `/listings/batch`.
- Use URLs de foto **estáveis**: a mesma URL não é baixada duas vezes; uma URL nova é tratada como foto nova.
- Trate `moderation_note` no seu CRM: um anúncio recusado precisa de ação humana.
- Guarde o `X-Tudonuma-Delivery` dos webhooks processados e ignore repetições.
