OVEYONDocumentação da API base https://api.oveyon.com/v1 OpenAPI 3.1

API HTTP

Integre seu sistema ao OVEYON sem sair do painel. REST sobre HTTPS, JSON nos dois sentidos, autenticação por chave.

Início rápido Autenticação Enviar e-mail Templates Consultar Domínio descartável Receber e-mails Webhook de recepção Supressões Regras allow/block Políticas de envio Pesquisas (NPS/CSAT) Webhooks de envio Domínios Erros & limites

Endereço base

A API roda em um host dedicado, separado do painel por decisão de segurança (origin separation). Toda chamada usa esta base:

https://api.oveyon.com/v1

Início rápido

Do zero ao primeiro envio em três passos.

export OVEYON_API_KEY="ov_suachaveaqui"

curl -X POST https://api.oveyon.com/v1/send \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Vendas <vendas@suaempresa.com>",
    "to": "cliente@exemplo.com",
    "subject": "Bem-vindo",
    "html": "<h1>Olá!</h1><p>Sua conta está pronta.</p>"
  }'

Resposta esperada: 202 com { "id": "<uuid>", "status": "accepted" }. Esse id é o que você usa para consultar a mensagem depois.

Autenticação

Toda requisição carrega a chave no header Authorization, esquema Bearer.

curl https://api.oveyon.com/v1/domains \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Permissões (escopos)

A chave só acessa o que os escopos dela liberam. Faltou escopo para a rota chamada → 403 insufficient_scope (a resposta diz qual escopo faltou):

HTTP/1.1 403 Forbidden
{ "error": "insufficient_scope", "required": "read:stats", "have": ["send"] }
EscopoLiberaEndpoints
send Enviar e-mails POST /send
read:messages Ler mensagens e timeline GET /messages · GET /messages/:uuid
read:stats Ler estatísticas GET /stats
read:suppressions Ler supressões GET /suppressions
write:suppressions Criar e remover supressões POST /suppressions · DELETE /suppressions/:email
read:domains Ler domínios GET /domains
write:domains Adicionar e verificar domínios POST /domains · POST /domains/:id/verify
manage:webhooks Gerenciar webhooks GET /webhooks · POST /webhooks · PATCH /webhooks/:id · POST /webhooks/:id · DELETE /webhooks/:id
read:inbound Ler e-mails recebidos (inbound) GET /inbound · GET /inbound/:uid · GET /inbound/:uid/content · GET /inbound/:uid/raw · GET /inbound/:uid/attachments/:n
read:policies Ler listas de política e regras de IP GET /policies · GET /credentials/ip-rules
write:policies Criar e remover listas de política e regras de IP POST /policies · DELETE /policies/:id · POST /credentials/ip-rules · DELETE /credentials/ip-rules/:id · POST /credentials/ip-rules/pause · POST /credentials/ip-rules/unpause
read:templates Ler templates de e-mail GET /templates · GET /templates/:ref
write:templates Criar, editar, publicar e remover templates de e-mail POST /templates · PUT /templates/:ref · POST /templates/:ref/publish · DELETE /templates/:ref
read:send-policies Ler políticas de envio GET /send-policies · GET /send-policies/decisions · GET /send-policies/:id
write:send-policies Criar, editar, ordenar e remover políticas de envio POST /send-policies · PUT /send-policies/:id · POST /send-policies/:id/pause · POST /send-policies/:id/unpause · POST /send-policies/reorder · DELETE /send-policies/:id
read:surveys Ler pesquisas e respostas GET /surveys · GET /surveys/:id · GET /surveys/:id/responses
write:surveys Criar, editar e remover pesquisas POST /surveys · PUT /surveys/:id · DELETE /surveys/:id
send:surveys Disparar pesquisas por e-mail POST /surveys/:id/send

Fora da tabela, e de propósito: /disposable · /openapi.json · /ping não exige autenticação — nem chave, nem escopo, nem cota. Não procure uma caixinha para ela em Credenciais: não há o que conceder. É a única rota da API nessa condição.

Papel no painel não é permissão de chave

Esta é a confusão que aparece assim que a conta passa a ter mais de uma pessoa, e ela erra sempre para o mesmo lado — o de achar que a chave é mais fraca do que ela é. Então, com todas as letras: o painel e a chave são dois sistemas de permissão que não se tocam. Nenhum consulta o outro.

 No painelNa API
Quem age Uma pessoa, com login e sessão. Uma chave, e ela é portadora: quem tem o segredo é quem pode.
O que decide O papel dela, em três escadas independentes. Os escopos da chave — a tabela acima, e nada além dela.
As escadas conta: viewer < admin < owner
organização: member < admin < owner
pessoal (sobre si mesmo): sessao < pessoa
Não há escada. Um escopo você tem ou não tem.
Alcance Uma pessoa alcança N contas, com papel diferente em cada uma, e troca de conta na tela. Uma chave é de uma conta e não troca. Toda leitura segue cercada por ela.

O que decorre daí, e é o que você leva para o seu código:

O papel decide uma coisa nesta história, e é do lado do painel: quem consegue apertar o botão. Cunhar segredo novo — criar chave de API, criar credencial SMTP e rotacionar qualquer uma das duas — é gesto de owner da conta. Matar e ajustar ficam com o admin: revogar, desativar, e também editar nome, escopos e limite de uma chave que já existe — porque um controle de acesso que proíbe reduzir exposição está pior que desligado. E owner é a titularidade da conta, não um papel que se concede: se você é admin e o botão de criar chave não está aí, não é falha da tela. Nada disso muda o que a chave faz depois de emitida — que é o assunto do resto desta página.

Enviar e-mail

O envio passa pelo mesmo pipeline do SMTP: idempotência, backpressure, domínio do remetente e do destinatário, supressão, cota e rampa — nessa ordem. O 202 só sai quando a mensagem já está no spool.

POST/v1/sendescopo send

Enfileira uma mensagem. Até 5 destinatários por chamada, somando to + cc + bcc — para mais que isso, faça mais de uma chamada.

Corpo (JSON)

  • from (obrigatório)"a@b.com" ou "Nome <a@b.com>". O domínio precisa estar verificado nesta conta.
  • to (obrigatório) — string ou lista de endereços. Vai no cabeçalho To: e no envelope.
  • cc — string ou lista. Vai no cabeçalho Cc: e no envelope: todos os destinatários veem quem está em cópia.
  • bcc — string ou lista. Vai só no envelope: nunca aparece em cabeçalho nenhum, e nenhum destinatário vê quem está em cópia oculta.
  • subject — assunto. Até 500 caracteres: é o mesmo limite que o histórico da mensagem e o rastro de políticas guardam, então um assunto maior seria cortado depois sem você saber. Acima disso a chamada é recusada com 400 bad_request, dizendo o limite e o que veio. Vale também para o assunto vindo de template.
  • html e/ou textao menos um é obrigatório.
  • templateId — id numérico ou o name de um template publicado. Mutuamente exclusivo com subject/html/text: com template, o conteúdo inteiro (assunto incluso) vem do molde — mandar os dois é 400 template_conflict.
  • data — objeto {variável: valor} para o render do template. Só faz sentido junto de templateId (sozinho é ignorado).
  • version — inteiro ≥ 1: fixa uma versão publicada específica do template. Sem ele, vale a corrente.
  • headers — objeto de headers extras (ex.: X-Campaign). Headers controlados por nós — List-Unsubscribe, X-Report-Abuse e qualquer um com prefixo X-Oveyon- — são ignorados se enviados.
  • idempotencyKey — string sua para deduplicar; alternativa ao header Idempotency-Key. A mesma chave nunca gera duas mensagens.
  • attachments — lista de { filename, content (base64), contentType? }. Máx. 20 anexos, somando ≤ 15 MB (base64 já decodificado).
  • sandboxtrue (ou header X-Oveyon-Sandbox: 1) aceita e congela sem entregar.

Destinatários, cota e cobrança

  • Cada destinatário é uma unidade. Um envio com to + 2 cc + 1 bcc consome 4 da sua cota diária e mensal, não 1 — é o que de fato é entregue.
  • Teto de 5 endereços por chamada, contando o que você mandou nos três campos. Acima disso: 400 too_many_recipients, com o limite e quantos vieram na resposta.
  • Endereço repetido entrega uma vez só. O mesmo endereço em to e bcc vira uma entrega — e é cobrado uma vez. Vale a primeira ocorrência (to antes de cc antes de bcc); os cabeçalhos continuam como você escreveu.
  • Tudo ou nada. Se um endereço for inválido, estiver suprimido, ou a cota não cobrir todos, a chamada inteira é recusada e nenhum e-mail sai. Não existe entrega parcial: a resposta traz um id só e não teria como dizer «foi para 3 dos 5».
  • O id é da mensagem. O desfecho (entregue, quicou) é por destinatário e aparece em deliveries[] no GET /v1/messages/:id.

Exemplo

curl -X POST https://api.oveyon.com/v1/send \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: pedido-8842" \
  -d '{
    "from": "Suporte <suporte@suaempresa.com>",
    "to": "cliente@exemplo.com",
    "cc": ["financeiro@exemplo.com"],
    "bcc": ["arquivo@suaempresa.com"],
    "subject": "Seu recibo",
    "html": "<p>Obrigado pela compra.</p>",
    "text": "Obrigado pela compra.",
    "headers": { "X-Campaign": "recibos" },
    "attachments": [
      { "filename": "recibo.pdf", "content": "JVBERi0xLjQK...", "contentType": "application/pdf" }
    ]
  }'

Sucesso

HTTP/1.1 202 Accepted
{ "id": "3f8a1c2e-9b4d-4e10-8a77-2b0c9d5e1f34", "status": "accepted", "recipients": 3 }

// `recipients` só aparece quando há mais de um destinatário, e é o número
// de endereços DISTINTOS que entraram no envelope — o mesmo que foi cobrado.
  • 202 · status: "accepted" — envio real, já no spool.
  • 202 · status: "sandbox" — aceito, congelado, não entregue (modo sandbox).
  • 200 · status: "duplicate" — a idempotencyKey já tinha sido usada; devolve o id original.

Erros

  • 400 bad_requestfrom/to ausentes ou inválidos, sem html nem text; too_many_attachments / bad_attachment.
  • 400 too_many_recipients — mais de 5 endereços somando to+cc+bcc. Traz limit e received.
  • 400 bad_recipient — um dos endereços não é válido. Traz field (to/cc/bcc) e cita o endereço na mensagem. Endereço inválido nunca é descartado em silêncio.
  • 401 unauthorized · 403 insufficient_scope, key_disabled (chave desligada no painel — religue-a ou use outra), sending_disabled, account_suspended ou account_unknown — os dois últimos são estado da conta, não limite: não retente.
  • 413 attachments_too_large — anexos acima de 15 MB.
  • 500 no POST /sendnão prova que a mensagem não entrou: existe uma janela estreita em que ela já está aceita quando o erro sai. Por isso, só retente um envio com idempotencyKey (a chave deduplica com segurança); sem a chave, retentar pode duplicar a mensagem — confirme antes pelo GET /v1/messages.
  • 422 domain_not_verified · invalid_recipient_domain · recipient_suppressed — os dois últimos trazem recipient dizendo qual endereço causou.
  • 400 template_conflicttemplateId junto de subject/html/text · 400 template_var_missing — variável exigida pelo template ausente do data (traz variable) · 422 template_not_found e os demais erros de template. Nenhum deles consome cota nem queima a idempotencyKey.
  • 422 unsub_footer_multi_recipient — esta chave envia com footer/List-Unsubscribe automático, e o link de descadastro é individual. Com vários destinatários ele cancelaria a inscrição de quem não pediu, então o envio é recusado: mande uma chamada por destinatário. O mesmo código sai quando uma política de envio exige o rodapé numa chamada com vários destinatários — nesse caso a resposta também traz policy.
  • 422 policy_refused — uma política de envio sua recusou a mensagem. A resposta traz policy (o nome da política que decidiu) e recipient (o destinatário que casou com ela). Não consome cota: as políticas são avaliadas antes do contador. Ajuste ou pause a política para voltar a enviar.
  • 429 quota_exceeded · service_quota_exceeded · warmup_cap_reached · rate_limited · queue_full.

