openapi: 3.1.0

# ATENÇÃO: esta spec é VERIFICADA POR TESTE.
#
# internal/api/docs_test.go lê as rotas realmente registradas no mux
# (internal/api/server.go) e falha se alguma não estiver documentada aqui. Ou seja:
# adicionou rota, documenta. É de propósito — documentação que apodrece é pior do
# que não ter documentação, porque o cliente confia nela.

info:
  title: AcionaAI — API de WhatsApp
  version: "1.0.0"
  summary: Hub de integração de WhatsApp — o encanamento entre o WhatsApp e as suas ferramentas.
  description: |
    A AcionaAI é um **hub de integração**, não um CRM de atendimento. Ela conecta o WhatsApp
    às suas ferramentas (n8n, Chatwoot, Typebot, seu backend) com entrega garantida,
    isolamento por tenant e segredos cifrados em repouso. A conversa acontece na SUA
    ferramenta — aqui fica o transporte.

    ## Autenticação

    Dois tipos de credencial, o mesmo cabeçalho:

    ```http
    Authorization: Bearer sk_a1b2c3...      # API key (máquina: seu backend, n8n, script)
    Authorization: Bearer sess_a1b2c3...    # token de sessão humana (login por e-mail/senha)
    ```

    O painel usa um cookie `apinofc_session` (HttpOnly) — não use cookie em integração
    servidor-a-servidor; use a API key. A API key também é aceita em `X-API-Key`.

    Cada API key carrega **escopos** (`instances:read`, `instances:write`, `messages:send`).
    Chamar um endpoint sem o escopo devido devolve `403 forbidden` — nunca 404 disfarçado.

    ## Envio é ASSÍNCRONO (202, não 200)

    `POST /v1/instances/{id}/messages` responde **202 Accepted** com `status: QUEUED`.
    Isso não é a mensagem entregue: é a mensagem **aceita e persistida na fila**. O envio
    real acontece no worker (com retry, backoff e dead-letter). Acompanhe o resultado por
    `GET /v1/messages/{id}` ou, melhor, assine o webhook `message.status`.

    Quem trata 202 como "entregue" mente para o próprio usuário. Não faça isso.

    ## Idempotência

    Mande `Idempotency-Key: <seu-id-único>` no envio. Se a mesma chave chegar duas vezes
    (retry de rede, replay do n8n), a segunda chamada devolve **200** com a mensagem
    original em vez de enviar de novo. A chave VIRA o `message_id` — use um id do seu
    domínio (ex.: `pedido-8842-confirmacao`) e você consulta o status sem guardar mapa.

    ## Rate limit

    Limite por tenant (padrão 20 req/s, rajada 40). Excedeu: `429 rate_limited`.
    `/health` e `/v1/status` são públicos e têm um limite global próprio.

    ## Estados

    **Instância**: `CREATING` (aguardando pareamento) → `CONNECTED` → `OFFLINE` (caiu, vai
    reconectar sozinha) / `LOGGED_OUT` (deslogada no aparelho — precisa parear de novo).

    **Mensagem**: `QUEUED` → `SENT` → `DELIVERED` → `READ`, ou `FAILED`, ou `BLOCKED`.

    > **`BLOCKED` não é erro — é o anti-ban te protegendo.** A mensagem foi barrada antes
    > de sair porque o destinatário deu opt-out ("SAIR"/"STOP") ou porque a instância bateu
    > o limite diário do aquecimento. Reenviar em loop uma `BLOCKED` é o caminho mais curto
    > para o seu número ser banido. `BLOCKED` fica **fora** do denominador da taxa de
    > entrega em `/v1/stats/delivery` — bloqueio não é falha de transporte.

    ## Webhooks: valide a assinatura

    Toda entrega vai assinada com HMAC-SHA256 do segredo do webhook:

    ```http
    X-Webhook-Event: message.received
    X-Webhook-Timestamp: 1752345600
    X-Webhook-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
    ```

    A assinatura é `HMAC_SHA256(secret, timestamp + "." + corpo_bruto)`, em hex, prefixada
    por `sha256=`. Compare em **tempo constante** e rejeite timestamps velhos (> 5 min) para
    cortar replay. O SDK Node já traz `verifyWebhook()` pronto — é o ponto onde todo mundo erra.

  contact:
    name: AcionaAI
  license:
    name: Proprietária

servers:
  - url: http://localhost:8090
    description: Desenvolvimento (API direta)
  - url: http://localhost:3000
    description: Desenvolvimento (via painel/nginx)
  - url: https://api.acionaai.com.br
    description: Produção

tags:
  - name: Documentação
    description: A spec e o console interativo — servidos pela própria API, sem CDN.
  - name: Saúde
    description: Sondas reais de dependência e status público da plataforma.
  - name: Autenticação
    description: Login humano (sessão). Para máquina, use API key.
  - name: Usuários
    description: Contas do tenant (RBAC owner/admin/member).
  - name: Instâncias
    description: Um número de WhatsApp pareado. É a unidade de transporte.
  - name: Mensagens
    description: Envio (assíncrono) e consulta de status.
  - name: Webhooks
    description: O coração do hub — para onde os eventos vão.
  - name: Conversas
    description: A linha do tempo do transporte (o que trafegou). Nao e CRM — o atendimento e na sua ferramenta.
  - name: Anti-ban
    description: A protecao do numero — limite diario, aquecimento e jitter. Os mesmos numeros que o worker aplica.
  - name: Opt-out
    description: Anti-ban e LGPD — quem pediu para não receber.
  - name: Campanhas
    description: >-
      Envio em lote como capacidade de API (lista + template + agendamento), distribuido
      respeitando o anti-ban. Nao e uma ferramenta de CRM: e transporte em lote.
  - name: Estabilidade
    description: 'A prova do "não cai": uptime medido e SLIs de entrega.'
  - name: Chatwoot
    description: Ponte bidirecional com o Chatwoot.
  - name: Administração
    description: Tenants e chaves de API.

security:
  - apiKey: []

