openapi: 3.1.0
info:
  title: API Asender
  version: '2026-08-28'
  summary: A API pública da plataforma Asender.
  description: 'Documentação completa em https://docs.asender.net. Para agentes: o contexto inteiro em
    um arquivo está em https://docs.asender.net/llms-full.txt.'
servers:
- url: https://api.asender.net
  description: Plataforma
- url: https://auth.asender.net
  description: Identidade (OIDC / OAuth 2.1)
- url: https://api.crm.asender.net
  description: CRM
- url: https://api.pages.asender.net
  description: Páginas
- url: https://api.sites.asender.net
  description: Publicação e analytics
tags:
- name: api
  description: Contas, identidade e o que atravessa as ferramentas. É a API da plataforma, não de um produto.
- name: auth
  description: Authorization Server. É daqui que sai o token que todas as outras APIs exigem.
- name: crm
  description: Jornadas, alertas, destinos e integrações de conversão. Sistema próprio, endereço próprio.
- name: pages
  description: Páginas, formulários, redirects e mídia. Sistema próprio, endereço próprio.
- name: runtime
  description: Publica as páginas e recebe os eventos delas. Sistema próprio, endereço próprio.
paths:
  /:
    get:
      tags:
      - runtime
      summary: resolve host+caminho na página publicada e devolve o HTML renderizado.
      description: 'resolve host+caminho na página publicada e devolve o HTML renderizado.


        Onde é usada: rota curinga do runtime, a de maior tráfego do sistema.


        Efeitos: consulta a fonte; escreve a resposta. 404 quando não há página: o host pode apontar para
        nós sem ter página naquele caminho, e devolver erro de servidor nesse caso confundiria monitoramento
        com tráfego normal de quem digitou a URL errada.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pagina.Servir
      x-asender-fonte: codigo
    post:
      tags:
      - runtime
      summary: resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina` e
        chama o MESMO pipeline dos outros dois modos.
      description: 'resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina`
        e chama o MESMO pipeline dos outros dois modos.


        Onde é usada: POST no caminho da própria página.


        Efeitos: os do pipeline (banco, outbox). O tenant e o form vêm da PÁGINA resolvida no servidor,
        nunca do corpo: o form da página é HTML público, e qualquer um pode reenviá-lo com outro `form_id`.
        Resolver pelo host+caminho é o que impede postar no formulário de outro tenant.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pagina.Submeter
      x-asender-fonte: codigo
  /healthz:
    get:
      tags:
      - runtime
      summary: 'responde 200 enquanto o processo está vivo. Não toca dependência: um liveness que consulta
        o banco derruba o pod quando o banco oscila.'
      description: 'responde 200 enquanto o processo está vivo. Não toca dependência: um liveness que
        consulta o banco derruba o pod quando o banco oscila.


        Onde é usada: rota pública /healthz.


        Efeitos: escreve JSON na resposta.'
      x-asender-servico: asender_runtime
      x-asender-handler: h.Healthz
      x-asender-fonte: codigo
  /version:
    get:
      tags:
      - runtime
      summary: nome do serviço, versão e o commit que gerou o binário.
      description: 'nome do serviço, versão e o commit que gerou o binário.


        Onde é usada: rota pública, usada por ops e por quem investiga incidente.


        Efeitos: escreve JSON na resposta. # Por que ela é PÚBLICA, e o que ela não conta A primeira pergunta
        de todo incidente é "que versão está no ar?" — e ela tem de ser respondível sem credencial, porque
        quem investiga muitas vezes ainda não tem uma. O que sai é o que já está no `docker inspect` de
        quem tem acesso ao host: nome, versão e commit. Nunca configuração, nunca dependência, nunca endereço
        interno — isso é topologia, e topologia é do console de plataforma. A versão vem de LDFLAGS no
        build, e o default é `dev`: um binário sem carimbo diz `dev` em vez de mentir uma versão que ninguém
        emitiu.'
      x-asender-servico: asender_runtime
      x-asender-handler: h.Versao
      x-asender-fonte: codigo
  /readyz:
    get:
      tags:
      - runtime
      summary: 503 `starting` antes do fim do boot; depois disso, CONSULTA as sondas e responde 200 `ready`
        ou 503 `degraded` com o nome de cada dependência.
      description: '503 `starting` antes do fim do boot; depois disso, CONSULTA as sondas e responde 200
        `ready` ou 503 `degraded` com o nome de cada dependência.


        Onde é usada: rota pública /readyz.


        Efeitos: escreve JSON na resposta; pode tocar a dependência (com cache de 1s). # Por que ele não
        é mais um trinco de boot A readiness era `MarkReady()` uma vez e pronto — memória do boot, não
        estado atual. Num incidente real de 2026-08-27 o serviço voltou com a credencial errada do Postgres,
        falhou ~30 vezes por minuto ao drenar o outbox, e /healthz e /readyz responderam 200 o tempo todo:
        o gate de saúde do deploy passou e quem descobriu foi o pentest, depois. Readiness que só lembra
        do boot não enxerga a dependência que caiu DEPOIS dele. /healthz continua sem tocar em nada, e
        isso é de propósito: liveness que consulta o banco derruba o processo quando o banco oscila. Quem
        tem de dizer "não me mande tráfego" é o readiness. O que sai na resposta é o NOME da dependência
        e "indisponível" — nunca a mensagem do driver, que carrega usuário, host e base. Rota pública
        não conta topologia; o erro inteiro vai para o log.'
      x-asender-servico: asender_runtime
      x-asender-handler: h.Readyz
      x-asender-fonte: codigo
  /metrics:
    get:
      operationId: AuthMetrics
      x-asender-authz: public
      tags:
      - meta
      summary: Métricas Prometheus.
      x-asender-divergence: STUB. Só um comentário. Sem OpenTelemetry.
      security: []
      responses:
        '200':
          description: Texto.
          content:
            text/plain:
              schema:
                type: string
      x-asender-servico: asender-auth
      x-asender-handler: s.tel.MetricsHandler
      x-asender-fonte: spec+codigo
  /v1/emails/send:
    post:
      operationId: PublicSendEmail
      x-asender-authz: apikey
      tags:
      - public-email
      summary: Enfileira um email (payload compatível com SES SendEmail).
      description: 'Persiste no `asender_messages` (mensagem + outbox no MESMO commit) e o

        publisher de outbox entrega ao NATS. **Não publica direto no broker** —

        seria dual-write.


        Cc e Bcc entram como destinatários independentes: o serviço materializa

        **uma mensagem por destinatário**, que é o comportamento correto de

        cópia oculta.

        '
      security:
      - ApiKeyBearer: []
      parameters:
      - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendEmailRequest'
      responses:
        '202':
          $ref: '#/components/responses/Enqueued'
        '400':
          $ref: '#/components/responses/ValidationDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TenantSuspended'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/Throttled'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: emailH.Send
      x-asender-fonte: spec+codigo
  /v1/emails/batch:
    post:
      operationId: PublicBatchEmail
      x-asender-authz: apikey
      tags:
      - public-email
      summary: Até 500 emails independentes numa chamada.
      description: '**Resultado parcial é legítimo e explícito.** Falha de um item não

        derruba os outros: cada entrada de `Results` traz `MessageId` ou

        `Error`, sempre com o `Index` de origem.

        '
      security:
      - ApiKeyBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - Messages
              properties:
                Messages:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    $ref: '#/components/schemas/SendEmailRequest'
      responses:
        '202':
          description: Lote processado (com ou sem itens rejeitados).
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Count:
                      type: integer
                    Results:
                      type: array
                      items:
                        $ref: '#/components/schemas/BatchResult'
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TenantSuspended'
        '429':
          $ref: '#/components/responses/Throttled'
      x-asender-servico: asender-api
      x-asender-handler: emailH.Batch
      x-asender-fonte: spec+codigo
  /v1/emails:
    get:
      operationId: PublicListEmails
      x-asender-authz: apikey
      tags:
      - public-email
      summary: Histórico de emails do tenant da API key.
      description: '`channel` é FIXO em `email` nesta rota — mandar `?channel=sms` é **422**

        (parâmetro fora da allowlist), não filtro silencioso.

        '
      security:
      - ApiKeyBearer: []
      parameters:
      - name: status
        in: query
        schema:
          $ref: '#/components/schemas/MessageStatus'
      - name: q
        in: query
        description: Busca. `%` e `_` são escapados no repositório.
        schema:
          type: string
      - name: cursor
        in: query
        schema:
          type: integer
          minimum: 0
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Página de mensagens.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessagePage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TenantSuspended'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/Throttled'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: emailH.List
      x-asender-fonte: spec+codigo
  /v1/emails/{id}:
    get:
      operationId: PublicGetEmail
      x-asender-authz: apikey
      tags:
      - public-email
      summary: Um email com corpo, metadata e timeline de eventos.
      description: 'Mensagem de outro tenant → **404** (não confirma existência). Mensagem

        deste tenant em outro canal também → 404: nesta rota só email existe.

        '
      security:
      - ApiKeyBearer: []
      parameters:
      - $ref: '#/components/parameters/MessageId'
      responses:
        '200':
          description: Detalhe.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageDetail'
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TenantSuspended'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '429':
          $ref: '#/components/responses/Throttled'
      x-asender-servico: asender-api
      x-asender-handler: emailH.Get
      x-asender-fonte: spec+codigo
  /v1/sms/send:
    post:
      operationId: PublicSendSMS
      x-asender-authz: apikey
      tags:
      - public-sms
      summary: Enfileira um SMS.
      security:
      - ApiKeyBearer: []
      parameters:
      - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - To
              - Body
              properties:
                From:
                  type: string
                  description: E.164, opcional.
                To:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    description: E.164
                Body:
                  type: string
                  maxLength: 1600
                IdempotencyKey:
                  type: string
                Tags:
                  type: object
                  additionalProperties:
                    type: string
      responses:
        '202':
          $ref: '#/components/responses/Enqueued'
        '400':
          $ref: '#/components/responses/ValidationDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TenantSuspended'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/Throttled'
        '502':
          $ref: '#/components/responses/BadGateway'
      description: 'valida o payload SES-shaped e persiste o envio no asender_messages, que enfileira
        para o worker de email.


        Onde é usada: API pública do cliente (autenticada por API key). Fluxo do dado: API key → APIKeyAuth
        → Principal.TenantID → POST /v1/messages (header X-Asender-Tenant) → messages + outbox no mesmo
        commit → NATS → worker.


        Saídas: 202 com `{MessageId, MessageIds, Status, ReusedIdempotency}`; 400 em validação local;
        422 quando o asender_messages recusa; 502 se ele está fora.


        Efeitos: escrita no serviço de mensageria. Idempotente por IdempotencyKey.'
      x-asender-servico: asender-api
      x-asender-handler: smsH.Send
      x-asender-fonte: spec+codigo
  /v1/push/send:
    post:
      operationId: PublicSendPush
      x-asender-authz: apikey
      tags:
      - public-push
      summary: Enfileira um push para tokens ou para um tópico.
      description: 'Envio só por tópico entra como destinatário sintético `topic:<nome>` —

        o `asender_messages` exige ao menos um destinatário e não modela tópico.

        '
      security:
      - ApiKeyBearer: []
      parameters:
      - $ref: '#/components/parameters/IdempotencyKeyHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - Notification
              properties:
                DeviceTokens:
                  type: array
                  items:
                    type: string
                Topic:
                  type: string
                Notification:
                  type: object
                  required:
                  - Title
                  - Body
                  properties:
                    Title:
                      type: string
                    Body:
                      type: string
                Data:
                  type: object
                  additionalProperties: true
                IdempotencyKey:
                  type: string
                Tags:
                  type: object
                  additionalProperties:
                    type: string
      responses:
        '202':
          $ref: '#/components/responses/Enqueued'
        '400':
          $ref: '#/components/responses/ValidationDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TenantSuspended'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/Throttled'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: pushH.Send
      x-asender-fonte: spec+codigo
  /v1/push/devices:
    post:
      operationId: PublicRegisterDevice
      x-asender-authz: apikey
      tags:
      - public-push
      summary: Registra (upsert por token) um device de push.
      description: 'Idempotente pelo par (tenant, token). Responde **200** sempre, mesmo

        quando o `asender_messages` respondeu 201 no primeiro registro.

        '
      security:
      - ApiKeyBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - Token
              - Platform
              properties:
                Token:
                  type: string
                Platform:
                  type: string
                  enum:
                  - ios
                  - android
                  - web
                UserRef:
                  type: string
                  description: ACEITO E NÃO PERSISTIDO — sem campo no contrato interno; a perda é logada
                    (Warn), não silenciosa.
                Metadata:
                  type: object
                  additionalProperties:
                    type: string
                  description: ACEITO E NÃO PERSISTIDO — idem UserRef.
      x-asender-divergence: '`UserRef` e `Metadata` são aceitos e descartados (logados em Warn). O

        contrato de devices do asender_messages não tem esses campos.

        '
      responses:
        '200':
          description: Device registrado ou reativado.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Device:
                      $ref: '#/components/schemas/Device'
        '400':
          $ref: '#/components/responses/ValidationDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TenantSuspended'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '429':
          $ref: '#/components/responses/Throttled'
      x-asender-servico: asender-api
      x-asender-handler: pushH.RegisterDevice
      x-asender-fonte: spec+codigo
    get:
      operationId: PublicListDevices
      x-asender-authz: apikey
      tags:
      - public-push
      summary: Lista os devices de push da conta da API key.
      description: 'Contraparte de leitura do registro: quem envia token precisa poder

        conferir o que está registrado. O tenant vem da API key, NUNCA da query —

        aceitar `tenant_id` do cliente aqui seria vazamento cross-tenant.

        '
      parameters:
      - name: cursor
        in: query
        schema:
          type: integer
          minimum: 0
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 200
      responses:
        '200':
          description: Página de devices, em PascalCase.
          content:
            application/json:
              schema:
                type: object
                required:
                - Devices
                - NextCursor
                - HasMore
                properties:
                  Devices:
                    type: array
                    items:
                      type: object
                  NextCursor:
                    type: integer
                  HasMore:
                    type: boolean
                  RequestId:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'
      x-asender-servico: asender-api
      x-asender-handler: pushH.ListDevices
      x-asender-fonte: spec+codigo
  /v1/account:
    get:
      operationId: PublicGetAccount
      x-asender-authz: apikey
      tags:
      - public-account
      summary: Conta da API key usada, mais o contexto da própria chave.
      security:
      - ApiKeyBearer: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    TenantId:
                      type: string
                      examples:
                      - acc_demo_sp
                    Name:
                      type: string
                    Plan:
                      type: string
                      description: 'Vem vazio hoje: o core não expõe plano no payload de tenant.'
                    Status:
                      type: string
                      enum:
                      - active
                    ApiKeyId:
                      type: string
                    ApiKeyScope:
                      type: string
                      description: Escopos da chave unidos por vírgula (`emails:send,emails:read`).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TenantSuspended'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '429':
          $ref: '#/components/responses/Throttled'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: accountH.Get
      x-asender-fonte: spec+codigo
  /v1/account/usage:
    get:
      operationId: PublicGetUsage
      x-asender-authz: apikey
      tags:
      - public-account
      summary: Contadores de consumo do tenant no período corrente.
      security:
      - ApiKeyBearer: []
      x-asender-divergence: '**Histórico — CORRIGIDO durante esta rodada, mantido por rastreabilidade.**

        O `asender-core` respondia `404 NotFound` quando o tenant não tinha

        linha em `core.usage_counters` (o caso de TODO tenant do seed), e este

        handler — que só trata 404 em `GET /v1/account`, não aqui — traduzia

        isso em **502 "Could not load usage."**, como se o core estivesse fora.

        O core passou a devolver contadores ZERADOS nesse caso, e a rota

        responde 200. O `502` abaixo continua documentado porque segue sendo a

        resposta quando o core está de fato indisponível.

        '
      responses:
        '200':
          description: 'Consumo do período. Tenant sem nenhum envio devolve os contadores em

            zero, com `UpdatedAt: null` — ausência de linha é consumo zero, não

            ausência de recurso.

            '
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Usage:
                      $ref: '#/components/schemas/Usage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/TenantSuspended'
        '429':
          $ref: '#/components/responses/Throttled'
        '502':
          description: Core fora **ou** tenant sem contador de uso (ver `x-asender-divergence`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      description: 'contadores de consumo do tenant da API key (mês, enviados, quota).


        Onde é usada: superfície pública `/v1`, consumida por integrações de cliente. Fluxo do dado: API
        key verificada → Principal.TenantID → asender-core GET /v1/tenants/{id}/usage (snake_case) → PascalMap
        → resposta pública.


        Saídas: 200 com `{Usage:{Period,EmailsSent,SmsSent,PushSent,ApiCalls}}`; 404 se o tenant da API
        key não existe mais no core; 502 se o core está fora. DEFEITO QUE ISTO TAMBÉM FECHA (item 7 do
        briefing Y2, metade da borda): o core respondia 404 quando não havia linha em `core.usage_counters`
        — e o seed não cria nenhuma —, e este handler traduzia QUALQUER erro do upstream em 502. Ou seja:
        a rota respondia "serviço indisponível" para todo tenant que ainda não enviou nada, mandando o
        alerta para o time errado. O 404 do core agora só significa "tenant inexistente" (a correção principal
        está em asender-core/internal/service/usage.Get) e é traduzido como 404, não 502. DEFEITO QUE
        ISTO FECHA (T3 §3.6): o payload do core saía CRU sob `Usage` (`{"Usage":{"sent":42}}`), fora do
        contrato PascalCase da API pública (API_DESIGN.md §51). O cliente que segue o contrato lê `Usage.Sent`
        e recebe `undefined` — mesma classe de divergência de fronteira do GET /api/tenants.'
      x-asender-servico: asender-api
      x-asender-handler: accountH.Usage
      x-asender-fonte: spec+codigo
  /api/auth/login:
    post:
      operationId: BffLogin
      x-asender-authz: public
      tags:
      - bff-auth
      summary: Autentica e devolve o token de sessão.
      description: 'Corpo aceito em snake_case (`{"email","password"}`) porque é o que o

        `asender-auth` define e o proxy repassa; a RESPOSTA é PascalCase.


        Com 2FA habilitado a resposta é 200 com `Token:""` e

        `TwoFactorRequired:true` — explícito, para o chamador não confundir com

        upstream quebrado.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - email
              - password
              properties:
                email:
                  type: string
                  format: email
                  examples:
                  - demo@asender.local
                password:
                  type: string
                  format: password
      x-asender-divergence: 'Oráculo de tempo: login de usuário existente leva ~200 ms (bcrypt) e de

        inexistente ~1 ms. Permite enumerar contas (RELATORIO-FINAL §3 item 9).

        '
      responses:
        '200':
          description: Sessão criada, ou 2FA pendente.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Token:
                      type: string
                      description: JWT. Vazio quando `TwoFactorRequired`.
                    UserId:
                      type: string
                    UserEmail:
                      type: string
                    ExpiresAt:
                      type: string
                      format: date-time
                    TwoFactorRequired:
                      type: boolean
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Conta bloqueada ou desabilitada.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: authH.Login
      x-asender-fonte: spec+codigo
  /api/auth/register:
    post:
      operationId: BffRegister
      x-asender-authz: public
      tags:
      - bff-auth
      summary: Cria usuário e (best-effort) o tenant raiz dele.
      description: 'A criação do tenant é best-effort: se falhar, a resposta ainda é 201

        com `Tenant: null` e um campo `Note` dizendo para repetir via

        `POST /api/tenants`. Falha silenciosa não é opção.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - Email
              - Password
              properties:
                Email:
                  type: string
                  format: email
                Password:
                  type: string
                  minLength: 8
                Name:
                  type: string
                TenantName:
                  type: string
      responses:
        '201':
          description: Usuário criado.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    UserId:
                      type: string
                    UserEmail:
                      type: string
                    Tenant:
                      oneOf:
                      - type: 'null'
                      - type: object
                        properties:
                          Tenant:
                            $ref: '#/components/schemas/Tenant'
                    Note:
                      type: string
        '400':
          $ref: '#/components/responses/Validation'
        '409':
          $ref: '#/components/responses/Conflict'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: authH.Register
      x-asender-fonte: spec+codigo
  /api/auth/2fa:
    post:
      operationId: BffTwoFactorLogin
      x-asender-authz: public
      tags:
      - bff-auth
      summary: Segunda etapa do login — confirma o código do segundo fator.
      responses:
        '200':
          description: OK
      description: 'troca o código do autenticador pela sessão.


        Onde é usada: tela `/2fa`, para onde o login manda quem tem segundo fator.


        Efeitos: cria sessão no asender-auth. # Por que esta rota é PÚBLICA Quem chega aqui ainda não
        tem sessão — é justamente o que ela está tentando obter. Exigir sessão tornaria o 2FA impossível
        de completar: a armadilha de aplicar a mesma guarda em toda rota "porque é mais seguro". O que
        protege é o par (user_id, código): o `user_id` sozinho não abre nada, e o código vale 30 segundos.
        Sem esta rota, o login de quem tem 2FA ficava sem passo seguinte — a conta ficava inacessível
        pelo painel.'
      x-asender-servico: asender-api
      x-asender-handler: authH.TwoFactor
      x-asender-fonte: spec+codigo
    get:
      operationId: BffTwoFactorState
      x-asender-authz: session
      tags:
      - bff-auth
      summary: Estado do segundo fator do usuário da sessão.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'diz se a conta da sessão tem TOTP CONFIRMADO.


        Onde é usada: tela de segurança do painel.


        Efeitos: uma leitura no asender-auth. Responde o estado CONFIRMADO, e não "existe segredo": um
        setup interrompido deixa segredo gravado sem confirmação, e mostrar "2FA ligado" aí faria a pessoa
        acreditar numa proteção que o login não exige.'
      x-asender-servico: asender-api
      x-asender-handler: authH.EstadoDoSegundoFator
      x-asender-fonte: spec+codigo
  /api/auth/verify:
    post:
      operationId: BffVerifyEmail
      x-asender-authz: public
      tags:
      - bff-auth
      summary: Confirma o e-mail a partir do token do link.
      responses:
        '200':
          description: OK
      description: 'repassa o token do link ao asender-auth.


        Onde é usada: tela `/verify`, com o token da URL.


        Efeitos: carimba `email_verified_at` no auth. # Também é PÚBLICA Quem abre o link do e-mail pode
        estar em outro navegador, ou nem ter sessão. Exigir login para verificar o e-mail cria a dependência
        circular clássica — e a pessoa que mais precisa verificar é justamente a que ainda não conseguiu
        entrar.'
      x-asender-servico: asender-api
      x-asender-handler: authH.Verify
      x-asender-fonte: spec+codigo
  /api/auth/logout:
    post:
      operationId: BffLogout
      x-asender-authz: bearer-unverified
      tags:
      - bff-auth
      summary: Revoga a sessão do Bearer apresentado. IDEMPOTENTE.
      description: 'Revoga no `asender-auth`; a validação de sessão passou a consultar o

        estado, então o token deixa de valer (401 no `/api/auth/me` seguinte).


        **Esta rota fica FORA do grupo `SessionAuth`, de propósito** — é o único

        caso em `/api/*`. Dentro do grupo, o segundo logout (retry, aba

        duplicada, refresh) morria em 401 no middleware, quebrando a

        idempotência de uma operação que descreve um ESTADO desejado ("estar

        fora"), não uma transição.


        Consequência observável, e é por isso que a classe declarada é

        `bearer-unverified` e não `session`: o handler exige o header

        `Authorization` (sem ele, 401) mas **não valida a sessão**. Qualquer

        string não vazia como Bearer — inclusive uma API key ou lixo — recebe

        **200**. Não abre nada (revogar um token que não existe é no-op no

        `asender-auth`), mas o 401 desta rota vem do HANDLER, não do middleware:

        quem for auditar a superfície não pode contá-la como autenticada.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: Revogado, ou já estava — inclusive para um Bearer que nunca foi sessão.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Status:
                      type: string
                      const: ok
        '401':
          $ref: '#/components/responses/Unauthorized'
      x-asender-servico: asender-api
      x-asender-handler: authH.Logout
      x-asender-fonte: spec+codigo
  /api/auth/me:
    get:
      operationId: BffMe
      x-asender-authz: session
      tags:
      - bff-auth
      summary: Principal da sessão.
      description: '`TenantId` vem do `asender-auth` e **costuma vir vazio** — a resolução

        real do tenant é feita por rota (`resolveSessionTenant`), a partir dos

        vínculos no core. Não use este campo como escopo.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    UserId:
                      type: string
                    UserEmail:
                      type: string
                    TenantId:
                      type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
      x-asender-servico: asender-api
      x-asender-handler: authH.Me
      x-asender-fonte: spec+codigo
    patch:
      operationId: BffUpdateMe
      x-asender-authz: session
      tags:
      - bff-auth
      summary: Altera o perfil do usuário da sessão.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'altera nome e/ou e-mail do usuário DA SESSÃO.


        Onde é usada: tela de perfil do painel.


        Efeitos: escreve no asender-auth; e-mail novo desverifica a conta e dispara a verificação do endereço
        novo (lá).'
      x-asender-servico: asender-api
      x-asender-handler: authH.UpdateMe
      x-asender-fonte: spec+codigo
  /api/auth/me/verify/resend:
    post:
      operationId: BffResendVerification
      x-asender-authz: session
      tags:
      - bff-auth
      summary: Reenvia o e-mail de verificação do usuário da sessão.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'pede ao asender-auth um link de verificação novo para o usuário da sessão.


        Onde é usada: aviso "confirme seu e-mail" do painel.


        Efeitos: um e-mail (o guard do auth decide se ele sai fora de prd). O 429 do upstream é REPASSADO
        como 429: quem pediu demais precisa saber que basta esperar, e um 502 aqui mandaria a pessoa procurar
        defeito onde não há.'
      x-asender-servico: asender-api
      x-asender-handler: authH.ResendVerification
      x-asender-fonte: spec+codigo
  /api/auth/me/emails:
    get:
      tags:
      - api
      summary: histórico paginado dos emails do tenant. Era 501; agora lê do asender_messages com o canal
        fixado em `email`.
      description: 'histórico paginado dos emails do tenant. Era 501; agora lê do asender_messages com
        o canal fixado em `email`.


        Onde é usada: API pública do cliente. Fluxo do dado: API key → Principal.TenantID → GET /v1/messages?channel=email.


        Entradas: `status`, `q`, `cursor`, `limit` (allowlist). `channel` do cliente é IGNORADO: nesta
        rota o canal é do contrato, não do parâmetro.


        Saídas: 200 com `{Messages, NextCursor, HasMore}`; 422 em query malformada.'
      x-asender-servico: asender-api
      x-asender-handler: emailsH.List
      x-asender-fonte: codigo
    post:
      tags:
      - api
      summary: 'Add: POST /api/auth/me/emails.'
      x-asender-servico: asender-api
      x-asender-handler: emailsH.Add
      x-asender-fonte: codigo
  /api/auth/me/emails/{id}:
    delete:
      tags:
      - api
      summary: 'Remove: DELETE /api/auth/me/emails/{id}.'
      x-asender-servico: asender-api
      x-asender-handler: emailsH.Remove
      x-asender-fonte: codigo
  /api/auth/me/emails/{id}/primary:
    post:
      tags:
      - api
      summary: 'Primary: POST /api/auth/me/emails/{id}/primary.'
      x-asender-servico: asender-api
      x-asender-handler: emailsH.Primary
      x-asender-fonte: codigo
  /api/auth/me/emails/{id}/verify/resend:
    post:
      tags:
      - api
      summary: 'Resend: POST /api/auth/me/emails/{id}/verify/resend.'
      x-asender-servico: asender-api
      x-asender-handler: emailsH.Resend
      x-asender-fonte: codigo
  /api/auth/2fa/setup:
    post:
      operationId: BffTwoFactorSetup
      x-asender-authz: session
      tags:
      - bff-auth
      summary: Inicia a ativação do segundo fator.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'começa a ativação e devolve o segredo e a URL do QR.


        Onde é usada: tela de segurança.


        Efeitos: grava o segredo PENDENTE de confirmação. # A senha é a invariante do piso 17, e ela para
        AQUI se faltar Para mexer num fator é preciso apresentar um fator diferente dele. O serviço recusa
        senha vazia, e o gateway recusa antes — não por desconfiança do serviço, mas porque um corpo sem
        senha é pedido malformado, e mandá-lo adiante gastaria uma viagem para receber a mesma recusa.'
      x-asender-servico: asender-api
      x-asender-handler: authH.LigarSegundoFator
      x-asender-fonte: spec+codigo
  /api/auth/2fa/verify:
    post:
      operationId: BffTwoFactorConfirm
      x-asender-authz: session
      tags:
      - bff-auth
      summary: Confirma a ativação do segundo fator.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'conclui a ativação com um código do aplicativo.


        Onde é usada: tela de segurança, depois de ler o QR.


        Efeitos: o login passa a EXIGIR o segundo fator.'
      x-asender-servico: asender-api
      x-asender-handler: authH.ConfirmarSegundoFator
      x-asender-fonte: spec+codigo
  /api/auth/2fa/disable:
    post:
      operationId: BffTwoFactorDisable
      x-asender-authz: session
      tags:
      - bff-auth
      summary: Desliga o segundo fator.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'remove o TOTP da conta da sessão.


        Onde é usada: tela de segurança.


        Efeitos: o login deixa de exigir o segundo fator. Exige a SENHA, e não o código: aceitar o próprio
        TOTP para removê-lo faria o fator se autorizar sozinho.'
      x-asender-servico: asender-api
      x-asender-handler: authH.DesligarSegundoFator
      x-asender-fonte: spec+codigo
  /api/auth/sessions:
    get:
      operationId: BffListSessions
      x-asender-authz: session
      tags:
      - bff-auth
      summary: Dispositivos e sessões ativas do usuário.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'lista onde a conta do usuário da sessão está aberta.


        Onde é usada: tela de segurança do painel.


        Efeitos: uma leitura no asender-auth. O token repassado é o da REQUISIÇÃO, e é ele que define
        de quem é a lista. Não existe parâmetro de usuário: um `?user_id=` seria a rota mais barata do
        sistema para descobrir de onde outra pessoa se conecta.'
      x-asender-servico: asender-api
      x-asender-handler: authH.Sessoes
      x-asender-fonte: spec+codigo
  /api/auth/sessions/{id}:
    delete:
      operationId: BffRevokeSession
      x-asender-authz: session
      tags:
      - bff-auth
      summary: Revoga uma sessão específica.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'derruba uma sessão do próprio usuário.


        Onde é usada: botão "remover" da tela de segurança.


        Efeitos: a sessão para de autenticar imediatamente. # Por que a impersonação NÃO passa por aqui
        A sessão de suporte é somente leitura (ADR-0017), e o middleware de impersonação já recusa escrita.
        Isto é uma escrita — e derrubar o dispositivo de um cliente durante uma sessão de suporte é exatamente
        o poder que o ADR tira de quem entra na conta alheia.'
      x-asender-servico: asender-api
      x-asender-handler: authH.RevogarSessao
      x-asender-fonte: spec+codigo
  /api/tenants:
    get:
      operationId: BffListTenants
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Vínculos de tenant do usuário logado.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Tenants:
                      type: array
                      items:
                        $ref: '#/components/schemas/Tenant'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '502':
          $ref: '#/components/responses/BadGateway'
      description: 'histórico paginado dos emails do tenant. Era 501; agora lê do asender_messages com
        o canal fixado em `email`.


        Onde é usada: API pública do cliente. Fluxo do dado: API key → Principal.TenantID → GET /v1/messages?channel=email.


        Entradas: `status`, `q`, `cursor`, `limit` (allowlist). `channel` do cliente é IGNORADO: nesta
        rota o canal é do contrato, não do parâmetro.


        Saídas: 200 com `{Messages, NextCursor, HasMore}`; 422 em query malformada.'
      x-asender-servico: asender-api
      x-asender-handler: tenantsH.List
      x-asender-fonte: spec+codigo
    post:
      operationId: BffCreateTenant
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Cria um tenant RAIZ pertencente ao usuário logado.
      description: '**Allowlist tipada de dois campos.** `parent_id`, `monthly_quota`,

        `status`, `plan`, `owner_user_id` e `id` são IGNORADOS e registrados —

        em nível ERROR, como tentativa de escalonamento de privilégio. O

        `owner_user_id` vem sempre do principal verificado.


        Ignorar (em vez de 422) preserva chamadores antigos que mandam

        `owner_user_id`; ignorar aqui **não é engolir**: fica no log com o

        usuário que enviou.


        Para pendurar um tenant sob outro use `POST /api/tenants/{id}/children`

        (autoriza o pai) ou `PATCH /api/tenants/{id}/parent` (autoriza os dois

        lados). É o único caminho autorizado para mexer na árvore.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - Name
              properties:
                Name:
                  type: string
                  minLength: 1
                Slug:
                  type: string
              additionalProperties:
                description: Aceito no wire e DESCARTADO; ver descrição.
      responses:
        '201':
          description: 'Tenant criado. **O objeto vem NO TOPO, sem a chave `Tenant`** — o

            `asender-core` responde o tenant sem chave de recurso nesta rota e o

            BFF só pascaliza o que recebeu. É inconsistente com

            `POST /api/tenants/{id}/children` e com `POST /api/auth/register`,

            que devolvem `{"Tenant":{...}}`. Documentado como está no ar.

            '
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - $ref: '#/components/schemas/Tenant'
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: tenantsH.Create
      x-asender-fonte: spec+codigo
  /api/tenants/tree:
    get:
      operationId: BffTenantTree
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Subárvore do tenant corrente, em pré-ordem.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - name: depth
        in: query
        schema:
          type: integer
          minimum: 0
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    TenantId:
                      type: string
                    Tree:
                      type: array
                      items:
                        $ref: '#/components/schemas/TenantNode'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '502':
          $ref: '#/components/responses/BadGateway'
      description: 'a subárvore do tenant corrente, para o seletor de tenant e a tela de hierarquia.


        Onde é usada: dashboard (settings/tenants) e backoffice. Fluxo do dado: sessão → tenant resolvido
        → asender-core GET /v1/tenants/{id}/tree → PascalCase.


        Entradas: `depth` — inteiro positivo; valor não numérico é 422, não "sem limite" silencioso.


        Saídas: 200 com `{TenantId, Tree:[...]}`; 404 se o tenant pedido não é acessível.'
      x-asender-servico: asender-api
      x-asender-handler: treeH.Tree
      x-asender-fonte: spec+codigo
  /api/tenants/{id}/children:
    post:
      operationId: BffCreateChildTenant
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Cria uma sub-conta sob um tenant administrado pelo usuário.
      description: 'Autoriza `{id}` por vínculo direto **ou herdado** (ser membro de um

        ancestral manda na subárvore). `MonthlyQuota` ausente/nulo = herda do

        ancestral mais próximo com valor.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantPathId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - Name
              additionalProperties: false
              properties:
                Name:
                  type: string
                  minLength: 1
                Slug:
                  type: string
                MonthlyQuota:
                  type:
                  - integer
                  - 'null'
                  minimum: 0
      responses:
        '201':
          description: Sub-conta criada.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Tenant:
                      $ref: '#/components/schemas/TenantNode'
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
      x-asender-servico: asender-api
      x-asender-handler: treeH.CreateChild
      x-asender-fonte: spec+codigo
  /api/tenants/{id}/parent:
    patch:
      operationId: BffMoveTenant
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Move o tenant para outro pai (ou para a raiz).
      description: '**Autoriza os DOIS lados**: `{id}` e o `ParentId` de destino. Autorizar

        só a origem deixaria pendurar um tenant na árvore de outro cliente, que

        passaria a vê-lo em rollup de relatório.


        Corpo com EXATAMENTE um campo `ParentId`. `null` promove a raiz; campo

        ausente é 422 (um `{}` acidental não pode promover uma sub-conta).

        Ciclo → 422 (o trigger de closure do core levanta `check_violation`),

        nunca 500.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantPathId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - ParentId
              additionalProperties: false
              properties:
                ParentId:
                  type:
                  - string
                  - 'null'
      responses:
        '200':
          description: Movido; a closure foi reescrita.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Tenant:
                      $ref: '#/components/schemas/TenantNode'
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
      x-asender-servico: asender-api
      x-asender-handler: treeH.MoveParent
      x-asender-fonte: spec+codigo
  /api/tenants/{id}/quota:
    patch:
      operationId: BffSetTenantQuota
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Define ou limpa a quota mensal do tenant.
      description: 'Corpo com EXATAMENTE um campo `MonthlyQuota`. `null` volta a herdar do

        ancestral mais próximo. Campo ausente é 422 — um nome digitado errado

        não pode limpar a quota em silêncio.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantPathId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
              - MonthlyQuota
              additionalProperties: false
              properties:
                MonthlyQuota:
                  type:
                  - integer
                  - 'null'
                  minimum: 0
      responses:
        '200':
          description: Quota gravada; `EffectiveQuota` já recalculada.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Tenant:
                      $ref: '#/components/schemas/TenantNode'
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
      x-asender-servico: asender-api
      x-asender-handler: treeH.SetQuota
      x-asender-fonte: spec+codigo
  /api/messages/send:
    post:
      operationId: BffSendMessage
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Composer do dashboard — envia em qualquer canal.
      description: 'Corpo em PascalCase, campo a campo espelhando o contrato interno.

        Campo desconhecido é **400** (`DisallowUnknownFields`), não silêncio.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
              - Channel
              - To
              properties:
                Channel:
                  $ref: '#/components/schemas/Channel'
                From:
                  type: string
                To:
                  type: array
                  minItems: 1
                  items:
                    type: string
                Subject:
                  type: string
                BodyText:
                  type: string
                BodyHtml:
                  type: string
                Template:
                  type: string
                  description: Slug do template. Variável declarada e não informada é 422.
                Variables:
                  type: object
                  additionalProperties: true
                IdempotencyKey:
                  type: string
                Metadata:
                  type: object
                  additionalProperties: true
      responses:
        '202':
          description: Enfileirado.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Messages:
                      type: array
                      items:
                        $ref: '#/components/schemas/Message'
                    ReusedIdempotency:
                      type: boolean
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.SendMessage
      x-asender-fonte: spec+codigo
  /api/messages:
    get:
      operationId: BffListMessages
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Histórico de envios do tenant corrente.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - name: channel
        in: query
        schema:
          $ref: '#/components/schemas/Channel'
      - name: status
        in: query
        schema:
          $ref: '#/components/schemas/MessageStatus'
      - name: q
        in: query
        schema:
          type: string
      - name: cursor
        in: query
        schema:
          type: integer
          minimum: 0
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessagePage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '502':
          $ref: '#/components/responses/BadGateway'
      description: 'página de mensagens do tenant, com filtros de canal/status/busca.


        Onde é usada: tela de histórico do dashboard.


        Entradas: Channel/Status/Q/Cursor/Limit são aceitos em snake_case (`channel`, `status`, `q`, `cursor`,
        `limit`) e repassados por allowlist — nenhum outro parâmetro atravessa.


        Saídas: 200 com `{Messages, NextCursor, HasMore}`; 422 em query malformada.'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.ListMessages
      x-asender-fonte: spec+codigo
  /api/messages/{id}:
    get:
      operationId: BffGetMessage
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Detalhe do envio com timeline.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - $ref: '#/components/parameters/MessageId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
      description: 'uma mensagem com corpo, metadata e a trilha de eventos.


        Onde é usada: tela de detalhe do envio.


        Saídas: 200 com `{Message, Events}`; 404 quando o id não existe NO TENANT — mensagem de outro
        tenant também é 404, para não confirmar existência (§22.8).'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.GetMessage
      x-asender-fonte: spec+codigo
  /api/templates:
    get:
      operationId: BffListTemplates
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Templates do tenant.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Templates:
                      type: array
                      items:
                        $ref: '#/components/schemas/Template'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
      description: 'templates do tenant, para a tela de templates e o seletor do composer.


        Onde é usada: dashboard.


        Saídas: 200 com `{Templates}`.'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.ListTemplates
      x-asender-fonte: spec+codigo
    post:
      operationId: BffUpsertTemplate
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Cria ou atualiza um template pelo par (tenant, slug).
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
              - Slug
              - Name
              - Channel
              properties:
                Slug:
                  type: string
                Name:
                  type: string
                Channel:
                  $ref: '#/components/schemas/Channel'
                Subject:
                  type: string
                BodyHtml:
                  type: string
                BodyText:
                  type: string
                Variables:
                  type: array
                  items:
                    type: string
      responses:
        '200':
          description: Gravado (upsert — 200 tanto na criação quanto na atualização).
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Template:
                      $ref: '#/components/schemas/Template'
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
      description: 'gravação idempotente do template pelo par (tenant, slug).


        Onde é usada: editor de templates do dashboard.


        Saídas: 200 com `{Template}`; 422 em validação do asender_messages.


        Efeitos: escreve em messages.templates.'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.UpsertTemplate
      x-asender-fonte: spec+codigo
  /api/push/devices:
    get:
      operationId: BffListDevices
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Devices de push do tenant, para o seletor da tela de disparo.
      description: 'Fonte do seletor de dispositivos em `/t/{conta}/send/push`. Antes de

        existir, o caminho respondia 404 e o seletor ficava vazio mesmo com

        devices no banco — a tela levava o usuário a concluir que a conta não

        tinha dispositivo nenhum.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - name: cursor
        in: query
        schema:
          type: integer
          minimum: 0
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: Página de devices, em PascalCase.
          content:
            application/json:
              schema:
                type: object
                required:
                - Devices
                - NextCursor
                - HasMore
                properties:
                  Devices:
                    type: array
                    items:
                      type: object
                  NextCursor:
                    type: integer
                  HasMore:
                    type: boolean
                  RequestId:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/ValidationError'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.ListDevices
      x-asender-fonte: spec+codigo
  /api/push/site-config:
    get:
      operationId: BffPushSiteConfig
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Pacote de integração de Web Push para o site do cliente.
      description: 'Devolve, num JSON só, tudo que o site precisa para receber Web Push:

        chave pública VAPID do tenant, `manifest.json`, o service worker e o

        snippet de inscrição — os três já preenchidos com os valores desta

        instalação. Gerar no servidor evita o chamado clássico do integrador que

        esqueceu de substituir um `<SUA_CHAVE_AQUI>`.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      responses:
        '200':
          description: Artefatos prontos para o site.
          content:
            application/json:
              schema:
                type: object
                required:
                - PublicKey
                - ApiBase
                - Manifest
                - ServiceWorker
                - Snippet
                properties:
                  PublicKey:
                    type: string
                  ApiBase:
                    type: string
                  TenantId:
                    type: string
                  Manifest:
                    type: object
                  ServiceWorker:
                    type: string
                  Snippet:
                    type: string
                  Instructions:
                    type: array
                    items:
                      type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
      x-asender-servico: asender-api
      x-asender-handler: siteCfgH.SiteConfig
      x-asender-fonte: spec+codigo
  /api/contacts:
    get:
      operationId: BffListContacts
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Audiência do tenant.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - name: q
        in: query
        schema:
          type: string
      - name: list_id
        in: query
        schema:
          type: string
      - name: cursor
        in: query
        schema:
          type: integer
          minimum: 0
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - $ref: '#/components/schemas/CursorPage'
                - type: object
                  properties:
                    Contacts:
                      type: array
                      items:
                        $ref: '#/components/schemas/Contact'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
      description: 'página de contatos do tenant, com busca e filtro por lista.


        Onde é usada: tela de audiência do dashboard.


        Entradas: `q`, `list_id`, `cursor`, `limit` (allowlist).


        Saídas: 200 com `{Contacts, NextCursor, HasMore}`.'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.ListContacts
      x-asender-fonte: spec+codigo
    post:
      operationId: BffCreateContact
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Cria um contato.
      description: '`Subscribed` ausente vira `true` — opt-in é o default do cadastro manual.'
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ContactInput'
      responses:
        '201':
          description: Criado.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Contact:
                      $ref: '#/components/schemas/Contact'
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.CreateContact
      x-asender-fonte: spec+codigo
  /api/contacts/bulk:
    post:
      operationId: BffBulkContacts
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Importa até 5000 contatos, com upsert pela identidade natural.
      description: 'Import parcial é resultado legítimo: itens ruins voltam em `Errors` com

        o índice de origem, e o lote não é invalidado por causa deles. Lote

        vazio ou acima do teto é 422.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
              - Contacts
              properties:
                Contacts:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items:
                    $ref: '#/components/schemas/ContactInput'
      responses:
        '200':
          description: Lote processado.
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Created:
                      type: integer
                    Updated:
                      type: integer
                    Errors:
                      type: array
                      items:
                        type: object
                        properties:
                          Index:
                            type: integer
                          Email:
                            type: string
                          Phone:
                            type: string
                          Error:
                            type: string
                    Contacts:
                      type: array
                      items:
                        $ref: '#/components/schemas/Contact'
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/Unprocessable'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.BulkContacts
      x-asender-fonte: spec+codigo
  /api/lists:
    get:
      operationId: BffListLists
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Listas de contatos com contagem de membros.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - name: cursor
        in: query
        schema:
          type: integer
          minimum: 0
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Lists:
                      type: array
                      items:
                        $ref: '#/components/schemas/ContactList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
      description: 'listas de contatos do tenant, com contagem de membros.


        Onde é usada: tela de audiência.


        Saídas: 200 com `{Lists}`.'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.ListLists
      x-asender-fonte: spec+codigo
    post:
      operationId: BffCreateList
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Cria uma lista de contatos.
      description: O upstream faz upsert por slug; este BFF responde sempre 201.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
              - Name
              properties:
                Name:
                  type: string
                  minLength: 1
                Slug:
                  type: string
                Description:
                  type: string
      responses:
        '201':
          description: Criada (ou atualizada pelo slug).
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    List:
                      $ref: '#/components/schemas/ContactList'
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '422':
          $ref: '#/components/responses/Unprocessable'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.CreateList
      x-asender-fonte: spec+codigo
  /api/lists/{id}/members:
    post:
      operationId: BffAddListMembers
      x-asender-authz: session
      tags:
      - bff-messaging
      summary: Anexa contatos a uma lista.
      description: '`NotFound` traz os ids que não existem no tenant — o servidor diz o que

        NÃO pôde fazer, em vez de descartar em silêncio. Idempotente.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: Public id da lista (`lst_<hex>`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
              - ContactIds
              properties:
                ContactIds:
                  type: array
                  minItems: 1
                  items:
                    type: string
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Added:
                      type: integer
                    NotFound:
                      type: array
                      items:
                        type: string
        '400':
          $ref: '#/components/responses/Validation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
      x-asender-servico: asender-api
      x-asender-handler: msgBFF.AddListMembers
      x-asender-fonte: spec+codigo
  /api/reports/overview:
    get:
      operationId: BffReportOverview
      x-asender-authz: session
      tags:
      - bff-reports
      summary: Funil do período com quebra por canal.
      description: 'Os três canais SEMPRE aparecem em `ByChannel`, zerados quando não houve

        tráfego. As chaves de `ByChannel` (`email`/`sms`/`push`) ficam em

        minúsculas porque são DADO, não nome de campo.


        Taxas em `[0,1]`, arredondadas a 4 casas, grampeadas no teto (a base

        pode ter `bounced > sent`, porque bounce não exige `sent_at`).

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - $ref: '#/components/parameters/From'
      - $ref: '#/components/parameters/To'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Totals:
                      $ref: '#/components/schemas/ReportCounters'
                    Rates:
                      $ref: '#/components/schemas/ReportRates'
                    ByChannel:
                      type: object
                      propertyNames:
                        enum:
                        - email
                        - sms
                        - push
                      additionalProperties:
                        type: object
                        properties:
                          Totals:
                            $ref: '#/components/schemas/ReportCounters'
                          Rates:
                            $ref: '#/components/schemas/ReportRates'
                    Period:
                      $ref: '#/components/schemas/ReportPeriod'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: reportsBFF.Overview
      x-asender-fonte: spec+codigo
  /api/reports/timeseries:
    get:
      operationId: BffReportTimeseries
      x-asender-authz: session
      tags:
      - bff-reports
      summary: Série temporal por bucket e canal.
      description: 'Um ponto por (bucket, canal), buckets vazios inclusos. O campo continua

        se chamando `Day` mesmo com `interval=hour` — é o início do bucket; a

        granularidade vem em `Period.Interval`.


        Este BFF aceita `hour|day|week|month`, mas o `asender_messages` só

        implementa `day|hour`: `week`/`month` passam a validação daqui e são

        recusados com 422 pelo upstream.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - $ref: '#/components/parameters/From'
      - $ref: '#/components/parameters/To'
      - name: channel
        in: query
        schema:
          $ref: '#/components/schemas/Channel'
      - name: interval
        in: query
        schema:
          type: string
          enum:
          - hour
          - day
          - week
          - month
          default: day
        description: '`week` e `month` são aceitos aqui e recusados pelo upstream (422).'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Points:
                      type: array
                      items:
                        allOf:
                        - $ref: '#/components/schemas/ReportCounters'
                        - type: object
                          properties:
                            Day:
                              type: string
                              format: date-time
                            Channel:
                              $ref: '#/components/schemas/Channel'
                    Period:
                      allOf:
                      - $ref: '#/components/schemas/ReportPeriod'
                      - type: object
                        properties:
                          Interval:
                            type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: reportsBFF.Timeseries
      x-asender-fonte: spec+codigo
  /api/reports/top-templates:
    get:
      operationId: BffReportTopTemplates
      x-asender-authz: session
      tags:
      - bff-reports
      summary: Ranking de templates por volume, com taxas.
      description: '`limit` aceito de 1 a 100 nesta borda; o teto REAL aplicado pelo

        repositório do `asender_messages` é 50.

        '
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantHeader'
      - $ref: '#/components/parameters/From'
      - $ref: '#/components/parameters/To'
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 100
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                - $ref: '#/components/schemas/RequestIdEnvelope'
                - type: object
                  properties:
                    Templates:
                      type: array
                      items:
                        type: object
                        properties:
                          Slug:
                            type: string
                          Name:
                            type: string
                          Total:
                            type: integer
                          Sent:
                            type: integer
                          Delivered:
                            type: integer
                          Opened:
                            type: integer
                          Clicked:
                            type: integer
                          DeliveryRate:
                            type: number
                          OpenRate:
                            type: number
                          ClickRate:
                            type: number
                    Period:
                      $ref: '#/components/schemas/ReportPeriod'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NoTenant'
        '404':
          $ref: '#/components/responses/NotFoundOrDenied'
        '422':
          $ref: '#/components/responses/Unprocessable'
        '502':
          $ref: '#/components/responses/BadGateway'
      x-asender-servico: asender-api
      x-asender-handler: reportsBFF.TopTemplates
      x-asender-fonte: spec+codigo
  /api/tenants/{id}/api-keys:
    get:
      tags:
      - api
      summary: ListKeys lista as chaves da conta.
      description: 'Onde é usada: GET /api/tenants/{id}/api-keys.'
      x-asender-servico: asender-api
      x-asender-handler: apiKeysH.ListKeys
      x-asender-fonte: codigo
    post:
      tags:
      - api
      summary: CreateKey emite uma chave. O texto puro volta UMA vez, no corpo do core.
      description: 'Onde é usada: POST /api/tenants/{id}/api-keys.'
      x-asender-servico: asender-api
      x-asender-handler: apiKeysH.CreateKey
      x-asender-fonte: codigo
  /api/tenants/{id}/api-keys/{keyId}:
    delete:
      tags:
      - api
      summary: RevokeKey revoga uma chave.
      description: 'Onde é usada: DELETE /api/tenants/{id}/api-keys/{keyId}.'
      x-asender-servico: asender-api
      x-asender-handler: apiKeysH.RevokeKey
      x-asender-fonte: codigo
  /api/tenants/{id}/members:
    get:
      operationId: BffListMembers
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Membros da conta.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantPathId'
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'quem tem acesso à conta.


        Onde é usada: tela de equipe.


        Efeitos: uma leitura no core.'
      x-asender-servico: asender-api
      x-asender-handler: membersH.ListMembers
      x-asender-fonte: spec+codigo
    post:
      operationId: BffAddMember
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Vincula um usuário à conta.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantPathId'
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'dá acesso direto a um usuário que já existe.


        Onde é usada: tela de equipe.


        Efeitos: uma escrita no core.'
      x-asender-servico: asender-api
      x-asender-handler: membersH.AddMember
      x-asender-fonte: spec+codigo
  /api/tenants/{id}/members/{userId}:
    delete:
      operationId: BffRemoveMember
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Desvincula um usuário da conta.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantPathId'
      - name: userId
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'tira o acesso de alguém.


        Onde é usada: tela de equipe.


        Efeitos: uma escrita no core.'
      x-asender-servico: asender-api
      x-asender-handler: membersH.RemoveMember
      x-asender-fonte: spec+codigo
  /api/tenants/{id}/invitations:
    get:
      operationId: BffListInvitations
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Convites pendentes da conta.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantPathId'
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: membersH.ListInvitations
      x-asender-fonte: spec+codigo
    post:
      operationId: BffCreateInvitation
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Convida alguém para a conta.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantPathId'
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'convida um e-mail para a conta.


        Onde é usada: tela de equipe.


        Efeitos: uma escrita no core (e, quando houver envio, um e-mail).'
      x-asender-servico: asender-api
      x-asender-handler: membersH.CreateInvitation
      x-asender-fonte: spec+codigo
  /api/tenants/{id}/invitations/{token}:
    delete:
      operationId: BffRevokeInvitation
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Cancela um convite pendente.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantPathId'
      - name: token
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'cancela um convite que ainda não foi aceito.


        Onde é usada: tela de equipe.


        Efeitos: uma escrita no core.'
      x-asender-servico: asender-api
      x-asender-handler: membersH.RevokeInvitation
      x-asender-fonte: spec+codigo
  /api/invitations/accept:
    post:
      operationId: BffAcceptInvitation
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Aceita um convite e vincula o usuário à conta.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'transforma o convite em associação, para quem está logado.


        Onde é usada: tela de convite.


        Efeitos: uma escrita no core. # Esta rota NÃO passa por contaAutorizada E não pode: quem aceita
        ainda NÃO pertence à conta — exigir pertinência aqui tornaria o convite impossível de aceitar.
        Quem autoriza é o TOKEN, que o core valida (existente, não expirado, não usado), e o usuário vem
        da sessão.'
      x-asender-servico: asender-api
      x-asender-handler: membersH.AcceptInvitation
      x-asender-fonte: spec+codigo
  /api/admin/tenants:
    get:
      operationId: PlatformListTenants
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Lista as contas, na visão da plataforma.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'todas as contas, para o suporte achar a do chamado.


        Onde é usada: tela `/impersonate`.


        Efeitos: uma leitura no core.'
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.ListarContas
      x-asender-fonte: spec+codigo
  /api/admin/tenants/{id}/enter:
    post:
      operationId: PlatformEnterTenant
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Abre sessão de suporte na conta (somente leitura, ADR-0017).
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'abre a entrada de suporte e grava o cookie com o ID dela.


        Onde é usada: tela `/impersonate`.


        Efeitos: uma escrita no core — e, a partir dela, LEITURA da conta alheia. O motivo é obrigatório:
        uma trilha sem motivo responde "alguém entrou", que é a metade inútil da pergunta que ela existe
        para responder.'
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.Entrar
      x-asender-fonte: spec+codigo
  /api/admin/impersonation/end:
    post:
      operationId: PlatformEndImpersonation
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Encerra a sessão de suporte em andamento.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'encerra a entrada em curso e apaga o cookie.


        Onde é usada: botão "sair da conta".


        Efeitos: a sessão de suporte para NA HORA.'
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.Sair
      x-asender-fonte: spec+codigo
  /api/tenants/{id}/impersonations:
    get:
      operationId: BffListImpersonations
      x-asender-authz: session
      tags:
      - bff-tenants
      summary: Trilha de sessões de suporte na conta.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - $ref: '#/components/parameters/TenantPathId'
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'quem entrou naquela conta, quando e por quê.


        Onde é usada: tela de segurança da conta — do CLIENTE.


        Efeitos: uma leitura no core. # Esta rota NÃO exige plataforma Ela exige PERTINÊNCIA, como qualquer
        leitura de conta. Uma trilha que só a plataforma consegue ler serve para a plataforma se defender,
        não para o cliente se proteger (ADR-0017).'
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.Trilha
      x-asender-fonte: spec+codigo
  /api/admin/me:
    get:
      operationId: PlatformWhoAmI
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Diz se o usuário da sessão é da plataforma.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'responde quem é o operador e confirma que ele é da plataforma.


        Onde é usada: o console, ao abrir — é o que decide entre mostrar o console e mostrar "não disponível".


        Efeitos: uma leitura na allowlist. Existe em vez de o console deduzir de outra rota: sem ela,
        o front descobriria que não é plataforma pelo 404 da PRIMEIRA rota que chamasse — e mostraria
        um erro de carregamento onde a resposta certa é "esta área não é sua".'
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.EuNaPlataforma
      x-asender-fonte: spec+codigo
  /api/admin/tenants/{id}:
    get:
      operationId: PlatformGetTenant
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Detalhe de uma conta, na visão da plataforma.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'a conta inteira, vista pela plataforma.


        Onde é usada: console, ao abrir um cliente.


        Efeitos: uma leitura no core. # Por que não reusar `GET /api/tenants/{id}` Aquela rota exige PERTINÊNCIA:
        o usuário tem de ser membro da conta. Quem opera a plataforma não é membro de nenhuma conta de
        cliente — e não deve virar, porque virar membro para poder ver é exatamente o atalho que o ADR-0017
        existe para impedir. A autorização aqui é a allowlist de plataforma, e a rota é outra.'
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.ContaDaPlataforma
      x-asender-fonte: spec+codigo
    patch:
      operationId: PlatformPatchTenant
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Altera uma conta, na visão da plataforma.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'muda status e metadados da conta, pela plataforma.


        Onde é usada: console.


        Efeitos: uma escrita no core. # Isto NÃO é a sessão de suporte A impersonação é somente leitura
        (ADR-0017) porque entrar na conta é ver o que o cliente vê. Isto é outra coisa: é a plataforma
        agindo COMO plataforma — suspender uma conta abusiva, por exemplo — e a ação fica no log com o
        ator. A distinção importa: se a sessão de suporte pudesse escrever, "entrar para ajudar" viraria
        "entrar para consertar", e o limite que o ADR desenhou desapareceria na prática.'
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.AtualizarContaDaPlataforma
      x-asender-fonte: spec+codigo
  /api/admin/metrics:
    get:
      operationId: PlatformMetrics
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Métricas agregadas da plataforma.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.MetricasDaPlataforma
      x-asender-fonte: spec+codigo
  /api/admin/blacklist:
    get:
      operationId: PlatformListBlacklist
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Lista os bloqueios da plataforma.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.ListarBlacklist
      x-asender-fonte: spec+codigo
    post:
      operationId: PlatformAddBlacklist
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Acrescenta uma entrada à lista de bloqueio.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.Bloquear
      x-asender-fonte: spec+codigo
  /api/admin/blacklist/{id}:
    delete:
      operationId: PlatformRemoveBlacklist
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Remove uma entrada da lista de bloqueio.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.Desbloquear
      x-asender-fonte: spec+codigo
  /api/admin/blacklist/suggestions:
    get:
      operationId: PlatformListBlacklistSuggestions
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Sugestões de bloqueio ainda não decididas.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.ListarSugestoes
      x-asender-fonte: spec+codigo
    post:
      operationId: PlatformCreateBlacklistSuggestion
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Registra uma sugestão de bloqueio.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.Sugerir
      x-asender-fonte: spec+codigo
  /api/admin/blacklist/suggestions/{id}/apply:
    post:
      operationId: PlatformApplyBlacklistSuggestion
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Aceita a sugestão e a promove a bloqueio.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.AplicarSugestao
      x-asender-fonte: spec+codigo
  /api/admin/blacklist/suggestions/{id}/dismiss:
    post:
      operationId: PlatformDismissBlacklistSuggestion
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Descarta a sugestão.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.DescartarSugestao
      x-asender-fonte: spec+codigo
  /api/admin/datacenter-asn:
    get:
      operationId: PlatformListDatacenterASN
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: ASNs classificados como datacenter.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.ListarASNs
      x-asender-fonte: spec+codigo
    post:
      operationId: PlatformAddDatacenterASN
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Classifica um ASN como datacenter.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.AcrescentarASN
      x-asender-fonte: spec+codigo
  /api/admin/datacenter-asn/{asn}:
    delete:
      operationId: PlatformRemoveDatacenterASN
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Remove a classificação de um ASN.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - name: asn
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.RemoverASN
      x-asender-fonte: spec+codigo
  /api/admin/platform-alerts:
    get:
      operationId: PlatformListAlerts
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Regras de alerta da plataforma.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.ListarAlertasDePlataforma
      x-asender-fonte: spec+codigo
    post:
      operationId: PlatformCreateAlert
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Cria uma regra de alerta de plataforma.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.CriarAlertaDePlataforma
      x-asender-fonte: spec+codigo
  /api/admin/platform-alerts/{id}:
    put:
      operationId: PlatformUpdateAlert
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Substitui uma regra de alerta.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.AtualizarAlertaDePlataforma
      x-asender-fonte: spec+codigo
    delete:
      operationId: PlatformDeleteAlert
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Remove uma regra de alerta.
      security:
      - SessionBearer: []
      - SessionCookie: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.ApagarAlertaDePlataforma
      x-asender-fonte: spec+codigo
  /api/admin/platform-alerts/events:
    get:
      operationId: PlatformListAlertEvents
      x-asender-authz: session
      tags:
      - bff-plataforma
      summary: Disparos de alerta da plataforma.
      security:
      - SessionBearer: []
      - SessionCookie: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      x-asender-servico: asender-api
      x-asender-handler: plataformaH.EventosDePlataforma
      x-asender-fonte: spec+codigo
  /.well-known/openid-configuration:
    get:
      tags:
      - auth
      summary: anuncia os endpoints e as capacidades (só `code`, só S256, só RS256 — o perfil OAuth2.1
        da casa).
      description: 'anuncia os endpoints e as capacidades (só `code`, só S256, só RS256 — o perfil OAuth2.1
        da casa).


        Onde é usada: os clients leem isto no bootstrap para se autoconfigurar. Cacheável (muda só em
        deploy).'
      x-asender-servico: asender-auth
      x-asender-handler: s.oidcH.Discovery
      x-asender-fonte: codigo
  /.well-known/jwks.json:
    get:
      tags:
      - auth
      summary: JWKS serve /.well-known/jwks.json — a(s) chave(s) pública(s) RS256. Os clients
      description: 'Onde é usada: verificação no client.'
      x-asender-servico: asender-auth
      x-asender-handler: s.oidcH.JWKS
      x-asender-fonte: codigo
  /authorize:
    get:
      tags:
      - auth
      summary: Authorize implementa GET /authorize (OAuth2.1 authorization code + PKCE).
      description: 'Onde é usada: o browser é redirecionado para cá pelo client (app) que quer logar.'
      x-asender-servico: asender-auth
      x-asender-handler: s.oidcH.Authorize
      x-asender-fonte: codigo
  /token:
    post:
      tags:
      - auth
      summary: Token implementa POST /token. Despacha por grant_type.
      description: 'Onde é usada: o client troca aqui, servidor-a-servidor (ou pelo BFF), o code/refresh
        por tokens.'
      x-asender-servico: asender-auth
      x-asender-handler: s.oidcH.Token
      x-asender-fonte: codigo
  /userinfo:
    get:
      tags:
      - auth
      summary: valida o Bearer (RS256, iss, exp) e devolve sub/email/name.
      description: 'valida o Bearer (RS256, iss, exp) e devolve sub/email/name.


        Onde é usada: o client chama para hidratar o perfil. Sem token válido -> 401 invalid_token.'
      x-asender-servico: asender-auth
      x-asender-handler: s.oidcH.UserInfo
      x-asender-fonte: codigo
  /logout:
    get:
      tags:
      - auth
      summary: 'Expects Authorization: Bearer <token>.'
      x-asender-servico: asender-auth
      x-asender-handler: s.oidcH.Logout
      x-asender-fonte: codigo
  /v1/users:
    post:
      operationId: AuthRegisterUser
      x-asender-authz: service
      tags:
      - users
      summary: Cria um usuário.
      description: 'A rota é declarada como `r.Post("/")` dentro de `r.Route("/users")`, ou

        seja o padrão registrado é `/v1/users/`. **Ambas as formas respondem

        201** (verificado na stack): `POST /v1/users` e `POST /v1/users/`.


        Senha é hasheada com bcrypt no custo configurado (`BCRYPT_COST`, ≥ 12).

        O hash NUNCA aparece em resposta alguma.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
              - email
              - password
              properties:
                email:
                  type: string
                  format: email
                name:
                  type: string
                password:
                  type: string
                  format: password
      responses:
        '201':
          description: Criado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    $ref: '#/components/schemas/User'
                  RequestId:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/ServiceUnauthorized'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalError'
      x-asender-servico: asender-auth
      x-asender-handler: s.usersH.Register
      x-asender-fonte: spec+codigo
  /v1/users/{id}:
    get:
      operationId: AuthGetUser
      x-asender-authz: service
      tags:
      - users
      summary: Um usuário pelo public id.
      parameters:
      - $ref: '#/components/parameters/UserId'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    $ref: '#/components/schemas/User'
                  RequestId:
                    type: string
        '401':
          $ref: '#/components/responses/ServiceUnauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
      x-asender-servico: asender-auth
      x-asender-handler: s.usersH.Get
      x-asender-fonte: spec+codigo
    patch:
      operationId: AuthPatchUser
      x-asender-authz: service
      tags:
      - users
      summary: Altera nome e/ou email.
      description: Senha NÃO é alterável por aqui — o caminho é `POST /v1/auth/password-reset/confirm`.
      parameters:
      - $ref: '#/components/parameters/UserId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                name:
                  type: string
                email:
                  type: string
                  format: email
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    $ref: '#/components/schemas/User'
                  RequestId:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/ServiceUnauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
      x-asender-servico: asender-auth
      x-asender-handler: s.usersH.Patch
      x-asender-fonte: spec+codigo
    delete:
      operationId: AuthDeleteUser
      x-asender-authz: service
      tags:
      - users
      summary: Remove o usuário.
      parameters:
      - $ref: '#/components/parameters/UserId'
      responses:
        '204':
          description: Removido.
        '401':
          $ref: '#/components/responses/ServiceUnauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
      x-asender-servico: asender-auth
      x-asender-handler: s.usersH.Delete
      x-asender-fonte: spec+codigo
  /v1/users/{id}/verify/resend:
    post:
      operationId: AuthResendVerification
      x-asender-authz: service
      tags:
      - users
      summary: Reenvia o e-mail de verificação de um usuário.
      security:
      - ServiceToken: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'Onde é usada:  POST /v1/users/{id}/emails/{emailId}/verify/resend.'
      x-asender-servico: asender-auth
      x-asender-handler: s.verifH.Reenviar
      x-asender-fonte: spec+codigo
  /v1/users/{id}/emails:
    get:
      tags:
      - auth
      summary: Listar devolve os e-mails do usuário.
      description: 'Onde é usada: GET /v1/users/{id}/emails.'
      x-asender-servico: asender-auth
      x-asender-handler: s.emailsH.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - auth
      summary: Adicionar cria um e-mail secundário (não-verificado) e dispara a verificação.
      description: 'Onde é usada: POST /v1/users/{id}/emails {email}.'
      x-asender-servico: asender-auth
      x-asender-handler: s.emailsH.Adicionar
      x-asender-fonte: codigo
  /v1/users/{id}/emails/{emailId}:
    delete:
      tags:
      - auth
      summary: Remover apaga um e-mail secundário.
      description: 'Onde é usada: DELETE /v1/users/{id}/emails/{emailId}.'
      x-asender-servico: asender-auth
      x-asender-handler: s.emailsH.Remover
      x-asender-fonte: codigo
  /v1/users/{id}/emails/{emailId}/primary:
    post:
      tags:
      - auth
      summary: DefinirPrimario promove um e-mail verificado a primário.
      description: 'Onde é usada:  POST /v1/users/{id}/emails/{emailId}/primary.'
      x-asender-servico: asender-auth
      x-asender-handler: s.emailsH.DefinirPrimario
      x-asender-fonte: codigo
  /v1/users/{id}/emails/{emailId}/verify/resend:
    post:
      tags:
      - auth
      summary: Reenviar redispara a verificação de um e-mail.
      description: 'Onde é usada:  POST /v1/users/{id}/emails/{emailId}/verify/resend.'
      x-asender-servico: asender-auth
      x-asender-handler: s.emailsH.Reenviar
      x-asender-fonte: codigo
  /v1/auth/login:
    post:
      operationId: AuthLogin
      x-asender-authz: service
      tags:
      - auth
      summary: Autentica e emite sessão.
      description: 'Com 2FA ativo, a resposta traz `two_factor_required:true` e **sem

        token**; a sessão só é emitida por `POST /v1/2fa/check`.


        User-Agent e IP do chamador entram no registro da sessão (o IP vem do

        `RealIP` do chi, portanto de `X-Forwarded-For` quando houver proxy).

        '
      x-asender-divergence: '**Oráculo de tempo** (RELATORIO-FINAL §3 item 9): email existente

        custa o bcrypt (~200 ms), inexistente retorna em ~1 ms. Dá para

        enumerar contas cronometrando. Corrigir exige hash dummy no caminho

        de "usuário não existe".

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
              - email
              - password
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
                  format: password
      responses:
        '200':
          description: Sessão emitida, ou 2FA pendente.
          content:
            application/json:
              schema:
                type: object
                properties:
                  login:
                    $ref: '#/components/schemas/LoginResult'
                  RequestId:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: '`401 InvalidCredentials` — credenciais inválidas.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
        '500':
          $ref: '#/components/responses/InternalError'
      x-asender-servico: asender-auth
      x-asender-handler: s.authH.Login
      x-asender-fonte: spec+codigo
  /v1/auth/logout:
    post:
      operationId: AuthLogout
      x-asender-authz: session
      tags:
      - auth
      summary: Revoga a sessão do Bearer apresentado.
      description: 'Credencial: `Authorization: Bearer <token de sessão>` — **não** o token

        de serviço. Ausente → 401 `MissingToken`.


        Corrigido na onda 7: revoga de fato e o `validate` passa a consultar o

        estado da sessão, então o token deixa de valer imediatamente.

        '
      security:
      - SessionBearer: []
      responses:
        '200':
          description: Revogada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    const: true
                  RequestId:
                    type: string
        '401':
          $ref: '#/components/responses/SessionUnauthorized'
      x-asender-servico: asender-auth
      x-asender-handler: s.authH.Logout
      x-asender-fonte: spec+codigo
  /v1/auth/validate:
    get:
      operationId: AuthValidateSession
      x-asender-authz: session
      tags:
      - auth
      summary: Resolve um token de sessão no usuário dono.
      description: 'É o hop que o middleware `SessionAuth` do `asender-api` faz em toda

        requisição `/api/*` autenticada.


        **A resposta DEVE trazer `user.id`.** Um 200 sem ele é violação de

        contrato: o cliente do `asender-api` recusa com `ErrContractViolation` e

        o middleware barra o principal anônimo (defesa em profundidade). Esta

        rota não devolve tenant — quem sabe de tenant é o `asender-core`.

        '
      security:
      - SessionBearer: []
      responses:
        '200':
          description: Sessão válida.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    type: object
                    required:
                    - id
                    properties:
                      id:
                        type: string
                      email:
                        type: string
                      name:
                        type: string
                  RequestId:
                    type: string
        '401':
          $ref: '#/components/responses/SessionUnauthorized'
      x-asender-servico: asender-auth
      x-asender-handler: s.authH.Validate
      x-asender-fonte: spec+codigo
  /v1/auth/password-reset:
    post:
      operationId: AuthPasswordReset
      x-asender-authz: service
      tags:
      - auth
      summary: Solicita o token de redefinição de senha.
      x-asender-divergence: '**O token de reset volta NO CORPO** (`reset_token`) em vez de ser

        enviado por email — há um `TODO(email)` explícito para enfileirar o

        envio via NATS. Quem alcança esta rota redefine a senha de qualquer

        usuário. Aceitável só porque o serviço é interno; some junto com a

        exposição das portas.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
              - email
              properties:
                email:
                  type: string
                  format: email
      responses:
        '200':
          description: Sempre `ok:true`; `reset_token` presente quando um token foi gerado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    const: true
                  reset_token:
                    type: string
                  expires_at:
                    type: string
                    format: date-time
                  RequestId:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/ServiceUnauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
      x-asender-servico: asender-auth
      x-asender-handler: s.authH.PasswordReset
      x-asender-fonte: spec+codigo
  /v1/auth/password-reset/confirm:
    post:
      operationId: AuthPasswordResetConfirm
      x-asender-authz: service
      tags:
      - auth
      summary: Redefine a senha com o token emitido.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
              - token
              - new_password
              properties:
                token:
                  type: string
                new_password:
                  type: string
                  format: password
      responses:
        '200':
          description: Senha alterada.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    const: true
                  RequestId:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: '`InvalidToken` ou `ExpiredToken`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'
      x-asender-servico: asender-auth
      x-asender-handler: s.authH.PasswordResetConfirm
      x-asender-fonte: spec+codigo
  /v1/auth/verify:
    post:
      operationId: AuthVerifyEmail
      x-asender-authz: service
      tags:
      - auth
      summary: Confirma o e-mail a partir do token do link.
      security:
      - ServiceToken: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'consome o token do link e carimba `email_verified_at`.


        Onde é usada: tela `/verify` do front, com o token da URL.


        Efeitos: duas escritas. Token inválido, expirado e já usado respondem IGUAL: distingui-los diria
        a quem tem um link velho se ele já foi usado por outra pessoa.'
      x-asender-servico: asender-auth
      x-asender-handler: s.verifH.Confirmar
      x-asender-fonte: spec+codigo
  /v1/auth/sessions:
    get:
      operationId: AuthListSessions
      x-asender-authz: service
      tags:
      - auth
      summary: Sessões ativas do usuário.
      security:
      - ServiceToken: []
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'lista onde a conta de quem apresenta o token está aberta.


        Onde é usada: tela de segurança do painel, através do BFF.


        Efeitos: uma leitura. Não recebe usuário por parâmetro: a lista é sempre a do dono do token. Um
        `?user_id=` seria a rota mais barata do sistema para descobrir de onde outra pessoa se conecta.'
      x-asender-servico: asender-auth
      x-asender-handler: s.authH.Sessoes
      x-asender-fonte: spec+codigo
  /v1/auth/sessions/{id}:
    delete:
      operationId: AuthRevokeSession
      x-asender-authz: service
      tags:
      - auth
      summary: Revoga uma sessão específica.
      security:
      - ServiceToken: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
      responses:
        '200':
          description: OK
        '401':
          description: credencial ausente ou inválida
      description: 'derruba UMA sessão do próprio usuário.


        Onde é usada: botão "remover" da tela de segurança.


        Efeitos: a sessão para de autenticar imediatamente. 404 tanto para sessão inexistente quanto para
        sessão de OUTRA pessoa: a distinção transformaria a rota num oráculo de id de sessão alheia.'
      x-asender-servico: asender-auth
      x-asender-handler: s.authH.RevogarSessao
      x-asender-fonte: spec+codigo
  /v1/authz/decide:
    post:
      tags:
      - auth
      summary: '`{permitido, motivo, papel, nivel}`.'
      description: '`{permitido, motivo, papel, nivel}`.


        Onde é usada: POST /v1/authz/decide.


        Efeitos: grava a trilha (dentro do PDP). Erro de banco vira 503, e não `{permitido:false}`: o
        PEP precisa distinguir "a política diz não" de "não consegui perguntar" — o primeiro ele mostra
        ao usuário, o segundo ele resolve com o cache que já tem (piso 10).'
      x-asender-servico: asender-auth
      x-asender-handler: s.authzH.Decidir
      x-asender-fonte: codigo
  /v1/authz/eu:
    get:
      tags:
      - auth
      summary: '`{produtos:[{slug,nome,subdominio,papel,nivel}]}`.'
      description: '`{produtos:[{slug,nome,subdominio,papel,nivel}]}`.


        Onde é usada:  GET /v1/authz/eu?sub=&tenant= — alimenta o switcher e a home do hub.'
      x-asender-servico: asender-auth
      x-asender-handler: s.authzH.Meus
      x-asender-fonte: codigo
  /v1/authz/produtos:
    get:
      tags:
      - auth
      summary: Catalogo devolve as ferramentas que existem.
      description: 'Onde é usada: GET /v1/authz/produtos.'
      x-asender-servico: asender-auth
      x-asender-handler: s.authzH.Catalogo
      x-asender-fonte: codigo
  /v1/tenants/{id}/produtos:
    get:
      tags:
      - auth
      summary: ListarAssinatura devolve o que a conta assina.
      description: 'Onde é usada: GET /v1/tenants/{id}/produtos.'
      x-asender-servico: asender-auth
      x-asender-handler: s.authzH.ListarAssinatura
      x-asender-fonte: codigo
    post:
      tags:
      - auth
      summary: Assinar liga ou suspende um produto na conta.
      description: 'Onde é usada: POST /v1/tenants/{id}/produtos {produto, status}. `status` é validado
        aqui E no CHECK da tabela: a borda dá a mensagem, o banco dá a garantia — a borda pode ser contornada
        por outro caminho de escrita.'
      x-asender-servico: asender-auth
      x-asender-handler: s.authzH.Assinar
      x-asender-fonte: codigo
  /v1/tenants/{id}/acessos:
    get:
      tags:
      - auth
      summary: ListarAcessos devolve quem acessa o quê na conta.
      description: 'Onde é usada: GET /v1/tenants/{id}/acessos.'
      x-asender-servico: asender-auth
      x-asender-handler: s.authzH.ListarAcessos
      x-asender-fonte: codigo
    post:
      tags:
      - auth
      summary: Conceder dá acesso de uma pessoa a uma ferramenta da conta.
      description: 'Onde é usada: POST /v1/tenants/{id}/acessos {user_id, produto, papel}. Papel que o
        produto não declara é recusado pela FK — 422 com a mensagem, e não 500: "papel inventado" é erro
        de quem chamou, não do servidor.'
      x-asender-servico: asender-auth
      x-asender-handler: s.authzH.Conceder
      x-asender-fonte: codigo
  /v1/tenants/{id}/acessos/{userId}/{produto}:
    delete:
      tags:
      - auth
      summary: Revogar tira o acesso.
      description: 'Onde é usada: DELETE /v1/tenants/{id}/acessos/{userId}/{produto}. Idempotente: revogar
        duas vezes responde ok nas duas. O efeito chega às ferramentas em até 60s (TTL do cache do PEP)
        — contrato escrito no ADR-0025.'
      x-asender-servico: asender-auth
      x-asender-handler: s.authzH.Revogar
      x-asender-fonte: codigo
  /v1/tenants/{id}/authz/decisoes:
    get:
      tags:
      - auth
      summary: Trilha devolve as decisões recentes da conta.
      description: 'Onde é usada: GET /v1/tenants/{id}/authz/decisoes?negadas=1&limite=50.'
      x-asender-servico: asender-auth
      x-asender-handler: s.authzH.Trilha
      x-asender-fonte: codigo
  /v1/2fa/setup:
    post:
      operationId: AuthTwoFASetup
      x-asender-authz: service
      tags:
      - twofa
      summary: Gera o segredo TOTP e a `otpauth://` URL.
      description: 'Ainda não confirmado: o 2FA só passa a valer depois de `POST /v1/2fa/verify`.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserIdBody'
      responses:
        '200':
          description: Segredo gerado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  setup:
                    type: object
                    properties:
                      secret:
                        type: string
                        description: Segredo base32. Sai UMA VEZ.
                      otpauth_url:
                        type: string
                  RequestId:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/ServiceUnauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
      x-asender-servico: asender-auth
      x-asender-handler: s.twofaH.Setup
      x-asender-fonte: spec+codigo
  /v1/2fa/verify:
    post:
      operationId: AuthTwoFAVerify
      x-asender-authz: service
      tags:
      - twofa
      summary: Confirma o setup do TOTP.
      description: NÃO emite sessão — isso é o `check`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwoFACodeBody'
      responses:
        '200':
          description: Confirmado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    const: true
                  RequestId:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/TwoFactorFailed'
        '404':
          $ref: '#/components/responses/NotFound'
      x-asender-servico: asender-auth
      x-asender-handler: s.twofaH.Verify
      x-asender-fonte: spec+codigo
  /v1/2fa/disable:
    post:
      operationId: AuthTwoFADisable
      x-asender-authz: service
      tags:
      - twofa
      summary: Desliga o TOTP do usuário.
      x-asender-divergence: 'Aceita só `user_id` — **não exige código TOTP nem senha**. Quem alcança

        esta rota remove o segundo fator de qualquer conta.

        '
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserIdBody'
      responses:
        '200':
          description: Desligado.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    const: true
                  RequestId:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/ServiceUnauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
      x-asender-servico: asender-auth
      x-asender-handler: s.twofaH.Disable
      x-asender-fonte: spec+codigo
  /v1/2fa/check:
    post:
      operationId: AuthTwoFACheck
      x-asender-authz: service
      tags:
      - twofa
      summary: Segunda etapa do login — valida o código e EMITE a sessão.
      description: Sucesso devolve o mesmo `login` de `POST /v1/auth/login`, agora com token.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwoFACodeBody'
      responses:
        '200':
          description: Código válido; sessão emitida.
          content:
            application/json:
              schema:
                type: object
                properties:
                  login:
                    $ref: '#/components/schemas/LoginResult'
                  RequestId:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/TwoFactorFailed'
        '404':
          $ref: '#/components/responses/NotFound'
      x-asender-servico: asender-auth
      x-asender-handler: s.twofaH.Check
      x-asender-fonte: spec+codigo
  /v1/jornadas:
    get:
      tags:
      - crm
      summary: GET /v1/alertas.
      description: 'GET /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma leitura.'
      x-asender-servico: asender_crm
      x-asender-handler: j.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - crm
      summary: POST /v1/alertas.
      description: 'POST /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: j.Criar
      x-asender-fonte: codigo
  /v1/jornadas/{id}:
    get:
      tags:
      - crm
      summary: GET /v1/contatos/{id}/consentimento.
      description: 'GET /v1/contatos/{id}/consentimento.


        Onde é usada: tela de contato, seção de preferências.


        Efeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver
        só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira
        que ficasse desatualizada esconderia um canal do operador.'
      x-asender-servico: asender_crm
      x-asender-handler: j.Obter
      x-asender-fonte: codigo
  /v1/jornadas/{id}/ativa:
    put:
      tags:
      - crm
      summary: PUT /v1/jornadas/{id}/ativa.
      description: 'PUT /v1/jornadas/{id}/ativa.


        Onde é usada: painel, interruptor da jornada.


        Efeitos: muda o estado; ligada, a jornada passa a inscrever contatos. # Revalida ANTES de ligar
        A jornada foi validada na criação, mas pode ter sido editada depois — e uma jornada com buraco
        na numeração deixaria o contato preso no meio do caminho, sem erro em lugar nenhum. Validar de
        novo aqui custa nada e é o último ponto antes de a coisa começar a mandar mensagem. Desligar NÃO
        revalida: uma jornada quebrada tem de poder ser desligada, e exigir que ela esteja válida para
        parar seria prender o operador justamente no caso em que ele mais precisa do botão.'
      x-asender-servico: asender_crm
      x-asender-handler: j.Ativar
      x-asender-fonte: codigo
  /v1/alertas:
    get:
      tags:
      - crm
      summary: GET /v1/alertas.
      description: 'GET /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma leitura.'
      x-asender-servico: asender_crm
      x-asender-handler: ha.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - crm
      summary: POST /v1/alertas.
      description: 'POST /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: ha.Criar
      x-asender-fonte: codigo
  /v1/alertas/eventos:
    get:
      tags:
      - crm
      summary: GET /v1/alertas/eventos?limite=N.
      description: 'GET /v1/alertas/eventos?limite=N.


        Onde é usada: tela de alertas — a linha do tempo.


        Efeitos: uma leitura. Da CONTA, e não de um alerta: quem investiga "o que aconteceu ontem" quer
        a linha do tempo inteira, e paginar por alerta a obrigaria a abrir um por um.'
      x-asender-servico: asender_crm
      x-asender-handler: ha.Eventos
      x-asender-fonte: codigo
  /v1/alertas/{id}:
    put:
      tags:
      - crm
      summary: PUT /v1/alertas/{id}.
      description: 'PUT /v1/alertas/{id}.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa,
        e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela
        condição NOVA, porque já estaria "disparado" pela antiga.'
      x-asender-servico: asender_crm
      x-asender-handler: ha.Atualizar
      x-asender-fonte: codigo
    delete:
      tags:
      - crm
      summary: DELETE /v1/alertas/{id}.
      description: 'DELETE /v1/alertas/{id}.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: ha.Apagar
      x-asender-fonte: codigo
  /v1/destinos:
    get:
      tags:
      - crm
      summary: GET /v1/alertas.
      description: 'GET /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma leitura.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - crm
      summary: POST /v1/alertas.
      description: 'POST /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Criar
      x-asender-fonte: codigo
  /v1/destinos/{id}:
    put:
      tags:
      - crm
      summary: PUT /v1/alertas/{id}.
      description: 'PUT /v1/alertas/{id}.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa,
        e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela
        condição NOVA, porque já estaria "disparado" pela antiga.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Atualizar
      x-asender-fonte: codigo
    delete:
      tags:
      - crm
      summary: DELETE /v1/alertas/{id}.
      description: 'DELETE /v1/alertas/{id}.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Apagar
      x-asender-fonte: codigo
  /v1/destinos/{id}/entregas:
    get:
      tags:
      - crm
      summary: GET /v1/destinos/{id}/entregas?limite=N.
      description: 'GET /v1/destinos/{id}/entregas?limite=N.


        Onde é usada: diagnóstico da tela de integrações.


        Efeitos: uma leitura. O PAYLOAD não volta: ele carrega a PII do contato, e quem pergunta "por
        que não chegou" precisa do estado e do código — não do dado da pessoa repetido numa segunda tela.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Entregas
      x-asender-fonte: codigo
  /v1/destinos/{id}/testar:
    post:
      tags:
      - crm
      summary: POST /v1/destinos/{id}/testar.
      description: 'POST /v1/destinos/{id}/testar.


        Onde é usada: botão "testar" da tela de integrações.


        Efeitos: cria uma entrega pendente. # ENFILEIRA, e não entrega na hora A entrega passa pelo MESMO
        dreno das entregas de verdade — e portanto pelo mesmo guard do piso 18, pela mesma guarda de rede
        e pela mesma assinatura. Um caminho de teste que chamasse o webhook direto seria um segundo caminho
        de saída, e o segundo caminho é sempre o que esquece uma guarda. O operador vê o resultado em
        `GET /entregas`, que é a mesma tela onde ele vê as entregas reais — então "o teste funcionou"
        e "a integração funciona" são a mesma leitura, e não duas afirmações que podem divergir.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Testar
      x-asender-fonte: codigo
  /v1/integracoes-de-conversao:
    get:
      tags:
      - crm
      summary: GET /v1/alertas.
      description: 'GET /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma leitura.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - crm
      summary: POST /v1/alertas.
      description: 'POST /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Criar
      x-asender-fonte: codigo
  /v1/integracoes-de-conversao/{id}:
    put:
      tags:
      - crm
      summary: PUT /v1/alertas/{id}.
      description: 'PUT /v1/alertas/{id}.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa,
        e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela
        condição NOVA, porque já estaria "disparado" pela antiga.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Atualizar
      x-asender-fonte: codigo
    delete:
      tags:
      - crm
      summary: DELETE /v1/alertas/{id}.
      description: 'DELETE /v1/alertas/{id}.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Apagar
      x-asender-fonte: codigo
  /v1/integracoes-de-conversao/{id}/entregas:
    get:
      tags:
      - crm
      summary: GET /v1/destinos/{id}/entregas?limite=N.
      description: 'GET /v1/destinos/{id}/entregas?limite=N.


        Onde é usada: diagnóstico da tela de integrações.


        Efeitos: uma leitura. O PAYLOAD não volta: ele carrega a PII do contato, e quem pergunta "por
        que não chegou" precisa do estado e do código — não do dado da pessoa repetido numa segunda tela.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Entregas
      x-asender-fonte: codigo
  /v1/integracoes-de-conversao/{id}/testar:
    post:
      tags:
      - crm
      summary: POST /v1/destinos/{id}/testar.
      description: 'POST /v1/destinos/{id}/testar.


        Onde é usada: botão "testar" da tela de integrações.


        Efeitos: cria uma entrega pendente. # ENFILEIRA, e não entrega na hora A entrega passa pelo MESMO
        dreno das entregas de verdade — e portanto pelo mesmo guard do piso 18, pela mesma guarda de rede
        e pela mesma assinatura. Um caminho de teste que chamasse o webhook direto seria um segundo caminho
        de saída, e o segundo caminho é sempre o que esquece uma guarda. O operador vê o resultado em
        `GET /entregas`, que é a mesma tela onde ele vê as entregas reais — então "o teste funcionou"
        e "a integração funciona" são a mesma leitura, e não duas afirmações que podem divergir.'
      x-asender-servico: asender_crm
      x-asender-handler: hd.Testar
      x-asender-fonte: codigo
  /v1/configuracoes/retencao:
    get:
      tags:
      - crm
      summary: GET /v1/configuracoes/retencao.
      description: 'GET /v1/configuracoes/retencao.


        Onde é usada: tela de configurações da conta.


        Efeitos: uma leitura. Conta sem política configurada responde 200 com `dias: null` — e não 404:
        "não configurei retenção" é o estado NORMAL, e a tela precisa dele para desenhar o formulário
        vazio.'
      x-asender-servico: asender_crm
      x-asender-handler: hc.ObterRetencao
      x-asender-fonte: codigo
    put:
      tags:
      - crm
      summary: 'PUT /v1/configuracoes/retencao com `{"dias": 90}` ou `{"dias": null}`.'
      description: 'PUT /v1/configuracoes/retencao com `{"dias": 90}` ou `{"dias": null}`.


        Onde é usada: tela de configurações da conta.


        Efeitos: uma escrita; passa a anonimizar contato antigo em até uma hora. # `null` desliga, ausente
        é erro Os dois cairiam no mesmo ponteiro nil se o corpo fosse decodificado direto — e um cliente
        que esquecesse o campo desligaria a retenção sem querer. `null` é uma ORDEM ("guarde para sempre");
        campo ausente é um pedido malformado.'
      x-asender-servico: asender_crm
      x-asender-handler: hc.DefinirRetencao
      x-asender-fonte: codigo
  /v1/uso:
    get:
      tags:
      - crm
      summary: GET /v1/uso?periodo=YYYY-MM — números locais do CRM mais a quota e os contadores do core.
      description: 'GET /v1/uso?periodo=YYYY-MM — números locais do CRM mais a quota e os contadores do
        core.


        Onde é usada: tela de conta/plano.


        Efeitos: uma leitura local e até duas chamadas ao core. # O core fora do ar NÃO derruba esta rota
        Os números locais (quantos contatos, quantos entraram no mês) são a metade que o CRM sabe sozinho,
        e são justamente a metade que a tela usa todo dia. Com o core fora, a resposta sai com `core_disponivel:
        false` — a tela mostra o que tem e diz o que não conseguiu saber (piso 10).'
      x-asender-servico: asender_crm
      x-asender-handler: hc.Uso
      x-asender-fonte: codigo
  /v1/contatos:
    get:
      tags:
      - crm
      summary: GET /v1/alertas.
      description: 'GET /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma leitura.'
      x-asender-servico: asender_crm
      x-asender-handler: c.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - crm
      summary: POST /v1/alertas.
      description: 'POST /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: c.Criar
      x-asender-fonte: codigo
  /v1/contatos/{id}:
    get:
      tags:
      - crm
      summary: GET /v1/contatos/{id}/consentimento.
      description: 'GET /v1/contatos/{id}/consentimento.


        Onde é usada: tela de contato, seção de preferências.


        Efeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver
        só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira
        que ficasse desatualizada esconderia um canal do operador.'
      x-asender-servico: asender_crm
      x-asender-handler: c.Obter
      x-asender-fonte: codigo
    patch:
      tags:
      - crm
      summary: PUT /v1/alertas/{id}.
      description: 'PUT /v1/alertas/{id}.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa,
        e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela
        condição NOVA, porque já estaria "disparado" pela antiga.'
      x-asender-servico: asender_crm
      x-asender-handler: c.Atualizar
      x-asender-fonte: codigo
    delete:
      tags:
      - crm
      summary: DELETE /v1/alertas/{id}.
      description: 'DELETE /v1/alertas/{id}.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: c.Apagar
      x-asender-fonte: codigo
  /v1/contatos/exportar.csv:
    get:
      tags:
      - crm
      summary: GET /v1/contatos/exportar.csv?limite=N.
      description: 'GET /v1/contatos/exportar.csv?limite=N.


        Onde é usada: botão "exportar" da lista de contatos.


        Efeitos: uma leitura; decifra a PII; registra a exportação na timeline. # A PII é decifrada AQUI,
        e só para quem é dono O tenant vem da sessão verificada e a RLS já filtra a consulta. Este handler
        nunca vê contato de outra conta, e a decifragem acontece com a chave do processo — não há caminho
        em que o CSV carregue dado alheio. # Cada exportação vira uma ATIVIDADE, e isso não é opcional
        A LGPD (art. 37) exige registro das operações de tratamento, e exportar é tratamento: a partir
        dali o dado existe num arquivo que ninguém controla. Sem esse registro, "quem baixou a base e
        quando" não tem resposta — e é a primeira pergunta de todo incidente de vazamento. O registro
        é gravado ANTES do arquivo sair. Se a gravação falhar, a exportação não acontece: um download
        sem trilha é exatamente o que a trilha existe para impedir, e "o arquivo já foi" não se desfaz.'
      x-asender-servico: asender_crm
      x-asender-handler: c.Exportar
      x-asender-fonte: codigo
  /v1/contatos/{id}/atividades:
    get:
      tags:
      - crm
      summary: GET /v1/alertas.
      description: 'GET /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma leitura.'
      x-asender-servico: asender_crm
      x-asender-handler: t.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - crm
      summary: POST /v1/alertas.
      description: 'POST /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: t.Criar
      x-asender-fonte: codigo
  /v1/contatos/{id}/consentimento:
    get:
      tags:
      - crm
      summary: GET /v1/contatos/{id}/consentimento.
      description: 'GET /v1/contatos/{id}/consentimento.


        Onde é usada: tela de contato, seção de preferências.


        Efeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver
        só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira
        que ficasse desatualizada esconderia um canal do operador.'
      x-asender-servico: asender_crm
      x-asender-handler: cs.Obter
      x-asender-fonte: codigo
    put:
      tags:
      - crm
      summary: PUT /v1/contatos/{id}/consentimento.
      description: 'PUT /v1/contatos/{id}/consentimento.


        Onde é usada: tela de contato, e o link de descadastro via BFF.


        Efeitos: cria ou remove a linha de opt-out. # `recebe` é ponteiro, e a ausência é ERRO Com um
        `bool` comum, um corpo sem o campo viria como `false` — e "esqueci de mandar o campo" seria indistinguível
        de "descadastra esta pessoa". O ponteiro transforma a omissão em 422 explícito.'
      x-asender-servico: asender_crm
      x-asender-handler: cs.Definir
      x-asender-fonte: codigo
  /v1/segmentos:
    get:
      tags:
      - crm
      summary: GET /v1/alertas.
      description: 'GET /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma leitura.'
      x-asender-servico: asender_crm
      x-asender-handler: sg.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - crm
      summary: POST /v1/alertas.
      description: 'POST /v1/alertas.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: sg.Criar
      x-asender-fonte: codigo
  /v1/segmentos/previa:
    post:
      tags:
      - crm
      summary: POST /v1/segmentos/previa.
      description: 'POST /v1/segmentos/previa.


        Onde é usada: enquanto o usuário monta os critérios na tela.


        Efeitos: uma consulta; NÃO grava nada. Existe para que a pergunta "isto alcança quem?" seja respondida
        ANTES de salvar. Sem ela, o caminho para descobrir o alcance é criar o segmento — e a tela enche
        de segmentos descartados chamados "teste 3".'
      x-asender-servico: asender_crm
      x-asender-handler: sg.Previsualizar
      x-asender-fonte: codigo
  /v1/segmentos/{id}:
    get:
      tags:
      - crm
      summary: GET /v1/contatos/{id}/consentimento.
      description: 'GET /v1/contatos/{id}/consentimento.


        Onde é usada: tela de contato, seção de preferências.


        Efeitos: uma leitura. Responde TODOS os canais conhecidos, inclusive os sem opt-out. Devolver
        só os que saíram faria a tela ter de saber a lista completa para desenhar as caixas — e a primeira
        que ficasse desatualizada esconderia um canal do operador.'
      x-asender-servico: asender_crm
      x-asender-handler: sg.Obter
      x-asender-fonte: codigo
    put:
      tags:
      - crm
      summary: PUT /v1/alertas/{id}.
      description: 'PUT /v1/alertas/{id}.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita. Mudar a configuração ACALMA o alerta: ele passa a observar outra coisa,
        e manter o estado faria a próxima avaliação comparar maçã com laranja — ele não avisaria pela
        condição NOVA, porque já estaria "disparado" pela antiga.'
      x-asender-servico: asender_crm
      x-asender-handler: sg.Atualizar
      x-asender-fonte: codigo
    delete:
      tags:
      - crm
      summary: DELETE /v1/alertas/{id}.
      description: 'DELETE /v1/alertas/{id}.


        Onde é usada: tela de alertas.


        Efeitos: uma escrita.'
      x-asender-servico: asender_crm
      x-asender-handler: sg.Apagar
      x-asender-fonte: codigo
  /internal/v1/formularios/{id}:
    get:
      tags:
      - pages
      summary: GET /internal/v1/formularios/{id}.
      description: 'GET /internal/v1/formularios/{id}.


        Onde é usada: chamado pelo `asender_runtime` para validar uma submissão.


        Efeitos: uma leitura. # Sem o tenant na rota, de propósito No modo SNIPPET o formulário está embutido
        no site de um terceiro e quem posta é o navegador de um visitante anônimo. O runtime não sabe
        de qual conta é o formulário — a conta é a RESPOSTA, tirada dele. Exigir o tenant na rota tornaria
        esta chamada impossível de fazer. Isto não afrouxa nada: o `public_id` é opaco e já está no HTML
        de quem visita o site. Quem o conhece já podia postar nele. O que protege esta rota é a credencial
        de serviço — sem ela, ela não responde nada.'
      x-asender-servico: asender_pages
      x-asender-handler: hi.Formulario
      x-asender-fonte: codigo
  /internal/v1/paginas:
    get:
      tags:
      - pages
      summary: GET /internal/v1/paginas?dominio=&slug=.
      description: 'GET /internal/v1/paginas?dominio=&slug=.


        Onde é usada: chamado pelo `asender_runtime` a cada requisição de página pública.


        Efeitos: uma leitura. # Query string e não caminho O domínio contém pontos e o slug pode conter
        hífen; os dois em segmentos de rota exigiriam escape que o roteador desfaz de formas diferentes
        conforme a versão. Query string é literal, e o que trafega aqui é rede interna.'
      x-asender-servico: asender_pages
      x-asender-handler: hi.PaginaPublicada
      x-asender-fonte: codigo
  /internal/v1/paginas/publicadas:
    get:
      tags:
      - pages
      summary: GET /internal/v1/paginas/publicadas?dominio=.
      description: 'GET /internal/v1/paginas/publicadas?dominio=.


        Onde é usada: chamado pelo `asender_runtime` para montar `/sitemap.xml`.


        Efeitos: uma leitura. Devolve só slug e data: o sitemap não precisa de título nem template, e
        cada campo a mais seria dado do cliente saindo por uma rota que qualquer visitante alcança pelo
        runtime. Domínio sem página responde lista VAZIA, e não 404: "este host não tem página publicada"
        é uma resposta sobre o mundo, e o runtime precisa dela para devolver um sitemap vazio em vez de
        um erro.'
      x-asender-servico: asender_pages
      x-asender-handler: hi.PaginasDoDominio
      x-asender-fonte: codigo
  /internal/v1/tls/authorize:
    get:
      tags:
      - pages
      summary: GET /internal/v1/tls/authorize?host=.
      description: 'GET /internal/v1/tls/authorize?host=.


        Onde é usada: o servidor de borda, no meio do handshake TLS.


        Efeitos: uma consulta. # Fail-closed, e a resposta é um booleano Host desconhecido é `false` —
        negar é a resposta correta. Falha nossa é 503, e NÃO `false`: o servidor de borda tem de distinguir
        "não autorizado" de "não consegui decidir", senão um soluço de banco viraria recusa de certificado
        para domínio legítimo. A resposta não carrega tenant nem token. Quem monta o handshake não precisa
        disso, e devolvê-lo daria a quem sonda um mapa de quais domínios pertencem à plataforma.'
      x-asender-servico: asender_pages
      x-asender-handler: hd.AutorizarTLS
      x-asender-fonte: codigo
  /internal/v1/paginas/{id}/cliques:
    post:
      tags:
      - pages
      summary: POST /internal/v1/paginas/{id}/cliques.
      description: 'POST /internal/v1/paginas/{id}/cliques.


        Onde é usada: descarga periódica do contador em memória do `asender_runtime`.


        Efeitos: uma escrita por destino, somando ao que já existe. # Soma, e não atribui O corpo traz
        o DELTA do lote, nunca um total. Dois processos do runtime descarregando ao mesmo tempo somam
        certo; com atribuição, o último a escrever apagaria a contagem do outro — e o relatório mostraria
        menos visitas do que houve, sem nada indicar a perda. # Teto de destinos no corpo O mapa vem da
        rede. Sem teto, um lote com um milhão de chaves viraria um milhão de `INSERT` numa transação só.
        O teto é o mesmo do roteamento (20 destinos), porque é o máximo que uma página pode ter.'
      x-asender-servico: asender_pages
      x-asender-handler: hi.Contagens
      x-asender-fonte: codigo
  /internal/v1/redirects:
    get:
      tags:
      - pages
      summary: GET /internal/v1/redirects?dominio=&slug=.
      description: 'GET /internal/v1/redirects?dominio=&slug=.


        Onde é usada: chamado pelo `asender_runtime` a cada clique.


        Efeitos: uma leitura. Não devolve o `tenant_id`: quem serve o redirect não precisa saber de quem
        ele é, e devolvê-lo daria a um visitante um mapa de qual conta encurta o quê.'
      x-asender-servico: asender_pages
      x-asender-handler: hi.Redirect
      x-asender-fonte: codigo
  /internal/v1/redirects/cliques:
    post:
      tags:
      - pages
      summary: POST /internal/v1/redirects/cliques.
      description: 'POST /internal/v1/redirects/cliques.


        Onde é usada: descarga periódica do contador em memória do runtime.


        Efeitos: uma escrita por link. SOMA, e não atribui: dois processos do runtime descarregando ao
        mesmo tempo somam certo; com atribuição, o último a escrever apagaria a contagem do outro.'
      x-asender-servico: asender_pages
      x-asender-handler: hi.CliquesDeRedirect
      x-asender-fonte: codigo
  /v1/paginas:
    get:
      tags:
      - pages
      summary: GET /v1/dominios.
      description: 'GET /v1/dominios.


        Onde é usada: tela de domínios.


        Efeitos: uma leitura.'
      x-asender-servico: asender_pages
      x-asender-handler: hp.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - pages
      summary: POST /v1/dominios.
      description: 'POST /v1/dominios.


        Onde é usada: tela de domínios.


        Efeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do
        produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado
        TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.'
      x-asender-servico: asender_pages
      x-asender-handler: hp.Criar
      x-asender-fonte: codigo
  /v1/paginas/{id}:
    get:
      tags:
      - pages
      summary: GET /v1/paginas/{id}/experimento.
      description: 'GET /v1/paginas/{id}/experimento.


        Onde é usada: tela de A/B do painel.


        Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não
        404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar
        o botão de criar. 404 mandaria a tela tratar o caso normal como erro.'
      x-asender-servico: asender_pages
      x-asender-handler: hp.Obter
      x-asender-fonte: codigo
    patch:
      tags:
      - pages
      summary: PATCH /v1/paginas/{id} — renomear, mudar o endereço público ou o domínio.
      description: 'PATCH /v1/paginas/{id} — renomear, mudar o endereço público ou o domínio.


        Onde é usada: tela de páginas do painel (renomear) e configurações da página.


        Efeitos: uma escrita. # Só o que veio é tocado `nil` é "não mandado" e `&""` é "mandado vazio".
        Sem essa distinção, editar o título apagaria o endereço público da página — e ela sairia do ar
        sem ninguém ter pedido. # Validação IGUAL à da criação O slug e o título passam pelas mesmas regras
        do `Criar`. Um PATCH mais frouxo seria a porta dos fundos: bastaria criar válido e editar para
        inválido para pôr no ar um endereço que a criação recusa.'
      x-asender-servico: asender_pages
      x-asender-handler: hp.Editar
      x-asender-fonte: codigo
  /v1/paginas/{id}/versoes:
    post:
      tags:
      - pages
      summary: POST /v1/paginas/{id}/versoes.
      description: 'POST /v1/paginas/{id}/versoes.


        Onde é usada: botão salvar do editor.


        Efeitos: cria uma versão.'
      x-asender-servico: asender_pages
      x-asender-handler: hp.Salvar
      x-asender-fonte: codigo
  /v1/paginas/{id}/publicar:
    post:
      tags:
      - pages
      summary: POST /v1/paginas/{id}/publicar.
      description: 'POST /v1/paginas/{id}/publicar.


        Onde é usada: botão publicar.


        Efeitos: move o ponteiro da página; o visitante passa a ver esta versão.'
      x-asender-servico: asender_pages
      x-asender-handler: hp.Publicar
      x-asender-fonte: codigo
  /v1/paginas/{id}/experimento:
    get:
      tags:
      - pages
      summary: GET /v1/paginas/{id}/experimento.
      description: 'GET /v1/paginas/{id}/experimento.


        Onde é usada: tela de A/B do painel.


        Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não
        404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar
        o botão de criar. 404 mandaria a tela tratar o caso normal como erro.'
      x-asender-servico: asender_pages
      x-asender-handler: hx.Obter
      x-asender-fonte: codigo
  /v1/paginas/{id}/experimento/auto-stop:
    put:
      tags:
      - pages
      summary: PUT /v1/paginas/{id}/experimento/auto-stop.
      description: 'PUT /v1/paginas/{id}/experimento/auto-stop.


        Onde é usada: tela de A/B.


        Efeitos: uma escrita. Aceito com o teste RODANDO, ao contrário das variantes: apertar o critério
        não reatribui visitante nem invalida amostra — só muda quando o job pode concluir.'
      x-asender-servico: asender_pages
      x-asender-handler: hx.DefinirAutoStop
      x-asender-fonte: codigo
  /v1/paginas/{id}/variantes:
    get:
      tags:
      - pages
      summary: GET /v1/paginas/{id}/variantes.
      description: 'GET /v1/paginas/{id}/variantes.


        Onde é usada: tela de A/B.


        Efeitos: uma leitura.'
      x-asender-servico: asender_pages
      x-asender-handler: hx.ListarVariantes
      x-asender-fonte: codigo
    post:
      tags:
      - pages
      summary: POST /v1/paginas/{id}/variantes.
      description: 'POST /v1/paginas/{id}/variantes.


        Onde é usada: botão "nova variante" da tela de A/B.


        Efeitos: uma ou duas escritas. # O experimento nasce aqui, e em rascunho Exigir uma chamada de
        "criar experimento" antes da primeira variante daria ao operador um objeto vazio que não mede
        nada e que ele teria de lembrar de apagar. A primeira variante cria o teste.'
      x-asender-servico: asender_pages
      x-asender-handler: hx.CriarVariante
      x-asender-fonte: codigo
  /v1/paginas/{id}/variantes/{varianteID}:
    put:
      tags:
      - pages
      summary: PUT /v1/paginas/{id}/variantes/{varianteID}.
      description: 'PUT /v1/paginas/{id}/variantes/{varianteID}.


        Onde é usada: ajustar peso ou versão na tela de A/B.


        Efeitos: uma escrita.'
      x-asender-servico: asender_pages
      x-asender-handler: hx.EditarVariante
      x-asender-fonte: codigo
    delete:
      tags:
      - pages
      summary: DELETE /v1/paginas/{id}/variantes/{varianteID}.
      description: 'DELETE /v1/paginas/{id}/variantes/{varianteID}.


        Onde é usada: tela de A/B.


        Efeitos: uma escrita.'
      x-asender-servico: asender_pages
      x-asender-handler: hx.ApagarVariante
      x-asender-fonte: codigo
  /v1/paginas/{id}/ab/promover:
    post:
      tags:
      - pages
      summary: POST /v1/paginas/{id}/ab/promover.
      description: 'POST /v1/paginas/{id}/ab/promover.


        Onde é usada: botão "promover vencedora" e botão "iniciar teste" da tela de A/B.


        Efeitos: uma escrita. # Duas operações na mesma rota, e por quê O lynz expõe `POST /v1/pages/{id}/ab/promote`,
        sem rota de iniciar — o teste começa ao ser promovido a "rodando". Manter uma rota só preserva
        a superfície do lynz, e o corpo diz qual das duas transições é pedida. # Iniciar EXIGE o experimento
        válido É o único ponto onde o rascunho inconsistente é barrado, e é o ponto certo: pesos que não
        somam 100 deixariam uma faixa do tráfego sem dono, e `Escolher` devolveria a última variante para
        essa fatia — um viés silencioso que o número final não denuncia.'
      x-asender-servico: asender_pages
      x-asender-handler: hx.Promover
      x-asender-fonte: codigo
  /v1/paginas/{id}/roteamento:
    get:
      tags:
      - pages
      summary: GET /v1/paginas/{id}/experimento.
      description: 'GET /v1/paginas/{id}/experimento.


        Onde é usada: tela de A/B do painel.


        Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não
        404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar
        o botão de criar. 404 mandaria a tela tratar o caso normal como erro.'
      x-asender-servico: asender_pages
      x-asender-handler: hr.Obter
      x-asender-fonte: codigo
    put:
      tags:
      - pages
      summary: POST /v1/paginas/{id}/versoes.
      description: 'POST /v1/paginas/{id}/versoes.


        Onde é usada: botão salvar do editor.


        Efeitos: cria uma versão.'
      x-asender-servico: asender_pages
      x-asender-handler: hr.Salvar
      x-asender-fonte: codigo
    delete:
      tags:
      - pages
      summary: DELETE /v1/dominios/{id}.
      description: 'DELETE /v1/dominios/{id}.


        Onde é usada: tela de domínios.


        Efeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito:
        um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém
        entender por quê.'
      x-asender-servico: asender_pages
      x-asender-handler: hr.Apagar
      x-asender-fonte: codigo
  /v1/paginas/{id}/cliques:
    get:
      tags:
      - pages
      summary: GET /v1/redirects/{id}/cliques.
      description: 'GET /v1/redirects/{id}/cliques.


        Onde é usada: relatório da tela de links curtos.


        Efeitos: uma leitura. Devolve o CONTADOR, e não uma lista de eventos: a pergunta é sempre "quantos",
        nunca "quais". Uma linha por clique transformaria o caminho mais quente do redirect num INSERT
        por requisição.'
      x-asender-servico: asender_pages
      x-asender-handler: hr.Cliques
      x-asender-fonte: codigo
  /v1/midias:
    get:
      tags:
      - pages
      summary: GET /v1/dominios.
      description: 'GET /v1/dominios.


        Onde é usada: tela de domínios.


        Efeitos: uma leitura.'
      x-asender-servico: asender_pages
      x-asender-handler: mds.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - pages
      summary: POST /v1/midias (multipart, campo `arquivo`).
      description: 'POST /v1/midias (multipart, campo `arquivo`).


        Onde é usada: botão de upload do editor.


        Efeitos: escreve no disco e no banco. # A ordem é DISCO e depois BANCO Se o disco falhar, nada
        foi gravado e o erro é honesto. A ordem inversa deixaria uma linha apontando para um arquivo inexistente,
        e o visitante receberia erro numa página publicada. O caso duplicado não grava nada: a mídia já
        está lá, com os mesmos bytes.'
      x-asender-servico: asender_pages
      x-asender-handler: mds.Upload
      x-asender-fonte: codigo
  /v1/midias/de-url:
    post:
      tags:
      - pages
      summary: POST /v1/midias/de-url.
      description: 'POST /v1/midias/de-url.


        Onde é usada: colar o endereço de uma imagem no editor.


        Efeitos: uma requisição a servidor de terceiro; escreve no disco e no banco. # Esta é a rota mais
        perigosa do serviço Ela faz o SERVIDOR buscar um endereço que o USUÁRIO escolheu — a definição
        de SSRF. Sem as guardas, `http://169.254.169.254/latest/meta-data/` devolveria as credenciais
        da instância, e `http://redis-interno:6379/` alcançaria um serviço que nunca deveria ver tráfego
        de fora. Toda a defesa está em `domain/midia`: allowlist de esquema e porta, recusa de IP interno,
        conferência REPETIDA no momento de discar (contra DNS rebinding), zero redirecionamento e teto
        de tamanho durante a leitura.'
      x-asender-servico: asender_pages
      x-asender-handler: mds.DeURL
      x-asender-fonte: codigo
  /v1/midias/{id}:
    delete:
      tags:
      - pages
      summary: DELETE /v1/dominios/{id}.
      description: 'DELETE /v1/dominios/{id}.


        Onde é usada: tela de domínios.


        Efeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito:
        um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém
        entender por quê.'
      x-asender-servico: asender_pages
      x-asender-handler: mds.Apagar
      x-asender-fonte: codigo
  /v1/public/midias/{id}:
    get:
      tags:
      - pages
      summary: GET e HEAD /v1/public/midias/{id}.
      description: 'GET e HEAD /v1/public/midias/{id}.


        Onde é usada: a tag `<img>` de uma landing publicada.


        Efeitos: abre e transmite o arquivo. # Sem sessão, e por isso com cuidado extra O id é o único
        parâmetro, e ele é resolvido no BANCO antes de tocar o disco: o caminho do arquivo nunca é montado
        com o que veio da URL. Path traversal não tem por onde entrar — e o `armazenamento` recusa id
        fora do formato de qualquer jeito, como segunda linha. # `X-Content-Type-Options: nosniff` é obrigatório
        aqui O tipo é o DETECTADO no upload, e a allowlist só tem imagem. Sem `nosniff`, o navegador pode
        reinterpretar o conteúdo e executar como outra coisa — e a allowlist de tipos perderia o sentido
        no último metro.'
      x-asender-servico: asender_pages
      x-asender-handler: mds.Publica
      x-asender-fonte: codigo
  /v1/dominios:
    get:
      tags:
      - pages
      summary: GET /v1/dominios.
      description: 'GET /v1/dominios.


        Onde é usada: tela de domínios.


        Efeitos: uma leitura.'
      x-asender-servico: asender_pages
      x-asender-handler: hd.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - pages
      summary: POST /v1/dominios.
      description: 'POST /v1/dominios.


        Onde é usada: tela de domínios.


        Efeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do
        produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado
        TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.'
      x-asender-servico: asender_pages
      x-asender-handler: hd.Criar
      x-asender-fonte: codigo
  /v1/dominios/{id}/verificar:
    post:
      tags:
      - pages
      summary: POST /v1/dominios/{id}/verificar.
      description: 'POST /v1/dominios/{id}/verificar.


        Onde é usada: botão "verificar" da tela de domínios.


        Efeitos: uma consulta DNS e uma escrita. # As duas falhas têm respostas DIFERENTES "Não consegui
        perguntar ao DNS" é 503 com "tente de novo"; "perguntei e o registro não está lá" é 422 com "publique
        o registro". Colapsar as duas faria o cliente ficar publicando um registro que já está lá. # E
        o resultado é GRAVADO nos dois casos Sem isso, quem tenta e falha vê a tela exatamente igual à
        de antes de tentar — e não tem como saber se o sistema chegou a olhar.'
      x-asender-servico: asender_pages
      x-asender-handler: hd.Verificar
      x-asender-fonte: codigo
  /v1/dominios/{id}:
    delete:
      tags:
      - pages
      summary: DELETE /v1/dominios/{id}.
      description: 'DELETE /v1/dominios/{id}.


        Onde é usada: tela de domínios.


        Efeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito:
        um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém
        entender por quê.'
      x-asender-servico: asender_pages
      x-asender-handler: hd.Apagar
      x-asender-fonte: codigo
  /v1/configuracoes/marca:
    get:
      tags:
      - pages
      summary: GET /v1/paginas/{id}/experimento.
      description: 'GET /v1/paginas/{id}/experimento.


        Onde é usada: tela de A/B do painel.


        Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não
        404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar
        o botão de criar. 404 mandaria a tela tratar o caso normal como erro.'
      x-asender-servico: asender_pages
      x-asender-handler: hm.Obter
      x-asender-fonte: codigo
    put:
      tags:
      - pages
      summary: PUT /v1/configuracoes/marca — os quatro campos de uma vez.
      description: 'PUT /v1/configuracoes/marca — os quatro campos de uma vez.


        Onde é usada: tela de marca do painel.


        Efeitos: uma escrita; muda o que o visitante vê na próxima visita. PUT e não PATCH: são quatro
        campos que a tela mostra e salva juntos, e campo vazio APAGA aquele elemento. Um PATCH criaria
        a pergunta "como apago o logo?", cuja resposta seria um campo especial que ninguém acerta na primeira
        leitura.'
      x-asender-servico: asender_pages
      x-asender-handler: hm.Definir
      x-asender-fonte: codigo
  /v1/redirects:
    get:
      tags:
      - pages
      summary: GET /v1/dominios.
      description: 'GET /v1/dominios.


        Onde é usada: tela de domínios.


        Efeitos: uma leitura.'
      x-asender-servico: asender_pages
      x-asender-handler: hr.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - pages
      summary: POST /v1/dominios.
      description: 'POST /v1/dominios.


        Onde é usada: tela de domínios.


        Efeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do
        produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado
        TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.'
      x-asender-servico: asender_pages
      x-asender-handler: hr.Criar
      x-asender-fonte: codigo
  /v1/redirects/{id}:
    get:
      tags:
      - pages
      summary: GET /v1/paginas/{id}/experimento.
      description: 'GET /v1/paginas/{id}/experimento.


        Onde é usada: tela de A/B do painel.


        Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não
        404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar
        o botão de criar. 404 mandaria a tela tratar o caso normal como erro.'
      x-asender-servico: asender_pages
      x-asender-handler: hr.Obter
      x-asender-fonte: codigo
    put:
      tags:
      - pages
      summary: PUT /v1/formularios/{id}.
      description: 'PUT /v1/formularios/{id}.


        Onde é usada: edição.


        Efeitos: escreve a linha.'
      x-asender-servico: asender_pages
      x-asender-handler: hr.Atualizar
      x-asender-fonte: codigo
    delete:
      tags:
      - pages
      summary: DELETE /v1/dominios/{id}.
      description: 'DELETE /v1/dominios/{id}.


        Onde é usada: tela de domínios.


        Efeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito:
        um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém
        entender por quê.'
      x-asender-servico: asender_pages
      x-asender-handler: hr.Apagar
      x-asender-fonte: codigo
  /v1/redirects/{id}/cliques:
    get:
      tags:
      - pages
      summary: GET /v1/redirects/{id}/cliques.
      description: 'GET /v1/redirects/{id}/cliques.


        Onde é usada: relatório da tela de links curtos.


        Efeitos: uma leitura. Devolve o CONTADOR, e não uma lista de eventos: a pergunta é sempre "quantos",
        nunca "quais". Uma linha por clique transformaria o caminho mais quente do redirect num INSERT
        por requisição.'
      x-asender-servico: asender_pages
      x-asender-handler: hr.Cliques
      x-asender-fonte: codigo
  /v1/ia/gerar-pagina:
    post:
      tags:
      - pages
      summary: POST /v1/paginas/gerar — manda o pedido ao provedor, saneia a saída e devolve o template.
      description: 'POST /v1/paginas/gerar — manda o pedido ao provedor, saneia a saída e devolve o template.


        Onde é usada: botão "gerar com IA" do editor.


        Efeitos: uma chamada externa; NÃO grava nada. # Não grava: devolve o rascunho Gravar direto faria
        o modelo publicar na conta do cliente. O que sai daqui é uma sugestão que a pessoa vê no editor
        e salva se quiser — a versão continua nascendo do gesto dela. # Sem provedor, a rota DEGRADA 503
        e não 500: é configuração ausente, e a distinção separa "o produto não tem essa função ligada"
        de "o produto quebrou". O resto do serviço segue de pé — nenhuma página deixa de ser servida porque
        não há chave de IA (piso 10).'
      x-asender-servico: asender_pages
      x-asender-handler: hg.Gerar
      x-asender-fonte: codigo
  /v1/formularios:
    get:
      tags:
      - pages
      summary: GET /v1/dominios.
      description: 'GET /v1/dominios.


        Onde é usada: tela de domínios.


        Efeitos: uma leitura.'
      x-asender-servico: asender_pages
      x-asender-handler: hf.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - pages
      summary: POST /v1/dominios.
      description: 'POST /v1/dominios.


        Onde é usada: tela de domínios.


        Efeitos: uma escrita. # O domínio nasce NÃO verificado E é isso que impede o pior desfecho do
        produto: sem a prova de DNS, qualquer conta declararia `banco.com.br` e passaria a receber certificado
        TLS para esse nome — servindo uma página de captura no domínio de outra pessoa, com cadeado verde.'
      x-asender-servico: asender_pages
      x-asender-handler: hf.Criar
      x-asender-fonte: codigo
  /v1/formularios/{id}:
    get:
      tags:
      - pages
      summary: GET /v1/paginas/{id}/experimento.
      description: 'GET /v1/paginas/{id}/experimento.


        Onde é usada: tela de A/B do painel.


        Efeitos: uma leitura. Página SEM experimento responde 200 com o corpo vazio de variantes, e não
        404: "esta página não tem teste" é uma resposta sobre o mundo, e a tela precisa dela para mostrar
        o botão de criar. 404 mandaria a tela tratar o caso normal como erro.'
      x-asender-servico: asender_pages
      x-asender-handler: hf.Obter
      x-asender-fonte: codigo
    put:
      tags:
      - pages
      summary: PUT /v1/formularios/{id}.
      description: 'PUT /v1/formularios/{id}.


        Onde é usada: edição.


        Efeitos: escreve a linha.'
      x-asender-servico: asender_pages
      x-asender-handler: hf.Atualizar
      x-asender-fonte: codigo
    delete:
      tags:
      - pages
      summary: DELETE /v1/dominios/{id}.
      description: 'DELETE /v1/dominios/{id}.


        Onde é usada: tela de domínios.


        Efeitos: uma escrita. Apagar TIRA a autorização de TLS, e é o único caminho para isso — de propósito:
        um domínio verificado que pudesse ser "desverificado" por outro caminho sairia do ar sem ninguém
        entender por quê.'
      x-asender-servico: asender_pages
      x-asender-handler: hf.Apagar
      x-asender-fonte: codigo
  /v1/forms/{formID}/submissions:
    post:
      tags:
      - runtime
      summary: autentica por API key (resolvida pelo middleware), lê o JSON e chama o pipeline.
      description: 'autentica por API key (resolvida pelo middleware), lê o JSON e chama o pipeline.


        Onde é usada: rota autenticada do runtime.


        Efeitos: os do pipeline (banco, outbox). O tenant vem do CONTEXTO, posto pelo middleware que validou
        a API key — nunca do corpo (§37: confiar em tenant_id do cliente). É isso que faz a key de um
        tenant não alcançar o form de outro.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Captura.API
      x-asender-fonte: codigo
  /v1/leads:
    post:
      tags:
      - runtime
      summary: autentica por API key (resolvida pelo middleware), lê o JSON e chama o pipeline.
      description: 'autentica por API key (resolvida pelo middleware), lê o JSON e chama o pipeline.


        Onde é usada: rota autenticada do runtime.


        Efeitos: os do pipeline (banco, outbox). O tenant vem do CONTEXTO, posto pelo middleware que validou
        a API key — nunca do corpo (§37: confiar em tenant_id do cliente). É isso que faz a key de um
        tenant não alcançar o form de outro.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Captura.API
      x-asender-fonte: codigo
  /f/{formID}:
    post:
      tags:
      - runtime
      summary: valida a ORIGEM contra os domínios verificados do tenant, ecoa os cabeçalhos de CORS e
        chama o mesmo pipeline.
      description: 'valida a ORIGEM contra os domínios verificados do tenant, ecoa os cabeçalhos de CORS
        e chama o mesmo pipeline.


        Onde é usada: rota pública, chamada de outro domínio pelo navegador.


        Efeitos: os do pipeline. Aqui não há API key: quem chama é o navegador do visitante, e uma key
        no snippet seria segredo no cliente (§13.4). A defesa é a allowlist de origem, verificada NO SERVIDOR
        — o header `Origin` é posto pelo navegador e não pode ser forjado por script da própria página.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Captura.Snippet
      x-asender-fonte: codigo
  /v1/analytics/overview:
    get:
      tags:
      - runtime
      summary: GET /v1/analytics/overview?pagina=&de=&ate=.
      description: 'GET /v1/analytics/overview?pagina=&de=&ate=.


        Onde é usada: tela inicial de analytics.


        Efeitos: duas leituras. Funil e atribuição juntos porque a tela mostra os dois lado a lado, e
        duas requisições poderiam ler janelas diferentes — o total da atribuição não fecharia com o topo
        do funil, e o número "errado" seria o certo.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Analytics.Overview
      x-asender-fonte: codigo
  /v1/analytics/breakdown:
    get:
      tags:
      - runtime
      summary: GET /v1/analytics/breakdown?dimensao=utm_source&…
      description: 'GET /v1/analytics/breakdown?dimensao=utm_source&…


        Onde é usada: tela de origens.


        Efeitos: uma leitura.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Analytics.Breakdown
      x-asender-fonte: codigo
  /v1/analytics/events:
    get:
      tags:
      - runtime
      summary: GET /v1/analytics/events?pagina=&limite=.
      description: 'GET /v1/analytics/events?pagina=&limite=.


        Onde é usada: tela de depuração da instrumentação.


        Efeitos: uma leitura.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Analytics.Eventos
      x-asender-fonte: codigo
  /v1/analytics/session/{id}:
    get:
      tags:
      - runtime
      summary: GET /v1/analytics/session/{id}.
      description: 'GET /v1/analytics/session/{id}.


        Onde é usada: tela de sessão — o que aquela visita fez, em ordem.


        Efeitos: uma leitura. Sessão de outra conta responde lista VAZIA, e não 404: o id de sessão é
        opaco e gerado no browser, e distinguir "não existe" de "não é sua" transformaria a rota num oráculo
        de existência de sessão alheia.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Analytics.Sessao
      x-asender-fonte: codigo
  /v1/analytics/traffic-quality:
    get:
      tags:
      - runtime
      summary: GET /v1/analytics/traffic-quality.
      description: 'GET /v1/analytics/traffic-quality.


        Onde é usada: tela de qualidade — "esse tráfego pago é gente?".


        Efeitos: uma leitura.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Analytics.Qualidade
      x-asender-fonte: codigo
  /v1/analytics/ab:
    get:
      tags:
      - runtime
      summary: GET /v1/analytics/ab?pagina=.
      description: 'GET /v1/analytics/ab?pagina=.


        Onde é usada: tela do experimento.


        Efeitos: uma leitura.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Analytics.AB
      x-asender-fonte: codigo
  /v1/analytics/heatmap:
    get:
      tags:
      - runtime
      summary: GET /v1/analytics/heatmap?pagina=&lado=.
      description: 'GET /v1/analytics/heatmap?pagina=&lado=.


        Onde é usada: tela de mapa de calor.


        Efeitos: uma leitura. # As posições são PORCENTAGEM, e não pixel Pixel absoluto é da tela de quem
        clicou: o mesmo botão sai em x=320 no celular e x=980 no monitor. A célula em porcentagem compara
        telas diferentes — que é a única pergunta que um mapa de calor responde.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Analytics.MapaDeCalor
      x-asender-fonte: codigo
  /v1/analytics/live:
    get:
      tags:
      - runtime
      summary: GET /v1/analytics/live — sessões e eventos da janela curta.
      description: 'GET /v1/analytics/live — sessões e eventos da janela curta.


        Onde é usada: cabeçalho da tela ao vivo, no primeiro carregamento.


        Efeitos: uma leitura. Existe além do stream porque quem abre a tela precisa ver ALGO antes do
        primeiro evento chegar. Sem isso, uma conta com movimento baixo mostraria tela vazia por minutos
        e pareceria quebrada.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.AoVivo.Agora
      x-asender-fonte: codigo
  /v1/analytics/live/stream:
    get:
      tags:
      - runtime
      summary: GET /v1/analytics/live/stream — Server-Sent Events.
      description: 'GET /v1/analytics/live/stream — Server-Sent Events.


        Onde é usada: tela ao vivo.


        Efeitos: mantém uma conexão aberta e uma inscrição no hub. # SSE, e não WebSocket O fluxo é de
        mão única (servidor → tela) e o cliente é um navegador. SSE atravessa proxy comum, reconecta sozinho
        e cabe em quatro linhas de JS; WebSocket traria um protocolo inteiro para carregar dado que só
        desce.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.AoVivo.Stream
      x-asender-fonte: codigo
  /v1/replay/sessions:
    get:
      tags:
      - runtime
      summary: GET /v1/replay/sessions?limite=N.
      description: 'GET /v1/replay/sessions?limite=N.


        Onde é usada: tela de replay.


        Efeitos: uma leitura.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Replay.Sessoes
      x-asender-fonte: codigo
  /v1/replay/sessions/{id}/events:
    get:
      tags:
      - runtime
      summary: GET /v1/replay/sessions/{id}/events.
      description: 'GET /v1/replay/sessions/{id}/events.


        Onde é usada: player da tela de replay.


        Efeitos: uma leitura. Sessão de outra conta responde lista VAZIA, e não 404: o id é opaco e gerado
        no browser, e distinguir "não existe" de "não é sua" transformaria a rota num oráculo de existência
        de sessão alheia.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Replay.EventosDaSessao
      x-asender-fonte: codigo
  /v1/pixels:
    get:
      tags:
      - runtime
      summary: GET /v1/pixels.
      description: 'GET /v1/pixels.


        Onde é usada: tela de pixels.


        Efeitos: uma leitura.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.Listar
      x-asender-fonte: codigo
    post:
      tags:
      - runtime
      summary: POST /v1/pixels.
      description: 'POST /v1/pixels.


        Onde é usada: tela de pixels.


        Efeitos: uma escrita.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.Criar
      x-asender-fonte: codigo
  /v1/pixels/{id}:
    get:
      tags:
      - runtime
      summary: GET /v1/pixels/{id}.
      description: 'GET /v1/pixels/{id}.


        Onde é usada: tela de detalhe do pixel.


        Efeitos: uma leitura.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.Obter
      x-asender-fonte: codigo
    patch:
      tags:
      - runtime
      summary: PATCH /v1/pixels/{id}.
      description: 'PATCH /v1/pixels/{id}.


        Onde é usada: tela de detalhe.


        Efeitos: uma escrita. PATCH parcial de verdade: campo ausente não é tocado. Sem isso, editar só
        o nome DESATIVARIA o pixel — e a coleta pararia sem ninguém ter pedido.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.Editar
      x-asender-fonte: codigo
    delete:
      tags:
      - runtime
      summary: DELETE /v1/pixels/{id}.
      description: 'DELETE /v1/pixels/{id}.


        Onde é usada: tela de detalhe.


        Efeitos: uma escrita; a coleta daquele pixel para.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.Apagar
      x-asender-fonte: codigo
  /v1/pixels/{id}/domains:
    get:
      tags:
      - runtime
      summary: GET /v1/pixels/{id}/domains.
      description: 'GET /v1/pixels/{id}/domains.


        Onde é usada: tela de detalhe.


        Efeitos: uma leitura.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.ListarDominios
      x-asender-fonte: codigo
    post:
      tags:
      - runtime
      summary: POST /v1/pixels/{id}/domains.
      description: 'POST /v1/pixels/{id}/domains.


        Onde é usada: tela de detalhe.


        Efeitos: uma escrita — e, a partir dela, o `/collect` ecoa CORS para aquele host.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.AcrescentarDominio
      x-asender-fonte: codigo
  /v1/pixels/{id}/domains/{domainId}:
    patch:
      tags:
      - runtime
      summary: PATCH /v1/pixels/{id}/domains/{domainId}.
      description: 'PATCH /v1/pixels/{id}/domains/{domainId}.


        Onde é usada: tela de detalhe.


        Efeitos: uma escrita.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.TrocarDominio
      x-asender-fonte: codigo
    delete:
      tags:
      - runtime
      summary: DELETE /v1/pixels/{id}/domains/{domainId}.
      description: 'DELETE /v1/pixels/{id}/domains/{domainId}.


        Onde é usada: tela de detalhe.


        Efeitos: uma escrita — a origem para de coletar na hora.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.RemoverDominio
      x-asender-fonte: codigo
  /v1/pixels/{id}/conversions:
    get:
      tags:
      - runtime
      summary: GET /v1/pixels/{id}/conversions.
      description: 'GET /v1/pixels/{id}/conversions.


        Onde é usada: tela de detalhe.


        Efeitos: uma leitura.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.ListarConversoes
      x-asender-fonte: codigo
    post:
      tags:
      - runtime
      summary: POST /v1/pixels/{id}/conversions.
      description: 'POST /v1/pixels/{id}/conversions.


        Onde é usada: tela de detalhe.


        Efeitos: uma escrita.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.CriarConversao
      x-asender-fonte: codigo
  /v1/pixels/{id}/conversions/{cid}:
    patch:
      tags:
      - runtime
      summary: PATCH /v1/pixels/{id}/conversions/{cid}.
      description: 'PATCH /v1/pixels/{id}/conversions/{cid}.


        Onde é usada: tela de detalhe.


        Efeitos: uma escrita.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.EditarConversao
      x-asender-fonte: codigo
    delete:
      tags:
      - runtime
      summary: DELETE /v1/pixels/{id}/conversions/{cid}.
      description: 'DELETE /v1/pixels/{id}/conversions/{cid}.


        Onde é usada: tela de detalhe.


        Efeitos: uma escrita.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pixels.RemoverConversao
      x-asender-fonte: codigo
  /pixel.js:
    get:
      tags:
      - runtime
      summary: GET /pixel.js.
      description: 'GET /pixel.js.


        Onde é usada: toda página publicada, uma vez por visita (depois é cache).


        Efeitos: escreve o script. Cache de 1 hora com ETag: o script muda com o deploy, e revalidar de
        hora em hora custa um 304. `immutable` seria errado — o conteúdo muda no mesmo caminho.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Coleta.Script
      x-asender-fonte: codigo
  /collect:
    post:
      tags:
      - runtime
      summary: POST /collect — abre a credencial, normaliza cada evento e publica.
      description: 'POST /collect — abre a credencial, normaliza cada evento e publica.


        Onde é usada: rota pública, a de maior volume do sistema depois da própria página.


        Efeitos: publica no broker; NUNCA escreve no banco. # Responde 204 quase sempre, e isso é deliberado
        Credencial inválida é 403 (o cliente precisa saber que aquele HTML está velho). Fora isso — evento
        inválido, tipo desconhecido, lote parcialmente ruim — a resposta é 204 e o que dava para aproveitar
        foi publicado. O pixel roda no browser de terceiros: transformar um evento malformado em erro
        visível não conserta nada e enche o console de quem só quer ver a página.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Coleta.Coletar
      x-asender-fonte: codigo
  /replay:
    post:
      tags:
      - runtime
      summary: POST /replay — abre a credencial, saneia e publica.
      description: 'POST /replay — abre a credencial, saneia e publica.


        Onde é usada: rota pública, chamada pelo pixel a cada poucos segundos de uma sessão gravada.


        Efeitos: publica no broker; NUNCA escreve no banco. Responde 204 quase sempre, como o `/collect`:
        credencial inválida é 403 (o HTML está velho), e o resto é aproveitado. O pixel roda no browser
        de terceiros, e transformar um lote parcialmente ruim em erro visível não conserta nada.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Replay.Gravar
      x-asender-fonte: codigo
  /r/{slug}:
    get:
      tags:
      - runtime
      summary: resolve host+caminho na página publicada e devolve o HTML renderizado.
      description: 'resolve host+caminho na página publicada e devolve o HTML renderizado.


        Onde é usada: rota curinga do runtime, a de maior tráfego do sistema.


        Efeitos: consulta a fonte; escreve a resposta. 404 quando não há página: o host pode apontar para
        nós sem ter página naquele caminho, e devolver erro de servidor nesse caso confundiria monitoramento
        com tráfego normal de quem digitou a URL errada.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Redirect.Servir
      x-asender-fonte: codigo
  /robots.txt:
    get:
      tags:
      - runtime
      summary: libera a indexação e aponta o sitemap do MESMO host.
      description: 'libera a indexação e aponta o sitemap do MESMO host.


        Onde é usada: rota pública.


        Efeitos: nenhum. # Por que liberar, e não bloquear A página existe para ser achada: é landing
        page de campanha, e quem a publica quer tráfego. Um `Disallow: /` como padrão transformaria uma
        decisão de produto ("esta página não deve ser indexada") em comportamento da plataforma — e o
        cliente descobriria meses depois, sem entender por que o anúncio orgânico nunca apareceu. O sitemap
        é apontado com o HOST da requisição, e não com um domínio configurado: cada cliente tem o dele,
        e um endereço fixo aqui mandaria o buscador de todo mundo para o sitemap de um só.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.SEO.Robots
      x-asender-fonte: codigo
  /sitemap.xml:
    get:
      tags:
      - runtime
      summary: lista as páginas PUBLICADAS daquele host.
      description: 'lista as páginas PUBLICADAS daquele host.


        Onde é usada: rota pública.


        Efeitos: uma leitura no `asender_pages`. # Host sem página responde XML VAZIO, e não 404 Um 404
        no sitemap faz o buscador registrar erro e tentar de novo; um documento vazio diz "não há nada
        para indexar aqui", que é a verdade. E o host que ainda não tem página publicada é o caso mais
        comum logo depois de alguém cadastrar um domínio. # Rascunho não entra A consulta do `asender_pages`
        filtra por versão publicada. Listar rascunho entregaria ao buscador o endereço de uma página que
        o cliente ainda não quis mostrar — e o buscador não esquece.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.SEO.Sitemap
      x-asender-fonte: codigo
  /{slug}:
    get:
      tags:
      - runtime
      summary: resolve host+caminho na página publicada e devolve o HTML renderizado.
      description: 'resolve host+caminho na página publicada e devolve o HTML renderizado.


        Onde é usada: rota curinga do runtime, a de maior tráfego do sistema.


        Efeitos: consulta a fonte; escreve a resposta. 404 quando não há página: o host pode apontar para
        nós sem ter página naquele caminho, e devolver erro de servidor nesse caso confundiria monitoramento
        com tráfego normal de quem digitou a URL errada.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pagina.Servir
      x-asender-fonte: codigo
    post:
      tags:
      - runtime
      summary: resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina` e
        chama o MESMO pipeline dos outros dois modos.
      description: 'resolve a página pela mesma rota que a serviu, monta a submissão com `Modo = pagina`
        e chama o MESMO pipeline dos outros dois modos.


        Onde é usada: POST no caminho da própria página.


        Efeitos: os do pipeline (banco, outbox). O tenant e o form vêm da PÁGINA resolvida no servidor,
        nunca do corpo: o form da página é HTML público, e qualquer um pode reenviá-lo com outro `form_id`.
        Resolver pelo host+caminho é o que impede postar no formulário de outro tenant.'
      x-asender-servico: asender_runtime
      x-asender-handler: d.Pagina.Submeter
      x-asender-fonte: codigo
components:
  schemas:
    api_ErrorEnvelope:
      type: object
      required:
      - Error
      - RequestId
      properties:
        Error:
          type: object
          required:
          - Type
          - Code
          - Message
          properties:
            Type:
              type: string
              enum:
              - Sender
              - Receiver
              description: 'Derivado do status: 4xx→Sender, 5xx→Receiver. O SDK decide retry por aqui.'
            Code:
              type: string
              examples:
              - ValidationError
              - AuthorizationError
              - NotFound
              - TenantSuspended
              - NoTenant
              - Conflict
              - PayloadTooLarge
              - Throttling
              - InternalError
            Message:
              type: string
            Details:
              description: 'Presente só na validação SES do `/v1/*`: mapa campo → motivo. Omitido quando
                vazio.'
              type: object
              additionalProperties:
                type: string
        RequestId:
          type: string
    api_RequestIdEnvelope:
      type: object
      required:
      - RequestId
      properties:
        RequestId:
          type: string
          description: 'Presente em TODA resposta de sucesso. Espelha o header

            `X-Request-Id`. **Divergência:** um `X-Request-Id` mandado pelo

            cliente é ecoado cru e vira o correlation id do log

            (RELATORIO-FINAL §3 item 15).

            '
    api_CursorPage:
      type: object
      properties:
        NextCursor:
          type: integer
          description: Cursor da próxima página; 0 quando acabou.
        HasMore:
          type: boolean
    api_Channel:
      type: string
      enum:
      - email
      - sms
      - push
    api_MessageStatus:
      type: string
      enum:
      - queued
      - sending
      - sent
      - delivered
      - bounced
      - failed
      - opened
      - clicked
    api_Message:
      type: object
      description: 'Projeção de listagem: SEM corpo (payload grande em lista é desperdício).'
      properties:
        Id:
          type: string
        TenantId:
          type: string
        Channel:
          $ref: '#/components/schemas/Channel'
        Status:
          $ref: '#/components/schemas/MessageStatus'
        From:
          type: string
        To:
          type: string
        Subject:
          type: string
        Provider:
          type:
          - string
          - 'null'
        ProviderMessageId:
          type:
          - string
          - 'null'
        ErrorCode:
          type:
          - string
          - 'null'
        ErrorMessage:
          type:
          - string
          - 'null'
        QueuedAt:
          type:
          - string
          - 'null'
          format: date-time
        SentAt:
          type:
          - string
          - 'null'
          format: date-time
        DeliveredAt:
          type:
          - string
          - 'null'
          format: date-time
        FailedAt:
          type:
          - string
          - 'null'
          format: date-time
        OpenedAt:
          type:
          - string
          - 'null'
          format: date-time
        ClickedAt:
          type:
          - string
          - 'null'
          format: date-time
        CreatedAt:
          type: string
          format: date-time
    api_MessagePage:
      allOf:
      - $ref: '#/components/schemas/RequestIdEnvelope'
      - $ref: '#/components/schemas/CursorPage'
      - type: object
        properties:
          Messages:
            type: array
            items:
              $ref: '#/components/schemas/Message'
    api_MessageDetail:
      allOf:
      - $ref: '#/components/schemas/RequestIdEnvelope'
      - type: object
        properties:
          Message:
            allOf:
            - $ref: '#/components/schemas/Message'
            - type: object
              properties:
                BodyText:
                  type: string
                BodyHtml:
                  type: string
                Metadata:
                  type: object
                  additionalProperties: true
                  description: VALOR OPACO — chaves internas passam intactas (não são pascalizadas).
          Events:
            type: array
            items:
              type: object
              properties:
                Type:
                  type: string
                OccurredAt:
                  type: string
                  format: date-time
                Payload:
                  type: object
                  additionalProperties: true
                  description: Valor opaco.
    api_SendEmailRequest:
      type: object
      required:
      - Source
      - Destination
      - Message
      properties:
        Source:
          type: string
          format: email
        Destination:
          type: object
          properties:
            ToAddresses:
              type: array
              items:
                type: string
                format: email
            CcAddresses:
              type: array
              items:
                type: string
                format: email
            BccAddresses:
              type: array
              items:
                type: string
                format: email
          description: Ao menos um endereço somando To+Cc+Bcc.
        Message:
          type: object
          required:
          - Subject
          - Body
          properties:
            Subject:
              type: object
              properties:
                Data:
                  type: string
                  minLength: 1
                Charset:
                  type: string
            Body:
              type: object
              description: Ao menos um de Text.Data ou Html.Data.
              properties:
                Text:
                  type: object
                  properties:
                    Data:
                      type: string
                    Charset:
                      type: string
                Html:
                  type: object
                  properties:
                    Data:
                      type: string
                    Charset:
                      type: string
        ReplyToAddresses:
          type: array
          items:
            type: string
            format: email
        ReturnPath:
          type: string
          format: email
        IdempotencyKey:
          type: string
          description: É ESTE campo que funciona, não o header.
        Tags:
          type: object
          additionalProperties:
            type: string
    api_BatchResult:
      type: object
      properties:
        Index:
          type: integer
        Status:
          type: string
          enum:
          - queued
          - rejected
          - failed
        MessageId:
          type: string
        MessageIds:
          type: array
          items:
            type: string
        Error:
          type: object
          properties:
            Code:
              type: string
            Message:
              type: string
            Details:
              type: object
              additionalProperties:
                type: string
    api_Template:
      type: object
      properties:
        Id:
          type: string
        TenantId:
          type: string
        Slug:
          type: string
        Name:
          type: string
        Channel:
          $ref: '#/components/schemas/Channel'
        Subject:
          type: string
        BodyHtml:
          type: string
        BodyText:
          type: string
        Variables:
          type: array
          items:
            type: string
          description: Valor opaco — não pascalizado.
        CreatedAt:
          type: string
          format: date-time
        UpdatedAt:
          type: string
          format: date-time
    api_ContactInput:
      type: object
      additionalProperties: false
      description: Ao menos um de Email ou Phone.
      properties:
        Email:
          type: string
        Phone:
          type: string
        Name:
          type: string
        Attributes:
          type: object
          additionalProperties: true
          description: Valor opaco.
        Subscribed:
          type: boolean
          default: true
    api_Contact:
      type: object
      properties:
        Id:
          type: string
        TenantId:
          type: string
        Email:
          type: string
        Phone:
          type: string
        Name:
          type: string
        Attributes:
          type: object
          additionalProperties: true
        Subscribed:
          type: boolean
        UnsubscribedAt:
          type:
          - string
          - 'null'
          format: date-time
        CreatedAt:
          type: string
          format: date-time
        UpdatedAt:
          type: string
          format: date-time
    api_ContactList:
      type: object
      properties:
        Id:
          type: string
        TenantId:
          type: string
        Name:
          type: string
        Slug:
          type: string
        Description:
          type: string
        MemberCount:
          type: integer
        CreatedAt:
          type: string
          format: date-time
        UpdatedAt:
          type: string
          format: date-time
    api_Device:
      type: object
      properties:
        Id:
          type: string
        TenantId:
          type: string
        ContactId:
          type: string
        Token:
          type: string
        Platform:
          type: string
          enum:
          - ios
          - android
          - web
        Active:
          type: boolean
        LastSeenAt:
          type:
          - string
          - 'null'
          format: date-time
        CreatedAt:
          type: string
          format: date-time
        UpdatedAt:
          type: string
          format: date-time
    api_Tenant:
      type: object
      description: Projeção do asender-core (`tenantPayload`), pascalizada.
      properties:
        Id:
          type: string
        Name:
          type: string
        Slug:
          type: string
        Status:
          type: string
          enum:
          - active
          - suspended
        TrialEndsAt:
          type:
          - string
          - 'null'
          format: date-time
        Metadata:
          type: object
          additionalProperties: true
          description: Valor opaco.
        CreatedAt:
          type: string
          format: date-time
        UpdatedAt:
          type: string
          format: date-time
    api_TenantNode:
      type: object
      description: Nó de árvore (`tenantNodePayload` do core), pascalizado.
      properties:
        Id:
          type: string
        Name:
          type: string
        Slug:
          type: string
        Status:
          type: string
        Kind:
          type: string
          enum:
          - root
          - leaf
        Depth:
          type: integer
          description: Profundidade absoluta na árvore.
        RelativeDepth:
          type: integer
          description: Profundidade relativa ao nó consultado — use para indentar.
        ParentId:
          type:
          - string
          - 'null'
        MonthlyQuota:
          type:
          - integer
          - 'null'
          description: '`null` = herda.'
        EffectiveQuota:
          type:
          - integer
          - 'null'
          description: Valor que realmente vale; `null` = sem limite em nenhum ancestral.
        ChildrenCount:
          type: integer
        CreatedAt:
          type: string
          format: date-time
        UpdatedAt:
          type: string
          format: date-time
    api_Usage:
      type: object
      properties:
        Period:
          type: string
          examples:
          - 2026-07
        EmailsSent:
          type: integer
        SmsSent:
          type: integer
        PushSent:
          type: integer
        ApiCalls:
          type: integer
        UpdatedAt:
          type: string
          format: date-time
    api_ReportCounters:
      type: object
      description: '`Total` é o volume do período; `Queued` é BACKLOG (o que ainda não saiu).'
      properties:
        Total:
          type: integer
        Queued:
          type: integer
        Sent:
          type: integer
        Delivered:
          type: integer
        Bounced:
          type: integer
        Failed:
          type: integer
        Opened:
          type: integer
        Clicked:
          type: integer
    api_ReportRates:
      type: object
      description: '`Delivery = Delivered/Sent`, `Bounce = Bounced/Sent`,

        `Open = Opened/Delivered`, `Click = Clicked/Delivered`.

        Denominador zero devolve `0` (nunca `null`, nunca `NaN`).

        '
      properties:
        Delivery:
          type: number
          minimum: 0
          maximum: 1
        Bounce:
          type: number
          minimum: 0
          maximum: 1
        Open:
          type: number
          minimum: 0
          maximum: 1
        Click:
          type: number
          minimum: 0
          maximum: 1
    api_ReportPeriod:
      type: object
      description: Janela efetivamente usada, já com o default aplicado. `To` é EXCLUSIVO.
      properties:
        From:
          type: string
          format: date-time
        To:
          type: string
          format: date-time
    auth_ErrorEnvelope:
      type: object
      required:
      - Error
      - RequestId
      properties:
        Error:
          type: object
          required:
          - Type
          - Code
          - Message
          properties:
            Type:
              type: string
              enum:
              - Sender
              - Receiver
            Code:
              type: string
              examples:
              - InvalidRequest
              - InvalidInput
              - MissingToken
              - InvalidCredentials
              - InvalidToken
              - ExpiredToken
              - TwoFactorFailed
              - TwoFactorMissing
              - NotFound
              - Conflict
              - NotReady
              - InternalError
            Message:
              type: string
            Details: {}
        RequestId:
          type: string
    auth_User:
      type: object
      description: Nunca traz `password_hash` nem o segredo TOTP.
      properties:
        id:
          type: string
          examples:
          - usr_demo_owner
        email:
          type: string
          format: email
        name:
          type: string
        created_at:
          type: string
          format: date-time
    auth_LoginResult:
      type: object
      properties:
        token:
          type: string
          description: JWT de sessão. VAZIO quando `two_factor_required`.
        expires_at:
          type: string
          format: date-time
        user_id:
          type: string
        two_factor_required:
          type: boolean
    auth_UserIdBody:
      type: object
      additionalProperties: false
      required:
      - user_id
      properties:
        user_id:
          type: string
    auth_TwoFACodeBody:
      type: object
      additionalProperties: false
      required:
      - user_id
      - code
      properties:
        user_id:
          type: string
        code:
          type: string
          description: TOTP de 6 dígitos.
  securitySchemes:
    ApiKeyBearer:
      type: http
      scheme: bearer
      description: '`Authorization: Bearer sk_live_...`. Prefixos aceitos: `sk_live_`,

        `sk_test_`, `sk_master_`, `pk_live_`, mínimo 16 caracteres. Validada no

        `asender-core`; tenant não-`active` → 403 `TenantSuspended`.


        **Alternativas aceitas e DIVERGENTES do piso da casa:** o header

        `X-Api-Key` e o parâmetro de query `?api_key=`. Credencial em query

        string vaza para access log, Referer e histórico do browser — é dívida

        registrada, não recomendação.

        '
    SessionBearer:
      type: http
      scheme: bearer
      description: Token de sessão do usuário final, emitido por `POST /v1/auth/login`.
    SessionCookie:
      type: apiKey
      in: cookie
      name: asender_session
      description: Cookie HttpOnly posto pelo dashboard. Equivalente ao Bearer de sessão.
    ServiceToken:
      type: apiKey
      in: header
      name: X-Asender-Svc-Token
      description: '`SERVICE_SECRET` **cru** (não é JWT), comparado em tempo constante —

        mesmo contrato do `asender-core` e do `asender_messages`. Exigido em

        todo ambiente; só `SERVICE_AUTH_DISABLED=true` desliga.

        '