Sandbox (teste sem entregar)

curl -X POST https://api.oveyon.com/v1/send \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "X-Oveyon-Sandbox: 1" \
  -H "Content-Type: application/json" \
  -d '{ "from": "a@suaempresa.com", "to": "cliente@exemplo.com",
        "subject": "Teste", "text": "Nada será entregue." }'

O sandbox conta cota/rate (a mensagem foi aceita), mas nada sai para o destino.

Templates

Molde de e-mail com variáveis, guardado uma vez e disparado com só os dados: {"templateId": …, "data": {…}} no POST /v1/send, em vez de 40KB de HTML repetido a cada chamada. O visual muda num lugar só, sem redeploy do seu sistema.

O ciclo: rascunho → publicada (imutável)

Sintaxe — o catálogo é fechado, e é contrato

BlocoO que faz
{{ var }}Substitui pelo valor. No corpo html, o valor sai escapado (um nome com <script> vira texto, nunca código). Em text e no subject, sai como veio. Caminho pontilhado funciona: {{ user.email }}.
{{ var | "padrão" }}Variável opcional: ausente (ou null) usa o literal entre aspas.
{{{ var }}}Substitui sem escape — você declara que o valor é HTML seu e assume o risco.
{{#if var}} … {{else}} … {{/if}}Seção condicional. Ausente, null, false, "", 0 e lista vazia contam como falso.
{{#each lista}} … {{/each}}Repete o miolo por item. {{this}} é o item; num item-objeto, {{campo}} resolve nele (e sobe para o contexto de fora se não achar). Lista ausente rende vazio.
GET/v1/templatesescopo read:templates

Lista os templates da conta: id, name, currentVersion (a publicada; null = nunca publicado), latestVersion e drafts.

GET/v1/templates/:refescopo read:templates

:ref é o id numérico ou o name — em todas as rotas desta seção. Devolve o template com todas as versões (fonte, status, variáveis declaradas).

POST/v1/templatesescopo write:templates

Cria o template como rascunho v1. Corpo: name (único na conta; começa com letra — letras, números, . - _, até 120), subject (obrigatório, também é template), e html e/ou text. A sintaxe é validada aqui: erro de template nunca sobrevive até o envio.

curl -X POST https://api.oveyon.com/v1/templates \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "pedidoChegou",
    "subject": "{{nome}}, seu pedido chegou",
    "html": "<p>Olá {{nome}}, use o cupom {{ cupom | \"SEM10\" }}.</p>",
    "text": "Olá {{nome}}, use o cupom {{ cupom | \"SEM10\" }}."
  }'
PUT/v1/templates/:refescopo write:templates

Edita: com rascunho vivo, atualiza o rascunho; sem, cria a próxima versão como rascunho. A publicada nunca é tocada.

POST/v1/templates/:ref/publishescopo write:templates

Sem corpo (ou {}): publica o rascunho mais novo. Com {"version": N}: torna aquela versão a corrente — inclusive uma já publicada (rollback).

curl -X POST https://api.oveyon.com/v1/templates/pedidoChegou/publish \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" -d '{}'
DELETE/v1/templates/:refescopo write:templates

Remove o template e todas as versões. As mensagens já enviadas mantêm templateId/templateVersion como rastro histórico.

Exemplo ponta a ponta

Criar → publicar → disparar com só os dados. O corpo no fio sai com as variáveis substituídas e com o pipeline inteiro por cima (footer, tracking, DKIM — template não pula guarda nenhuma).

curl -X POST https://api.oveyon.com/v1/templates \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "pedidoChegou",
    "subject": "{{nome}}, seu pedido chegou",
    "html": "<p>Olá {{nome}}, use o cupom {{ cupom | \"SEM10\" }}.</p>",
    "text": "Olá {{nome}}, use o cupom {{ cupom | \"SEM10\" }}."
  }'
curl -X POST https://api.oveyon.com/v1/templates/pedidoChegou/publish \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" -d '{}'
curl -X POST https://api.oveyon.com/v1/send \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Loja <vendas@suaempresa.com>",
    "to": "cliente@exemplo.com",
    "templateId": "pedidoChegou",
    "data": { "nome": "Ana" }
  }'

Erros

Consultar

Listagem, estatísticas agregadas e a timeline de uma mensagem. Toda leitura é cercada pelo tenant da chave — você nunca vê dados de outra conta.

GET/v1/messagesescopo read:messages

Lista as mensagens, mais nova primeiro, com paginação por cursor (estável sob inserção concorrente).

Query

  • limit — 1 a 100 (default 25).
  • cursor — o next_cursor da página anterior.
  • status — o valor cru: accepted · queued · delivered · deferred · bounced · suppressed · failed · frozen.
  • outcome — o desfecho agrupado, os mesmos cinco recortes de triagem do painel. Existe porque as perguntas que se fazem de verdade não são de um status só: «o que foi devolvido» é bounced ou failed, e responder isso com status exige saber o agrupamento de cor e fazer duas chamadas.
    • devolvidasbounced, failed. O destino recusou, ou desistimos de entregar. Não confundir com a recusa de entrada no seu MX, que é outro recurso (GET /v1/inbound) e outra coisa: aqui é o que você mandou e voltou.
    • bloqueadassuppressed. Paramos antes de tentar (supressão ou regra sua).
    • filaaccepted, queued, deferred. Ainda vai sair sozinha.
    • heldfrozen. Retida, esperando decisão.
    • entreguesdelivered.
    Os cinco particionam os oito status: cada mensagem cai em exatamente um, e a soma dos cinco é o total. Valor fora da lista devolve 400 bad_outcome.
  • recipient — destinatário exato. Procura em todos os destinatários do envio (to, cc e bcc), não só no primeiro: um endereço que estava em cópia devolve a mensagem que foi para ele.
  • domain — o id numérico ou o nome do domínio (aceita de volta o que a resposta mostra). Nome que não é seu devolve lista vazia, nunca 403. credential — id numérico da chave.
  • from / to — intervalo ISO 8601 (YYYY-MM-DD ou timestamp).
  • event — filtra por um evento na timeline (ex.: opened).

Exemplo

curl "https://api.oveyon.com/v1/messages?status=delivered&limit=25" \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Resposta

{
  "data": [
    {
      "id": "3f8a1c2e-9b4d-4e10-8a77-2b0c9d5e1f34",
      "from": "vendas@suaempresa.com",
      "to": "cliente@exemplo.com",
      "subject": "Bem-vindo",
      "status": "delivered",
      "statusDetail": null,
      "route": "mailchannels",
      "acceptedAt": "2026-07-21T13:02:11.000Z",
      "deliveredAt": "2026-07-21T13:02:14.000Z"
    }
  ],
  "next_cursor": "eyJpZCI6MTg0Mn0"
}

O id público é sempre o uuid. next_cursor: null ⇒ última página.

GET/v1/statsescopo read:stats

Rollup diário pré-computado, com taxas calculadas no servidor. Default: últimos 30 dias.

Query

  • group_byday (default) · domain · credential.
  • from / to — intervalo ISO 8601.
  • breakdownprovider, reason e/ou credential, separados por vírgula. Acrescenta a chave breakdown à resposta. Valor fora dessa lista → 400 bad_breakdown.

Exemplo

curl "https://api.oveyon.com/v1/stats?group_by=day&from=2026-07-01&to=2026-07-21" \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Resposta

{
  "series": [
    {
      "day": "2026-07-21",
      "sent": 1200, "delivered": 1176, "bounced": 12,
      "opened": 640, "clicked": 210, "complained": 1,
      "rates": { "delivery": 98.0, "bounce": 1.0, "complaint": 0.08 }
    }
  ]
}

Facetas do período (breakdown)

Para onde você envia (provider), por que não chegou (reason) e quem mais enviou (credential) — os mesmos três blocos da tela de Analytics.

curl "https://api.oveyon.com/v1/stats?breakdown=provider,reason,credential&from=2026-07-01&to=2026-07-30" \
  -H "Authorization: Bearer $OVEYON_API_KEY"
{
  "series": [ ... ],
  "breakdown": {
    "provider": {
      "total": 4210,
      "items": [
        { "value": "Google",        "label": "Google",        "count": 2604, "pct": 61.8 },
        { "value": "Microsoft",     "label": "Microsoft",     "count": 812,  "pct": 19.3 },
        { "value": "empresa.com.br","label": "empresa.com.br","count": 519,  "pct": 12.3 },
        { "value": "Outros",        "label": "Outros",        "count": 275,  "pct": 6.5 }
      ]
    },
    "reason": {
      "total": 63,
      "items": [
        { "value": "invalid_recipient", "label": "Endereço ou domínio inexistente", "count": 41, "pct": 65.1 },
        { "value": "spam_content",      "label": "Filtro de spam ou conteúdo",      "count": 14, "pct": 22.2 },
        { "value": "mailbox_full",      "label": "Caixa do destinatário cheia",     "count": 8,  "pct": 12.7 }
      ]
    },
    "credential": {
      "total": 4210,
      "items": [
        { "value": "s:12", "label": "suporte@empresa.com.br", "count": 2180, "pct": 51.8 },
        { "value": "k:3",  "label": "producao",               "count": 1602, "pct": 38.1 },
        { "value": "s:9",  "label": "faturas@empresa.com.br", "count": 375,  "pct": 8.9 },
        { "value": "-",    "label": "Origem não identificada", "count": 53,  "pct": 1.3 }
      ]
    }
  }
}
  • A faceta é o total da janela, não uma série diária, e é sempre o agregado da conta — ela não acompanha group_by.
  • value é estável e é o que você deve usar para ramificar: em provider é a família (Google, Microsoft…) ou o próprio domínio quando não é um provedor conhecido; em reason é o código do motivo; em credential é s:<id> para credencial SMTP e k:<id> para chave de API. label é texto para humano e pode mudar — o nome de uma chave de API é editável, e é justamente por isso que ele não é o value.
  • breakdown=credential e group_by=credential não respondem à mesma pergunta. group_by devolve uma série diária e enxerga somente chaves de API; o breakdown devolve o total da janela e inclui também as credenciais SMTP. Se você envia por SMTP, é o breakdown que enxerga esse tráfego.
  • "value": "-" em credential é a mensagem sem origem registrada (histórico antigo e injeção interna). Ela aparece em vez de ser descartada para que a soma da faceta continue batendo com sent da série.
  • Outros em provider é a cauda dos destinos fora dos 50 maiores, somada sobre a janela inteira — nunca uma soma de recortes por dia.
  • reason conta entregas com recusa definitiva do destino. Mensagens retidas em revisão e endereços na sua lista de supressão não entram: nesses dois casos o destino nunca chegou a recusar nada.
  • Janela ainda sendo calculada: a faceta é escrita no dia do envio, e um período que ainda não foi totalmente recomposto vem com total menor que a soma de sent da série — os pct são sobre o total da faceta, não sobre o período. É assim que você detecta: compare os dois. A recomposição é automática, cobre os mesmos 90 dias que o período máximo oferecido e roda de hora em hora; nada precisa ser pedido.
GET/v1/messages/:uuidescopo read:messages

Detalhe de uma mensagem: status, timeline de eventos e eventos de tracking (open/click). Fora do escopo do tenant → 404 not_found.

Exemplo

curl https://api.oveyon.com/v1/messages/3f8a1c2e-9b4d-4e10-8a77-2b0c9d5e1f34 \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Resposta

{
  "id": "3f8a1c2e-9b4d-4e10-8a77-2b0c9d5e1f34",
  "status": "delivered",
  "statusDetail": null,
  "route": "mailchannels",
  "from": "vendas@suaempresa.com",
  "to": "cliente@exemplo.com",
  "subject": "Bem-vindo",
  "sizeBytes": 4821,
  "acceptedAt": "2026-07-21T13:02:11.000Z",
  "deliveredAt": "2026-07-21T13:02:14.000Z",

  // UMA ENTREGA POR DESTINATÁRIO. `status` e `to` acima são o resumo e o
  // primeiro endereço; o desfecho de cada um está aqui. O resumo é
  // pessimista: se um destinatário não recebeu, a mensagem não é "delivered".
  "deliveries": [
    { "id": "d_8f2a…", "to": "cliente@exemplo.com", "kind": "to",  "status": "delivered", "statusDetail": null,
      "deliveredAt": "2026-07-21T13:02:14.000Z", "completedAt": "2026-07-21T13:02:14.000Z", "latencyMs": 2900 },
    { "id": "d_1c07…", "to": "copia@exemplo.com", "kind": "bcc", "status": "bounced",
      "statusDetail": "550 5.1.1 User unknown", "deliveredAt": null, "completedAt": "2026-07-21T13:02:13.000Z", "latencyMs": 1800 }
  ],

  // `deliveryId`/`to` dizem de QUAL entrega é o evento; null = evento da
  // mensagem (aceite, por exemplo), que não pertence a destinatário nenhum.
  "events": [
    { "event": "accepted",  "deliveryId": null,      "to": null,                  "detail": { "source": "api" }, "at": "2026-07-21T13:02:11.000Z" },
    { "event": "delivered", "deliveryId": "d_8f2a…", "to": "cliente@exemplo.com", "detail": {},                  "at": "2026-07-21T13:02:14.000Z" },
    { "event": "bounced",   "deliveryId": "d_1c07…", "to": "copia@exemplo.com",   "detail": { "smtpResponse": "550 5.1.1 User unknown" }, "at": "2026-07-21T13:02:13.000Z" }
  ],
  "tracking": [
    { "type": "open",  "url": null, "host": "email.suaempresa.com", "readMsEstimate": 3200, "at": "..." },
    { "type": "click", "url": "https://loja.suaempresa.com/x", "host": "email.suaempresa.com", "readMsEstimate": null, "at": "..." }
  ]
}

readMsEstimate é uma aproximação fraca (delta entre beacons), não tempo de leitura exato.

Domínio descartável

O domínio de um endereço é de e-mail descartável/temporário (Mailinator, 10minutemail e afins)? Use no cadastro, antes de aceitar um e-mail que nunca vai ser lido duas vezes.

Esta é a única rota da API que não exige autenticação. Sem Authorization, sem chave, sem conta: a base é uma lista pública e não há nada seu envolvido na resposta. Também não consome cota. Em troca, o teto é por IP e é apertado — veja o fim desta seção.

GET/v1/disposablesem autenticação

Aceita um endereço completo ou um domínio. Informe um dos dois. Sem header de autenticação.

Query

  • email — endereço completo; extraímos o domínio depois do @.
  • domain — o domínio direto.
  • Os dois juntos, ou nenhum → 400 bad_request. Valor do qual não sai um domínio válido → 400 bad_domain.

Exemplo

# sem Authorization: esta rota e publica
curl "https://api.oveyon.com/v1/disposable?email=alguem@mailinator.com"
HTTP/1.1 200 OK
{
  "domain": "mailinator.com",
  "result": "disposable",
  "disposable": true,
  "list": {
    "updatedAt": "2026-08-07T00:10:34.812Z",
    "domains": 8201,
    "source": "https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf"
  }
}
curl "https://api.oveyon.com/v1/disposable?domain=gmail.com"
HTTP/1.1 200 OK
{
  "domain": "gmail.com",
  "result": "not_listed",
  "disposable": false,
  "list": { "updatedAt": "2026-08-07T00:10:34.812Z", "domains": 8201, "source": "…" }
}

Campos da resposta

  • domain — o domínio que efetivamente consultamos, já normalizado (minúsculo, sem espaço, sem ponto final).
  • result"disposable" · "not_listed" · "unknown". É este o campo para automatizar em cima.
  • disposable — o mesmo em booleano: true, false ou null (quando unknown).
  • list.updatedAt — quando a nossa base foi carregada pela última vez, e list.domains quantos domínios ela tem agora. Estão aí para você decidir o quanto confiar na resposta em vez de acreditar por fé.

Se a nossa base não estiver carregada

HTTP/1.1 503 Service Unavailable
Retry-After: 3600
{
  "error": "list_unavailable",
  "domain": "mailinator.com",
  "result": "unknown",
  "disposable": null,
  "message": "não foi possível consultar: a base ainda não foi carregada neste servidor"
}

// `disposable` vem null, NUNCA false. Se a nossa base não estiver carregada,
// a resposta é "não sei" — dizer "não é descartável" sem ter o que consultar
// seria o único erro que esta consulta não pode cometer.

O que esta consulta não faz

  • not_listed não é atestado de idoneidade. A base é uma lista de bloqueio: o que ela diz é «este domínio não está nela», não «este domínio é confiável». Domínio descartável novo entra na lista depois de existir.
  • A comparação é exata, sem subir para o domínio-pai. mail.exemplo.com não herda o veredito de exemplo.com. É deliberado: casar por sufixo fabricaria falso positivo, e o erro caro aqui é barrar o cadastro de um cliente real.
  • Não verificamos se a caixa existe nem se o endereço recebe — só o domínio, contra a lista.
  • A base é atualizada uma vez por dia. Uma carga que chegue truncada ou vazia é recusada e a base anterior é mantida — por isso list.domains nunca despenca de um dia para o outro.

Limite

Pública não é ilimitada — e como não há chave, o teto é por IP: 60 consultas por hora, em janela deslizante (não zera de uma vez na virada da hora). Estourou → 429 rate_limited com Retry-After em segundos, já calculado para o instante em que a próxima vaga existe.

Se o seu caso é validar uma lista grande de uma vez, não use este endpoint: baixe a lista direto da fonte pública (disposable-email-domains) e rode local. É mais rápido, não depende de nós e não esbarra em teto nenhum.

Receber e-mails

Você aponta o MX de um domínio para nós e cria os endereços que quer receber. A partir daí, tudo o que chega fica guardado e você escolhe como consumir: puxar pelas rotas abaixo, ou ser avisado por webhook. As duas coisas convivem — o webhook é aviso, o armazenamento é o chão.

Primeiros passos

Cinco passos, uma vez por domínio. Do DNS ao primeiro e-mail lido.

Passo 1 de 5

Publique o MX do seu domínio. É o registro que diz ao mundo para onde mandar o e-mail de @suaempresa.com. Enquanto ele não existir, nada chega — não há o que ligar do nosso lado.

; No DNS de suaempresa.com — um registro MX, prioridade 10, apontando
; para o nosso host de entrada. O ponto final faz parte do registro.
suaempresa.com.    IN    MX    10    mx1.oveyon.com.

# Confira a propagação antes de pedir a verificação no painel:
dig +short MX suaempresa.com
# esperado:  10 mx1.oveyon.com.

Se o domínio já recebe e-mail em outro provedor, trocar o MX move a caixa inteira para cá. Para experimentar sem mexer no principal, use um subdomínio (ex.: recebe.suaempresa.com).

Passo 2 de 5

Verifique. Em Domínios, abra o domínio e clique em Verificar MX. Nós consultamos o DNS na hora e confirmamos que ele aponta para mx1.oveyon.com. Propagação de DNS leva de minutos a algumas horas; pode repetir à vontade.

A verificação só grava no positivo — uma consulta que falha por DNS lento nunca desfaz um domínio já verificado.

Passo 3 de 5

Ligue a recepção no mesmo lugar. É o interruptor do domínio: desligado, todo e-mail para ele é recusado; ligado, valem os endereços do passo 4 — e eles.

Ligar antes de publicar o MX não quebra nada, mas também não recebe nada. O painel avisa quando está nessa situação.

Passo 4 de 5

Crie o endereço. Duas formas:

  • Exatosuporte aceita suporte@suaempresa.com e mais nada. Letras, números e . _ % + -, até 64 caracteres, sem @.
  • Catch-all*@suaempresa.com aceita qualquer endereço do domínio. Um por domínio. Prático para testar, mas ele também aceita o lixo que varredores mandam para admin@, info@ e afins.

Endereço que não tem rota ativa é recusado no SMTP, com 550 5.1.1 — quem enviou recebe a devolutiva na hora, em vez de achar que entregou. Não existe «aceita e decide depois».

Passo 5 de 5

Escolha o que fazer com o que chega. Mande um e-mail de teste de uma caixa sua e confira:

# Mande um e-mail de uma caixa sua para suporte@suaempresa.com e liste:
curl "https://api.oveyon.com/v1/inbound?limit=5" \
  -H "Authorization: Bearer $OVEYON_API_KEY"
  • Puxar — as cinco rotas desta seção. Precisam do escopo read:inbound na chave (veja o aviso logo abaixo).
  • Ser avisadowebhook inbound.received: nós batemos no seu servidor a cada e-mail aceito, e você não precisa ficar perguntando.

A chave precisa do escopo — e chave antiga não ganha sozinha

Estas rotas exigem read:inbound, que é um escopo próprio: as outras leituras (read:messages) mostram o que você mandou; esta mostra o que você recebeu — corpo e anexo escritos por terceiros. Uma chave emitida antes de este escopo existir não passa a tê-lo por deploy nosso: quem entregou aquela chave a um integrador não podia consentir com um poder que ainda não existia. Marque o escopo na chave em Credenciais (gesto de admin da conta), ou emita uma nova (gesto de ownerpor quê). Rotacionar não resolve — a rotação herda as permissões da chave anterior, verbatim. Sem o escopo: 403 insufficient_scope.

Cinco coisas antes de escrever código

GET/v1/inboundescopo read:inbound

Lista as mensagens recebidas, mais nova primeiro, com paginação por cursor.

Query

  • limit — 1 a 100 (default 25).
  • cursor — o next_cursor da página anterior. O cursor desta rota é de um tipo próprio: reaproveitar aqui um cursor de /v1/messages devolve 400 bad_cursor, em vez de uma página silenciosamente errada.
  • recipient — endereço de destino exato. Procura em todos os destinatários da transação, não só no primeiro.
  • sender — endereço de origem exato. Casa tanto o remetente do envelope (from) quanto o do cabeçalho (fromHeader), porque os dois divergem na vida real. Busca por endereço, não por nome — o fromName não entra aqui de propósito: nome de exibição é escolhido por quem envia e não identifica ninguém.
  • domain — o nome do domínio que recebeu (o mesmo valor que a resposta traz em domain; o filtro aceita de volta o que a resposta mostrou). Domínio que não é seu devolve lista vazia, nunca 403: a resposta não conta a ninguém de quem é um domínio.
  • dmarc — filtra pelo veredito DMARC (pass, fail, none…). O vocabulário é aberto de propósito — o veredito vem da biblioteca de autenticação, e um valor que ela ainda não emite simplesmente não casa linha nenhuma.
  • has_attachmentstrue ou false (também aceita 1/0 e yes/no).
  • outcome — filtra pelo desfecho (accepted, blocked). Vocabulário aberto, pelo mesmo motivo do dmarc: um desfecho que ainda não exista simplesmente não casa linha nenhuma, em vez de virar erro. Sem o parâmetro, a lista traz todos os desfechos.
  • from / to — intervalo de recebimento, ISO 8601 (YYYY-MM-DD ou timestamp).

Exemplo

curl "https://api.oveyon.com/v1/inbound?limit=25&has_attachments=true" \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Resposta

{
  "data": [
    {
      "id": "b71e0c34-5a2f-4d18-9c60-77ab31e2d905",
      "receivedAt": "2026-08-05T09:14:02.317Z",
      "from": "cliente@exemplo.com",        // envelope (MAIL FROM)
      "fromHeader": "vendas@exemplo.com",   // ENDEREÇO do cabeçalho From
      "fromName": "Vendas Exemplo",         // nome de exibição, ou null
      "subject": "Re: seu orçamento",
      "domain": "suaempresa.com",

      // UM E-MAIL, N DESTINATÁRIOS. Nunca achatamos num campo só: se a
      // mensagem chegou para suporte@ e para vendas@ na MESMA transação,
      // os dois estão aqui, cada um com o seu id.
      "recipients": [
        { "id": "9c1d…", "to": "suporte@suaempresa.com" },
        { "id": "3af0…", "to": "vendas@suaempresa.com" }
      ],

      "authentication": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },

      // Score interno de spam (0-100; quanto maior, mais cara de spam tem).
      // null = NÃO CALCULADO (mensagem anterior ao recurso) — não é 0.
      "spamScore": 2,

      "sizeBytes": 18422,
      "attachmentCount": 2,
      "expiresAt": "2026-09-04T09:14:02.000Z",

      // O DESFECHO. "accepted" = entregue a pelo menos um destinatário.
      "outcome": "accepted",
      "blockedReason": null
    }
  ],
  "next_cursor": "aTo3Nw"
}

