openapi: 3.1.0
info:
  title: API do Ranqal
  version: "1.0.0"
  summary: API REST só leitura com os dados da agência.
  description: |
    Leia clientes, fichas, medições (geogrid), Ranqal Score, avaliações, visibilidade em IA, plano de ação e leads
    da sua agência. Só leitura: a API nunca altera dados nem dispara coletas pagas.

    **Autenticação:** Dono ou Admin cria a chave em Configurações › Desenvolvedores; envie no header
    `Authorization: Bearer rq_live_...`. Nunca na URL.

    **Limites:** 60 chamadas por minuto e 5.000 por dia, por chave. Acima disso: `429` com `Retry-After`.

    **Paginação:** listas devolvem `{ data, next_cursor }`. Passe `cursor=<next_cursor>` para a próxima página
    (`limit` até 100). Com `format=csv`, o cursor vem no header `X-Next-Cursor`.

    **Webhooks:** o Ranqal faz `POST` no seu endereço (https) quando chega um lead, um teste termina ou uma
    medição fica pronta. Cada envio é assinado (`X-Ranqal-Signature`) e tem um id (`X-Ranqal-Event-Id`) que se
    repete nas novas tentativas. Veja a seção Webhooks e https://ranqal.com.br/desenvolvedores#webhooks.

    Disponível nos planos Agência e Escala.
  contact:
    name: Ranqal
    email: contato@ranqal.com.br
    url: https://ranqal.com.br/desenvolvedores
servers:
  - url: https://app.ranqal.com.br/api/v1
security:
  - chave: []
tags:
  - name: Conta
  - name: Clientes e fichas
  - name: Medições e Score
  - name: Reputação e IA
  - name: Ações e leads
  - name: Webhooks
    description: Eventos que o Ranqal envia para o seu endereço (POST, JSON assinado).