paths:
  # ------------------------------------------------------------------ Documentação
  /docs:
    get:
      tags: [Documentação]
      summary: Console interativo da API (playground)
      description: |
        HTML autocontido: navega os endpoints, mostra exemplos em cURL/Node/Python e
        dispara chamadas reais com a SUA chave. Nenhum asset vem de CDN — funciona
        offline e em ambiente fechado. Rota pública: só serve conteúdo estático, não
        toca no banco e não vaza nada.
      security: []
      responses:
        "200":
          description: A página do console.
          content:
            text/html:
              schema: { type: string }

  /v1/docs:
    get:
      tags: [Documentação]
      summary: Console interativo (mesmo de /docs, alcançável pelo proxy do painel)
      security: []
      responses:
        "200":
          description: A página do console.
          content:
            text/html:
              schema: { type: string }

  /v1/docs/assets/{file}:
    get:
      tags: [Documentação]
      summary: CSS/JS do console (embutidos no binário)
      security: []
      parameters:
        - name: file
          in: path
          required: true
          schema: { type: string, enum: [console.css, console.js] }
      responses:
        "200":
          description: O asset.
          content:
            text/css: { schema: { type: string } }
            text/javascript: { schema: { type: string } }
        "404": { $ref: "#/components/responses/NotFound" }

  /v1/docs/examples.json:
    get:
      tags: [Documentação]
      summary: Exemplos de código por endpoint (cURL, Node, Python)
      description: |
        Gerado a partir DESTA spec, não escrito à mão — por isso não diverge dela.
        O painel consome este JSON para renderizar o botão "copiar cURL" e as abas de
        linguagem em cada ação.
      security: []
      responses:
        "200":
          description: Mapa `"MÉTODO /caminho" → { curl, node, python }`.
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  $ref: "#/components/schemas/CodeSamples"
              example:
                "POST /v1/instances/{id}/messages":
                  operation_id: sendText
                  summary: Enviar mensagem de texto
                  curl: "curl -X POST http://localhost:8090/v1/instances/{id}/messages ..."
                  node: "const res = await fetch(...)"
                  python: "import requests\n..."

  /v1/openapi.yaml:
    get:
      tags: [Documentação]
      summary: A spec OpenAPI 3.1 (YAML)
      security: []
      responses:
        "200":
          description: A spec.
          content:
            application/yaml:
              schema: { type: string }

  /v1/openapi.json:
    get:
      tags: [Documentação]
      summary: A spec OpenAPI 3.1 (JSON)
      description: Mesmo documento, convertido de YAML — para ferramentas que só leem JSON.
      security: []
      responses:
        "200":
          description: A spec.
          content:
            application/json:
              schema: { type: object }

  # ------------------------------------------------------------------ Saúde
  /health:
    get:
      tags: [Saúde]
      summary: Sonda real das dependências (DB, Redis, Vault)
      description: |
        **503 quando o banco está fora** — de propósito. Um `/health` que responde 200 com
        o banco caído engana o orquestrador e mantém um pod quebrado recebendo tráfego.
        Público (rate limit global). Nunca devolve DSN, host ou credencial.
      security: []
      responses:
        "200":
          description: Operacional (ou degradado, mas servindo).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
              example:
                status: operational
                service: apinofc
                components:
                  - { key: database, name: PostgreSQL, status: operational, latency_ms: 1.8 }
                  - { key: queue, name: Redis (fila), status: operational, latency_ms: 0.7 }
                  - { key: vault, name: Vault Transit (KEK), status: "off", latency_ms: 0 }
                time: "2026-07-12T14:03:11Z"
        "503":
          description: Fora do ar (o banco não responde).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
              example:
                status: outage
                service: apinofc
                components:
                  - { key: database, name: PostgreSQL, status: outage, latency_ms: 2000, detail: "timeout" }
                time: "2026-07-12T14:03:11Z"
        "429": { $ref: "#/components/responses/RateLimited" }

  /metrics:
    get:
      tags: [Saúde]
      summary: Métricas Prometheus
      description: |
        Formato de exposição do Prometheus. Inclui `apinofc_messages_total{status=...}`,
        `apinofc_queue_latency_seconds`, `apinofc_sessions_connected`,
        `apinofc_platform_uptime_ratio{window=...}` e as métricas HTTP.
      security: []
      responses:
        "200":
          description: As métricas.
          content:
            text/plain:
              schema: { type: string }
              example: |
                # HELP apinofc_messages_total Total de mensagens por status
                apinofc_messages_total{status="sent"} 1428
                apinofc_messages_total{status="blocked"} 37

  /v1/status:
    get:
      tags: [Saúde, Estabilidade]
      summary: Status público da plataforma (status page)
      description: |
        Aberto e **anônimo por construção**: só componentes e percentuais agregados.
        Nenhum tenant, instância, número ou contagem de mensagens — isso vazaria o
        tamanho da carteira. Cacheado por 15s. Os incidentes são DERIVADOS da medição
        (não há tabela escrita à mão: não dá para esconder uma queda "esquecendo" de
        publicar o post-mortem).
      security: []
      responses:
        "200":
          description: O retrato da plataforma.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PublicStatus" }
              example:
                status: operational
                checked_at: "2026-07-12T14:03:11Z"
                components:
                  - { key: database, name: PostgreSQL, status: operational, latency_ms: 1.8 }
                  - { key: queue, name: Redis (fila), status: operational, latency_ms: 0.7 }
                slo: { session_uptime_percent: 99.9, queue_latency_p95_seconds: 30 }
                sessions:
                  uptime_24h: 99.97
                  has_data_24h: true
                  uptime_7d: 99.91
                  has_data_7d: true
                  uptime_30d: 99.88
                  has_data_30d: true
                history:
                  - { date: "2026-07-11", uptime_percent: 100, downs: 0, has_data: true }
                  - { date: "2026-07-12", uptime_percent: 99.97, downs: 1, has_data: true }
                incidents:
                  - { date: "2026-06-28", uptime_percent: 98.4, downs: 3, summary: "degradação de conectividade de sessões" }
                delivery:
                  success_rate_24h: 99.6
                  has_data: true
                  queue_latency_p50_seconds: 1.2
                  queue_latency_p95_seconds: 4.8
        "429": { $ref: "#/components/responses/RateLimited" }

  # ------------------------------------------------------------------ Autenticação
  /v1/auth/login:
    post:
      tags: [Autenticação]
      summary: Trocar e-mail e senha por uma sessão
      description: |
        Para o PAINEL (humano). Integração servidor-a-servidor usa API key, não isto.

        Devolve um cookie `apinofc_session` (HttpOnly, SameSite=Lax) **e** o token no corpo
        — o navegador usa o cookie e ignora o campo; curl/SDK usam o token em
        `Authorization: Bearer sess_...`.

        Toda falha devolve a MESMA mensagem e o MESMO status: não revelamos se o e-mail
        existe, se a conta está desativada ou se só a senha está errada. Rate limit por
        e-mail (5 em rajada, 1 a cada 30s) e por IP (20 em rajada, 1 a cada 5s).

        Se `must_change_password` vier `true`, a senha é provisória: só os endpoints da
        própria conta funcionam até você chamar `POST /v1/auth/password`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email, examples: ["joao.silva@acme.com.br"] }
                password: { type: string, format: password, examples: ["senha-forte-do-joao"] }
            example:
              email: joao.silva@acme.com.br
              password: senha-forte-do-joao
      responses:
        "200":
          description: Autenticado.
          headers:
            Set-Cookie:
              description: "`apinofc_session=sess_...; HttpOnly; SameSite=Lax; Path=/`"
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                properties:
                  user: { $ref: "#/components/schemas/User" }
                  must_change_password: { type: boolean }
                  expires_at: { type: string, format: date-time }
                  token:
                    type: string
                    description: Token de sessão para clientes não-navegador (Bearer).
              example:
                user:
                  id: 3f2b1c8e-9d4a-4f77-b1a2-6c0e5d3a7b91
                  tenant_id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                  email: joao.silva@acme.com.br
                  name: João Silva
                  role: admin
                  must_change_password: false
                  disabled: false
                  created_at: "2026-05-02T10:11:12Z"
                  last_login_at: "2026-07-12T14:03:11Z"
                must_change_password: false
                expires_at: "2026-07-13T02:03:11Z"
                token: sess_8f14e45fceea167a5a36dedd4bea2543
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Credenciais inválidas (mensagem genérica de propósito).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: invalid_credentials, message: "credenciais inválidas" } }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/auth/logout:
    post:
      tags: [Autenticação]
      summary: Encerrar a sessão atual
      description: Apaga a sessão no servidor e limpa o cookie. API key não tem logout (revogue a chave).
      responses:
        "204": { description: Sessão encerrada. }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/me:
    get:
      tags: [Autenticação]
      summary: Quem sou eu (funciona para sessão E para API key)
      description: |
        Um único endpoint para o painel reidratar o login e para o script conferir quais
        escopos a chave tem de fato. `type` diz qual credencial chegou.
      responses:
        "200":
          description: O principal autenticado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
              examples:
                api_key:
                  summary: Autenticado por API key
                  value:
                    type: api_key
                    tenant_id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                    tenant_name: Acme Distribuidora
                    scopes: [instances:read, instances:write, messages:send]
                    is_admin: false
                usuario:
                  summary: Autenticado por sessão humana
                  value:
                    type: user
                    tenant_id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                    tenant_name: Acme Distribuidora
                    scopes: [instances:read, instances:write, messages:send]
                    is_admin: false
                    must_change_password: false
                    user:
                      id: 3f2b1c8e-9d4a-4f77-b1a2-6c0e5d3a7b91
                      tenant_id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                      email: joao.silva@acme.com.br
                      name: João Silva
                      role: admin
                      must_change_password: false
                      disabled: false
                      created_at: "2026-05-02T10:11:12Z"
                      last_login_at: "2026-07-12T14:03:11Z"
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/auth/password:
    post:
      tags: [Autenticação]
      summary: Trocar a própria senha
      description: |
        Derruba TODAS as outras sessões do usuário — quem tivesse um token roubado perde o
        acesso na hora. É também o endpoint que libera a conta quando `must_change_password`
        está ligado.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: { type: string, format: password }
                new_password: { type: string, format: password, minLength: 12 }
            example:
              current_password: senha-provisoria-Ab12
              new_password: minha-nova-senha-forte-2026
      responses:
        "200":
          description: Senha trocada.
          content:
            application/json:
              schema: { type: object, properties: { changed: { type: boolean } } }
              example: { changed: true }
        "400":
          description: Corpo inválido, senha igual à atual (`same_password`) ou fraca (`weak_password`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: weak_password, message: "a senha deve ter ao menos 12 caracteres" } }
        "401":
          description: Senha atual incorreta.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: invalid_credentials, message: "senha atual incorreta" } }
        "403":
          description: Autenticado por API key (chave não tem senha).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: forbidden, message: "apenas contas de usuário trocam senha (API key não tem senha)" } }

  # ------------------------------------------------------------------ Usuários
  /v1/users:
    get:
      tags: [Usuários]
      summary: Listar usuários do tenant
      description: "Permissão: `owner` (qualquer tenant) ou `admin` (só o próprio). `member` recebe 403."
      parameters:
        - name: tenant_id
          in: query
          description: Só o owner pode apontar outro tenant.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Os usuários.
          content:
            application/json:
              schema:
                type: object
                properties:
                  users: { type: array, items: { $ref: "#/components/schemas/User" } }
                  tenant_id: { type: string, format: uuid }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    post:
      tags: [Usuários]
      summary: Convidar um usuário
      description: |
        Sem `password`, geramos uma **senha inicial** e devolvemos UMA ÚNICA VEZ, com
        troca obrigatória no primeiro login (mesmo contrato da API key: guardamos só o hash).

        Anti-escalonamento: só quem já é owner da plataforma cria outro `owner`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                name: { type: string }
                role: { type: string, enum: [owner, admin, member], default: member }
                password:
                  type: string
                  description: Opcional. Vazio → senha inicial gerada + troca obrigatória.
                tenant_id: { type: string, format: uuid, description: Só o owner pode apontar outro tenant. }
            example:
              email: maria.souza@acme.com.br
              name: Maria Souza
              role: member
      responses:
        "201":
          description: Criado. `initial_password` só aparece aqui.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/User"
                  - type: object
                    properties:
                      initial_password: { type: string }
                      warning: { type: string }
              example:
                id: b7e2c9a1-3d55-4c8e-9f10-2a4b6c8d0e2f
                tenant_id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                email: maria.souza@acme.com.br
                name: Maria Souza
                role: member
                must_change_password: true
                disabled: false
                created_at: "2026-07-12T14:05:00Z"
                last_login_at: null
                initial_password: "Qx7-tp42-Mv9k"
                warning: "guarde esta senha — ela não será exibida novamente; a troca é obrigatória no primeiro login"
        "400":
          description: "`bad_request` | `bad_email` | `bad_role` | `weak_password`"
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: bad_role, message: "papel inválido: use owner, admin ou member" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: E-mail já cadastrado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: email_taken, message: "já existe uma conta com este e-mail" } }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/users/{id}:
    parameters:
      - $ref: "#/components/parameters/UserID"
    patch:
      tags: [Usuários]
      summary: Ativar/desativar uma conta
      description: |
        Suspender sem apagar. Desativar **invalida as sessões existentes na hora**.
        Um id de outro tenant devolve **404** (não 403): não vazamos nem a existência da
        conta alheia.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [disabled]
              properties:
                disabled: { type: boolean }
            example: { disabled: true }
      responses:
        "200":
          description: A conta atualizada.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Você não pode desativar a própria conta (`self_disable`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: self_disable, message: "você não pode desativar a própria conta" } }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Usuários]
      summary: Remover uma conta
      description: As sessões do usuário caem por cascade. A plataforma nunca fica sem owner.
      responses:
        "204": { description: Removida. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`self_delete` (a própria conta) ou `last_owner` (o último owner da plataforma)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: last_owner, message: "não é possível remover o último owner da plataforma" } }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ------------------------------------------------------------------ Instâncias
  /v1/instances:
    post:
      tags: [Instâncias]
      summary: Criar instância (inicia o pareamento)
      description: |
        **Escopo: `instances:write`.**

        Cria a instância e abre a sessão no WhatsApp. Ela nasce `CREATING`: o próximo passo
        é buscar o QR em `GET /v1/instances/{id}/qr` e escanear no aparelho.

        **Cota.** Cada instância é uma sessão de WhatsApp de verdade (conexão, memória,
        risco no número) — por isso o tenant tem um teto contratado (`max_instances`). Se
        ele já estiver cheio, a criação é recusada com **409 `instance_quota_exceeded`**, e
        o erro traz `limit`/`used`. O teto atual e o consumo estão no `GET /v1/instances`
        (bloco `quota`): consulte-o antes para não descobrir o limite no erro. Só o dono da
        plataforma muda a cota (`PATCH /v1/tenants/{id}`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, examples: ["Vendas SP"] }
            example:
              name: Vendas SP
      responses:
        "201":
          description: Criada — vá parear.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  status: { $ref: "#/components/schemas/InstanceStatus" }
                  next: { type: string, description: O caminho do QR. }
              example:
                id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                name: Vendas SP
                status: CREATING
                next: /v1/instances/4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a/qr
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: O tenant da credencial não existe mais (`not_found`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: |
            **`instance_quota_exceeded`** — a cota de instâncias do tenant está cheia.
            Não é erro de infraestrutura: é a regra de negócio. Apague um número que não
            usa, ou peça ao dono da plataforma para aumentar a cota. O erro traz `limit`
            (o teto) e `used` (o consumo) para você não precisar de regex na mensagem.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      error:
                        type: object
                        properties:
                          limit: { type: integer, description: O teto contratado. }
                          used: { type: integer, description: Instâncias já existentes. }
              example:
                error:
                  code: instance_quota_exceeded
                  message: "cota de instâncias atingida: 1 de 1 em uso (apague um número ou aumente a cota do cliente)"
                  limit: 1
                  used: 1
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: A engine não conseguiu abrir a sessão (`engine_error`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: engine_error, message: "falha ao iniciar sessão: ..." } }
        "503":
          description: |
            `engine_unavailable` — o motor de WhatsApp não está ligado nesta instalação. A
            instância **fica criada** (e consome a cota), em `OFFLINE`: apague-a ou pareie
            quando o motor voltar.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: engine_unavailable, message: "engine indisponível" } }
    get:
      tags: [Instâncias]
      summary: Listar instâncias do tenant (com a cota)
      description: |
        **Escopo: `instances:read`.** Só as do SEU tenant — o isolamento é no WHERE, não no código.

        Vem com o bloco **`quota`** (consumo e teto de instâncias). É o que permite à sua
        tela — e ao painel — desabilitar o "novo número" **antes** de tomar um 409.
      responses:
        "200":
          description: As instâncias e a cota do tenant.
          content:
            application/json:
              schema:
                type: object
                properties:
                  instances: { type: array, items: { $ref: "#/components/schemas/Instance" } }
                  quota: { $ref: "#/components/schemas/InstanceQuota" }
              example:
                quota: { used: 2, max: 3, remaining: 1, full: false }
                instances:
                  - id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                    name: Vendas SP
                    jid: 5511987654321:12@s.whatsapp.net
                    status: CONNECTED
                    created_at: "2026-07-01T09:00:00Z"
                    updated_at: "2026-07-12T13:59:02Z"
                  - id: 9a1b2c3d-4e5f-4061-8273-6a5b4c3d2e1f
                    name: Suporte
                    jid: null
                    status: CREATING
                    created_at: "2026-07-12T14:02:00Z"
                    updated_at: "2026-07-12T14:02:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/instances/{id}:
    parameters:
      - $ref: "#/components/parameters/InstanceID"
    get:
      tags: [Instâncias]
      summary: Detalhe da instância
      description: "**Escopo: `instances:read`.** Instância de outro tenant → 404 (nunca confirmamos que existe)."
      responses:
        "200":
          description: A instância.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Instance" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Instâncias]
      summary: Remover a instância (desloga do WhatsApp)
      description: "**Escopo: `instances:write`.** Faz logout no aparelho e apaga a sessão cifrada."
      responses:
        "204": { description: Removida. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/instances/{id}/qr:
    parameters:
      - $ref: "#/components/parameters/InstanceID"
    get:
      tags: [Instâncias]
      summary: QR do pareamento (faça polling)
      description: |
        **Escopo: `instances:read`.**

        Chame em loop (~2s) até `status: connected`. O QR do WhatsApp **rotaciona**: use o
        `expires_in_seconds` e busque de novo. `qr_png_base64` já vem pronto para um
        `<img src="data:image/png;base64,...">`.

        - `200` + `status: pending` → tem QR, mostre.
        - `202` + `status: pending` → a sessão subiu mas o QR ainda não saiu (tente em ~1s).
        - `200` + `status: connected` → pareou. Pare o polling.
        - `409 session_inactive` → o servidor reiniciou antes do pareamento; recrie a instância.
      responses:
        "200":
          description: QR pronto, ou já conectado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QR" }
              examples:
                pendente:
                  summary: QR disponível para escanear
                  value:
                    status: pending
                    code: "2@Xr7f0K...,BQm1c3Vt...,Q0hFQ0s="
                    qr_png_base64: iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAA...
                    expires_in_seconds: 20
                conectado:
                  summary: Pareamento concluído
                  value: { status: connected }
        "202":
          description: A sessão está viva, o QR ainda não foi emitido. Tente de novo em ~1s.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/QR" }
              example: { status: pending, message: "QR ainda sendo gerado, tente novamente em ~1s" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Sessão não está ativa em memória (`session_inactive`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error:
                  code: session_inactive
                  message: "sessão não está ativa em memória (o servidor pode ter reiniciado antes do pareamento). Recrie a instância."
        "429": { $ref: "#/components/responses/RateLimited" }

  # ------------------------------------------------------------------ Mensagens
  /v1/instances/{id}/messages:
    parameters:
      - $ref: "#/components/parameters/InstanceID"
    post:
      tags: [Mensagens]
      summary: Enviar texto (assíncrono — 202)
      operationId: sendText
      description: |
        **Escopo: `messages:send`.**

        Responde **202 `QUEUED`**: aceita e persistida, não entregue. O worker envia com
        retry/backoff e o status avança sozinho (`SENT` → `DELIVERED` → `READ`) conforme os
        recibos do WhatsApp. Acompanhe por `GET /v1/messages/{message_id}` ou assine o
        webhook `message.status`.

        Mande `Idempotency-Key` e a chave vira o `message_id`. Reenviou a mesma chave?
        Devolvemos **200** com a mensagem original, sem enviar de novo.

        A mensagem pode terminar em `BLOCKED` — não é erro: é o anti-ban (opt-out ou limite
        diário) te protegendo de um ban.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, text]
              properties:
                to:
                  type: string
                  description: Número em formato internacional, sem `+`. Aceita também o JID completo.
                  examples: ["5511987654321"]
                text: { type: string, examples: ["Olá, João! Seu pedido #8842 saiu para entrega 🚚"] }
            example:
              to: "5511987654321"
              text: "Olá, João! Seu pedido #8842 saiu para entrega 🚚"
      responses:
        "202":
          description: Enfileirada.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Enqueued" }
              example:
                message_id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                status: QUEUED
                status_url: /v1/messages/7c9e6679-7425-40de-944b-e07fc1f90ae7
        "200":
          description: |
            Replay idempotente: a `Idempotency-Key` já tinha sido usada. Nada foi enviado
            de novo; este é o estado ATUAL da mensagem original.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: Falha ao enfileirar (Redis fora). A mensagem já fica `FAILED`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: enqueue_failed, message: "dial tcp redis:6379: connect: connection refused" } }
    get:
      tags: [Mensagens]
      summary: Listar mensagens da instância
      description: "**Escopo: `instances:read`.** Paginada, mais recentes primeiro."
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        "200":
          description: As mensagens.
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages: { type: array, items: { $ref: "#/components/schemas/Message" } }
                  limit: { type: integer }
                  offset: { type: integer }
                  count: { type: integer }
              example:
                messages:
                  - id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                    instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                    to: "5511987654321"
                    status: READ
                    wa_message_id: 3EB0C767D82B0F3A1B2C
                    error: ""
                    created_at: "2026-07-12T14:00:00Z"
                    updated_at: "2026-07-12T14:00:09Z"
                  - id: pedido-8842-confirmacao
                    instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                    to: "5521912345678"
                    status: BLOCKED
                    wa_message_id: ""
                    error: "opt-out: destinatário pediu para não receber"
                    created_at: "2026-07-12T13:58:00Z"
                    updated_at: "2026-07-12T13:58:01Z"
                limit: 50
                offset: 0
                count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/instances/{id}/media:
    parameters:
      - $ref: "#/components/parameters/InstanceID"
    post:
      tags: [Mensagens]
      summary: Enviar mídia (assíncrono — 202)
      operationId: sendMedia
      description: |
        **Escopo: `messages:send`.** Limite: **20 MB**.

        Três formas de mandar os bytes, escolha uma:
        1. `media_base64` — o arquivo em base64 no JSON.
        2. `media_url` — a gente baixa. A URL é **checada contra SSRF** (IP privado/reservado
           é recusado, inclusive em redirect e DNS rebinding).
        3. `multipart/form-data` com o campo `file` (e `to`, `caption`, `type` como campos).

        Sem `type`, inferimos do mimetype (`image` | `audio` | `video` | `document`).
        Vale o mesmo contrato de idempotência e de `BLOCKED` do envio de texto.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to: { type: string, examples: ["5511987654321"] }
                type: { type: string, enum: [image, audio, video, document], description: Opcional — inferido do mimetype. }
                mimetype: { type: string, examples: ["application/pdf"] }
                filename: { type: string, examples: ["nota-fiscal-8842.pdf"] }
                caption: { type: string, examples: ["Segue a NF do pedido #8842"] }
                media_base64: { type: string, contentEncoding: base64 }
                media_url: { type: string, format: uri, examples: ["https://cdn.acme.com.br/nf/8842.pdf"] }
            examples:
              por_url:
                summary: Documento por URL
                value:
                  to: "5511987654321"
                  media_url: https://cdn.acme.com.br/nf/8842.pdf
                  filename: nota-fiscal-8842.pdf
                  caption: "Segue a NF do pedido #8842"
              por_base64:
                summary: Imagem em base64
                value:
                  to: "5511987654321"
                  type: image
                  mimetype: image/png
                  filename: comprovante.png
                  media_base64: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=
          multipart/form-data:
            schema:
              type: object
              required: [to, file]
              properties:
                to: { type: string }
                file: { type: string, format: binary }
                type: { type: string, enum: [image, audio, video, document] }
                filename: { type: string }
                caption: { type: string }
      responses:
        "202":
          description: Enfileirada.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Enqueued"
                  - type: object
                    properties:
                      type: { type: string, enum: [image, audio, video, document] }
              example:
                message_id: 1b4e28ba-2fa1-11d2-883f-0016d3cca427
                status: QUEUED
                type: document
                status_url: /v1/messages/1b4e28ba-2fa1-11d2-883f-0016d3cca427
        "200":
          description: Replay idempotente (a chave já foi usada).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400":
          description: "`bad_request` | `bad_base64` | mídia vazia | multipart inválido."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: bad_request, message: "informe media_base64 ou media_url (ou use multipart com 'file')" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413":
          description: Mídia acima de 20 MB (`too_large`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: too_large, message: "mídia excede 20 MB" } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: "`download_failed` (a `media_url` não pôde ser baixada — inclui recusa anti-SSRF) ou `enqueue_failed`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: download_failed, message: "destino não permitido: IP privado" } }

  /v1/messages:
    get:
      tags: [Mensagens]
      summary: Log de transporte do TENANT (todas as instâncias)
      description: |
        **Escopo: `instances:read`.**

        O log de entrega do tenant inteiro — o `GET /v1/instances/{id}/messages` continua
        valendo, mas quem tem cinco números precisava fazer cinco chamadas e juntar no
        cliente (que trunca, ordena errado e mente no total).

        Isolado por tenant: um `instance_id` de outro tenant não casa e devolve vazio —
        nunca dado alheio. Ordenado do mais recente para o mais antigo.

        **Não é histórico de conversa** (isso é `/v1/conversations`): é o que a plataforma
        aceitou transportar e o que aconteceu com cada item.
      parameters:
        - name: instance_id
          in: query
          description: Filtra uma instância do PRÓPRIO tenant.
          schema: { type: string, format: uuid }
        - name: status
          in: query
          description: Filtra o estado de entrega. Estado inválido → 400 (e não um filtro que não filtra).
          schema: { $ref: "#/components/schemas/MessageStatus" }
        - name: to
          in: query
          description: Filtra o destinatário (casamento parcial — `9000123` acha `5511990001234`).
          schema: { type: string }
        - name: since
          in: query
          description: "Corte inferior: RFC3339 (`2026-07-01T00:00:00Z`) ou data pura (`2026-07-01`, 00:00 UTC)."
          schema: { type: string }
        - name: until
          in: query
          description: "Corte superior (exclusivo), mesmo formato de `since`."
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        "200":
          description: As mensagens do tenant (com o total para a paginação).
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages: { type: array, items: { $ref: "#/components/schemas/Message" } }
                  total: { type: integer, description: Total que casa com os filtros (não só a página). }
                  limit: { type: integer }
                  offset: { type: integer }
                  count: { type: integer }
              example:
                messages:
                  - id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                    instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                    to: "5511987654321"
                    status: DELIVERED
                    wa_message_id: 3EB0C767D82B0F3A1B2C
                    error: ""
                    created_at: "2026-07-12T14:00:00Z"
                    updated_at: "2026-07-12T14:00:06Z"
                total: 1428
                limit: 50
                offset: 0
                count: 1
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/messages/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: O `message_id` (ou a sua `Idempotency-Key`, que é a mesma coisa).
        schema: { type: string }
        examples:
          uuid: { value: 7c9e6679-7425-40de-944b-e07fc1f90ae7 }
          idempotency: { value: pedido-8842-confirmacao }
    get:
      tags: [Mensagens]
      summary: Status de uma mensagem
      description: |
        **Escopo: `instances:read`.**

        É aqui que o 202 vira verdade. `status` é o estado real do transporte;
        `wa_message_id` só existe depois do `SENT`.
      responses:
        "200":
          description: A mensagem.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
              examples:
                entregue:
                  summary: Caminho feliz
                  value:
                    id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                    instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                    to: "5511987654321"
                    status: DELIVERED
                    wa_message_id: 3EB0C767D82B0F3A1B2C
                    error: ""
                    created_at: "2026-07-12T14:00:00Z"
                    updated_at: "2026-07-12T14:00:06Z"
                bloqueada:
                  summary: BLOCKED — o anti-ban protegeu você (não é erro)
                  value:
                    id: pedido-8842-confirmacao
                    instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                    to: "5521912345678"
                    status: BLOCKED
                    wa_message_id: ""
                    error: "opt-out: destinatário pediu para não receber"
                    created_at: "2026-07-12T13:58:00Z"
                    updated_at: "2026-07-12T13:58:01Z"
                falhou:
                  summary: FAILED — esgotou as tentativas (dá para reprocessar)
                  value:
                    id: 2c1a9f30-55bb-4a77-9e0d-3f8c1b2a4d6e
                    instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                    to: "5511999999999"
                    status: FAILED
                    wa_message_id: ""
                    error: "número não está no WhatsApp"
                    created_at: "2026-07-12T13:40:00Z"
                    updated_at: "2026-07-12T13:41:12Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/messages/{id}/attempts:
    parameters:
      - name: id
        in: path
        required: true
        description: O `message_id`.
        schema: { type: string }
    get:
      tags: [Mensagens]
      summary: Linha do tempo de tentativas de uma mensagem (auditoria)
      description: |
        **Escopo: `instances:read`.**

        A resposta ao "minha mensagem não chegou". O `status` sozinho é só o estado atual —
        aqui está a HISTÓRIA: enfileirada → tentativa 1 (erro X) → tentativa 2 (erro Y) →
        estado final, com o erro exato e o horário de cada uma.

        - `attempt: 0` é o **enfileiramento** (o 202 da API, a campanha, ou um reprocessamento
          da dead-letter) — por isso ele pode aparecer mais de uma vez.
        - `RETRYING` é uma tentativa que falhou mas **ainda seria re-tentada** (não é perda);
          `FAILED` é o fim da linha (esgotou as tentativas ou falha permanente).
        - `tries` conta só as tentativas do worker (o enfileiramento não é tentativa).

        Os números vêm do próprio asynq (`retry_count`), não de um contador paralelo nosso:
        é o que a fila realmente fez. Retenção: `MESSAGE_ATTEMPT_RETENTION_DAYS` (padrão 30d).
      responses:
        "200":
          description: A linha do tempo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { $ref: "#/components/schemas/Message" }
                  attempts: { type: array, items: { $ref: "#/components/schemas/MessageAttempt" } }
                  tries: { type: integer, description: Tentativas do worker (sem contar o enfileiramento). }
                  note: { type: string }
              example:
                message:
                  id: 2c1a9f30-55bb-4a77-9e0d-3f8c1b2a4d6e
                  instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                  to: "5511999999999"
                  status: FAILED
                  wa_message_id: ""
                  error: "instância não pareada"
                  created_at: "2026-07-13T03:25:48Z"
                  updated_at: "2026-07-13T03:26:30Z"
                attempts:
                  - { attempt: 0, status: QUEUED, error: null, wa_message_id: null, at: "2026-07-13T03:25:48Z" }
                  - { attempt: 1, status: RETRYING, error: "instância não pareada", wa_message_id: null, at: "2026-07-13T03:25:48Z" }
                  - { attempt: 2, status: RETRYING, error: "instância não pareada", wa_message_id: null, at: "2026-07-13T03:25:49Z" }
                  - { attempt: 6, status: FAILED, error: "instância não pareada", wa_message_id: null, at: "2026-07-13T03:26:30Z" }
                tries: 6
                note: "attempt=0 é o enfileiramento; RETRYING é uma tentativa que ainda seria re-tentada"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/messages/{id}/retry:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    post:
      tags: [Mensagens]
      summary: Reprocessar uma mensagem FAILED (dead-letter)
      operationId: retryMessage
      description: |
        **Escopo: `messages:send`.**

        Reusa o payload arquivado na dead-letter — você não precisa reenviar os bytes da
        mídia nem o texto. Só vale para `FAILED`: `QUEUED`/`SENT`/`BLOCKED` devolvem 409.

        `BLOCKED` **não** é reprocessável de propósito. Se o destinatário deu opt-out,
        insistir é caminho de ban. Remova o opt-out primeiro (se ele pediu de volta) e
        envie uma mensagem nova.
      responses:
        "202":
          description: Reenfileirada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message_id: { type: string }
                  status: { $ref: "#/components/schemas/MessageStatus" }
              example: { message_id: 2c1a9f30-55bb-4a77-9e0d-3f8c1b2a4d6e, status: QUEUED }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`not_failed` (o status não é FAILED) ou `not_archived` (a retenção da dead-letter expirou)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                nao_falhou:
                  value: { error: { code: not_failed, message: "só é possível reprocessar mensagens com status FAILED" } }
                nao_arquivada:
                  value: { error: { code: not_archived, message: "a tarefa não está mais na dead-letter (retenção pode ter expirado)" } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: Falha ao falar com a fila (`retry_failed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ------------------------------------------------------------------ Estabilidade
  /v1/instances/{id}/uptime:
    parameters:
      - $ref: "#/components/parameters/InstanceID"
    get:
      tags: [Estabilidade]
      summary: Uptime medido da sessão (SLI)
      description: |
        **Escopo: `instances:read`.**

        O uptime é **derivado das transições reais** da sessão (`session_events`), não de um
        número escrito à mão. `has_data: false` significa "não medimos ainda" — e dizemos
        isso em vez de mostrar 100% ou 0% mentirosos.
      parameters:
        - $ref: "#/components/parameters/Window"
      responses:
        "200":
          description: O SLI da sessão.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InstanceUptime" }
              example:
                instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                name: Vendas SP
                window: 24h
                uptime:
                  uptime_percent: 99.97
                  connected_seconds: 86375
                  measured_seconds: 86400
                  downs: 1
                  has_data: true
                  window_start: "2026-07-11T14:03:11Z"
                  window_end: "2026-07-12T14:03:11Z"
                days:
                  - { date: "2026-07-11", uptime_percent: 100, downs: 0, has_data: true }
                  - { date: "2026-07-12", uptime_percent: 99.97, downs: 1, has_data: true }
                events:
                  - { at: "2026-07-12T13:59:02Z", event: connected, reason: "" }
                  - { at: "2026-07-12T13:58:37Z", event: disconnected, reason: network }
                live: true
                slo: { session_uptime_percent: 99.9 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/stats/delivery:
    get:
      tags: [Estabilidade]
      summary: Taxa de entrega e latência de fila do tenant
      description: |
        **Escopo: `instances:read`.**

        Métricas de TRANSPORTE (não é histórico de conversa): quantas saíram, quantas foram
        entregues/lidas, quantas falharam, e quanto tempo a mensagem ficou na fila
        (do 202 até o envio efetivo).

        `blocked` fica **fora** do `total`: bloqueio do anti-ban é proteção, não falha —
        contá-lo como falha faria você "consertar" justamente o que evita o ban.

        **`group_by=day`** acrescenta a SÉRIE (uma linha por dia por status), agregada com
        `date_trunc` **no banco** e no fuso de `tz` — é o gráfico do painel. Sem isto, quem
        quisesse o gráfico teria que paginar o log inteiro e somar no cliente (que trunca).
        Os dias sem mensagem vêm com zero: o gráfico não inventa uma reta entre dois pontos.
      parameters:
        - $ref: "#/components/parameters/Window"
        - name: instance_id
          in: query
          description: Filtra uma instância. Instância de outro tenant simplesmente não casa (zeros).
          schema: { type: string, format: uuid }
        - name: group_by
          in: query
          description: "`day` acrescenta a série temporal por dia e por status. Ausente = só o agregado (contrato antigo)."
          schema: { type: string, enum: [day] }
        - name: tz
          in: query
          description: |
            Fuso IANA usado para cortar o dia (default `UTC`). Ex.: `America/Sao_Paulo` — a
            meia-noite de SP é 03:00 UTC, e sem isto o "hoje" do painel não seria o do cliente.
            Fuso desconhecido → 400 (`bad_timezone`), nunca um gráfico silenciosamente errado.
          schema: { type: string, examples: ["America/Sao_Paulo"] }
      responses:
        "200":
          description: Os SLIs de entrega (e a série, quando `group_by=day`).
          content:
            application/json:
              schema:
                type: object
                properties:
                  window: { type: string, enum: ["24h", "7d", "30d"] }
                  delivery: { $ref: "#/components/schemas/DeliveryStats" }
                  group_by: { type: string, enum: [day] }
                  timezone: { type: string }
                  series:
                    type: array
                    description: Um ponto por dia da janela (inclusive os zerados). Só com `group_by=day`.
                    items: { $ref: "#/components/schemas/DeliveryPoint" }
                  slo:
                    type: object
                    properties:
                      queue_latency_p95_seconds: { type: number }
                      delivery_success_percent: { type: number }
              example:
                window: 7d
                delivery:
                  total: 1428
                  sent: 1422
                  delivered: 1390
                  read: 1102
                  failed: 6
                  queued: 0
                  blocked: 37
                  success_rate: 99.58
                  queue_latency_p50_seconds: 1.2
                  queue_latency_p95_seconds: 4.8
                group_by: day
                timezone: America/Sao_Paulo
                series:
                  - { date: "2026-07-11", total: 210, queued: 0, sent: 6, delivered: 180, read: 22, failed: 2, blocked: 0 }
                  - { date: "2026-07-12", total: 0, queued: 0, sent: 0, delivered: 0, read: 0, failed: 0, blocked: 0 }
                  - { date: "2026-07-13", total: 195, queued: 3, sent: 12, delivered: 160, read: 19, failed: 1, blocked: 5 }
                slo: { queue_latency_p95_seconds: 30, delivery_success_percent: 99.9 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ------------------------------------------------------------------ Opt-out
  /v1/instances/{id}/opt-outs:
    parameters:
      - $ref: "#/components/parameters/InstanceID"
    post:
      tags: [Opt-out]
      summary: Registrar opt-out de um número
      description: |
        **Escopo: `instances:write`.**

        A partir daqui, todo envio para esse número vira `BLOCKED` (o worker nem tenta).
        Além deste endpoint, o opt-out entra **sozinho** quando o contato responde
        SAIR/STOP — é o que segura o anti-ban e o que a LGPD espera.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipient]
              properties:
                recipient: { type: string, examples: ["5521912345678"] }
            example:
              recipient: "5521912345678"
      responses:
        "201":
          description: Registrado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recipient: { type: string }
                  status: { type: string, const: opted_out }
              example: { recipient: "5521912345678", status: opted_out }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Opt-out]
      summary: Listar opt-outs da instância
      description: "**Escopo: `instances:read`.**"
      responses:
        "200":
          description: A lista.
          content:
            application/json:
              schema:
                type: object
                properties:
                  opt_outs: { type: array, items: { $ref: "#/components/schemas/OptOut" } }
              example:
                opt_outs:
                  - { instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a, recipient: "5521912345678", reason: "auto:stop-keyword", created_at: "2026-07-10T18:22:00Z" }
                  - { instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a, recipient: "5511900001111", reason: manual, created_at: "2026-07-11T09:15:00Z" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/instances/{id}/opt-outs/{recipient}:
    parameters:
      - $ref: "#/components/parameters/InstanceID"
      - name: recipient
        in: path
        required: true
        description: O número (formato internacional, sem `+`).
        schema: { type: string }
        example: "5521912345678"
    delete:
      tags: [Opt-out]
      summary: Remover um opt-out
      description: |
        **Escopo: `instances:write`.**

        Só faça isso se o contato pediu para voltar a receber. Remover opt-out para
        "continuar a campanha" é exatamente o comportamento que leva o número a ser banido.
      responses:
        "204": { description: Removido. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ------------------------------------------------------------------ Webhooks
  /v1/webhooks:
    post:
      tags: [Webhooks]
      summary: Criar um webhook
      operationId: createWebhook
      description: |
        **Escopo: `instances:write`.**

        É por aqui que a AcionaAI vira hub: o evento sai do WhatsApp e cai no seu n8n, no seu
        backend, no seu Typebot. Entrega com **retry, backoff e dead-letter** (reusa a mesma
        fila do envio) — não é um POST solto que se perde se você estiver reiniciando.

        Sem `instance_id`, o webhook vale para TODAS as instâncias do tenant.
        Sem `events`, assina só `message.received`. Use `["*"]` para receber tudo.

        O `secret` é devolvido **uma única vez** (guardamos cifrado, envelope encryption).
        Use-o para validar o `X-Webhook-Signature` — veja o esquema `WebhookDelivery`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: |
                    Destino. **Anti-SSRF**: em SaaS, IPs privados/reservados são recusados na
                    entrega (`WEBHOOK_ALLOW_PRIVATE=true` libera, para self-host).
                  examples: ["https://n8n.acme.com.br/webhook/whatsapp"]
                instance_id: { type: string, format: uuid, description: Opcional — vazio = todas as instâncias do tenant. }
                events:
                  type: array
                  items: { $ref: "#/components/schemas/WebhookEvent" }
                  default: [message.received]
                secret:
                  type: string
                  description: Opcional — se vazio, geramos um e devolvemos aqui (uma vez só).
            example:
              url: https://n8n.acme.com.br/webhook/whatsapp
              events: [message.received, message.status]
      responses:
        "201":
          description: Criado. **Guarde o `secret` agora** — ele não aparece de novo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  url: { type: string, format: uri }
                  instance_id: { type: [string, "null"], format: uuid }
                  events: { type: array, items: { $ref: "#/components/schemas/WebhookEvent" } }
                  secret: { type: string }
                  warning: { type: string }
              example:
                id: e3b0c442-98fc-4c14-9afb-f4c8996fb924
                url: https://n8n.acme.com.br/webhook/whatsapp
                instance_id: null
                events: [message.received, message.status]
                secret: sk_9f2b7c1e4a6d8f0b3c5e7a9d1f3b5c7e9a1d3f5b7c9e1a3d5f7b9c1e3a5d7f9b
                warning: guarde o secret — ele não será exibido novamente
        "400":
          description: "`bad_request` (url ausente) ou `bad_event` (evento desconhecido)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: bad_event, message: "evento inválido: message.sent" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: A `instance_id` informada não existe neste tenant.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Webhooks]
      summary: Listar webhooks do tenant
      description: "**Escopo: `instances:read`.** O `secret` nunca é listado — só `has_secret`."
      responses:
        "200":
          description: Os webhooks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks: { type: array, items: { $ref: "#/components/schemas/Webhook" } }
              example:
                webhooks:
                  - id: e3b0c442-98fc-4c14-9afb-f4c8996fb924
                    url: https://n8n.acme.com.br/webhook/whatsapp
                    instance_id: null
                    events: [message.received, message.status]
                    enabled: true
                    has_secret: true
                    created_at: "2026-07-12T14:10:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ------------------------------------------------------------------ Chatwoot
  /v1/instances/{id}/chatwoot:
    parameters:
      - $ref: "#/components/parameters/InstanceID"
    post:
      tags: [Chatwoot]
      summary: Configurar a ponte com o Chatwoot
      description: |
        **Escopo: `instances:write`.**

        Salva a config e, se `enabled`, cria/reusa a inbox do tipo `api` no Chatwoot já com
        o `webhook_url` do callback (que carrega um segredo por instância).

        O `token` é cifrado em repouso (envelope encryption, KEK no Vault ou local): um dump
        do banco não revela o token do Chatwoot de ninguém. No update, `token` vazio
        **mantém** o atual — não precisa reenviar o segredo para mudar uma flag.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChatwootConfigIn" }
            example:
              enabled: true
              url: https://chat.acme.com.br
              account_id: "1"
              token: "cw_xxxxxxxxxxxxxxxxxxxxxxxx"
              name_inbox: WhatsApp Vendas SP
              sign_msg: true
              reopen_conversation: true
              merge_brazil_contacts: true
      responses:
        "200":
          description: Config salva (com a inbox garantida, se habilitada).
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  account_id: { type: string }
                  url: { type: string }
                  inbox_id: { type: integer }
                  inbox_warning: { type: string, description: Config salva, mas a inbox não pôde ser criada/achada agora. }
              example: { enabled: true, account_id: "1", url: "https://chat.acme.com.br", inbox_id: 7 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Chatwoot]
      summary: Ler a config do Chatwoot
      description: "**Escopo: `instances:read`.** O token nunca volta — só `token_set`."
      responses:
        "200":
          description: "A config (ou `configured: false` se nunca foi configurada)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ChatwootConfigOut" }
              examples:
                configurada:
                  value:
                    configured: true
                    enabled: true
                    account_id: "1"
                    url: https://chat.acme.com.br
                    inbox_id: 7
                    name_inbox: WhatsApp Vendas SP
                    sign_msg: true
                    sign_delimiter: "\n"
                    reopen_conversation: true
                    conversation_pending: false
                    merge_brazil_contacts: true
                    groups_enabled: false
                    bot_contact_enabled: true
                    token_set: true
                    callback_url: /v1/chatwoot/callback/4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                nunca_configurada:
                  value: { enabled: false, configured: false }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/chatwoot/callback/{instance}/{secret}:
    parameters:
      - name: instance
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: secret
        in: path
        required: true
        description: 'Segredo compartilhado por instância. **Fail-closed**: sem ele, 401.'
        schema: { type: string }
    post:
      tags: [Chatwoot]
      summary: Callback do Chatwoot (o agente respondeu)
      description: |
        **Chamado PELO Chatwoot, não por você.** Sem API key: a inbox do tipo `api` só aceita
        uma URL, sem headers customizados — por isso a autenticação é um segredo no PATH
        (não em query string, que vaza em log de proxy e Referer; o log da API redige o
        último segmento).

        Apenas enfileira o corpo; o worker processa com retry. Você não chama este endpoint.
      security: []
      requestBody:
        required: true
        description: O corpo bruto do webhook do Chatwoot (até 8 MB).
        content:
          application/json:
            schema: { type: object }
      responses:
        "200":
          description: Enfileirado.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, const: queued } } }
              example: { status: queued }
        "400":
          description: Corpo ilegível (`bad_body`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401":
          description: Segredo do callback inválido ou ausente.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: unauthorized, message: "segredo do callback inválido" } }
        "502":
          description: Falha ao enfileirar (`enqueue_failed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/chatwoot/callback/{instance}:
    parameters:
      - name: instance
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: secret
        in: query
        required: true
        description: Forma LEGADA (segredo em query string).
        schema: { type: string }
    post:
      tags: [Chatwoot]
      summary: Callback do Chatwoot — forma legada (segredo em query string)
      deprecated: true
      description: |
        Mantida apenas para não quebrar inboxes ainda não migradas. O backfill de boot
        reescreve o `webhook_url` delas para a forma de path (segredo em query string vaza
        com facilidade). Não use em integração nova.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200":
          description: Enfileirado.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, const: queued } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502":
          description: Falha ao enfileirar.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ------------------------------------------------------------------ Administração
  /v1/tenants:
    post:
      tags: [Administração]
      summary: Criar um tenant (com a cota de instâncias do plano)
      description: |
        **Escopo: `admin`** (API key de bootstrap ou usuário `owner`).

        `max_instances` é a **cota de instâncias** do cliente — quantos números de WhatsApp
        ele pode abrir. É aqui que o plano vendido vira regra: `1` = um número, e o
        `POST /v1/instances` do cliente recusa o segundo com 409.

        Omitido (ou `null`) = **ilimitado**, a não ser que a instalação tenha um padrão
        (`DEFAULT_MAX_INSTANCES`), que então é aplicado.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, examples: ["Acme Distribuidora"] }
                max_instances:
                  type: [integer, "null"]
                  minimum: 0
                  description: "Cota de instâncias. `null`/ausente = ilimitado (ou o padrão da instalação). `0` = suspenso."
                  examples: [1]
            example:
              name: Acme Distribuidora
              max_instances: 1
      responses:
        "201":
          description: Criado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Tenant" }
              example:
                id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                name: Acme Distribuidora
                max_instances: 1
                created_at: "2026-07-12T14:00:00Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Administração]
      summary: Listar tenants
      description: "**Escopo: `admin`.**"
      responses:
        "200":
          description: Os tenants.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenants: { type: array, items: { $ref: "#/components/schemas/Tenant" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/tenants/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: O tenant.
        schema: { type: string, format: uuid }
    get:
      tags: [Administração]
      summary: Detalhe do cliente (cota de instâncias + consumo)
      description: |
        **Escopo: `admin`.** O que o dono precisa antes de mexer no plano: quem é o cliente,
        quantas instâncias ele usa e qual é o teto contratado.

        É a **mesma** verdade que o `POST /v1/instances` do cliente aplica — nada é
        recalculado por fora.
      responses:
        "200":
          description: O cliente, com a cota e o consumo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  max_instances: { type: [integer, "null"], description: "A cota (`null` = ilimitado)." }
                  created_at: { type: string, format: date-time }
                  is_system: { type: boolean, description: O tenant da plataforma (nunca tem cota). }
                  instances: { $ref: "#/components/schemas/InstanceQuota" }
              example:
                id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                name: Acme Distribuidora
                max_instances: 1
                created_at: "2026-07-12T14:00:00Z"
                is_system: false
                instances: { used: 1, max: 1, remaining: 0, full: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Administração]
      summary: Definir a COTA DE INSTÂNCIAS do cliente
      description: |
        **Escopo: `admin`** — só o dono da plataforma (owner / chave de bootstrap). O
        `admin` **do próprio tenant** não chega aqui: se o cliente pudesse subir a própria
        cota, o limite seria decorativo.

        ```jsonc
        {"max_instances": 1}     // um número só (o plano vendido)
        {"max_instances": null}  // ilimitado (sem teto contratado)
        {"max_instances": 0}     // suspenso: não cria mais nenhum número
        ```

        O campo é **obrigatório** no corpo: um PATCH sem ele devolve 400, em vez de adivinhar
        que você queria apagar a cota.

        **Reduzir abaixo do uso atual é permitido e não apaga nada.** As sessões de WhatsApp
        que já existem continuam funcionando (derrubá-las por uma mudança de plano faria o
        cliente perder mensagens) — ele só não cria novas enquanto estiver acima do teto. A
        resposta traz o consumo para você ver exatamente em que pé ficou.

        O tenant `system` não tem cota (403 `system_tenant`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [max_instances]
              properties:
                max_instances:
                  type: [integer, "null"]
                  minimum: 0
                  description: "O teto. `null` = ilimitado, `0` = suspenso."
            example:
              max_instances: 1
      responses:
        "200":
          description: Cota definida — com o consumo atual do cliente.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  max_instances: { type: [integer, "null"] }
                  created_at: { type: string, format: date-time }
                  instances: { $ref: "#/components/schemas/InstanceQuota" }
              example:
                id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                name: Acme Distribuidora
                max_instances: 1
                created_at: "2026-07-12T14:00:00Z"
                instances: { used: 1, max: 1, remaining: 0, full: true }
        "400":
          description: "`max_instances` ausente, não inteiro ou negativo."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error:
                  code: bad_request
                  message: "informe 'max_instances' (número = teto, null = ilimitado, 0 = suspenso)"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Sem escopo `admin`, ou é o tenant `system` (`system_tenant`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error: { code: system_tenant, message: "o tenant system é o dono da plataforma e não tem cota de instâncias" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Administração]
      summary: Remover um tenant (destrutivo, com confirmação)
      description: |
        **Escopo: `admin`.** Irreversível: apaga o cliente e **tudo** o que é dele em
        cascata — instâncias (e as sessões de WhatsApp, desconectadas antes), mensagens,
        opt-outs, chaves, usuários, webhooks, conversas e campanhas.

        Três travas, porque não há desfazer:

        1. escopo `admin` (owner ou API key de bootstrap);
        2. o tenant **`system`** nunca é removível (é o dono da chave de bootstrap —
           apagá-lo derrubaria a própria administração) → 403 `system_tenant`;
        3. **confirmação pelo NOME**: repita o nome exato do tenant em `?confirm=` ou no
           corpo `{"confirm":"..."}`. Sem isso → 400 `confirmation_required`. Um DELETE com
           o id errado não apaga o cliente errado por acidente.

        Um usuário humano também não remove o tenant da própria conta (403 `own_tenant`).

        A resposta traz o **recibo** do que caiu na cascata (contado ANTES do DELETE) e
        quantas tarefas foram retiradas da **fila** (`queued_purged`) — a cascata do banco
        não alcança o Redis, e sem essa limpeza a DLQ do console mostraria itens de um
        cliente que não existe mais.

        Os **arquivos** de mídia continuam no volume — o banco só guarda a referência.
      parameters:
        - name: confirm
          in: query
          description: O nome exato do tenant (alternativa ao corpo).
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                confirm: { type: string, description: O nome exato do tenant. }
            example:
              confirm: Acme Distribuidora
      responses:
        "200":
          description: Removido — com o recibo do que foi apagado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  deleted: { type: boolean }
                  removed: { $ref: "#/components/schemas/TenantFootprint" }
                  queued_purged: { type: integer, description: Tarefas do tenant retiradas da fila (pendentes/agendadas/DLQ). }
                  note: { type: string }
              example:
                id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                name: Acme Distribuidora
                deleted: true
                removed:
                  instances: 2
                  messages: 1428
                  api_keys: 3
                  users: 2
                  webhooks: 1
                  conversations: 96
                  campaigns: 4
                  opt_outs: 12
                queued_purged: 7
                note: "os ARQUIVOS de mídia continuam no volume até a próxima faxina"
        "400":
          description: Confirmação ausente ou diferente do nome do tenant.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error:
                  code: confirmation_required
                  message: 'remoção destrutiva: repita o nome exato do tenant em ?confirm= ou no corpo {"confirm":"..."} (esperado: Acme Distribuidora)'
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: Sem escopo `admin`, tenant `system`, ou o tenant da própria conta.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error: { code: system_tenant, message: "o tenant system não pode ser removido" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ------------------------------------------------- Console de administração
  /v1/admin/overview:
    get:
      tags: [Administração]
      summary: Visão consolidada de TODOS os tenants (uma agregação, não N chamadas)
      description: |
        **Escopo: `admin`.**

        A tela do dono: por tenant — instâncias (e quantas conectadas), volume de mensagens
        (30 dias e hoje), falhas, chaves ativas e usuários ativos — **mais os totais da
        plataforma inteira** (não só os da página).

        Tudo sai de **uma agregação no banco**. Antes, o painel fazia N×3 chamadas HTTP (uma
        por tenant, com `X-Tenant-ID`): com muitos clientes era inviável, e a tela precisava
        capar a lista e avisar que era "uma amostra".

        `instances_connected` vem do mesmo estado que o `GET /v1/instances` devolve (a engine
        escreve a cada transição) — só que contado no banco.
      parameters:
        - name: sort
          in: query
          description: "Coluna de ordenação (default `volume` = mensagens em 30 dias)."
          schema:
            type: string
            enum: [volume, today, failed, instances, name, created_at]
            default: volume
        - name: order
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
        - name: q
          in: query
          description: Filtra pelo nome do tenant (casamento parcial, sem diferenciar maiúsculas).
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        "200":
          description: A página de tenants e os totais da plataforma.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenants: { type: array, items: { $ref: "#/components/schemas/TenantOverview" } }
                  totals: { $ref: "#/components/schemas/PlatformTotals" }
                  limit: { type: integer }
                  offset: { type: integer }
                  sort: { type: string }
                  order: { type: string }
                  count: { type: integer }
              example:
                tenants:
                  - id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                    name: Acme Distribuidora
                    created_at: "2026-06-01T10:00:00Z"
                    instances: 3
                    max_instances: 3
                    instances_connected: 2
                    messages_30d: 18422
                    messages_today: 612
                    messages_failed_30d: 37
                    active_api_keys: 2
                    active_users: 4
                    is_system: false
                totals:
                  tenants: 12
                  instances: 21
                  instances_connected: 18
                  messages_30d: 94211
                  messages_today: 3140
                  messages_failed_30d: 210
                  active_api_keys: 19
                  active_users: 27
                limit: 50
                offset: 0
                sort: volume
                order: desc
                count: 1
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/admin/queues:
    get:
      tags: [Administração]
      summary: Profundidade real das filas e a dead-letter (DLQ)
      description: |
        **Escopo: `admin`.**

        Por fila (`messages`, `chatwoot`, `webhooks`, `campaigns`): pending / active /
        scheduled / retry / **archived (DLQ)** / completed, mais os contadores do dia e a
        latência (idade da tarefa pendente mais antiga). E a lista dos itens arquivados —
        id, tipo, erro e nº de tentativas — que é o que o operador olha antes de reprocessar.

        **Mídia não tem fila própria**: `media:send` vai na fila `messages` de propósito (o
        limite diário do anti-ban é por instância — duas filas concorrentes furariam o cap).

        Uma fila que ainda não recebeu nenhuma tarefa vem com `exists: false` e zeros (é
        honesto: "vazia" e "nunca usada" são a mesma coisa na tela).

        > O **asynqmon** (porta 8091) roda **sem a nossa autenticação** e por isso não é
        > exposto: quem chegasse nele reprocessaria/deletaria tarefa de qualquer tenant.
        > Este endpoint é a versão com escopo.
      parameters:
        - name: dlq_limit
          in: query
          description: Quantos itens arquivados listar por fila.
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
      responses:
        "200":
          description: A foto das filas e a DLQ.
          content:
            application/json:
              schema:
                type: object
                properties:
                  queues: { type: array, items: { $ref: "#/components/schemas/QueueStat" } }
                  dlq: { type: array, items: { $ref: "#/components/schemas/ArchivedTask" } }
                  totals:
                    type: object
                    properties:
                      pending: { type: integer }
                      active: { type: integer }
                      scheduled: { type: integer }
                      retry: { type: integer }
                      archived: { type: integer }
                  dlq_limit: { type: integer }
              example:
                queues:
                  - queue: messages
                    pending: 0
                    active: 1
                    scheduled: 4
                    retry: 2
                    archived: 9
                    completed: 0
                    size: 16
                    processed_today: 3140
                    failed_today: 12
                    paused: false
                    latency_seconds: 0.4
                    exists: true
                  - queue: campaigns
                    pending: 0
                    active: 0
                    scheduled: 0
                    retry: 0
                    archived: 0
                    completed: 0
                    size: 0
                    processed_today: 0
                    failed_today: 0
                    paused: false
                    latency_seconds: 0
                    exists: false
                dlq:
                  - id: 2c1a9f30-55bb-4a77-9e0d-3f8c1b2a4d6e
                    queue: messages
                    type: "message:send"
                    error: "instância não pareada"
                    attempts: 5
                    max_retry: 5
                    last_failed_at: "2026-07-13T03:26:30Z"
                totals: { pending: 0, active: 1, scheduled: 4, retry: 2, archived: 9 }
                dlq_limit: 20
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: O Redis (fila) não respondeu.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: A fila não está ligada nesta instalação.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/admin/queues/{queue}/retry-all:
    parameters:
      - name: queue
        in: path
        required: true
        description: A fila (allow-list — um nome inventado não chega ao Redis).
        schema: { type: string, enum: [messages, chatwoot, webhooks, campaigns] }
    post:
      tags: [Administração]
      summary: Reprocessar a dead-letter (tudo, ou os ids escolhidos)
      description: |
        **Escopo: `admin`.**

        Devolve para a fila as tarefas que esgotaram as tentativas. Corpo vazio =
        **tudo** o que está arquivado naquela fila (teto de 1000 por chamada); com
        `{"ids": [...]}` reprocessa só os escolhidos.

        Na fila `messages` o efeito é **duplo, e tem que ser**: a tarefa volta ao Redis **e**
        a mensagem volta para `QUEUED` no banco (o TaskID é o próprio `message_id`). Se só a
        tarefa voltasse, o painel seguiria mostrando `FAILED` enquanto a mensagem era
        enviada — dois números para uma verdade só.

        Idempotente na prática: um id que não está mais arquivado (retenção expirou) aparece
        em `failed_reason` e o resto segue.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  description: Ids das tarefas arquivadas. Ausente/vazio = reprocessa tudo.
                  items: { type: string }
            example:
              ids: ["2c1a9f30-55bb-4a77-9e0d-3f8c1b2a4d6e"]
      responses:
        "200":
          description: O desfecho do reprocessamento.
          content:
            application/json:
              schema:
                type: object
                properties:
                  queue: { type: string }
                  requeued: { type: integer }
                  requeued_ids: { type: array, items: { type: string } }
                  failed: { type: integer }
                  failed_reason:
                    type: object
                    additionalProperties: { type: string }
                    description: id → motivo de não ter voltado.
                  messages_marked_queued:
                    type: integer
                    description: Mensagens devolvidas a `QUEUED` no banco (só na fila `messages`).
              example:
                queue: messages
                requeued: 9
                requeued_ids: ["2c1a9f30-55bb-4a77-9e0d-3f8c1b2a4d6e"]
                failed: 0
                failed_reason: {}
                messages_marked_queued: 9
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Fila desconhecida (fora da allow-list).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example:
                error: { code: unknown_queue, message: "fila desconhecida: media (conhecidas: messages, chatwoot, webhooks, campaigns)" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: O Redis (fila) não respondeu.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: A fila não está ligada nesta instalação.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/tenants/{id}/api-keys:
    parameters:
      - name: id
        in: path
        required: true
        description: O tenant.
        schema: { type: string, format: uuid }
    post:
      tags: [Administração]
      summary: Criar uma API key
      operationId: createAPIKey
      description: |
        **Permissão:** `owner` (qualquer tenant) ou `admin` **do próprio** tenant — é assim
        que o cliente se auto-atende no painel. Não há escopo fixo na rota: a checagem é fina,
        no handler.

        Escopos atribuíveis: `instances:read`, `instances:write`, `messages:send`.
        `admin` **não** é atribuível a uma chave de tenant (isso seria escalonamento).
        Sem `scopes`, a chave nasce com os três.

        A chave em claro (`sk_…`) é devolvida **uma única vez** — guardamos só o SHA-256.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, examples: ["Integração n8n — produção"] }
                scopes:
                  type: array
                  items: { $ref: "#/components/schemas/Scope" }
                  default: [instances:read, instances:write, messages:send]
            example:
              name: Integração n8n — produção
              scopes: [instances:read, messages:send]
      responses:
        "201":
          description: Criada. **Copie a `api_key` agora.**
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  tenant_id: { type: string, format: uuid }
                  name: { type: string }
                  scopes: { type: array, items: { $ref: "#/components/schemas/Scope" } }
                  api_key: { type: string }
                  warning: { type: string }
              example:
                id: c56a4180-65aa-42ec-a945-5fd21dec0538
                tenant_id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                name: Integração n8n — produção
                scopes: [instances:read, messages:send]
                api_key: sk_4a7d1ed414474e4033ac29ccb8653d9b1c2e5f7a9b0d3e6f8a1c4e7b0d3f6a9c
                warning: guarde esta chave — ela não será exibida novamente
        "400":
          description: Escopo inválido (`bad_scope`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: bad_scope, message: "escopo inválido: admin" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Administração]
      summary: Listar as chaves do tenant (metadados)
      description: "A chave em claro NUNCA é listada. `last_used_at` mostra se a chave está viva."
      responses:
        "200":
          description: As chaves.
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_keys: { type: array, items: { $ref: "#/components/schemas/APIKey" } }
              example:
                api_keys:
                  - id: c56a4180-65aa-42ec-a945-5fd21dec0538
                    tenant_id: 8c1d0f42-77aa-4b3e-9e21-0a5b6c7d8e9f
                    name: Integração n8n — produção
                    scopes: [instances:read, messages:send]
                    revoked: false
                    created_at: "2026-07-12T14:00:00Z"
                    last_used_at: "2026-07-12T14:31:07Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/tenants/{id}/api-keys/{keyId}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: keyId
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      tags: [Administração]
      summary: Revogar uma API key
      description: Vale **imediatamente** — a próxima requisição com essa chave já toma 401.
      responses:
        "200":
          description: Revogada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  revoked: { type: boolean }
              example: { id: c56a4180-65aa-42ec-a945-5fd21dec0538, revoked: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }


  # ------------------------------------------------------------------ Pareamento por código
  /v1/instances/{id}/pair-code:
    parameters:
      - $ref: "#/components/parameters/InstanceID"
    post:
      tags: [Instâncias]
      summary: Parear por código de 8 dígitos (alternativa ao QR)
      operationId: pairCode
      description: |
        **Escopo: `instances:write`.**

        Para quem **não tem a câmera do aparelho à mão** (ou está num servidor remoto):
        em vez de escanear o QR, o usuário digita um código de 8 dígitos no celular
        (WhatsApp > Aparelhos conectados > Conectar aparelho > Conectar com número de telefone).

        O estado ao vivo continua saindo pelo MESMO `GET /v1/instances/{id}/qr`: quando parear,
        ele passa a responder `{"status":"connected"}`. Ou seja, uma tela só, um polling só.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone:
                  type: string
                  description: Formato internacional, sem `+`.
                  examples: ["5511987654321"]
            example:
              phone: "5511987654321"
      responses:
        "200":
          description: O código para digitar no aparelho.
          content:
            application/json:
              schema:
                type: object
                properties:
                  pair_code: { type: string, examples: ["ABCD-1234"] }
                  expires_in_seconds: { type: integer }
                  instructions: { type: string }
                  status_url: { type: string, description: Onde acompanhar o pareamento (o mesmo endpoint do QR). }
              example:
                pair_code: ABCD-1234
                expires_in_seconds: 180
                instructions: "No celular: WhatsApp > Aparelhos conectados > Conectar aparelho > Conectar com número de telefone. Digite o código."
                status_url: /v1/instances/4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a/qr
        "400":
          description: "`bad_request` (campo ausente) ou `invalid_phone` (número em formato inválido)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: invalid_phone, message: "número inválido" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: |
            `already_paired` (desconecte antes de parear de novo), `not_connected` (a sessão
            ainda está subindo — tente em ~2s) ou `session_inactive` (o servidor reiniciou
            antes do pareamento: recrie a instância).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: already_paired, message: "esta instância já está pareada — desconecte antes de parear de novo" } }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: O WhatsApp recusou o pedido (`pair_failed`) — número sem conta, rate limit deles etc.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Engine indisponível (`engine_unavailable`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ------------------------------------------------------------------ Anti-ban
  /v1/instances/{id}/antiban:
    parameters:
      - $ref: "#/components/parameters/InstanceID"
    get:
      tags: [Anti-ban]
      summary: A política de proteção do número (limite, aquecimento, jitter)
      operationId: getAntiban
      description: |
        **Escopo: `instances:read`.**

        Os **mesmos números que o worker usa para bloquear** — a tela não pode divergir do que
        roda de verdade.

        O `tomorrow_cap` existe por um motivo comercial: o aquecimento é **proteção, não
        punição**. Sem ver que o limite sobe, o cliente lê "50 mensagens/dia" como bug ou plano
        capado, e abre chamado. Mostrar "hoje 50 · amanhã 50 · semana que vem 150" transforma
        reclamação em confiança.
      responses:
        "200":
          description: O retrato do anti-ban desta instância.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AntibanStatus" }
              example:
                instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                instance_created_at: "2026-07-01T09:00:00Z"
                age_days: 11
                daily_cap: 150
                sent_today: 37
                remaining_today: 113
                usage_percent: 24.7
                warmup_week: 2
                warmup_complete: false
                tomorrow_cap: 150
                next_ramp_at: "2026-07-15T00:00:00Z"
                base_daily_cap: 50
                ramp_per_week: 100
                max_daily_cap: 1000
                jitter_min_ms: 800
                jitter_max_ms: 3000
                opt_out_count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: Política anti-ban não configurada nesta instalação (`antiban_unavailable`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ------------------------------------------------------------------ Conversas
  /v1/conversations:
    get:
      tags: [Conversas]
      summary: Listar conversas (a linha do tempo do transporte)
      operationId: listConversations
      description: |
        **Escopo: `instances:read`.**

        É o **histórico do que trafegou** (recebidas E enviadas), isolado por tenant — não é um
        CRM de atendimento. Serve para você auditar/reconciliar o transporte e para o painel
        mostrar o que aconteceu. A conversa **acontece na sua ferramenta** (Chatwoot, n8n…).

        Se a instalação não persiste conversas, os endpoints respondem **503** em vez de fingir
        que existem.
      parameters:
        - name: instance_id
          in: query
          schema: { type: string, format: uuid }
        - name: status
          in: query
          schema: { type: string, enum: [OPEN, CLOSED] }
        - name: q
          in: query
          description: Busca por nome/número do contato.
          schema: { type: string }
        - name: assignee
          in: query
          description: "`me` (exige sessão de usuário — uma API key não é atendente), `unassigned`, ou o UUID de um usuário."
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 30, minimum: 1, maximum: 100 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        "200":
          description: As conversas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  conversations: { type: array, items: { $ref: "#/components/schemas/Conversation" } }
                  limit: { type: integer }
                  offset: { type: integer }
                  count: { type: integer }
        "400":
          description: "`bad_status` ou `bad_assignee`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ConversationsOff" }

  /v1/conversations/{id}:
    parameters:
      - $ref: "#/components/parameters/ConversationID"
    get:
      tags: [Conversas]
      summary: Detalhe de uma conversa
      description: "**Escopo: `instances:read`.** Conversa de outro tenant → 404."
      responses:
        "200":
          description: A conversa.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Conversation" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ConversationsOff" }
    patch:
      tags: [Conversas]
      summary: Atribuir a um usuário e/ou abrir/fechar
      description: |
        **Escopo: `instances:write`.** Informe `assignee_user_id` e/ou `status`.
        `assignee_user_id: null` desatribui.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                assignee_user_id: { type: [string, "null"], format: uuid }
                status: { type: string, enum: [OPEN, CLOSED] }
            example:
              status: CLOSED
      responses:
        "200":
          description: A conversa atualizada.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Conversation" }
        "400":
          description: "`bad_request`, `bad_assignee` (usuário inexistente/de outro tenant) ou `bad_status`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ConversationsOff" }

  /v1/conversations/{id}/messages:
    parameters:
      - $ref: "#/components/parameters/ConversationID"
    get:
      tags: [Conversas]
      summary: Mensagens da conversa (paginação por cursor)
      description: |
        **Escopo: `instances:read`.**

        Mais recentes primeiro. Para a próxima página, mande o `next_before` da resposta em
        `?before=` — cursor por timestamp, não offset: mensagem nova chegando não embaralha a
        paginação.

        Os bytes da mídia **não** vêm aqui: vem `media_url` (`/v1/media/{id}`), autenticada e
        isolada por tenant.
      parameters:
        - name: before
          in: query
          description: Cursor — timestamp RFC3339 (use o `next_before` da página anterior).
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
      responses:
        "200":
          description: As mensagens.
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages: { type: array, items: { $ref: "#/components/schemas/ConversationMessage" } }
                  count: { type: integer }
                  limit: { type: integer }
                  next_before: { type: string, format: date-time, description: Cursor da próxima página (ausente = acabou). }
        "400":
          description: Cursor inválido (`bad_cursor`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ConversationsOff" }
    post:
      tags: [Conversas]
      summary: Responder na conversa (assíncrono — 202)
      operationId: sendConversationMessage
      description: |
        **Escopo: `messages:send`.**

        Mesmo contrato de `POST /v1/instances/{id}/messages`: **202/QUEUED** (aceita, não
        entregue), `Idempotency-Key` (repetiu → 200 com a original) e `BLOCKED` como proteção
        do anti-ban. A diferença é só o endereçamento: o destinatário sai da conversa.

        Aceita `text` **ou** `media_base64` (até 20 MB).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                text: { type: string }
                type: { type: string, enum: [image, audio, video, document] }
                mimetype: { type: string }
                filename: { type: string }
                media_base64: { type: string, contentEncoding: base64 }
            example:
              text: "Claro, João! Seu pedido sai hoje ainda."
      responses:
        "202":
          description: Enfileirada.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Enqueued"
                  - type: object
                    properties:
                      conversation_id: { type: string, format: uuid }
                      type: { type: string }
              example:
                message_id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                conversation_id: a1b2c3d4-5e6f-4071-8293-a4b5c6d7e8f9
                status: QUEUED
                type: text
                status_url: /v1/messages/7c9e6679-7425-40de-944b-e07fc1f90ae7
        "200":
          description: Replay idempotente (a `Idempotency-Key` já tinha sido usada).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400":
          description: "`bad_request` (nem `text` nem `media_base64`) ou `bad_base64`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413":
          description: Mídia acima de 20 MB (`too_large`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: Falha ao enfileirar (`enqueue_failed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503": { $ref: "#/components/responses/ConversationsOff" }

  /v1/conversations/{id}/read:
    parameters:
      - $ref: "#/components/parameters/ConversationID"
    post:
      tags: [Conversas]
      summary: Zerar as não-lidas
      description: "**Escopo: `instances:write`.**"
      responses:
        "200":
          description: Zeradas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  unread_count: { type: integer, const: 0 }
              example: { unread_count: 0 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/ConversationsOff" }

  /v1/media/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: O id da mídia (vem no `media_url` da mensagem).
        schema: { type: string, format: uuid }
    get:
      tags: [Conversas]
      summary: Baixar os bytes de uma mídia
      description: |
        **Escopo: `instances:read`.** Autenticada e **isolada por tenant** — mídia de outro
        cliente é 404. Os bytes vivem num volume; o banco guarda só a referência (por isso o
        webhook manda `media_url` e nunca os bytes).
      responses:
        "200":
          description: O arquivo.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: |
            `not_found` (não existe neste tenant) ou `media_gone` (a referência existe, mas o
            arquivo sumiu do volume — volume recriado).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: Armazenamento de mídia não configurado (`media_unavailable`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  # ------------------------------------------------------------------ Webhooks (catálogo)
  /v1/webhooks/events:
    get:
      tags: [Webhooks]
      summary: Catálogo dos eventos que dá para assinar
      operationId: listWebhookEventTypes
      description: |
        **Escopo: `instances:read`.**

        A resposta à pergunta "o que eu posso assinar?" — sem ler documentação. É a MESMA
        lista que valida o `events` do `POST /v1/webhooks`: o catálogo não tem como divergir
        do que a API aceita, e cada nome daqui tem um emissor **provado em teste** (nenhum
        evento fantasma, que se assina e nunca chega).

        **A taxonomia.** Cada transição tem um evento **canônico**, que carrega o estado num
        campo (`message.status` traz `status`; `connection.updated` traz `status`), e um
        **alias granular** com o mesmo payload (`message.sent`, `message.failed`,
        `instance.logged_out`…). O alias é um **filtro do lado do servidor**: quem só liga
        para falha assina `message.failed` e não recebe o resto.

        Assinar o alias ou o canônico entrega o **mesmo corpo** — muda só o `type` do
        envelope. E um webhook recebe **no máximo uma entrega por transição**: se assinou o
        alias, o `type` é o alias; senão (canônico ou `*`), é o canônico. É isso que deixa
        quem já consumia `message.status` continuar funcionando.
      responses:
        "200":
          description: O catálogo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      type: object
                      properties:
                        event:
                          type: string
                          description: O nome que vai no `events` da assinatura e no `type` da entrega.
                        canonical:
                          type: string
                          description: >-
                            O evento canônico correspondente, quando este é um alias granular.
                            Vazio quando o próprio evento já é canônico.
                        description: { type: string }
                  wildcard:
                    type: object
                    description: O coringa — assina tudo. A entrega chega sempre com o nome canônico no `type`.
                    properties:
                      event: { type: string, examples: ["*"] }
                      description: { type: string }
                  note: { type: string }
              examples:
                catalogo:
                  value:
                    events:
                      - event: message.received
                        canonical: ""
                        description: mensagem recebida (com media_url quando há mídia)
                      - event: message.status
                        canonical: ""
                        description: "qualquer transição de entrega (campo `status`: QUEUED|SENT|DELIVERED|READ|FAILED|BLOCKED)"
                      - event: message.queued
                        canonical: message.status
                        description: aceita e enfileirada
                      - event: message.sent
                        canonical: message.status
                        description: "entregue ao WhatsApp (pode vir com `notice` se houve degradação)"
                      - event: message.delivered
                        canonical: message.status
                        description: recibo de entrega no aparelho do destinatário
                      - event: message.read
                        canonical: message.status
                        description: recibo de leitura
                      - event: message.failed
                        canonical: message.status
                        description: "falhou (`reason` e `reason_code` explicam por quê)"
                      - event: message.blocked
                        canonical: message.status
                        description: "o anti-ban barrou (opt-out ou limite diário) — `reason_code` diz qual"
                      - event: connection.updated
                        canonical: ""
                        description: "a sessão mudou de estado (campo `status`)"
                      - event: instance.connected
                        canonical: connection.updated
                        description: a instância conectou
                      - event: instance.disconnected
                        canonical: connection.updated
                        description: a instância caiu (reconecta sozinha)
                      - event: instance.logged_out
                        canonical: connection.updated
                        description: "deslogada no aparelho — exige NOVO pareamento (ação do cliente)"
                      - event: qr.updated
                        canonical: ""
                        description: novo QR de pareamento disponível
                    wildcard:
                      event: "*"
                      description: assina tudo; a entrega chega sempre com o nome canônico no campo `type`
                    note: >-
                      assinar o alias granular (ex.: message.failed) ou o canônico (message.status)
                      entrega o MESMO payload — o alias só filtra. Um webhook nunca recebe a mesma
                      transição duas vezes.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  # ------------------------------------------------------------------ Webhooks (operação)
  /v1/webhooks/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Webhooks]
      summary: Detalhe do webhook (com a SAÚDE dele)
      description: |
        **Escopo: `instances:read`.**

        Traz o `health`: taxa de sucesso, falhas consecutivas, última entrega e o `state`
        (`ACTIVE` | `FAILING` | `PAUSED`). É o que transforma "temos webhooks" em "webhooks
        confiáveis": o cliente enxerga que a URL dele está quebrada **antes** de perceber que
        parou de receber evento.
      responses:
        "200":
          description: O webhook.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
              example:
                id: e3b0c442-98fc-4c14-9afb-f4c8996fb924
                url: https://n8n.acme.com.br/webhook/whatsapp
                instance_id: null
                events: [message.received, message.status]
                enabled: true
                has_secret: true
                created_at: "2026-07-12T14:10:00Z"
                state: FAILING
                success_rate: 62.5
                health:
                  state: FAILING
                  success_rate: 62.5
                  deliveries_total: 40
                  deliveries_success: 25
                  deliveries_failed: 15
                  deliveries_pending: 0
                  consecutive_failures: 6
                  last_delivery_at: "2026-07-12T14:31:07Z"
                  last_status: FAILED
                  last_http_status: 502
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      tags: [Webhooks]
      summary: Alterar URL/eventos e PAUSAR/RETOMAR
      description: |
        **Escopo: `instances:write`.** Alteração parcial: informe `url`, `events` e/ou `enabled`.

        `enabled: false` **pausa** a entrega (o dispatcher já respeitava a coluna; faltava o
        endpoint). É o que você faz quando o seu servidor vai entrar em manutenção — em vez de
        apagar o webhook e perder o segredo.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                events: { type: array, items: { $ref: "#/components/schemas/WebhookEvent" } }
                enabled: { type: boolean, description: "`false` pausa a entrega; `true` retoma." }
            example:
              enabled: false
      responses:
        "200":
          description: O webhook atualizado.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Webhook" }
        "400":
          description: "`bad_request` (nada para alterar, ou `url` vazia) ou `bad_event`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

    delete:
      tags: [Webhooks]
      summary: Remover um webhook
      description: "**Escopo: `instances:write`.** Webhook de outro tenant → 404."
      responses:
        "204": { description: Removido. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks/{id}/rotate-secret:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Webhooks]
      summary: Rotacionar o segredo HMAC
      operationId: rotateWebhookSecret
      description: |
        **Escopo: `instances:write`.**

        Gera um segredo novo e devolve **uma única vez**. **As entregas a partir de agora já são
        assinadas com ele** — não há período de convivência entre o segredo velho e o novo.
        Portanto: atualize o segredo no seu servidor **junto** com esta chamada, ou as próximas
        entregas vão falhar na sua validação.

        Use isto quando o segredo vazar (log, print, ex-funcionário), sem precisar recriar o
        webhook e reconfigurar a URL.
      responses:
        "200":
          description: O novo segredo. **Guarde agora.**
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  secret: { type: string }
                  warning: { type: string }
              example:
                id: e3b0c442-98fc-4c14-9afb-f4c8996fb924
                secret: sk_1f3b5c7e9a1d3f5b7c9e1a3d5f7b9c1e3a5d7f9b1c3e5a7d9f1b3c5e7a9d1f3b
                warning: "guarde o secret — ele não será exibido novamente. As entregas a partir de agora já são assinadas com ele."
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503":
          description: Cofre de segredos indisponível (`seal_unavailable`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/webhooks/{id}/deliveries:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      tags: [Webhooks]
      summary: Histórico de entregas (o que chegou, o que quebrou)
      operationId: listWebhookDeliveries
      description: |
        **Escopo: `instances:read`.**

        Hora, evento, código HTTP, tentativas, duração e o erro. É o diagnóstico que o cliente
        usa para consertar sozinho, sem abrir chamado.

        **`RETRYING` não é `FAILED`.** Uma falha que ainda vai ser re-tentada não conta como
        entrega perdida — só o esgotamento das tentativas (dead-letter) vira `FAILED`. Sem essa
        distinção, um webhook que sempre entrega na 2ª tentativa apareceria como 50% de falha, e
        o cliente desligaria um webhook que funciona.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        "200":
          description: As entregas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries: { type: array, items: { $ref: "#/components/schemas/WebhookDeliveryRecord" } }
                  limit: { type: integer }
                  offset: { type: integer }
                  count: { type: integer }
              example:
                deliveries:
                  - id: 5f2a8c31-7b4d-4e69-9a1c-0d3e5f7a9b1c
                    webhook_id: e3b0c442-98fc-4c14-9afb-f4c8996fb924
                    event: message.received
                    url: https://n8n.acme.com.br/webhook/whatsapp
                    status: SUCCESS
                    http_status: 200
                    attempts: 1
                    error: null
                    duration_ms: 87
                    created_at: "2026-07-12T14:31:07Z"
                    updated_at: "2026-07-12T14:31:07Z"
                  - id: 6a3b9d42-8c5e-4f7a-8b2d-1e4f6a8b0c2d
                    webhook_id: e3b0c442-98fc-4c14-9afb-f4c8996fb924
                    event: message.status
                    url: https://n8n.acme.com.br/webhook/whatsapp
                    status: FAILED
                    http_status: 502
                    attempts: 5
                    error: "webhook retornou status 502"
                    duration_ms: 10004
                    created_at: "2026-07-12T14:28:00Z"
                    updated_at: "2026-07-12T14:30:12Z"
                limit: 50
                offset: 0
                count: 2
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string, format: uuid }
      - name: deliveryId
        in: path
        required: true
        schema: { type: string, format: uuid }
    post:
      tags: [Webhooks]
      summary: Reenviar uma entrega (o "consertei meu servidor, manda de novo")
      operationId: redeliverWebhook
      description: |
        **Escopo: `instances:write`.**

        Reusa o payload arquivado. É uma entrega **NOVA** (linha nova no histórico), não uma
        reescrita da que falhou: o registro do que deu errado tem que continuar lá — histórico
        que se reescreve não é histórico.
      responses:
        "202":
          description: Reenfileirada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivery_id: { type: string, format: uuid, description: A entrega NOVA. }
                  redelivered_of: { type: string, format: uuid, description: A entrega original (que continua no histórico). }
                  event: { $ref: "#/components/schemas/WebhookEvent" }
                  status: { $ref: "#/components/schemas/DeliveryStatus" }
              example:
                delivery_id: 9c4d1e53-2a6b-4c8d-9e0f-3a5b7c9d1e3f
                redelivered_of: 6a3b9d42-8c5e-4f7a-8b2d-1e4f6a8b0c2d
                event: message.status
                status: PENDING
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Webhook não encontrado, ou a entrega não é deste webhook.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502":
          description: Não foi possível enfileirar o reenvio (`enqueue_failed`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Entrega de webhooks indisponível (`dispatcher_unavailable`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }


  # ------------------------------------------------------------------ Campanhas
  /v1/campaigns:
    post:
      tags: [Campanhas]
      summary: Criar uma campanha (rascunho)
      operationId: createCampaign
      description: |
        **Escopo: `campaigns:write`.**

        Envio em lote como **capacidade de API**: lista + template + agendamento, distribuído
        **respeitando o anti-ban** (o mesmo teto diário e o mesmo jitter do envio avulso — uma
        campanha não fura a fila nem queima o número).

        Nasce em `DRAFT`. O fluxo é: criar → subir a lista (`/recipients`) → conferir a
        distribuição (`/plan`, que **não envia nada**) → agendar (`/schedule`).

        O `message_template` aceita variáveis `{{nome}}`, resolvidas por linha do CSV.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [instance_id, name]
              properties:
                instance_id: { type: string, format: uuid }
                name: { type: string }
                message_template:
                  type: string
                  description: Variáveis no formato `{{coluna}}` — as colunas vêm do CSV.
            example:
              instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
              name: Black Friday — clientes SP
              message_template: "Oi {{nome}}! Sua oferta de {{desconto}} está liberada até sexta 🎁"
      responses:
        "201":
          description: Criada em `DRAFT`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404":
          description: Instância não encontrada neste tenant.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }
    get:
      tags: [Campanhas]
      summary: Listar campanhas
      description: "**Escopo: `campaigns:read`.**"
      parameters:
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/CampaignStatus" }
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        "200":
          description: As campanhas.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaigns: { type: array, items: { $ref: "#/components/schemas/Campaign" } }
                  limit: { type: integer }
                  offset: { type: integer }
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }

  /v1/campaigns/{id}:
    parameters:
      - $ref: "#/components/parameters/CampaignID"
    get:
      tags: [Campanhas]
      summary: Detalhe da campanha (com o progresso)
      description: "**Escopo: `campaigns:read`.**"
      responses:
        "200":
          description: A campanha.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
              example:
                id: 7a1c3e59-4b2d-4f8a-9c1e-2d4f6a8b0c2e
                instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
                name: Black Friday — clientes SP
                message_template: "Oi {{nome}}! Sua oferta de {{desconto}} está liberada até sexta 🎁"
                status: RUNNING
                scheduled_at: "2026-07-13T09:00:00Z"
                next_dispatch_at: "2026-07-14T09:00:00Z"
                started_at: "2026-07-13T09:00:04Z"
                finished_at: null
                total_recipients: 420
                error: null
                created_at: "2026-07-12T18:00:00Z"
                updated_at: "2026-07-13T09:00:04Z"
                progress: { pending: 270, queued: 0, sent: 150, delivered: 141, read: 96, failed: 2, blocked: 8 }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }
    patch:
      tags: [Campanhas]
      summary: Editar nome/template (só enquanto dá)
      description: |
        **Escopo: `campaigns:write`.** Uma campanha que já saiu do rascunho não se reescreve:
        `409 campaign_locked`. Histórico de disparo que muda depois do disparo não é histórico.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                message_template: { type: string }
            example:
              message_template: "Oi {{nome}}! Sua oferta de {{desconto}} vai até domingo 🎁"
      responses:
        "200":
          description: Atualizada.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: A campanha não está mais editável (`campaign_locked`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }
    delete:
      tags: [Campanhas]
      summary: Remover a campanha
      description: "**Escopo: `campaigns:write`.** Uma campanha ATIVA não é removida (`409 campaign_active`) — cancele antes."
      responses:
        "204": { description: Removida. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Campanha ativa (`campaign_active`) — pause/cancele antes de remover.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }

  /v1/campaigns/{id}/recipients:
    parameters:
      - $ref: "#/components/parameters/CampaignID"
    post:
      tags: [Campanhas]
      summary: Subir a lista (CSV) — com dry-run
      operationId: uploadRecipients
      description: |
        **Escopo: `campaigns:write`.**

        Manda o CSV como `multipart/form-data` (campo `file`) ou como corpo bruto
        (`text/csv`). A primeira coluna é o telefone; as demais viram **variáveis** do
        template (`{{nome}}`, `{{desconto}}`…).

        **Use `?dry_run=true` antes de importar de verdade.** Ele valida e devolve o mesmo
        relatório (válidos, inválidos, duplicados, colunas, amostra e os erros linha a linha)
        **sem gravar nada**. É o que evita descobrir que metade da lista estava torta depois
        de disparar.

        Importar **substitui** a lista anterior. Só funciona enquanto a campanha é editável.
      parameters:
        - name: dry_run
          in: query
          description: "`true` = só valida e relata, não grava."
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
          text/csv:
            schema:
              type: string
            example: |
              telefone,nome,desconto
              5511987654321,João,30%
              5521912345678,Maria,25%
      responses:
        "200":
          description: Relatório do **dry-run** (nada foi gravado).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CSVReport" }
              example:
                dry_run: true
                total_rows: 3
                valid: 2
                invalid: 1
                duplicates: 0
                columns: [telefone, nome, desconto]
                sample:
                  - { phone: "5511987654321", variables: { nome: "João", desconto: "30%" } }
                errors:
                  - { line: 4, error: "telefone inválido: 11999" }
                imported: 0
        "201":
          description: Importada (a lista anterior foi substituída).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CSVReport" }
              example:
                dry_run: false
                total_rows: 3
                valid: 2
                invalid: 1
                duplicates: 0
                columns: [telefone, nome, desconto]
                imported: 2
        "400":
          description: "`bad_csv` (arquivo ilegível/malformado) ou `no_valid_rows` (nenhuma linha aproveitável)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: A lista não é mais editável (`campaign_locked`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }
    get:
      tags: [Campanhas]
      summary: Listar os destinatários (e o status de cada um)
      description: "**Escopo: `campaigns:read`.** O `status` é o **efetivo**: o da mensagem quando ela já existe."
      parameters:
        - name: status
          in: query
          description: "Filtra por status efetivo (ex.: `BLOCKED` para ver quem o anti-ban protegeu)."
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 200 }
        - name: offset
          in: query
          schema: { type: integer, default: 0, minimum: 0 }
      responses:
        "200":
          description: Os destinatários.
          content:
            application/json:
              schema:
                type: object
                properties:
                  recipients: { type: array, items: { $ref: "#/components/schemas/CampaignRecipient" } }
                  limit: { type: integer }
                  offset: { type: integer }
                  count: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }

  /v1/campaigns/{id}/plan:
    parameters:
      - $ref: "#/components/parameters/CampaignID"
    get:
      tags: [Campanhas]
      summary: A distribuição prevista — SEM enviar nada
      operationId: getCampaignPlan
      description: |
        **Escopo: `campaigns:read`.**

        O "confira antes de disparar": quantos vão sair, **quantos NÃO vão** (opt-out, linha
        sem variável do template), quanto cabe **hoje** dado o teto do aquecimento, e **em
        quantos dias** a lista termina.

        `exceeds_daily_cap: true` não é erro — é a plataforma dizendo que vai **distribuir ao
        longo dos dias** em vez de queimar o número numa rajada. É esse número que evita o
        chamado "por que minha campanha não saiu toda hoje?".
      responses:
        "200":
          description: O plano.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CampaignPlan" }
              example:
                total_recipients: 420
                pending: 420
                opted_out: 8
                missing_var_rows: 2
                eligible: 410
                daily_cap: 150
                sent_today: 37
                remaining_today: 113
                exceeds_daily_cap: true
                days: 3
                estimated_finish: "2026-07-16T09:00:00Z"
                template_vars: [nome, desconto]
                warmup: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }

  /v1/campaigns/{id}/progress:
    parameters:
      - $ref: "#/components/parameters/CampaignID"
    get:
      tags: [Campanhas]
      summary: Progresso do disparo
      description: |
        **Escopo: `campaigns:read`.**

        Os números saem do status **efetivo** das mensagens — "enviados" aqui é envio de
        verdade, não "coloquei na fila". `blocked` é o anti-ban protegendo, não falha.
      responses:
        "200":
          description: O progresso.
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign_id: { type: string, format: uuid }
                  status: { $ref: "#/components/schemas/CampaignStatus" }
                  total: { type: integer }
                  progress: { $ref: "#/components/schemas/CampaignProgress" }
                  next_dispatch_at: { type: [string, "null"], format: date-time }
                  finished_at: { type: [string, "null"], format: date-time }
              example:
                campaign_id: 7a1c3e59-4b2d-4f8a-9c1e-2d4f6a8b0c2e
                status: RUNNING
                total: 420
                progress: { pending: 270, queued: 0, sent: 150, delivered: 141, read: 96, failed: 2, blocked: 8 }
                next_dispatch_at: "2026-07-14T09:00:00Z"
                finished_at: null
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }

  /v1/campaigns/{id}/schedule:
    parameters:
      - $ref: "#/components/parameters/CampaignID"
    post:
      tags: [Campanhas]
      summary: Agendar (ou disparar agora)
      operationId: scheduleCampaign
      description: |
        **Escopo: `campaigns:write`.**

        Sem `scheduled_at`, começa **agora**. Com, agenda (asynq com `ProcessAt`).

        Responde **202**: a campanha foi aceita e vai rodar — pode levar **dias**, porque o
        despacho respeita o teto diário do aquecimento (veja `/plan` e `next_dispatch_at`).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                scheduled_at:
                  type: [string, "null"]
                  format: date-time
                  description: Vazio/nulo = agora. No passado = 400 `past_schedule`.
            example:
              scheduled_at: "2026-07-13T09:00:00Z"
      responses:
        "202":
          description: Agendada/disparada.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "400":
          description: "`no_template` (campanha sem mensagem), `no_recipients` (lista vazia) ou `past_schedule` (data no passado)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: no_recipients, message: "a campanha não tem destinatários" } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: 'Estado incompatível (`bad_state`) — ex.: agendar uma campanha já em execução.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }

  /v1/campaigns/{id}/pause:
    parameters:
      - $ref: "#/components/parameters/CampaignID"
    post:
      tags: [Campanhas]
      summary: Pausar o disparo
      description: |
        **Escopo: `campaigns:write`.**

        Tira da fila o que ainda não saiu. **Somos honestos sobre o limite**: mensagens que já
        estão em envio no worker (`in_flight`) **não dá para desfazer** — elas vão sair e serão
        contabilizadas. Prometer o contrário seria mentira.
      responses:
        "200":
          description: Pausada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { $ref: "#/components/schemas/CampaignStatus" }
                  canceled_from_queue: { type: integer, description: Quantas foram tiradas da fila a tempo. }
                  in_flight: { type: integer, description: Quantas já estavam saindo (não dá para desfazer). }
                  note: { type: string }
              example:
                status: PAUSED
                canceled_from_queue: 112
                in_flight: 3
                note: "mensagens já em envio no worker não podem ser desfeitas e serão contabilizadas"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: 'Estado incompatível (`bad_state`) — ex.: pausar uma campanha que já terminou.'
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }

  /v1/campaigns/{id}/resume:
    parameters:
      - $ref: "#/components/parameters/CampaignID"
    post:
      tags: [Campanhas]
      summary: Retomar o disparo
      description: "**Escopo: `campaigns:write`.** Continua de onde parou, respeitando o teto do dia."
      responses:
        "202":
          description: Retomada.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Campaign" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Estado incompatível (`bad_state`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }

  /v1/campaigns/{id}/cancel:
    parameters:
      - $ref: "#/components/parameters/CampaignID"
    post:
      tags: [Campanhas]
      summary: Cancelar (o que sobrou não será enviado)
      description: |
        **Escopo: `campaigns:write`.** Definitivo — diferente de `pause`, não dá para retomar.
        Mesma honestidade do pause sobre o `in_flight`.
      responses:
        "200":
          description: Cancelada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { $ref: "#/components/schemas/CampaignStatus" }
                  canceled_from_queue: { type: integer }
                  in_flight: { type: integer }
                  note: { type: string }
              example:
                status: CANCELED
                canceled_from_queue: 270
                in_flight: 1
                note: "mensagens já em envio no worker não podem ser desfeitas e serão contabilizadas"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Estado incompatível (`bad_state`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { $ref: "#/components/responses/CampaignsOff" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer sk_...` (API key de tenant) ou `Bearer sess_...` (sessão humana).
        A API key também é aceita em `X-API-Key: sk_...`. O painel usa o cookie
        `apinofc_session` (HttpOnly) — não use cookie em integração servidor-a-servidor.

  parameters:
    InstanceID:
      name: id
      in: path
      required: true
      description: O id da instância.
      schema: { type: string, format: uuid }
      example: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
    CampaignID:
      name: id
      in: path
      required: true
      description: O id da campanha.
      schema: { type: string, format: uuid }
    ConversationID:
      name: id
      in: path
      required: true
      description: O id da conversa.
      schema: { type: string, format: uuid }
    UserID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Seu id único para este envio. Vira o `message_id`. Repetiu a chave → 200 com a
        mensagem original, sem reenviar. Use um id do seu domínio (`pedido-8842-confirmacao`).
      schema: { type: string, maxLength: 128 }
      example: pedido-8842-confirmacao
    Window:
      name: window
      in: query
      description: Janela de medição.
      schema: { type: string, enum: ["24h", "7d", "30d"], default: "24h" }

  responses:
    BadRequest:
      description: Corpo/parâmetro inválido.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: bad_request, message: "campos 'to' e 'text' são obrigatórios" } }
    Unauthorized:
      description: Credencial ausente, inválida, revogada ou sessão expirada.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: unauthorized, message: "credenciais ausentes ou inválidas" } }
    Forbidden:
      description: |
        Autenticado, mas sem permissão: falta o escopo, falta o papel (`admin`/`owner`),
        a origem é cruzada (`csrf`), ou há troca de senha pendente
        (`password_change_required`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          examples:
            escopo:
              value: { error: { code: forbidden, message: "escopo 'messages:send' necessário" } }
            senha_pendente:
              value: { error: { code: password_change_required, message: "troque a senha inicial em POST /v1/auth/password antes de usar a API" } }
    NotFound:
      description: |
        Não existe **no seu tenant**. É deliberadamente indistinguível de "existe, mas é de
        outro cliente" — confirmar a existência já seria um vazamento (anti-BOLA).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: not_found, message: "instância não encontrada" } }
    CampaignsOff:
      description: Servico de campanhas indisponivel nesta instalacao (`campaigns_disabled`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: campaigns_disabled, message: "servico de campanhas indisponivel" } }
    ConversationsOff:
      description: |
        Esta instalacao nao persiste conversas. Respondemos 503 em vez de fingir que o
        recurso existe (um 200 com lista vazia seria mentira).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: conversations_unavailable, message: "persistencia de conversas nao configurada" } }
    RateLimited:
      description: Limite de requisições do tenant excedido. Faça backoff e tente de novo.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: rate_limited, message: "limite de requisições excedido para o tenant" } }

  schemas:
    Error:
      type: object
      description: 'O formato de erro de TODA a API — sempre `{"error": {"code", "message"}}`.'
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Código estável, para o seu `switch`. A `message` é para humanos e pode mudar.
            message: { type: string }
          required: [code, message]
      required: [error]

    Scope:
      type: string
      enum: [instances:read, instances:write, messages:send, campaigns:read, campaigns:write, admin]
      description: |
        `instances:read` — ler instâncias, mensagens, opt-outs, webhooks, SLIs.
        `instances:write` — criar/apagar instância, opt-out, webhook, config do Chatwoot.
        `messages:send` — enviar e reprocessar mensagens.
        `campaigns:read` / `campaigns:write` — ler / criar e operar campanhas.
        `admin` — plataforma (tenants). **Não atribuível** a chave de tenant.

    InstanceStatus:
      type: string
      enum: [CREATING, CONNECTED, OFFLINE, LOGGED_OUT]
      description: |
        `CREATING` — criada, aguardando o scan do QR.
        `CONNECTED` — pareada e online.
        `OFFLINE` — caiu (rede/servidor). Reconecta sozinha, **sem QR novo**.
        `LOGGED_OUT` — deslogada no aparelho. Precisa parear de novo (QR).

    MessageStatus:
      type: string
      enum: [QUEUED, SENT, DELIVERED, READ, FAILED, BLOCKED]
      description: |
        `QUEUED` — aceita e na fila (é o que o 202 devolve).
        `SENT` — saiu para o WhatsApp (tem `wa_message_id`).
        `DELIVERED` — chegou no aparelho do destinatário.
        `READ` — o destinatário abriu.
        `FAILED` — esgotou as tentativas (veja `error`; dá para reprocessar).
        `BLOCKED` — **não é erro**: o anti-ban barrou antes de sair (opt-out do destinatário
        ou limite diário do aquecimento). Não reenvie em loop — é assim que se toma ban.
        `BLOCKED` fica fora do denominador da taxa de entrega.

    Instance:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        jid:
          type: [string, "null"]
          description: O JID do WhatsApp. `null` enquanto não pareou.
          examples: ["5511987654321:12@s.whatsapp.net"]
        status: { $ref: "#/components/schemas/InstanceStatus" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    QR:
      type: object
      properties:
        status: { type: string, enum: [pending, connected] }
        code: { type: string, description: O conteúdo bruto do QR (se você quiser renderizar por conta própria). }
        qr_png_base64: { type: string, description: "PNG 256px pronto para `data:image/png;base64,...`." }
        expires_in_seconds: { type: integer, description: O QR rotaciona — busque de novo quando expirar. }
        message: { type: string }

    Enqueued:
      type: object
      description: A resposta do 202. `QUEUED` significa ACEITA, não entregue.
      properties:
        message_id: { type: string }
        status: { $ref: "#/components/schemas/MessageStatus" }
        status_url: { type: string, description: Onde acompanhar o resultado real. }

    Message:
      type: object
      properties:
        id: { type: string }
        instance_id: { type: string, format: uuid }
        to: { type: string }
        status: { $ref: "#/components/schemas/MessageStatus" }
        wa_message_id: { type: string, description: O id no WhatsApp. Vazio antes do `SENT`. }
        error:
          type: string
          description: "O motivo do `FAILED` — ou do `BLOCKED` (ex.: `opt-out`, `limite diário` do aquecimento)."
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    OptOut:
      type: object
      properties:
        instance_id: { type: string, format: uuid }
        recipient: { type: string }
        reason:
          type: string
          description: "`manual` (via API) ou `auto:stop-keyword` (o contato respondeu SAIR/STOP)."
        created_at: { type: string, format: date-time }

    WebhookEvent:
      type: string
      enum:
        - message.received
        - message.status
        - message.queued
        - message.sent
        - message.delivered
        - message.read
        - message.failed
        - message.blocked
        - connection.updated
        - instance.connected
        - instance.disconnected
        - instance.logged_out
        - qr.updated
        - "*"
      description: |
        Esta lista é a MESMA que a API usa para validar a assinatura, e cada nome tem um
        emissor provado em teste — nenhum evento aqui é fantasma. Consulte-a em tempo de
        execução no `GET /v1/webhooks/events`.

        **Canônicos** (carregam o estado num campo) e seus **aliases granulares** (mesmo
        payload, nome do estado no `type` — servem de filtro no servidor):

        `message.received` — chegou mensagem (com `media_url`, quando houver).

        `message.status` — QUALQUER transição de entrega (campo `status`). Aliases:
        `message.queued` (aceita e enfileirada — inclusive quando volta da dead-letter),
        `message.sent` (entregue ao WhatsApp; pode trazer `notice` se houve degradação),
        `message.delivered`, `message.read`,
        `message.failed` (`reason` e `reason_code` explicam),
        `message.blocked` (o anti-ban barrou: `reason_code` = `opt_out` ou `daily_cap`).

        `connection.updated` — a sessão mudou de estado (é o seu alarme de queda). Aliases:
        `instance.connected`, `instance.disconnected`,
        `instance.logged_out` (deslogada no aparelho — **exige novo pareamento**).

        `qr.updated` — um QR novo foi emitido (para telas de pareamento).

        `*` — assina tudo, inclusive eventos futuros. A entrega chega sempre com o nome
        **canônico** no `type`.

        Assinar o alias ou o canônico entrega o mesmo corpo; um webhook nunca recebe a mesma
        transição duas vezes.

    Webhook:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        instance_id: { type: [string, "null"], format: uuid, description: "`null` = todas as instâncias do tenant." }
        events: { type: array, items: { $ref: "#/components/schemas/WebhookEvent" } }
        enabled: { type: boolean }
        has_secret: { type: boolean, description: O segredo em si nunca é devolvido — só se existe. }
        created_at: { type: string, format: date-time }

    WebhookDelivery:
      type: object
      description: |
        **O que a AcionaAI faz POST no SEU servidor.** Este esquema não é um endpoint da API:
        é o contrato do que você recebe.

        Cabeçalhos:
        ```http
        Content-Type: application/json
        User-Agent: apinofc-webhook/1.0
        X-Webhook-Event: message.received
        X-Webhook-Timestamp: 1752345600
        X-Webhook-Signature: sha256=<hex>
        ```

        **Como validar (faça isto — é onde todo mundo erra):**
        `assinatura = "sha256=" + hex(HMAC_SHA256(secret, timestamp + "." + corpo_bruto))`

        1. Use o corpo **bruto** (bytes), não o JSON re-serializado — reserializar troca a
           ordem das chaves e o HMAC nunca vai bater.
        2. Compare em **tempo constante** (`crypto.timingSafeEqual`, `hmac.compare_digest`).
        3. Rejeite `X-Webhook-Timestamp` com mais de ~5 min — corta replay.

        Responda **2xx rápido**. Qualquer status >= 300 (ou timeout de 10s) faz a entrega
        ser re-tentada com backoff e, no fim, cair na dead-letter.
      properties:
        type: { $ref: "#/components/schemas/WebhookEvent" }
        timestamp: { type: integer, description: Epoch em segundos — o MESMO valor de `X-Webhook-Timestamp`. }
        data: { type: object, description: O payload do evento (varia por `type`). }
      examples:
        - type: message.received
          timestamp: 1752345600
          data:
            instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
            message_id: 3EB0C767D82B0F3A1B2C
            from: 5511987654321@s.whatsapp.net
            chat: 5511987654321@s.whatsapp.net
            push_name: João Silva
            text: "Oi, meu pedido já saiu?"
            from_me: false
            is_group: false
            timestamp: "2026-07-12T14:03:11Z"
        - type: message.status
          timestamp: 1752345606
          data:
            message_id: pedido-8842-confirmacao
            wa_message_id: 3EB0C767D82B0F3A1B2C
            instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
            status: DELIVERED
        - type: connection.updated
          timestamp: 1752345700
          data:
            instance_id: 4d9f8a12-6b3c-4e57-8a90-1f2e3d4c5b6a
            status: OFFLINE
            reason: network
        - type: qr.updated
          timestamp: 1752345500
          data:
            instance_id: 9a1b2c3d-4e5f-4061-8273-6a5b4c3d2e1f
            code: "2@Xr7f0K...,BQm1c3Vt...,Q0hFQ0s="

    Tenant:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        max_instances:
          type: [integer, "null"]
          description: |
            A **cota de instâncias** contratada (quantos números o cliente pode abrir).
            `null` = ilimitado. `0` = suspenso (não cria nenhum). Só o `admin` (owner /
            chave de bootstrap) altera, em `PATCH /v1/tenants/{id}`.
          examples: [1]
        created_at: { type: string, format: date-time }

    InstanceQuota:
      type: object
      description: |
        Consumo e teto de instâncias do tenant. Vem junto do `GET /v1/instances` — é o que
        permite desabilitar o "novo número" **antes** de tomar um `409
        instance_quota_exceeded`.
      properties:
        used: { type: integer, description: Instâncias existentes., examples: [1] }
        max:
          type: [integer, "null"]
          description: O teto contratado. `null` = ilimitado.
          examples: [1]
        remaining:
          type: [integer, "null"]
          description: Quantas ainda cabem. `null` = ilimitado. Nunca negativo (a cota pode ter sido reduzida abaixo do uso).
          examples: [0]
        full: { type: boolean, description: "`true` = o próximo `POST /v1/instances` devolve 409." }
      required: [used, max, remaining, full]

    TenantOverview:
      type: object
      description: A linha de um tenant na visão consolidada da plataforma.
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        created_at: { type: string, format: date-time }
        instances: { type: integer, description: Instâncias em uso (o consumo da cota). }
        max_instances:
          type: [integer, "null"]
          description: A cota contratada (`null` = ilimitado). Com `instances`, forma o "3 / 3" do console.
        instances_connected: { type: integer, description: Instâncias pareadas e online (mesmo estado do `GET /v1/instances`). }
        messages_30d: { type: integer }
        messages_today: { type: integer, description: Do início do dia UTC. }
        messages_failed_30d: { type: integer }
        active_api_keys: { type: integer, description: Chaves não revogadas. }
        active_users: { type: integer, description: Usuários não desativados. }
        is_system: { type: boolean, description: "O tenant `system` (bootstrap): não é cliente e não é removível." }

    PlatformTotals:
      type: object
      description: A soma de TODOS os tenants que casam com o filtro (não só os da página).
      properties:
        tenants: { type: integer }
        instances: { type: integer }
        instances_connected: { type: integer }
        messages_30d: { type: integer }
        messages_today: { type: integer }
        messages_failed_30d: { type: integer }
        active_api_keys: { type: integer }
        active_users: { type: integer }

    TenantFootprint:
      type: object
      description: O recibo da remoção — o que existia sob o tenant (contado ANTES do DELETE).
      properties:
        instances: { type: integer }
        messages: { type: integer }
        api_keys: { type: integer }
        users: { type: integer }
        webhooks: { type: integer }
        conversations: { type: integer }
        campaigns: { type: integer }
        opt_outs: { type: integer }

    DeliveryPoint:
      type: object
      description: Um dia da série de entrega — contagem por ESTADO ATUAL das mensagens criadas naquele dia.
      properties:
        date: { type: string, description: "`YYYY-MM-DD` no fuso pedido." }
        total: { type: integer }
        queued: { type: integer }
        sent: { type: integer }
        delivered: { type: integer }
        read: { type: integer }
        failed: { type: integer }
        blocked: { type: integer }

    MessageAttempt:
      type: object
      description: Um ponto da linha do tempo de uma mensagem (uma tentativa, ou o enfileiramento).
      properties:
        attempt:
          type: integer
          description: "0 = enfileiramento (API, campanha ou reprocessamento); 1..N = tentativas do worker."
        status:
          type: string
          enum: [QUEUED, SENT, RETRYING, FAILED, BLOCKED]
          description: "`RETRYING` = falhou mas ainda seria re-tentada; `FAILED` = fim da linha."
        error: { type: [string, "null"], description: O erro exato daquela tentativa. }
        wa_message_id: { type: [string, "null"] }
        at: { type: string, format: date-time }

    QueueStat:
      type: object
      description: A foto de uma fila do asynq.
      properties:
        queue: { type: string, enum: [messages, chatwoot, webhooks, campaigns] }
        pending: { type: integer, description: Esperando um worker. }
        active: { type: integer, description: Sendo processadas agora. }
        scheduled: { type: integer, description: Agendadas (jitter anti-ban, campanha futura). }
        retry: { type: integer, description: Falharam e vão ser re-tentadas (backoff). }
        archived: { type: integer, description: "DEAD-LETTER: esgotaram as tentativas." }
        completed: { type: integer }
        size: { type: integer }
        processed_today: { type: integer, description: Contador do dia (o asynq zera à meia-noite). }
        failed_today: { type: integer }
        paused: { type: boolean }
        latency_seconds: { type: number, description: Idade da tarefa pendente mais antiga. }
        exists: { type: boolean, description: "`false` = a fila ainda não recebeu nenhuma tarefa (zeros honestos)." }

    ArchivedTask:
      type: object
      description: Um item da dead-letter. O payload NÃO vai junto (é conteúdo do cliente).
      properties:
        id: { type: string, description: "Nas filas de envio é o próprio `message_id` (é ele que dá a idempotência)." }
        queue: { type: string }
        type: { type: string, examples: ["message:send", "media:send", "webhook:deliver"] }
        error: { type: string, description: O erro da última tentativa. }
        attempts: { type: integer }
        max_retry: { type: integer }
        last_failed_at: { type: string, format: date-time }

    APIKey:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        name: { type: string }
        scopes: { type: array, items: { $ref: "#/components/schemas/Scope" } }
        revoked: { type: boolean }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, "null"], format: date-time }

    User:
      type: object
      properties:
        id: { type: string, format: uuid }
        tenant_id: { type: string, format: uuid }
        email: { type: string, format: email }
        name: { type: string }
        role:
          type: string
          enum: [owner, admin, member]
          description: |
            `owner` — dono da plataforma (equivale à API key de bootstrap: manda em todos os tenants).
            `admin` — administra o PRÓPRIO tenant (usuários e chaves).
            `member` — usa (ler + enviar), não administra.
        must_change_password: { type: boolean }
        disabled: { type: boolean }
        created_at: { type: string, format: date-time }
        last_login_at: { type: [string, "null"], format: date-time }

    Me:
      type: object
      properties:
        type: { type: string, enum: [api_key, user] }
        tenant_id: { type: string, format: uuid }
        tenant_name: { type: string }
        scopes: { type: array, items: { $ref: "#/components/schemas/Scope" } }
        is_admin: { type: boolean }
        user: { $ref: "#/components/schemas/User" }
        must_change_password: { type: boolean }

    HealthComponent:
      type: object
      properties:
        key: { type: string, enum: [database, queue, vault] }
        name: { type: string }
        status: { type: string, enum: [operational, degraded, outage, "off"] }
        latency_ms: { type: number }
        detail: { type: string, description: Nunca carrega DSN, host, credencial ou stack. }

    Health:
      type: object
      properties:
        status: { type: string, enum: [operational, degraded, outage] }
        service: { type: string, const: apinofc }
        components: { type: array, items: { $ref: "#/components/schemas/HealthComponent" } }
        time: { type: string, format: date-time }

    UptimeTotals:
      type: object
      properties:
        uptime_percent: { type: number }
        connected_seconds: { type: integer }
        measured_seconds: { type: integer }
        downs: { type: integer }
        has_data: { type: boolean, description: "`false` = não medimos ainda. Não é 0% nem 100% — é 'sem dado'." }
        window_start: { type: string, format: date-time }
        window_end: { type: string, format: date-time }

    UptimeDay:
      type: object
      properties:
        date: { type: string, format: date }
        uptime_percent: { type: number }
        downs: { type: integer }
        has_data: { type: boolean }

    InstanceUptime:
      type: object
      properties:
        instance_id: { type: string, format: uuid }
        name: { type: string }
        window: { type: string, enum: ["24h", "7d", "30d"] }
        uptime: { $ref: "#/components/schemas/UptimeTotals" }
        windows:
          type: object
          description: As três janelas calculadas de uma vez (24h, 7d, 30d).
          additionalProperties: { $ref: "#/components/schemas/UptimeTotals" }
        days: { type: array, items: { $ref: "#/components/schemas/UptimeDay" }, description: A uptime bar (30 dias). }
        events:
          type: array
          description: As últimas transições da janela (até 50). É resumo de operação, não log completo.
          items:
            type: object
            properties:
              at: { type: string, format: date-time }
              event: { type: string, enum: [pairing, connected, disconnected, logged_out] }
              reason: { type: string, examples: ["network", "shutdown", "api_logout", "remote"] }
        live: { type: boolean, description: A sessão está conectada AGORA (em memória). }
        slo:
          type: object
          properties:
            session_uptime_percent: { type: number }

    DeliveryStats:
      type: object
      properties:
        total: { type: integer, description: "Enfileiradas no período, **exceto BLOCKED**." }
        sent: { type: integer, description: "`SENT` + `DELIVERED` + `READ`." }
        delivered: { type: integer, description: "`DELIVERED` + `READ`." }
        read: { type: integer }
        failed: { type: integer }
        queued: { type: integer, description: Ainda na fila. }
        blocked: { type: integer, description: "Anti-ban — proteção, **não** é falha (fora do `total`)." }
        success_rate: { type: number, description: "% de `sent`/`total` (0 quando `total` = 0)." }
        queue_latency_p50_seconds: { type: [number, "null"], description: Do 202 até o envio efetivo. }
        queue_latency_p95_seconds: { type: [number, "null"] }

    PublicStatus:
      type: object
      description: Agregado e anônimo — nada aqui identifica tenant, instância ou número.
      properties:
        status: { type: string, enum: [operational, degraded, outage] }
        checked_at: { type: string, format: date-time }
        components: { type: array, items: { $ref: "#/components/schemas/HealthComponent" } }
        slo:
          type: object
          properties:
            session_uptime_percent: { type: number }
            queue_latency_p95_seconds: { type: number }
        sessions:
          type: object
          description: Uptime somado de TODAS as sessões (sobre segundos, não média de percentuais).
          properties:
            uptime_24h: { type: number }
            has_data_24h: { type: boolean }
            uptime_7d: { type: number }
            has_data_7d: { type: boolean }
            uptime_30d: { type: number }
            has_data_30d: { type: boolean }
        history: { type: array, items: { $ref: "#/components/schemas/UptimeDay" } }
        incidents:
          type: array
          description: DERIVADOS da medição (dias abaixo de 99,9%). Não há tabela escrita à mão.
          items:
            type: object
            properties:
              date: { type: string, format: date }
              uptime_percent: { type: number }
              downs: { type: integer }
              summary: { type: string }
        delivery:
          type: object
          description: Só percentuais e latência — volume de mensagens é informação comercial.
          properties:
            success_rate_24h: { type: number }
            has_data: { type: boolean }
            queue_latency_p50_seconds: { type: [number, "null"] }
            queue_latency_p95_seconds: { type: [number, "null"] }

    ChatwootConfigIn:
      type: object
      properties:
        enabled: { type: boolean }
        url: { type: string, format: uri, examples: ["https://chat.acme.com.br"] }
        account_id: { type: string, examples: ["1"] }
        token: { type: string, description: Token de acesso do Chatwoot. Cifrado em repouso. Vazio no update = mantém o atual. }
        name_inbox: { type: string }
        sign_msg: { type: boolean, description: Assina a mensagem com o nome do agente. }
        sign_delimiter: { type: string, default: "\n" }
        reopen_conversation: { type: boolean }
        conversation_pending: { type: boolean }
        merge_brazil_contacts: { type: boolean, description: Casa o número com e sem o 9 (o eterno problema brasileiro). }
        groups_enabled: { type: boolean }
        bot_contact_enabled: { type: boolean, description: Cria o contato-bot que recebe o QR dentro do Chatwoot. }

    ChatwootConfigOut:
      allOf:
        - $ref: "#/components/schemas/ChatwootConfigIn"
        - type: object
          properties:
            configured: { type: boolean }
            inbox_id: { type: integer }
            token_set: { type: boolean, description: O token nunca volta — só se existe. }
            callback_url: { type: string }

    CodeSamples:
      type: object
      description: O que `/v1/docs/examples.json` devolve por operação.
      properties:
        operation_id: { type: string }
        summary: { type: string }
        curl: { type: string }
        node: { type: string }
        python: { type: string }

    AntibanStatus:
      type: object
      description: |
        A política de proteção do número — os MESMOS valores que o worker usa para decidir
        `BLOCKED`. O aquecimento é proteção, não punição: o teto sobe sozinho.
      properties:
        instance_id: { type: string, format: uuid }
        instance_created_at: { type: string, format: date-time }
        age_days: { type: integer, description: Idade da instância — é ela que define a fase do aquecimento. }
        daily_cap: { type: integer, description: O teto de HOJE. }
        sent_today: { type: integer }
        remaining_today: { type: integer }
        usage_percent: { type: number }
        warmup_week: { type: integer, description: "1 = primeira semana." }
        warmup_complete: { type: boolean, description: Já bateu o teto máximo. }
        tomorrow_cap: { type: integer, description: O teto de AMANHÃ. }
        next_ramp_at: { type: string, format: date-time, description: Quando o teto sobe de novo. }
        base_daily_cap: { type: integer }
        ramp_per_week: { type: integer }
        max_daily_cap: { type: integer }
        jitter_min_ms: { type: integer, description: Espaçamento humano aplicado no enfileiramento. }
        jitter_max_ms: { type: integer }
        opt_out_count: { type: integer }

    Contact:
      type: object
      properties:
        id: { type: string, format: uuid }
        phone: { type: string }
        name: { type: string }
        push_name: { type: string }
        jid: { type: string }
        is_group: { type: boolean }

    Conversation:
      type: object
      description: |
        Uma linha do tempo do TRANSPORTE (o que trafegou), isolada por tenant. **Não é um CRM**:
        o atendimento acontece na ferramenta do cliente.
      properties:
        id: { type: string, format: uuid }
        instance_id: { type: string, format: uuid }
        instance_name: { type: string }
        contact_id: { type: string, format: uuid }
        contact: { $ref: "#/components/schemas/Contact" }
        status: { type: string, enum: [OPEN, CLOSED] }
        assignee_user_id: { type: [string, "null"], format: uuid }
        assignee_name: { type: [string, "null"] }
        unread_count: { type: integer }
        last_message_at: { type: [string, "null"], format: date-time }
        last_message_preview: { type: string }
        created_at: { type: string, format: date-time }

    ConversationMessage:
      type: object
      properties:
        id: { type: string, format: uuid }
        conversation_id: { type: string, format: uuid }
        instance_id: { type: string, format: uuid }
        direction: { type: string, enum: [IN, OUT] }
        from_me: { type: boolean }
        wa_message_id: { type: [string, "null"] }
        message_id: { type: [string, "null"], description: "A chave de idempotência — casa esta bolha com `GET /v1/messages/{id}`." }
        type: { type: string, examples: ["text", "image", "audio", "video", "document"] }
        body: { type: string }
        media_id: { type: [string, "null"], format: uuid }
        media_url: { type: [string, "null"], description: "`/v1/media/{id}` — autenticada. Os BYTES nunca vêm no corpo." }
        mimetype: { type: string }
        filename: { type: string }
        status: { $ref: "#/components/schemas/MessageStatus" }
        error: { type: [string, "null"] }
        sender_jid: { type: string }
        sender_name: { type: string }
        quoted_wa_id: { type: [string, "null"] }
        created_at: { type: string, format: date-time }

    DeliveryStatus:
      type: string
      enum: [PENDING, SUCCESS, RETRYING, FAILED]
      description: |
        `PENDING` — enfileirada, o worker ainda não pegou.
        `SUCCESS` — o seu servidor respondeu 2xx.
        `RETRYING` — falhou, mas **ainda há tentativas** (NÃO é entrega perdida).
        `FAILED` — esgotou as tentativas (dead-letter). Só isto conta como falha.

    WebhookState:
      type: string
      enum: [ACTIVE, FAILING, PAUSED]
      description: "`FAILING` = a SUA URL está quebrando. `PAUSED` = você pausou (`enabled: false`)."

    WebhookHealth:
      type: object
      description: A saúde da entrega — o que diz se a sua URL está viva.
      properties:
        state: { $ref: "#/components/schemas/WebhookState" }
        success_rate: { type: [number, "null"] }
        deliveries_total: { type: integer }
        deliveries_success: { type: integer }
        deliveries_failed: { type: integer }
        deliveries_pending: { type: integer }
        consecutive_failures: { type: integer }
        last_delivery_at: { type: [string, "null"], format: date-time }
        last_status: { type: [string, "null"] }
        last_http_status: { type: [integer, "null"] }

    WebhookDeliveryRecord:
      type: object
      description: |
        Uma linha do HISTÓRICO de entregas (diagnóstico). Não confundir com
        `WebhookDelivery`, que é o contrato do que a AcionaAI faz POST no seu servidor.
      properties:
        id: { type: string, format: uuid }
        webhook_id: { type: string, format: uuid }
        event: { $ref: "#/components/schemas/WebhookEvent" }
        url: { type: string, format: uri }
        status: { $ref: "#/components/schemas/DeliveryStatus" }
        http_status: { type: [integer, "null"] }
        attempts: { type: integer }
        error: { type: [string, "null"] }
        duration_ms: { type: [integer, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CampaignStatus:
      type: string
      enum: [DRAFT, SCHEDULED, RUNNING, PAUSED, DONE, CANCELED, FAILED]
      description: |
        `DRAFT` — criada, ainda sem agendamento (lista e template editáveis).
        `SCHEDULED` — agendada.
        `RUNNING` — despachando. **Pode levar dias**: o disparo respeita o teto diário do
        aquecimento (veja `next_dispatch_at` e `/plan`).
        `PAUSED` — pausada por você (dá para retomar).
        `DONE` — todos os destinatários chegaram a um status terminal.
        `CANCELED` — cancelada; o que sobrou não será enviado.
        `FAILED` — erro irrecuperável no despacho.

    Campaign:
      type: object
      properties:
        id: { type: string, format: uuid }
        instance_id: { type: string, format: uuid }
        name: { type: string }
        message_template: { type: string, description: "Variáveis `{{coluna}}`, resolvidas por linha do CSV." }
        status: { $ref: "#/components/schemas/CampaignStatus" }
        scheduled_at: { type: [string, "null"], format: date-time }
        next_dispatch_at:
          type: [string, "null"]
          format: date-time
          description: Quando sai a próxima leva (o teto diário do anti-ban distribui a lista ao longo dos dias).
        started_at: { type: [string, "null"], format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
        total_recipients: { type: integer }
        error: { type: [string, "null"] }
        progress: { $ref: "#/components/schemas/CampaignProgress" }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CampaignProgress:
      type: object
      description: |
        Sai do status **efetivo** das mensagens — "enviados" é envio de verdade, não
        "enfileirei". `blocked` é o anti-ban protegendo o número: **não é falha**.
      properties:
        pending: { type: integer }
        queued: { type: integer }
        sent: { type: integer, description: "`SENT` + `DELIVERED` + `READ`." }
        delivered: { type: integer }
        read: { type: integer }
        failed: { type: integer }
        blocked: { type: integer }

    CampaignRecipient:
      type: object
      properties:
        id: { type: string, format: uuid }
        campaign_id: { type: string, format: uuid }
        phone: { type: string }
        variables:
          type: object
          additionalProperties: { type: string }
          description: As colunas do CSV que alimentam o template.
        status: { type: string, description: "Efetivo: o da mensagem quando ela já existe." }
        message_id: { type: [string, "null"], description: "Casa com `GET /v1/messages/{id}`." }
        error: { type: [string, "null"] }
        dispatched_at: { type: [string, "null"], format: date-time }
        sent_at: { type: [string, "null"], format: date-time, description: Do envio real, não do despacho. }
        created_at: { type: string, format: date-time }

    CampaignPlan:
      type: object
      description: |
        A distribuição prevista, calculada **sem enviar nada**. É o "confira antes de disparar".
      properties:
        total_recipients: { type: integer }
        pending: { type: integer }
        opted_out: { type: integer, description: Não vão receber — pediram para sair. }
        missing_var_rows: { type: integer, description: Linhas sem alguma variável exigida pelo template. }
        eligible: { type: integer, description: O que de fato deve sair. }
        daily_cap: { type: integer, description: O teto do número HOJE (aquecimento). }
        sent_today: { type: integer, description: Já consumido hoje por outros envios. }
        remaining_today: { type: integer }
        exceeds_daily_cap:
          type: boolean
          description: |
            `true` **não é erro**: a lista é maior do que cabe hoje, então será **distribuída ao
            longo dos dias** em vez de queimar o número numa rajada.
        days: { type: integer, description: Em quantos dias a lista termina. }
        estimated_finish: { type: string, format: date-time }
        template_vars: { type: array, items: { type: string } }
        warmup: { type: boolean, description: O número ainda está em aquecimento. }

    CSVReport:
      type: object
      description: O relatório da importação — o mesmo no `dry_run` e na importação real.
      properties:
        dry_run: { type: boolean }
        total_rows: { type: integer }
        valid: { type: integer }
        invalid: { type: integer }
        duplicates: { type: integer }
        columns: { type: array, items: { type: string } }
        sample:
          type: array
          description: Uma amostra do que seria importado (para conferir o de-para das variáveis).
          items:
            type: object
            properties:
              phone: { type: string }
              variables: { type: object, additionalProperties: { type: string } }
        errors:
          type: array
          description: Os erros linha a linha — é o que deixa o cliente consertar o CSV sozinho.
          items:
            type: object
            properties:
              line: { type: integer }
              error: { type: string }
        imported: { type: integer, description: "`0` no dry-run (nada foi gravado)." }