O id público é o uid da mensagem; é ele que vai nas rotas abaixo. next_cursor: null ⇒ última página.

O mesmo item, com desfecho blocked

Mesma rota, mesma forma — o que muda é o desfecho. Se o seu código pressupõe que toda mensagem listada tem destinatário, é esta a resposta que vai encontrá-lo. Ver «nem toda mensagem listada foi entregue».

{
  "id": "c04b19f7-3d5a-4a02-b7e1-6d8f0a2c4419",
  "receivedAt": "2026-08-05T11:02:40.118Z",
  "from": "cliente@exemplo.com",
  "subject": "Chegou enquanto estava desligado",
  "domain": "suaempresa.com",

  // VAZIO, e não ausente: ninguém recebeu esta mensagem.
  "recipients": [],

  "attachmentCount": 0,
  "expiresAt": "2026-09-04T11:02:40.000Z",

  // Chegou, foi guardada, não foi entregue e NÃO foi cobrada.
  "outcome": "blocked",
  "blockedReason": "inbound_disabled"
}
GET/v1/inbound/:uidescopo read:inbound

Detalhe da mensagem: tudo o que a listagem traz, mais o manifesto de anexos e os links de conteúdo. Não devolve o corpo — ele tem rota própria.

Exemplo

curl https://api.oveyon.com/v1/inbound/b71e0c34-5a2f-4d18-9c60-77ab31e2d905 \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Resposta