paths:
  /me:
    get:
      tags: [Conta]
      operationId: getMe
      summary: Agência, chave e plano
      description: Confere a chave. Útil como primeiro teste.
      responses:
        "200":
          description: Agência, chave (prefixo, escopos, validade), plano e limites.
          headers:
            X-RateLimit-Limit: { $ref: "#/components/headers/RateLimit" }
            X-RateLimit-Remaining: { $ref: "#/components/headers/RateRemaining" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /clients:
    get:
      tags: [Clientes e fichas]
      operationId: listClients
      summary: Clientes
      description: Clientes da agência, por nome. Oportunidades e testes ficam de fora.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Format"
        - $ref: "#/components/parameters/IncludeArchived"
      responses:
        "200":
          description: Página de clientes.
          headers:
            X-Next-Cursor: { $ref: "#/components/headers/NextCursor" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ClientList" }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/Validation" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /businesses:
    get:
      tags: [Clientes e fichas]
      operationId: listBusinesses
      summary: Fichas
      description: Fichas do Google dos clientes, por nome.
      parameters:
        - name: client_id
          in: query
          description: Só as fichas deste cliente.
          schema: { type: string, format: uuid }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Format"
        - $ref: "#/components/parameters/IncludeArchived"
      responses:
        "200":
          description: Página de fichas.
          headers:
            X-Next-Cursor: { $ref: "#/components/headers/NextCursor" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BusinessList" }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/Validation" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /businesses/{id}:
    get:
      tags: [Clientes e fichas]
      operationId: getBusiness
      summary: Uma ficha
      parameters:
        - $ref: "#/components/parameters/BusinessId"
      responses:
        "200":
          description: A ficha.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Business" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /businesses/{id}/score:
    get:
      tags: [Medições e Score]
      operationId: listBusinessScore
      summary: Histórico do Ranqal Score
      description: Notas gravadas da ficha, mais recente primeiro.
      parameters:
        - $ref: "#/components/parameters/BusinessId"
        - $ref: "#/components/parameters/Since"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Format"
      responses:
        "200":
          description: Página de notas.
          headers:
            X-Next-Cursor: { $ref: "#/components/headers/NextCursor" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScoreList" }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/Validation" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /businesses/{id}/scans:
    get:
      tags: [Medições e Score]
      operationId: listBusinessScans
      summary: Medições da ficha
      description: Medições (geogrid), mais recente primeiro, com as métricas de cada palavra-chave.
      parameters:
        - $ref: "#/components/parameters/BusinessId"
        - $ref: "#/components/parameters/Since"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Format"
      responses:
        "200":
          description: Página de medições.
          headers:
            X-Next-Cursor: { $ref: "#/components/headers/NextCursor" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScanList" }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/Validation" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /scans/{id}:
    get:
      tags: [Medições e Score]
      operationId: getScan
      summary: Uma medição com a grade
      description: Posição em cada ponto da grade e os 10 primeiros concorrentes de cada palavra-chave.
      parameters:
        - name: id
          in: path
          required: true
          description: Id da medição.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: A medição.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScanDetail" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /businesses/{id}/reviews:
    get:
      tags: [Reputação e IA]
      operationId: listBusinessReviews
      summary: Avaliações do Google
      description: |
        Avaliações coletadas da ficha, mais nova primeiro. Cada consulta cobre as 1.000 mais recentes; para as mais antigas, use `since`.
        Sem nome, foto ou link do perfil de quem avaliou.
      parameters:
        - $ref: "#/components/parameters/BusinessId"
        - $ref: "#/components/parameters/Since"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Format"
      responses:
        "200":
          description: Página de avaliações.
          headers:
            X-Next-Cursor: { $ref: "#/components/headers/NextCursor" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReviewList" }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/Validation" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /businesses/{id}/ai-visibility:
    get:
      tags: [Reputação e IA]
      operationId: listBusinessAiVisibility
      summary: Visibilidade em IA
      description: Respostas dos assistentes de IA às perguntas da ficha. Sem `since`, os últimos 90 dias.
      parameters:
        - $ref: "#/components/parameters/BusinessId"
        - $ref: "#/components/parameters/Since"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Format"
      responses:
        "200":
          description: Página de respostas.
          headers:
            X-Next-Cursor: { $ref: "#/components/headers/NextCursor" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AiRunList" }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/Validation" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /businesses/{id}/actions:
    get:
      tags: [Ações e leads]
      operationId: listBusinessActions
      summary: Plano de ação
      description: Ações da ficha, mais nova primeiro.
      parameters:
        - $ref: "#/components/parameters/BusinessId"
        - name: status
          in: query
          schema: { type: string, enum: [pending, in_progress, done, dismissed] }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Format"
      responses:
        "200":
          description: Página de ações.
          headers:
            X-Next-Cursor: { $ref: "#/components/headers/NextCursor" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ActionList" }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/Validation" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /leads:
    get:
      tags: [Ações e leads]
      operationId: listLeads
      summary: Leads
      description: Leads do teste grátis no site da agência (`source=site`) e testes de prospecção (`source=prospect`), mais novo primeiro.
      parameters:
        - name: source
          in: query
          schema: { type: string, enum: [site, prospect] }
        - $ref: "#/components/parameters/Since"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/Format"
      responses:
        "200":
          description: Página de leads.
          headers:
            X-Next-Cursor: { $ref: "#/components/headers/NextCursor" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LeadList" }
            text/csv:
              schema: { type: string }
        "400": { $ref: "#/components/responses/Validation" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
  /usage:
    get:
      tags: [Conta]
      operationId: getUsage
      summary: Plano, uso e gasto
      description: Exige o escopo `usage:read`.
      responses:
        "200":
          description: Plano, limites x uso do mês, gasto e chamadas da API.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }
        "401": { $ref: "#/components/responses/InvalidKey" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }
webhooks:
  lead.created:
    post:
      tags: [Webhooks]
      operationId: webhookLeadCreated
      summary: Lead novo
      description: |
        Chegou um lead pelo teste grátis no seu site (`source: site`) ou alguém da equipe criou um teste de
        prospecção (`source: prospect`). `data` é igual a um item de `GET /leads`.
      parameters: &webhookHeaders
        - { $ref: "#/components/parameters/WebhookEventId" }
        - { $ref: "#/components/parameters/WebhookEvent" }
        - { $ref: "#/components/parameters/WebhookSignature" }
        - { $ref: "#/components/parameters/WebhookAttempt" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookLeadCreated" }
            example:
              id: 3f6b2a0e-8d1c-4d55-9a51-2a3f7c9e1b20
              type: lead.created
              created_at: "2026-10-21T13:04:11.120Z"
              data:
                id: 9a1c7e44-0b7d-4c1e-a0b5-6c2d3e4f5a6b
                source: site
                business_name: Padaria Pão Bom
                city: Campinas
                keyword: padaria
                name: Ana Souza
                email: ana@padariapaobom.com.br
                phone: "+5519999990000"
                page: https://suaagencia.com.br
                status: new
                test_status: queued
                report_url: null
                created_at: "2026-10-21T13:04:10.981Z"
      responses: &webhookResponses
        "2XX": { description: Recebido. Responda em até 10 s; o corpo da resposta é ignorado. }
        default: { description: "Qualquer outra resposta (ou nenhuma em 10 s) = falha: o Ranqal tenta de novo depois de 1 min, 5 min, 30 min, 2 h e 12 h." }
  test.completed:
    post:
      tags: [Webhooks]
      operationId: webhookTestCompleted
      summary: Teste concluído
      description: |
        O teste do lead (ou do prospect) terminou: `test_status: done` com `report_url` (link do resultado com a
        marca da agência) e `summary`; ou `test_status: error` (sem resultado, `summary: null`).
      parameters: *webhookHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookTestCompleted" }
            example:
              id: 0d9e8f7a-6b5c-4d3e-8f1a-2b3c4d5e6f70
              type: test.completed
              created_at: "2026-10-21T13:09:42.507Z"
              data:
                id: 9a1c7e44-0b7d-4c1e-a0b5-6c2d3e4f5a6b
                source: site
                business_name: Padaria Pão Bom
                city: Campinas
                keyword: padaria
                name: Ana Souza
                email: ana@padariapaobom.com.br
                phone: "+5519999990000"
                page: https://suaagencia.com.br
                status: new
                test_status: done
                report_url: https://app.ranqal.com.br/diagnostico/padaria-pao-bom-Xk2m9QaZ7pL4sT1v
                created_at: "2026-10-21T13:04:10.981Z"
                summary:
                  business: Padaria Pão Bom
                  city: Campinas
                  category: Padaria
                  keyword: padaria
                  map: { top3_pct: 18.4, coverage_pct: 61.2, avg_rank: 7.3 }
                  rating: 4.6
                  reviews_count: 212
                  ai: { cited: 1, total: 4 }
                  site_performance_mobile: 54
                  brand_position: 1
                  actions: [Responder as avaliações sem resposta, Completar os horários da ficha]
      responses: *webhookResponses
  scan.completed:
    post:
      tags: [Webhooks]
      operationId: webhookScanCompleted
      summary: Medição concluída
      description: |
        Uma medição (geogrid) de ficha de cliente terminou (`status: succeeded`) ou falhou (`status: failed`).
        `data` é igual a um item de `GET /businesses/{id}/scans`, mais `business`. Para a grade ponto a ponto, chame
        `GET /scans/{id}`. Medições de oportunidades e de testes não geram evento.
      parameters: *webhookHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookScanCompleted" }
            example:
              id: 6c5b4a39-2817-4f60-9e8d-7c6b5a493827
              type: scan.completed
              created_at: "2026-10-21T14:30:05.004Z"
              data:
                id: 1e2d3c4b-5a69-4788-9a0b-c1d2e3f40516
                business_id: 6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b
                status: succeeded
                created_at: "2026-10-21T14:21:40.000Z"
                finished_at: "2026-10-21T14:30:04.880Z"
                grid_size: 7
                radius_km: 3
                center: { lat: -22.9056, lng: -47.0608 }
                keywords:
                  - { keyword: dentista campinas, points: 49, valid_points: 49, found: 41, top3: 12, top10: 30, arp: 5.8, atrp: 8.1, solv_pct: 24.5, coverage_pct: 83.7 }
                business: { id: 6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b, name: Clínica Sorriso, client_id: 2a3b4c5d-6e7f-4801-9a2b-3c4d5e6f7a8b, client_name: Clínica Sorriso }
      responses: *webhookResponses
  webhook.test:
    post:
      tags: [Webhooks]
      operationId: webhookTest
      summary: Evento de teste
      description: Só pelo botão "Enviar evento de teste" em Configurações › Desenvolvedores. Não se assina; uma tentativa só.
      parameters: *webhookHeaders
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookTest" }
            example:
              id: 7d8e9f00-1a2b-4c3d-8e4f-5a6b7c8d9e0f
              type: webhook.test
              created_at: "2026-10-21T13:00:00.000Z"
              data: { message: "Evento de teste do Ranqal. Se chegou aqui, o endereço e a assinatura estão funcionando.", endpoint_id: 4b5c6d7e-8f90-4a1b-9c2d-3e4f5a6b7c8d }
      responses: *webhookResponses
components:
  securitySchemes:
    chave:
      type: http
      scheme: bearer
      description: "Chave da agência: rq_live_<prefixo>_<segredo>. Crie em Configurações › Desenvolvedores."
  headers:
    NextCursor:
      description: Só com format=csv. Cursor da próxima página (ausente na última).
      schema: { type: string }
    RateLimit:
      description: Chamadas por minuto da chave.
      schema: { type: integer, example: 60 }
    RateRemaining:
      description: Chamadas que ainda cabem neste minuto.
      schema: { type: integer }
  parameters:
    WebhookEventId:
      name: X-Ranqal-Event-Id
      in: header
      required: true
      description: Id do evento (o mesmo `id` do corpo). Igual em todas as tentativas e no reenvio manual. Use para não processar duas vezes.
      schema: { type: string, format: uuid }
    WebhookEvent:
      name: X-Ranqal-Event
      in: header
      required: true
      schema: { type: string, enum: [lead.created, test.completed, scan.completed, webhook.test] }
    WebhookSignature:
      name: X-Ranqal-Signature
      in: header
      required: true
      description: '`t=<unix>,v1=<hex>`: v1 = HMAC-SHA256 com o segredo do endpoint (whsec_...) sobre `"<t>.<corpo cru>"`. Recuse t com mais de 5 minutos de diferença.'
      schema: { type: string, example: "t=1760965451,v1=5f2b0c9e8d7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c" }
    WebhookAttempt:
      name: X-Ranqal-Attempt
      in: header
      required: false
      description: Número do envio (1 a 6).
      schema: { type: integer, minimum: 1 }
    BusinessId:
      name: id
      in: path
      required: true
      description: Id da ficha.
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      description: Itens por página (1 a 100).
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    Cursor:
      name: cursor
      in: query
      description: O next_cursor da página anterior.
      schema: { type: string }
    Format:
      name: format
      in: query
      description: json (padrão) ou csv (UTF-8, vírgula).
      schema: { type: string, enum: [json, csv], default: json }
    Since:
      name: since
      in: query
      description: A partir desta data (AAAA-MM-DD) ou data e hora ISO 8601.
      schema: { type: string, example: "2026-10-01" }
    IncludeArchived:
      name: include_archived
      in: query
      description: Inclui os arquivados.
      schema: { type: boolean, default: false }
  responses:
    Validation:
      description: Parâmetro inválido (`validation`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InvalidKey:
      description: Chave ausente, inválida, revogada ou vencida (`invalid_key`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Forbidden:
      description: "`insufficient_scope` (falta escopo), `plan_required` (plano sem API) ou `plan_blocked` (plano bloqueado)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NotFound:
      description: Não existe nesta agência (`not_found`).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    RateLimited:
      description: Passou de 60 por minuto ou 5.000 por dia (`rate_limited`).
      headers:
        Retry-After:
          description: Segundos para tentar de novo.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [invalid_key, insufficient_scope, plan_required, plan_blocked, not_found, validation, rate_limited, internal]
            message: { type: string }
      example: { error: { code: invalid_key, message: "Chave de API inválida, revogada ou vencida." } }
    NextCursor:
      type: [string, "null"]
      description: Passe em `cursor` para a próxima página. null = acabou.
    Me:
      type: object
      properties:
        agency:
          type: object
          properties:
            name: { type: string }
        key:
          type: object
          properties:
            name: { type: string }
            prefix: { type: string, example: rq_live_a1b2c3d4 }
            scopes: { type: array, items: { type: string, enum: [read, "usage:read"] } }
            expires_at: { type: [string, "null"], format: date-time }
        plan:
          type: object
          properties:
            id: { type: [string, "null"] }
            name: { type: [string, "null"] }
        rate_limits: { $ref: "#/components/schemas/RateLimits" }
    RateLimits:
      type: object
      properties:
        per_minute: { type: integer, example: 60 }
        per_day: { type: integer, example: 5000 }
    Client:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        sector: { type: [string, "null"] }
        archived: { type: boolean }
    ClientList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Client" } }
        next_cursor: { $ref: "#/components/schemas/NextCursor" }
    Business:
      type: object
      properties:
        id: { type: string, format: uuid }
        client_id: { type: string, format: uuid }
        client_name: { type: string }
        name: { type: string }
        category: { type: [string, "null"] }
        address: { type: [string, "null"] }
        neighborhood: { type: [string, "null"] }
        city: { type: [string, "null"] }
        state: { type: [string, "null"] }
        lat: { type: number }
        lng: { type: number }
        website: { type: [string, "null"] }
        maps_url: { type: [string, "null"] }
        place_id: { type: [string, "null"] }
        rating: { type: [number, "null"] }
        reviews_count: { type: [integer, "null"] }
        keywords:
          type: array
          items:
            type: object
            properties:
              keyword: { type: string }
              monthly_volume: { type: [integer, "null"] }
        archived: { type: boolean }
        created_at: { type: [string, "null"], format: date-time }
        latest_scan_id: { type: [string, "null"], format: uuid }
        latest_scan_at: { type: [string, "null"], format: date-time }
        scans_count: { type: integer }
    BusinessList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Business" } }
        next_cursor: { $ref: "#/components/schemas/NextCursor" }
    KeywordMetrics:
      type: object
      properties:
        keyword: { type: string }
        points: { type: integer, description: Pontos da grade. }
        valid_points: { type: integer }
        found: { type: integer, description: Pontos em que a ficha aparece no top 20. }
        top3: { type: integer }
        top10: { type: integer }
        arp: { type: [number, "null"], description: Posição média onde aparece. }
        atrp: { type: [number, "null"], description: Posição média na grade toda (fora do top 20 conta 21). }
        solv_pct: { type: [number, "null"], description: "% dos pontos no top 3." }
        coverage_pct: { type: [number, "null"], description: "% dos pontos no top 20." }
    Scan:
      type: object
      properties:
        id: { type: string, format: uuid }
        business_id: { type: string, format: uuid }
        status: { type: string, enum: [queued, running, succeeded, failed, canceled] }
        created_at: { type: string, format: date-time }
        finished_at: { type: [string, "null"], format: date-time }
        grid_size: { type: integer, example: 7 }
        radius_km: { type: number }
        center:
          type: object
          properties:
            lat: { type: number }
            lng: { type: number }
        keywords: { type: array, items: { $ref: "#/components/schemas/KeywordMetrics" } }
    ScanList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Scan" } }
        next_cursor: { $ref: "#/components/schemas/NextCursor" }
    ScanDetail:
      allOf:
        - $ref: "#/components/schemas/Scan"
        - type: object
          properties:
            keywords:
              type: array
              items:
                allOf:
                  - $ref: "#/components/schemas/KeywordMetrics"
                  - type: object
                    properties:
                      grid:
                        type: array
                        items:
                          type: object
                          properties:
                            row: { type: integer }
                            col: { type: integer }
                            lat: { type: number }
                            lng: { type: number }
                            rank: { type: [integer, "null"], description: null = fora do top 20. }
                            no_results: { type: boolean }
                            error: { type: boolean }
                      competitors:
                        type: array
                        items:
                          type: object
                          properties:
                            position: { type: integer }
                            name: { type: string }
                            category: { type: [string, "null"] }
                            rating: { type: [number, "null"] }
                            reviews_count: { type: [integer, "null"] }
                            is_business: { type: boolean, description: true = a própria ficha. }
                            top3: { type: integer }
                            avg_rank: { type: number }
                            solv_pct: { type: [number, "null"] }
                            coverage_pct: { type: [number, "null"] }
    Score:
      type: object
      properties:
        id: { type: string, format: uuid }
        computed_at: { type: string, format: date-time }
        score: { type: number, minimum: 0, maximum: 100 }
        band: { type: string, enum: [fraco, medio, forte] }
        components:
          type: object
          description: Partes da nota (mapa, avaliações, IA...), cada uma com nota e peso.
          additionalProperties:
            type: object
            properties:
              score: { type: [number, "null"] }
              weight: { type: [number, "null"] }
    ScoreList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Score" } }
        next_cursor: { $ref: "#/components/schemas/NextCursor" }
    Review:
      type: object
      properties:
        id: { type: string }
        rating: { type: [number, "null"] }
        published_at: { type: [string, "null"], format: date-time }
        text: { type: [string, "null"], description: Trecho (até 280 caracteres). }
        language: { type: [string, "null"] }
        has_answer: { type: boolean }
        answered_at: { type: [string, "null"], format: date-time }
        answer_excerpt: { type: [string, "null"] }
    ReviewList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Review" } }
        next_cursor: { $ref: "#/components/schemas/NextCursor" }
    AiRun:
      type: object
      properties:
        id: { type: string }
        prompt_id: { type: string }
        prompt: { type: [string, "null"] }
        intent: { type: [string, "null"] }
        engine: { type: string, enum: [chatgpt, gemini, claude, perplexity, google_ai_overview, google_ai_mode] }
        run_at: { type: string, format: date-time }
        status: { type: string, enum: [ok, empty, error] }
        cited: { type: [boolean, "null"], description: A resposta cita a ficha. }
        mentioned_name: { type: [boolean, "null"] }
        own_site_cited: { type: [boolean, "null"] }
        position: { type: [integer, "null"], description: Posição na lista da resposta. }
        list_size: { type: [integer, "null"] }
        competitors: { type: array, items: { type: string } }
    AiRunList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/AiRun" } }
        next_cursor: { $ref: "#/components/schemas/NextCursor" }
    Action:
      type: object
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        detail: { type: [string, "null"] }
        category: { type: string, enum: [categoria, servicos, avaliacoes, posts, fotos, site, outro] }
        status: { type: string, enum: [pending, in_progress, done, dismissed] }
        keyword: { type: [string, "null"] }
        done_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ActionList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Action" } }
        next_cursor: { $ref: "#/components/schemas/NextCursor" }
    Lead:
      type: object
      properties:
        id: { type: string, format: uuid }
        source: { type: string, enum: [site, prospect] }
        business_name: { type: [string, "null"] }
        city: { type: [string, "null"] }
        keyword: { type: [string, "null"] }
        name: { type: [string, "null"] }
        email: { type: [string, "null"] }
        phone: { type: [string, "null"] }
        page: { type: [string, "null"], description: Site onde o teste foi feito. }
        status: { type: string, enum: [new, contacted, client, discarded] }
        test_status: { type: string }
        report_url: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
    WebhookEnvelope:
      type: object
      required: [id, type, created_at, data]
      properties:
        id: { type: string, format: uuid, description: Igual ao header X-Ranqal-Event-Id. }
        type: { type: string, enum: [lead.created, test.completed, scan.completed, webhook.test] }
        created_at: { type: string, format: date-time, description: Quando o evento aconteceu. }
        data: { type: object }
    WebhookLeadCreated:
      allOf:
        - { $ref: "#/components/schemas/WebhookEnvelope" }
        - type: object
          properties:
            type: { const: lead.created }
            data: { $ref: "#/components/schemas/Lead" }
    TestSummary:
      type: [object, "null"]
      properties:
        business: { type: [string, "null"] }
        city: { type: [string, "null"] }
        category: { type: [string, "null"] }
        keyword: { type: [string, "null"] }
        map:
          type: [object, "null"]
          properties:
            top3_pct: { type: [number, "null"] }
            coverage_pct: { type: [number, "null"] }
            avg_rank: { type: [number, "null"] }
        rating: { type: [number, "null"] }
        reviews_count: { type: [integer, "null"] }
        ai:
          type: [object, "null"]
          properties:
            cited: { type: [integer, "null"] }
            total: { type: [integer, "null"] }
        site_performance_mobile: { type: [number, "null"] }
        brand_position: { type: [integer, "null"] }
        actions: { type: array, items: { type: string } }
    WebhookTestCompleted:
      allOf:
        - { $ref: "#/components/schemas/WebhookEnvelope" }
        - type: object
          properties:
            type: { const: test.completed }
            data:
              allOf:
                - { $ref: "#/components/schemas/Lead" }
                - type: object
                  properties:
                    test_status: { type: string, enum: [done, error] }
                    summary: { $ref: "#/components/schemas/TestSummary" }
    WebhookScanCompleted:
      allOf:
        - { $ref: "#/components/schemas/WebhookEnvelope" }
        - type: object
          properties:
            type: { const: scan.completed }
            data:
              allOf:
                - { $ref: "#/components/schemas/Scan" }
                - type: object
                  properties:
                    status: { type: string, enum: [succeeded, failed] }
                    business:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        client_id: { type: string, format: uuid }
                        client_name: { type: string }
    WebhookTest:
      allOf:
        - { $ref: "#/components/schemas/WebhookEnvelope" }
        - type: object
          properties:
            type: { const: webhook.test }
            data:
              type: object
              properties:
                message: { type: string }
                endpoint_id: { type: string, format: uuid }
    LeadList:
      type: object
      properties:
        data: { type: array, items: { $ref: "#/components/schemas/Lead" } }
        next_cursor: { $ref: "#/components/schemas/NextCursor" }
    Usage:
      type: object
      properties:
        plan:
          type: [object, "null"]
          properties:
            id: { type: string }
            name: { type: string }
            status: { type: string }
            trial_ends_at: { type: [string, "null"], format: date-time }
            paid_until: { type: [string, "null"], format: date }
        period_start: { type: [string, "null"], format: date-time }
        limits:
          type: [object, "null"]
          properties:
            businesses: { type: [integer, "null"] }
            keywords_per_business: { type: [integer, "null"] }
            scans_month: { type: [integer, "null"] }
            ai_prompts_per_business: { type: [integer, "null"] }
            collections_month: { type: [integer, "null"] }
        usage:
          type: [object, "null"]
          properties:
            businesses: { type: integer }
            scans_month: { type: integer }
            collections_month: { type: integer }
        spend_usd:
          type: [object, "null"]
          properties:
            month: { type: number }
            monthly_limit: { type: number }
            prospects_month: { type: number }
            prospects_limit: { type: number }
        api:
          type: object
          properties:
            requests_today: { type: integer }
            requests_30d: { type: integer }
            rate_limits: { $ref: "#/components/schemas/RateLimits" }