{
  // … todos os campos da listagem, mais:

  // MANIFESTO DOS ANEXOS. O `ord` é o ENDEREÇO do anexo — carimbado quando
  // a mensagem entrou, estável para sempre. O download é por ele, NUNCA
  // pelo `filename` (que veio de quem enviou: pode repetir, vir vazio ou
  // trazer caminho).
  "attachments": [
    { "ord": 1, "filename": "orcamento.pdf", "contentType": "application/pdf",
      "sizeBytes": 14233, "sha256": "9f2c…",
      "url": "/v1/inbound/b71e0c34-…/attachments/1" },
    { "ord": 2, "filename": "logo.png", "contentType": "image/png",
      "sizeBytes": 3180, "sha256": "0ab7…",
      "url": "/v1/inbound/b71e0c34-…/attachments/2" }
  ],
  "links": {
    "self":    "/v1/inbound/b71e0c34-…",
    "content": "/v1/inbound/b71e0c34-…/content",
    "raw":     "/v1/inbound/b71e0c34-…/raw"
  }
}
GET/v1/inbound/:uid/contentescopo read:inbound

O corpo já parseado, em text e html — para quem não quer implementar MIME. Cada campo é cortado em 262.144 caracteres (256 KB), e truncated diz quando isso aconteceu (se você precisa do conteúdo inteiro, use /raw). Um truncated: false fixo seria decoração esperando o primeiro e-mail grande — este campo é medido.

Exemplo

curl https://api.oveyon.com/v1/inbound/b71e0c34-5a2f-4d18-9c60-77ab31e2d905/content \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Resposta

{
  "id": "b71e0c34-5a2f-4d18-9c60-77ab31e2d905",
  "text": "Bom dia, segue em anexo o orçamento aprovado…",
  "html": "<p>Bom dia, segue em anexo o orçamento aprovado…</p>",
  "truncated": false
}

O html é HTML de terceiro. Ele volta como dado, exatamente como chegou. Renderizar sem sanitizar é XSS na sua origem — passe por um sanitizador (DOMPurify e afins) ou use só o text.

GET/v1/inbound/:uid/rawescopo read:inbound

O .eml original, byte a byte como chegou pelo SMTP — sem reescrita nenhuma. É o que você usa para parsear com a sua própria biblioteca, reverificar DKIM ou arquivar.

Exemplo

curl -o mensagem.eml \
  https://api.oveyon.com/v1/inbound/b71e0c34-5a2f-4d18-9c60-77ab31e2d905/raw \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Resposta: message/rfc822, sempre como anexo (Content-Disposition: attachment), sem cache. Cópia já expurgada pela retenção → 404.

GET/v1/inbound/:uid/attachments/:nescopo read:inbound

Os bytes de um anexo. O :n é o ord do manifesto — não o nome do arquivo.

Exemplo

curl -OJ https://api.oveyon.com/v1/inbound/b71e0c34-5a2f-4d18-9c60-77ab31e2d905/attachments/1 \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Por que pelo ord: dois anexos podem ter o mesmo nome, e o nome pode vir vazio ou com caminho dentro. O ord é atribuído por nós quando a mensagem entra e não muda nunca. ord inexistente → 404.

Tudo baixa como anexo, com nosniff e CSP restritiva. Tipos que o navegador executaria (text/html, image/svg+xml, XML, JavaScript) são servidos como application/octet-stream de propósito: HTML de terceiro nunca vira documento numa origem nossa nem sua.

Erros destas rotas

Webhook de recepção

Em vez de perguntar «chegou alguma coisa?» a cada minuto, nós avisamos: um POST assinado no seu servidor a cada e-mail aceito. O evento é inbound.received.

«Aceito» é literal: o aviso é por destinatário, então uma mensagem com outcome: "blocked" — que não foi entregue a ninguém — não gera evento nenhum. Não é uma condição que alguém tenha de lembrar de manter: sem destinatário não há por quem disparar. Se a recepção de um domínio ficou desligada por um período, o que chegou nele está em GET /v1/inbound, e só lá.

Onde se liga

O webhook de recepção é um canal do endereço, e o cadastro é no painel: Domínios → abra o domínio → na tabela de endereços de recepção, a linha «Avisar por webhook» logo abaixo do endereço. Você informa a URL e escolhe o modo; o segredo de assinatura é gerado por nós e aparece uma única vez, na criação — copie na hora, porque não há tela que o mostre de novo (perdeu, remova o webhook e crie outro).

Não é o mesmo cadastro dos webhooks de envio — aqueles assinam o que aconteceu com o que você mandou (delivered, bounced…) e inbound.received não está no vocabulário deles: não adianta pedi-lo no POST /v1/webhooks. Também não há rota de API para cadastrar canal de recepção — hoje isso é só painel. Endereços diferentes podem ter destinos diferentes, e o mesmo endereço pode ter mais de um webhook (cada um com o seu segredo, o seu modo e a sua contagem de tentativas).

A URL passa pela mesma checagem dos webhooks de envio: destino interno ou privado é recusado no cadastro, e o IP é validado no momento da conexão — trocar o DNS depois do cadastro não nos leva para dentro da sua rede.

Antes de salvar, nós batemos na sua URL uma vez

Desde 24/08/2026, uma URL de webhook de recepção só é gravada depois que o endpoint responde a um desafio. Vale nos três gestos: ao criar o canal, ao trocar a URL de um canal existente, e ao alargar o modo (de «Resumo» para «Completo», por exemplo). Se o desafio não passa, nada é salvo — e num reaponte a URL antiga continua valendo.

O motivo é a simetria com os outros canais: no encaminhamento, o dono da caixa confirma por e-mail; no Telegram, o chat_id nunca é digitado, ele vem de um /start com nonce nosso; no Slack, é OAuth. O webhook era o único em que bastava digitar um endereço para ele passar a valer — e apontar correio para o endpoint de um terceiro que nunca disse sim é um jeito de nos transformar em instrumento.

O que a prova cobre, e o que ela não cobre: ela prova que quem controla aquele endpoint aceita receber. Ela não prova que o dono do sistema do outro lado autorizou — quem aponta para o próprio servidor passa no desafio sem esforço. Isso é deliberado, não uma lacuna a ser fechada depois.

O desafio é um POST comum, com os mesmos três cabeçalhos de assinatura das entregas de verdade e x-oveyon-event: url_verification. Timeout de 10 s.

POST https://seu-endpoint.exemplo/hook
x-oveyon-event: url_verification
x-oveyon-timestamp: 1756041600
x-oveyon-signature: sha256=…
content-type: application/json

{
  "type": "url_verification",
  "challenge": "3f9a…64 hex…",
  "url": "https://seu-endpoint.exemplo/hook",
  "sentAt": "2026-08-24T12:00:00.000Z"
}

Para passar, responda 2xx devolvendo o valor de challenge, de uma destas duas formas — as duas são aceitas:

Trate o url_verification antes da sua lógica de mensagem e devolva ali mesmo: ele não é um e-mail, e processá-lo como se fosse cria uma entrega fantasma no seu sistema. Um 200 de corpo vazio não passa — é exatamente o caso que o desafio existe para pegar.

Na criação você ainda não conhece o segredo (ele só aparece depois, uma vez), então não há como verificar a assinatura desse primeiro desafio — só o eco é exigido. Numa troca de URL o segredo já é seu, e aí vale verificar a assinatura antes de ecoar, como em qualquer entrega.

E se o destino é um receptor de terceiros que você não programa (n8n, Make, webhook.site)? Ecoar o challenge pode ser impossível ali. Para esse caso existe o caminho alternativo, no painel: quando o seu endpoint aceita o POST (2xx) mas não devolve o desafio, o formulário oferece a opção «aceitar sem prova de leitura» — marque-a e a URL é salva assim mesmo, com uma marca visível no canal. Seja honesto consigo sobre o que se perde: a prova cai de «alguém lê o que chega lá» para «a URL existe e aceita POST». E o atalho só vale para esse desfecho — endpoint que responde erro ou não responde no prazo continua sendo recusado, porque ali não há receptor nenhum, só um endereço quebrado.

app.post('/hook', (req, res) => {
  // O desafio vem ANTES de tudo — e sai daqui.
  if (req.body && req.body.type === 'url_verification') {
    return res.json({ challenge: req.body.challenge });
  }
  // … daqui para baixo, o inbound.received de verdade
});

Um evento por destinatário, por canal

É o ponto que mais confunde quem integra, então vale devagar: um e-mail que chegou para três endereços seus gera três eventos, não um com uma lista.

O motivo é que cada destinatário tem desfecho próprio. Uma transação SMTP é um corpo e N destinatários, mas o aviso de um pode falhar e entrar em retentativa enquanto o do outro já foi entregue no primeiro tiro. Um evento só teria que carregar um status agregado — e status agregado de coisas que terminam diferente é sempre mentira sobre alguma delas. Pelo mesmo motivo, se o endereço tiver dois canais, cada canal tem a sua própria contagem de tentativas: o mesmo destinatário aparece uma vez por canal.

O payload

POST https://suaapp.com/hooks/oveyon-inbound
content-type: application/json
x-oveyon-event: inbound.received
x-oveyon-timestamp: 1786000443
x-oveyon-signature: sha256=9c4f2b7e…
x-spam-score: 2

{
  "event": "inbound.received",

  // ID ESTÁVEL desta entrega — este destinatário, neste canal. Retentativa e
  // reentrega repetem o MESMO valor: é por ele que você deduplica.
  "eventId": "6e5a1b90-3c77-4f02-b1ad-8e4409c2d611",

  // Versão do FORMATO deste corpo. Só sobe em mudança incompatível — guarde-a
  // e recuse o que não souber ler, em vez de adivinhar.
  "schemaVersion": 1,
  "timestamp": "2026-08-05T09:14:03.902Z",

  // O e-mail. Quase o mesmo objeto do GET /v1/inbound/:uid, com três
  // diferenças: aqui vêm `messageIdHeader` e `inReplyTo`, as URLs são
  // absolutas (`url`/`rawUrl`, não o objeto `links`), e NÃO há `recipients` —
  // este aviso é de UM destinatário, e ele está fora, em `recipient`.
  // `message.id` é o uid: é ele que vai nas rotas /v1/inbound/*.
  "message": {
    "id": "b71e0c34-5a2f-4d18-9c60-77ab31e2d905",
    "receivedAt": "2026-08-05T09:14:02.317Z",
    "domain": "suaempresa.com",
    "from": "cliente@exemplo.com",
    "fromHeader": "vendas@exemplo.com",
    "fromName": "Vendas",
    "subject": "Re: seu orçamento",
    "messageIdHeader": "<a1b2@exemplo.com>",
    "inReplyTo": "<z9@suaempresa.com>",
    "sizeBytes": 18422,
    "attachmentCount": 1,
    "authentication": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },

    // Score interno de spam (0-100), o mesmo do GET /v1/inbound. Também vai
    // no header `x-spam-score` do POST — presente SÓ quando há score; se o
    // campo é null, o header simplesmente não existe. null = não calculado
    // (mensagem anterior ao recurso), que NÃO é o mesmo que 0 (= limpa).
    "spamScore": 2,
    "url":    "https://api.oveyon.com/v1/inbound/b71e0c34-…",
    "rawUrl": "https://api.oveyon.com/v1/inbound/b71e0c34-…/raw"
  },

  // A QUEM esta cópia se refere — um OBJETO, não uma string. Um e-mail que
  // chegou para suporte@ E vendas@ gera DOIS eventos: mesmo `message.id`,
  // `recipient` e `eventId` distintos.
  "recipient": { "id": "4d2f77a1-…", "to": "suporte@suaempresa.com" },

  // MANIFESTO, no TOPO do corpo (não dentro de `message`): nome, tipo,
  // tamanho, sha256 e a URL de onde se buscam os bytes. Vem em TODOS os
  // modos, inclusive `summary`. O endereço do anexo é o `ord`, NUNCA o
  // `filename`.
  "attachments": [
    { "ord": 1, "filename": "orcamento.pdf", "contentType": "application/pdf",
      "sizeBytes": 14233, "sha256": "e3b0c442…",
      "url": "https://api.oveyon.com/v1/inbound/b71e0c34-…/attachments/1" }
  ],

  // SEMPRE presente — inclusive quando nada foi cortado. Cheque sempre.
  "truncation": {
    "degraded": false,          // saiu menos do que você pediu?
    "requestedMode": "full",    // o modo cadastrado no canal
    "mode": "full",             // o modo que de fato saiu
    "reason": null,             // texto legível quando degradou; null quando não
    "maxBytes": 262144,         // o teto em vigor para ESTE POST
    "attachmentsInline": false, // os bytes vieram embutidos em `content`?
    "attachmentsListed": 1,     // quantos anexos couberam no manifesto
    "attachmentCount": 1,       // quantos a mensagem tem, no total
    "textTruncated": false,
    "htmlTruncated": false
  },

  // Nos modos `full` e `full+attachments`, e também no TOPO do corpo. São
  // `null` quando a mensagem não tem aquela parte; no modo `summary` as duas
  // chaves simplesmente não existem.
  "text": "Bom dia, segue em anexo o orçamento aprovado…",
  "html": "<p>Bom dia, segue em anexo o orçamento aprovado…</p>"
}

Três modos — você escolhe quanto volume quer no aviso

ModoO que vai no POSTBom quando
summary Remetente, assunto, vereditos de autenticação, as URLs da API e o manifesto dos anexos. Sem text e sem html. Você só quer o gatilho e vai buscar o corpo quando (e se) precisar.
full O de cima + text e html. O caso comum: dá para processar o e-mail sem uma segunda chamada.
full+attachments O de cima + os bytes dos anexos em base64, em attachments[].content, desde que o POST inteiro caiba no teto. Anexos pequenos e previsíveis, e você não quer autenticar um download.

O manifesto dos anexos vem nos três modos — o que muda de um para o outro é o volume (corpo e bytes), nunca a lista. E o modo que vem marcado por padrão na tela é o full+attachments: quem não escolhe recebe tudo o que couber.

// modo `summary` — sem `text` e sem `html` (as chaves nem aparecem). Tudo o
// mais continua: cabeçalho, vereditos, URLs e o MANIFESTO dos anexos.
{ "event": "inbound.received", "eventId": "…", "schemaVersion": 1,
  "message": { … }, "recipient": { "id": "…", "to": "…" },
  "attachments": [ { "ord": 1, "filename": "orcamento.pdf", "sha256": "…", "url": "…" } ],
  "truncation": { "degraded": false, "requestedMode": "summary", "mode": "summary", … } }

// modo `full+attachments` — cada item do manifesto ganha os BYTES em base64,
// no mesmo formato do `attachments[].content` do POST /send. É TUDO OU NADA:
// ou todos os anexos vêm embutidos, ou nenhum vem.
"attachments": [
  { "ord": 1, "filename": "orcamento.pdf", "contentType": "application/pdf",
    "sizeBytes": 14233, "sha256": "e3b0c442…",
    "url": "https://api.oveyon.com/v1/inbound/b71e0c34-…/attachments/1",
    "content": "JVBERi0xLjQK…" }
]
"truncation": { …, "mode": "full+attachments", "attachmentsInline": true }

// … e quando não coube, o payload DEGRADA e DIZ. Aqui pediram
// `full+attachments` e saiu `full`: o manifesto ficou, os bytes não.
"truncation": {
  "degraded": true,
  "requestedMode": "full+attachments",
  "mode": "full",
  "reason": "payload com anexos embutidos passaria de 262144 bytes: vão por link",
  "maxBytes": 262144,
  "attachmentsInline": false,
  "attachmentsListed": 1,
  "attachmentCount": 1,
  "textTruncated": false,
  "htmlTruncated": false
}

O teto do POST

Todo aviso tem um teto de tamanho, e ele vale para o corpo inteiro do POST — não só para os anexos. O padrão é 256 KB (262 144 bytes). O valor em vigor para aquele POST vem declarado dentro dele, em truncation.maxBytes: leia dali em vez de fixar o número no seu código.

O teto é aplicado quando o aviso é montado, não na hora de entregar. Um e-mail de 15 MB nunca vira um POST de 20 MB que nós tentaríamos seis vezes: o payload grande não chega nem a existir. Duas consequências práticas para quem integra: text e html entram cada um cortado em no máximo um quarto do teto (o corte é por byte, e nunca parte um caractere no meio), e os bytes de anexo só são embutidos se todos couberem no que sobrar.

Degradação é declarada, nunca silenciosa

Quando o payload não cabe no modo pedido, ele degrada e diz que degradou. O bloco truncation está sempre presente — com degraded: false no caminho normal — justamente para você poder checar sempre, com uma linha só, sem descobrir a existência do assunto no dia do primeiro e-mail grande.

Não existe campo level: o que responde «quanto saiu?» é o par requestedMode / mode, no mesmo vocabulário dos três modos acima.

O que degradou não se perdeu: está na API, sob o mesmo message.id. Degradar é tirar volume do aviso, nunca do arquivo.

Deduplique por eventId

Nós preferimos entregar duas vezes a perder um aviso. Se o seu servidor processar e o 200 se perder no caminho, a tentativa seguinte traz o mesmo eventId — ele é estável por entrega e sobrevive a retentativa e a reentrega. Guarde-o, e trate repetido como sucesso silencioso. Não deduplique por hash do corpo: uma reentrega remonta o payload, e o que chega pode não ser byte a byte o que chegou antes.

// A MESMA entrega pode bater duas vezes: retentativa depois de um timeout no
// qual você já tinha processado, ou reentrega nossa. `eventId` é estável
// nos dois casos — é a chave de deduplicação.
async function processar(evento) {
  // INSERT com chave única no eventId: quem perder a corrida já sabe que
  // é repetido, sem depender de um SELECT-antes-do-INSERT (que corre).
  const novo = await marcarComoVisto(evento.eventId);
  if (!novo) return;               // já processado — responda 2xx e siga

  if (evento.truncation.degraded) {
    // Faltou volume no aviso: busque o que falta na API, pelo message.id.
    await baixarDaApi(evento.message.id);
  }
  await salvar(evento);
}

Assinatura (HMAC-SHA256)

Mesmo esquema dos webhooks de envio. Cada POST leva três cabeçalhos:

O timestamp entra dentro do conteúdo assinado: um POST capturado não pode ser reenviado com carimbo novo sem quebrar a assinatura. Recuse o que chegar com |agora − timestamp| > 300 s, mesmo com HMAC válido — sem essa janela a assinatura sozinha autentica um replay de ontem.

Verificando em Node

const express = require('express');
const crypto = require('crypto');
const app = express();

// O CORPO CRU é o que foi assinado. Capture os bytes ANTES de qualquer parse
// — reserializar o JSON muda um espaço e a assinatura não fecha mais.
app.post('/hooks/oveyon-inbound', express.raw({ type: 'application/json' }), (req, res) => {
  const ts  = req.get('x-oveyon-timestamp') || '';
  const sig = (req.get('x-oveyon-signature') || '').replace(/^sha256=/, '');
  const raw = req.body; // Buffer

  // Janela anti-replay: 300 s. Fora dela, recuse — mesmo com HMAC válido.
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

  const esperado = crypto.createHmac('sha256', process.env.OVEYON_WEBHOOK_SECRET)
    .update(ts + '.').update(raw).digest('hex');

  const a = Buffer.from(esperado, 'hex');
  const b = Buffer.from(sig, 'hex');
  // Comparação em tempo constante (o == vaza o prefixo certo por timing).
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  const evento = JSON.parse(raw.toString('utf8'));
  enfileireParaProcessar(evento);   // trabalho pesado FORA da requisição
  res.sendStatus(200);              // 2xx rápido: a tentativa expira em 10 s
});

Verificando em PHP

<?php
// O corpo CRU, byte a byte. Nada de json_decode antes de conferir.
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_OVEYON_TIMESTAMP'] ?? '';
$sig = preg_replace('/^sha256=/', '', $_SERVER['HTTP_X_OVEYON_SIGNATURE'] ?? '');

// Janela anti-replay: 300 s.
if ($ts === '' || abs(time() - (int) $ts) > 300) { http_response_code(400); exit; }

$esperado = hash_hmac('sha256', $ts . '.' . $raw, getenv('OVEYON_WEBHOOK_SECRET'));

// hash_equals: comparação em tempo constante.
if (!hash_equals($esperado, $sig)) { http_response_code(401); exit; }

$evento = json_decode($raw, true);
enfileireParaProcessar($evento);   // trabalho pesado depois de responder
http_response_code(200);

Retentativas

Entrega é 2xx. Qualquer outra coisa conta como falha e entra na fila de retentativa: 4xx, 5xx, timeout de 10 s, conexão recusada — e também 3xx, porque redirecionamento não é seguido (seguir um redirect de endpoint de cliente é como um SSRF entra pela porta da frente).

TentativaQuandoAcumulado desde a 1ª
1assim que o evento entra na fila (o worker roda a cada 10 s)
230 segundos depois da falha anterior~30 s
32 minutos~2,5 min
410 minutos~12,5 min
51 hora~1h12
66 horas~7h12

São 6 tentativas, dentro de uma janela de pouco mais de 7 horas. Esse é o número que importa para quem opera: uma queda de manutenção de duas horas é absorvida sem perder nada; uma queda de um dia inteiro, não.

Esgotadas as seis, a entrega é marcada como falha e não há sétima — a fila para de bater no seu endpoint em vez de martelá-lo para sempre. Nada se perde por isso: o e-mail continua guardado, e GET /v1/inbound é a rede de segurança. Endpoint que ficou fora do ar? Reconcilie listando a janela da queda (?from=…&to=…) e deduplicando pelo que você já tinha — é por isso que a API existe mesmo para quem usa webhook.

Onde você vê isso acontecendo: no painel, na mesma linha em que o webhook foi cadastrado (Domínios → o domínio → o endereço), a coluna Último envio mostra o desfecho de cada aviso e o motivo em texto: pending (vai haver nova tentativa), delivered, failed (as seis se esgotaram) e dropped. Este último é o caso em que não havia mais para onde entregar — o webhook foi removido ou desativado entre a chegada do e-mail e a tentativa. dropped não é falha sua nem nossa, e não tem retentativa.

Responda 2xx rápido e faça o trabalho pesado depois. Um endpoint que processa 12 segundos antes de responder falha por timeout mesmo tendo feito tudo certo — e aí você recebe o mesmo evento seis vezes.

Endpoint atrás de Cloudflare (ou WAF): se você vê 403 nas tentativas e o seu código nunca roda, quem está recusando é a borda, não o seu servidor — bot-fight e regras de WAF adoram matar POST de máquina. Nossas entregas se identificam como User-Agent: OVEYON-Webhook/1.0: crie uma exceção de WAF para esse UA (ou para o caminho do seu webhook) e o problema some. Dica de diagnóstico que vale para tudo: o código que o SEU código devolve você encontra no seu log de aplicação; 403 sem linha de log nenhuma = a borda comeu a requisição antes.

Anexos

O payload carrega o manifestoord, nome, tipo, tamanho, sha256 e a URL de cada anexo — nos três modos. Os bytes se buscam pela API, com a sua chave: é a mesma rota da seção anterior.

curl -OJ https://api.oveyon.com/v1/inbound/b71e0c34-5a2f-4d18-9c60-77ab31e2d905/attachments/1 \
  -H "Authorization: Bearer $OVEYON_API_KEY"

⚠ O que chega no seu endpoint é conteúdo de terceiro

Assunto, corpo, HTML, nome e bytes de anexo foram escritos por quem enviou o e-mail — não por nós, e não por você. Qualquer pessoa na internet pode mandar um e-mail para um endereço seu, e portanto qualquer pessoa na internet escolhe o que vai dentro deste payload. A assinatura HMAC prova que nós enviamos o POST; ela não diz nada sobre o conteúdo dele.

Supressões

Endereços que você nunca quer atingir. Só as linhas do seu tenant são listadas; as supressões globais da plataforma também bloqueiam, mas não aparecem aqui.

GET/v1/suppressionsescopo read:suppressions

Lista com filtros e paginação por offset.

Query

  • email — busca por substring.
  • reasonhard_bounce · complaint · manual · unsubscribe.
  • limit — ≤ 500 (default 100) · offset.
curl "https://api.oveyon.com/v1/suppressions?reason=hard_bounce&limit=100" \
  -H "Authorization: Bearer $OVEYON_API_KEY"
{
  "suppressions": [
    { "email": "invalido@exemplo.com", "reason": "hard_bounce",
      "source": "bounce", "createdAt": "2026-07-20T09:11:00.000Z" }
  ],
  "total": 1, "limit": 100, "offset": 0
}
POST/v1/suppressionsescopo write:suppressions

Adiciona um endereço. reason default manual. Resposta 201 { email, reason }.

curl -X POST https://api.oveyon.com/v1/suppressions \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "naoenviar@exemplo.com", "reason": "manual" }'
DELETE/v1/suppressions/:emailescopo write:suppressions

Remove uma supressão (e-mail URL-encoded no path). Resposta 200 { deleted }.

  • Removível apenas manual e hard_bounce.
  • complaint e unsubscribe403 removal_blocked: quem reclamou de spam ou se descadastrou só volta com re-opt-in comprovado.
  • Inexistente ou fora do escopo → 404 not_found (não distinguimos os dois).
curl -X DELETE https://api.oveyon.com/v1/suppressions/naoenviar%40exemplo.com \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Regras de envio e recebimento (allow/block)

Quatro listas por conta: allow e block, para outbound (julga o destinatário) e inbound (julga o remetente, no nosso MX). Entrada é um e-mail exato, um domínio exato ou *.dominio.com (cobre subdomínios em qualquer profundidade, nunca o próprio domínio). Semântica: block vence allow; a allow só restringe quando tem pelo menos uma entrada; no block, user+tag@ conta como user@. É a cerca recomendada para caixas operadas por agentes de IA: uma allow list de destinatários limita o estrago de qualquer prompt que dê errado.

GET/v1/policiesescopo read:policies

Lista as entradas (filtros scope e kind). Escopo read:policies.

Cada entrada traz paused (booleano) e pausedAt (carimbo, ou null). Regra pausada continua na lista e não vale — se você só olhar a presença da entrada, vai concluir que ela está bloqueando correio quando ela está dormindo.

Estes dois campos são de leitura aqui: pausar uma entrada de lista é gesto do painel (Regras), e não há rota de API para isso — não procure. Quem tem pausa pela API é a cerca de rede, logo abaixo, que é outro recurso. A assimetria é real e está aqui declarada para você não escrever código contra uma rota que não existe.

POST/v1/policiesescopo write:policies

Cria uma entrada. Escopo write:policies.

curl -X POST https://api.oveyon.com/v1/policies   -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json"   -d '{ "scope": "outbound", "kind": "allow", "pattern": "cliente.com.br" }'

Refinamento opcional: domainId (a entrada vale só naquele domínio) e credentialType+credentialId (smtp|apiKey — a cerca de UMA credencial, o desenho por agente). Duplicada responde 409 policy_exists; entrada fora do formato, 400. Teto de 5.000 entradas por lista.

DELETE/v1/policies/:idescopo write:policies

Remove uma entrada (o id vem do create/list). A mutação vale em segundos em todas as portas — API, SMTP e MX.

GET/v1/credentials/ip-rulesescopo read:policies
POST/v1/credentials/ip-rulesescopo write:policies
DELETE/v1/credentials/ip-rules/:idescopo write:policies

Amarração de rede: CIDRs permitidos por credencial (SMTP ou API key). Opt-in — credencial sem regra autentica de qualquer lugar; com regra, só de dentro dos CIDRs: senha vazada sem o IP certo não autentica (SMTP responde 535 5.7.8; a API, 403 ip_not_allowed nomeando os CIDRs permitidos). Cuidado com IP dinâmico/CGNAT: cadastre a faixa, não o /32 do momento.

curl -X POST https://api.oveyon.com/v1/credentials/ip-rules   -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json"   -d '{ "credentialType": "smtp", "credentialId": 42, "cidr": "203.0.113.0/24" }'
POST/v1/credentials/ip-rules/pauseescopo write:policies
POST/v1/credentials/ip-rules/unpauseescopo write:policies

Pausar a cerca sem perder as faixas. Corpo: { "credentialType": "smtp"|"apiKey", "credentialId": … }. Pausada, a credencial volta a autenticar de qualquer IP e as faixas continuam cadastradas — unpause devolve exatamente as mesmas, sem recadastrar nada. É o gesto para testar se a cerca é a causa de um envio que não sai: antes disto o único jeito de desligá-la era apagar as faixas, e apagar perde CIDR, comentário e autoria.

  • O GET /v1/credentials/ip-rules traz paused e pausedAt em cada linha — faixa listada com paused: true não está valendo.
  • unpause pode devolver warning.uncoveredRecentIps: origens que enviaram nos últimos 30 dias e ficam fora das faixas. Elas param de autenticar na hora — a cerca voltou.
  • Credencial sem nenhuma faixa → 409 no_ip_rules: não há cerca para pausar (ela já autentica de qualquer IP).
  • Escopo write:policies nas duas. Reiniciar o serviço não desfaz a pausa.
curl -X POST https://api.oveyon.com/v1/credentials/ip-rules/pause   -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json"   -d '{ "credentialType": "smtp", "credentialId": 42 }'

Erros que o /send passa a devolver

Políticas de envio

Um motor SE→ENTÃO ordenado avaliado em todo envio, na API e no SMTP autenticado. As regras allow/block acima só enxergam endereço; aqui a condição pode falar de assunto, remetente, credencial e hora do dia — e a ação pode recusar, reter, exigir rodapé, forçar o tier ou limitar por hora. As duas peças são restritivas e ordenadas: uma política nunca reabre o que uma lista, uma supressão ou a cota já fecharam.

A forma de uma política

Sem regex e sem negação, e isso é decisão de segurança e não falta de tempo: uma expressão escrita pelo cliente e avaliada no caminho de toda mensagem é a definição de superfície de ReDoS. O vocabulário é fechado e está inteiro na tabela abaixo.

Condições — o catálogo fechado

campoopvalorObservação
destinatarioigual · contem · termina_emtexto (até 320)Casa se algum destinatário casar. No igual, joao+nota@x.com e joao@x.com são a mesma caixa; no contem/termina_em, não (é o que permite pegar a etiqueta).
remetenteigual · contem · termina_emtexto (até 320)O from que você escreveu, não o envelope reescrito.
assuntocontem · igualtexto (até 500)Sem sensibilidade a caixa e com acentuação normalizada. Assunto acima de 500 é comparado pelo início e nunca casa igual — o corte não pode virar falso positivo.
credenciale_id{ "tipo": "api"|"smtp", "id": N }O tipo é obrigatório: chave de API e credencial SMTP têm espaços de id separados.
credencialtier_etextoEx.: transactional.
horaentre{ "de": "22:00", "ate": "06:00" }de inclusivo, ate exclusivo; de > ate vira a meia-noite. No fuso da sua conta (ajustável em Políticas; padrão America/Sao_Paulo).

Ações

First-match-wins: a primeira política ativa que casar decide, e a varredura para. Não existe «a mais específica ganha» nem acúmulo de ações. Política pausada não julga nada. Se nenhuma casar, nada acontece: zero evento, zero linha.

O teto por hora (limitar_hora)

Deixa passar até max mensagens por hora de relógio para o que a política descreve, e recusa o excedente até a hora virar. É um teto da fatia, não da conta: o resto do seu tráfego não é afetado.

GET/v1/send-policiesescopo read:send-policies

A lista completa, na ordem de julgamento, com as pausadas. Sem paginação: o teto da conta é 50 e a resposta traz max. Cada política traz paused (booleano) e pausedAtpausada continua na lista e não vale.

GET/v1/send-policies/:idescopo read:send-policies

Uma política. Id inexistente e id de outra conta respondem o mesmo 404 send_policy_not_found.

POST/v1/send-policiesescopo write:send-policies

Nasce PAUSADA e no fim da ordem. Nada muda no seu envio até você ativar — é o que dá a você a chance de conferir o que escreveu. Resposta 201 com a política.

curl -X POST https://api.oveyon.com/v1/send-policies \
  -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" \
  -d '{
        "name": "No maximo 200/h para o dominio do cliente",
        "conditions": [{ "campo": "destinatario", "op": "termina_em", "valor": "@cliente.com" }],
        "action": "limitar_hora",
        "actionParams": { "max": 200 }
      }'

Teto de 50 políticas por conta422 send_policy_limit_reached. Entrada inválida → 400 bad_request, e o campo reason traz o motivo estável (param_obrigatorio, hora_invalida, condicoes_demais…) para você ramificar sem ler a prosa.

PUT/v1/send-policies/:idescopo write:send-policies

Substituição inteira, não remendo: conditions é a lista completa. Não mexe em posição nem em pausa — são gestos próprios. Salvar uma política ativa muda o julgamento em segundos, nas duas portas.

POST/v1/send-policies/:id/pauseescopo write:send-policies
POST/v1/send-policies/:id/unpauseescopo write:send-policies

Ligar e desligar sem apagar. Ativar é sempre um gesto explícito: nenhuma política passa a julgar por ter sido salva.

POST/v1/send-policies/reorderescopo write:send-policies

Corpo { "order": [id, id, …] }, a lista completa e na ordem desejada. Como a avaliação é first-match, reordenar muda quem decide sem tocar em política nenhuma.

Ids que não são seus são ignorados (não dão 404: a lista é um desejo, não uma referência), e os que faltarem vão para o fim na ordem antiga — é o que impede uma cópia velha da lista de apagar a posição de uma política criada por outra via. A resposta devolve a lista já reordenada.

DELETE/v1/send-policies/:idescopo write:send-policies

Remove a política e reindexa a ordem. O rastro não morre junto: as decisões dela continuam no feed, com o nome que ela tinha, e policyId passa a vir null.

GET/v1/send-policies/decisionsescopo read:send-policies

O que as suas políticas decidiram — mais novo primeiro. Parâmetros limit (1–200, padrão 50) e before (o id da última linha da página anterior; a resposta traz nextBefore pronto).

Leia este endpoint se você usa reter ou limitar_hora: a primeira aceita a mensagem com 202 e a congela — sem erro nenhum na resposta —, e a segunda registra uma linha por janela. Sem o feed, as duas são invisíveis para quem integra só por API. Retenção de 90 dias (o campo retentionDays confirma).

O teto de escrita destas rotas

As rotas de mutação desta seção (POST, PUT, pause, unpause, reorder, DELETE) têm um teto por chave, somado ao teto por IP: padrão 120 escritas/min. As leituras desta seção não pagam esse teto. Estourou → 429 rate_limited com Retry-After; a mensagem diz o limite.

Ele existe porque cada mutação aqui é mais cara do que parece: ela invalida o cache de avaliação de políticas, o reorder reescreve a ordem inteira e o DELETE ainda solta o ponteiro de 90 dias de rastro. É o mesmo teto que a tela Políticas aplica — nenhum uso humano ou script bem-comportado chega perto dele, já que a conta inteira só pode ter 50 políticas.

Erros que o /send passa a devolver

A mesma política vale na API e no SMTP autenticado. Não vale na porta de recepção (recepção não é envio) nem em correio de terceiro relayado.

Pesquisas (NPS / CSAT)

Dispare a pergunta pelo seu sistema (fechou o ticket, entregou o pedido) e receba a nota de volta por webhook. A pesquisa sai como e-mail normal desta plataforma — domínio verificado, DKIM, regras, supressões, políticas de envio e cota valem todas, sem exceção. A nota é coletada no clique, numa página servida no seu domínio de rastreio.

Os dois tipos, e a faixa de cada um

kindFaixaO que o rollup publica
nps0 a 10 (onze links)nps = %promotores (9–10) − %detratores (0–6), arredondado; mais promotores, detratores, neutros, media e distribuicao.
csat1 a 5 (cinco links)media e distribuicao. nps vem null — CSAT não tem promotor, e inventar um seria uma métrica que ninguém reconhece.

Toda resposta de pesquisa traz range: { min, max }. Leia dele em vez de cravar 0..10 no seu código. E distribuicao vem sempre com todas as notas da faixa, inclusive as zeradas: um gráfico que some as notas sem voto mente sobre a forma da curva, que é o que se olha primeiro. Sem nenhuma resposta, nps e media vêm nullnunca 0: «NPS 0» é um resultado ruim de verdade, e mostrá-lo onde não há dado seria o produto mentindo com um número plausível.

A nota é o clique — e o corpo do e-mail diz isso

Cada nota é um link próprio, e o clique registra o voto mesmo que a página de agradecimento não carregue: a gravação acontece antes do redirecionamento. Não há votação por responder o e-mail, e o corpo avisa isso em HTML e em texto — sem o aviso, quem responde «9» por instinto sairia achando que votou e a sua pesquisa colheria silêncio.

Anti-fadiga — a regra que protege a sua base

O mesmo destinatário só é pesquisado uma vez por janela. A janela é da CONTA, não da pesquisa: quem recebeu o seu NPS ontem não recebe o seu CSAT hoje — a sua base não sabe que os seus moldes são dois. O tamanho da janela vem da pesquisa que você está disparando (throttleDays, padrão 90, mínimo 7).

Pedido repetido dentro da janela → 429 recipient_recently_surveyed, com Retry-After (que pode ser de semanas — é a resposta verdadeira), mais throttleDays e lastSentAt no corpo. 429 e não 422, e a diferença importa: isto volta a passar quando a janela vencer. Envio recusado por outro motivo (supressão, cota, política) não consome a janela — a tentativa vira ata e o endereço continua livre.

GET/v1/surveysescopo read:surveys

Todas as suas pesquisas. Sem paginação: o teto da conta é 50 e a resposta traz max.

GET/v1/surveys/:idescopo read:surveys

A pesquisa e o rollup na mesma chamada — é a pergunta que se faz («como está o meu NPS?»), e separá-la em duas custaria duas chamadas para montar uma tela. Id inexistente e id de outra conta respondem o mesmo 404 survey_not_found.

POST/v1/surveysescopo write:surveys

Cria o molde. Resposta 201 com a pesquisa.

curl -X POST https://api.oveyon.com/v1/surveys \
  -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" \
  -d '{
        "kind": "nps",
        "name": "NPS pos-atendimento",
        "subject": "Como foi o seu atendimento?",
        "fromEmail": "pesquisa@suaempresa.com",
        "fromName": "Equipe",
        "throttleDays": 90,
        "brandColor": "#0b5fff"
      }'
  • kindnps ou csat. Não muda depois: um molde com respostas gravadas viraria uma mistura de faixas 0–10 e 1–5 no mesmo rollup, e o número resultante não significaria nada. Quem quer o outro tipo cria outro molde.
  • fromEmail — precisa ser de um domínio da sua conta, e isso é conferido aqui, para o erro chegar cedo. A verificação (DNS/DKIM) é conferida a cada disparo, como em qualquer envio.
  • question — opcional. Vazio usa a redação canônica do tipo (a do NPS é a que torna o seu número comparável com o do mercado).
  • throttleDays — 7 a 3650, padrão 90. Texto ou número fora da faixa é 400, nunca «caiu no padrão».
  • brandColor (#rrggbb) e logoUrl (https absoluto) — a marca na página de voto.

Teto de 50 pesquisas por conta422 survey_limit_reached. Entrada inválida → 400 bad_request com reason (código estável: kind_invalido, throttle_invalido, from_dominio_alheio, logo_invalido…) e field, para você ramificar sem ler a prosa.

PUT/v1/surveys/:idescopo write:surveys

Substituição inteira, não remendo. kind no corpo é ignorado. Mande "active": false para parar de disparar sem perder nada — é o gesto reversível; apagar é o outro.

DELETE/v1/surveys/:idescopo write:surveys

Apaga as respostas junto — e a resposta diz quantas (responsesDeleted), para você não descobrir o tamanho do que perdeu depois. Para só parar de disparar, use active: false acima.

POST/v1/surveys/:id/sendescopo send:surveys

Dispara para um destinatário. Resposta 202 com o id da mensagem — a mesma forma do POST /v1/send, porque é literalmente o mesmo caminho de envio.

curl -X POST https://api.oveyon.com/v1/surveys/7/send \
  -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" \
  -d '{
        "to": "cliente@exemplo.com",
        "meta": { "ticket": 4821, "agente": "bia" },
        "idempotencyKey": "ticket-4821"
      }'
  • meta — o seu contexto (nº do pedido, id do ticket, quem atendeu). Até 4 KB de JSON. Volta inteiro no webhook e na leitura das respostas: é o que liga a nota ao fato que a motivou, do seu lado.
  • idempotencyKey — a mesma chave devolve o mesmo disparo (200 status: "duplicate") e nenhum segundo e-mail. Sem ela, um retry de rede vira um segundo e-mail para a mesma pessoa — ou, pior, um 429 de anti-fadiga contra o seu próprio disparo de três segundos antes.

Todos os erros do /v1/send valem aqui, com os mesmos códigos: recipient_suppressed, recipient_blocked, policy_refused, quota_exceeded, domain_not_verified… Os próprios da pesquisa são dois: 429 recipient_recently_surveyed (anti-fadiga) e 422 survey_inactive (a pesquisa está com active: false).

GET/v1/surveys/:id/responsesescopo read:surveys

As notas, mais nova primeiro, com recipient, comment, o seu meta e o rollup no fim. Parâmetros limit (1–200, padrão 50) e before (o id da última linha da página anterior; a resposta traz nextBefore pronto).

Cursor, e não offset: a lista é viva, e com offset uma resposta que chega entre duas páginas empurra a fronteira e faz a última linha da página 1 reaparecer como primeira da página 2. O campo updated diz se aquela nota foi trocada depois — sem ele, uma exportação não sabe se aquele 3 já foi um 9.

O webhook survey.response

Assine survey.response num endpoint de webhook e receba a nota no seu servidor, sem precisar perguntar. Mesma assinatura HMAC e mesma política de retentativa dos outros eventos.

{
  "event": "survey.response",
  "surveyId": 7,
  "surveyName": "NPS pos-atendimento",
  "kind": "nps",
  "sendId": 91,
  "recipient": "cliente@exemplo.com",
  "score": 9,
  "comment": "Atendimento rapido.",
  "updated": true,
  "meta": { "ticket": 4821, "agente": "bia" },
  "messageId": "8f3c...-uuid-da-mensagem",
  "timestamp": "2026-08-25T14:02:11.000Z"
}

O payload é o ESTADO ATUAL da resposta, não um delta. Case por sendId e sobrescreva. Você recebe mais de um aviso por resposta quando ela muda: o primeiro no voto (updated: false, comment: null), e outro quando a pessoa escreve o comentário ou troca a nota (updated: true). Não é duplicidade — o comentário sempre chega depois da nota, são dois gestos na página, e com um aviso só o campo mais valioso de um detrator nunca chegaria até você.

Endpoint com escopo de credencial não recebe survey.response: quem votou foi o destinatário, sem chave nenhuma, e mandar o aviso para o canal errado é pior que não mandar.

O teto de escrita destas rotas

As mutações desta seção (POST, PUT, DELETE) e o disparo têm um teto por chave, somado ao teto por IP: padrão 120/min. As leituras não o pagam. Estourou → 429 rate_limited com Retry-After.

O disparo entra nesse teto por um motivo concreto: o link de voto tem de existir antes do corpo do e-mail, então o registro nasce antes de o envio ser julgado e é cancelado quando ele é recusado. Um laço contra um destinatário sempre-recusado ficaria criando e cancelando sem parar.

Webhooks de envio

Receba eventos de entrega no seu servidor. Vários endpoints por conta; cada um assina um subconjunto de eventos: delivered, bounced, opened, clicked, complained, blocked. Estes são os eventos do que você mandou — o aviso de e-mail recebido é outro cadastro, na seção Webhook de recepção.

Timeout e retentativas: cada tentativa espera até 10 segundos pela sua resposta; entrega é 2xx dentro desse prazo. Qualquer outra coisa — 4xx, 5xx, 3xx (redirecionamento não é seguido), timeout, conexão recusada — conta como falha e entra na retentativa: são 6 tentativas no total, com esperas crescentes de 30 s, 2 min, 10 min, 1 h e 6 h (janela de ~7 h), a mesma política e a mesma tabela do webhook de recepção. Esgotadas as seis, a entrega é marcada como falha e não há sétima. Responda 2xx rápido e faça o trabalho pesado depois: um endpoint que processa por 12 s antes de responder falha por timeout mesmo tendo feito tudo certo.

O evento blocked dispara quando as suas regras de envio barram um destinatário (na API, no SMTP ou numa resposta a inbound). O corpo traz reason (block_list ou not_on_allow_list), entry (a entrada que casou, quando é block), address e origin. Endpoint com escopo de credencial não recebe blocked — o bloqueio não carrega credencial.

POST/v1/webhooksescopo manage:webhooks

Cria um endpoint. O secret de assinatura é devolvido uma única vez.

curl -X POST https://api.oveyon.com/v1/webhooks \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://suaapp.com/hooks/oveyon",
        "events": ["delivered", "bounced", "complained"],
        "credential": { "type": "api", "id": 42 } }'
{
  "id": 7,
  "url": "https://suaapp.com/hooks/oveyon",
  "events": ["bounced", "complained", "delivered"],
  "active": true,
  "secret": "3b1f...64hex...  (exibido UMA vez)",
  "signature": {
    "header": "x-oveyon-signature: sha256=HMAC-SHA256(secret, `${timestamp}.${rawBody}`)",
    "timestampHeader": "x-oveyon-timestamp (epoch em segundos)",
    "verify": "Recompute o HMAC sobre `timestamp + \".\" + corpo-cru` e rejeite se |agora - timestamp| > 300s."
  }
}

Cada entrega leva x-oveyon-signature (HMAC-SHA256 de timestamp.corpo-cru) e x-oveyon-timestamp. Recompute o HMAC e rejeite se o timestamp divergir mais de 300s de agora. URLs internas/privadas são recusadas na criação (422 unsafe_url).

Escopo por credencial (opcional): com "credential": { "type": "smtp"|"api", "id": … }, o endpoint só recebe eventos de mensagens que entraram por aquela credencial — uma credencial por sistema, um hook por sistema, sem re-filtrar do seu lado. Omitido, o endpoint recebe a conta inteira (comportamento de sempre). A credencial precisa existir e ser sua: id alheio responde 404 credential_not_found. O escopo aparece de volta no GET /v1/webhooks.

GET/v1/webhooksescopo manage:webhooks

Lista seus endpoints. O secret nunca é ecoado (aparece mascarado).

POST/v1/webhooks/:idescopo manage:webhooks

Atualiza url, events e/ou active. Também disponível como PATCH (o POST é alias para clientes sem PATCH).

curl -X POST https://api.oveyon.com/v1/webhooks/7 \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "active": false }'
DELETE/v1/webhooks/:idescopo manage:webhooks

Remove um endpoint. Resposta 200 { deleted } · inexistente → 404.

Domínios

Cadastre e verifique os domínios de envio. Um domínio já de outra conta não pode ser recadastrado (anti-sequestro).

POST/v1/domainsescopo write:domains

Cadastra um domínio e devolve os registros DNS a publicar (SPF, DKIM e opcionais).

curl -X POST https://api.oveyon.com/v1/domains \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "domain": "suaempresa.com" }'
{
  "id": 42,
  "domain": "suaempresa.com",
  "records": [
    { "type": "TXT",   "name": "suaempresa.com", "value": "v=spf1 include:...", "purpose": "SPF (autoriza os IPs de envio)" },
    { "type": "CNAME", "name": "s90abc._domainkey.suaempresa.com", "value": "...", "purpose": "DKIM" }
  ],
  "verification": { "spf": false, "dkim": false, "dmarc": false }
}
  • 409 domain_exists — já é seu.
  • 409 domain_taken — indisponível (de outra conta).
  • 400 bad_domain — sintaxe inválida.
  • 403 domain_not_allowed_free — plano Free: o domínio foi recusado na triagem de reputação (Spamhaus Intelligence) antes de nascer. A resposta não diz o motivo de propósito; o suporte vê. Planos pagos não passam por essa triagem.
GET/v1/domainsescopo read:domains

Lista seus domínios e o estado de verificação (spf / dkim / dmarc).

POST/v1/domains/:id/verifyescopo write:domains

Dispara a checagem DNS ao vivo e persiste o resultado. Só o domínio da sua conta (senão 404). Se o resolvedor não responder para alguma checagem, ela aparece em inconclusive e o estado anterior daquela checagem é preservado — dúvida não desverifica.

curl -X POST https://api.oveyon.com/v1/domains/42/verify \
  -H "Authorization: Bearer $OVEYON_API_KEY"
{
  "id": 42,
  "domain": "suaempresa.com",
  "verification": { "spf": true, "dkim": true, "dmarc": false },
  "inconclusive": [],
  "detail": { "spf": "...", "dkim": "...", "dmarc": "registro DMARC não encontrado" }
}

Erros & limites

Erros são JSON com um campo error estável (e, quando útil, message e contexto). Trate pelo código HTTP e pelo error, não pela mensagem.

Idioma da resposta

message é localizada: sai em português para quem chama do Brasil e em inglês para todo o resto do mundo — inclusive quando não conseguimos determinar o país. O país vem do CF-IPCountry da Cloudflare; você não precisa mandar nada.

O error NUNCA muda de idioma. Ele é o contrato de máquina, é idêntico byte a byte em qualquer país, e é nele que a sua integração deve ramificar. O mesmo vale para todo o resto do corpo (required, have, domain, result, disposable, limit…): são dados, não texto.

# mesma chamada, mesmo erro, paises diferentes
# (o CF-IPCountry e injetado pela Cloudflare, voce nao manda nada)

BR  { "error": "bad_request", "message": "informe email OU domain, nunca os dois" }
US  { "error": "bad_request", "message": "provide email OR domain, never both" }
DE  { "error": "bad_request", "message": "provide email OR domain, never both" }
--  { "error": "bad_request", "message": "provide email OR domain, never both" }

// `error` identico nos quatro. So `message` muda.

Ou seja: if (body.error === 'rate_limited') — nunca if (body.message === '…'). Uma comparação por texto quebra no dia em que o primeiro cliente seu chamar de outro país.

CódigoSignificadoerror típico
200OK / duplicata idempotenteduplicate
201Criado
202Aceito (envio no spool)
400Requisição malformadabad_request, bad_cursor, bad_date, bad_status, bad_outcome, bad_events, bad_group_by, bad_domain, too_many_attachments
401Chave inválida/revogadaunauthorized
403Sem permissão / bloqueadoinsufficient_scope, key_disabled, sending_disabled, removal_blocked, account_suspended, account_unknown
404Não encontrado / fora do escoponot_found
409Conflitodomain_exists, domain_taken
413Payload grande demaisattachments_too_large
422Regra de negóciodomain_not_verified, invalid_recipient_domain, recipient_suppressed, unsafe_url, policy_refused
429Limite atingidorate_limited, too_many_auth_failures, quota_exceeded, service_quota_exceeded, warmup_cap_reached, queue_full
500 / 503Erro interno / injeção indisponível / base não carregadainternal_error, injection_failed, list_unavailable

Rate limit, cota e rampa

Regra de ouro para 429 e 503: recuo exponencial com jitter e retentativa. Erros 4xx de validação (400/422) não devem ser retentados sem corrigir a requisição.