API HTTP
Integre seu sistema ao OVEYON sem sair do painel. REST sobre HTTPS, JSON nos dois sentidos, autenticação por chave.
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/v1Início rápido
Do zero ao primeiro envio em três passos.
- 1. Crie uma chave em Credenciais. Ela aparece uma única vez (prefixo
ov_…). Guarde num cofre de segredos. - 2. Verifique um domínio em Domínios — sem domínio verificado o envio é recusado com
422 domain_not_verified. - 3. Envie com
POST /send:
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"- Chave ausente, inválida, revogada ou expirada →
401 unauthorized. É sempre a mesma resposta, de propósito: ela não conta se a chave existe nem de quem ela é. - Chave válida que foi desligada no painel →
403 key_disabled, em qualquer rota. O interruptor fica em Credenciais → Chaves de API; religar devolve a chave com o mesmo segredo. Não rotacione por causa deste erro — a chave não tem nada de errado, só está desligada; use outra chave ou religue esta. - Chave válida de uma conta bloqueada →
403 account_suspended, e vale em qualquer rota, não só no envio. Não é problema de credencial: rotacionar a chave ou criar outra não muda nada — fale com o suporte. Sem uma chave válida da conta, a resposta é o401acima; é por isso que este403não revela a ninguém que a conta existe. - Cada chave tem um nome/tag (para você distinguir integrações) e um conjunto de permissões (escopos). Gerencie ambos em Credenciais.
- Cada chave (e cada credencial SMTP) envia por todos os domínios da conta ou só pelos selecionados — a escolha fica em Credenciais, no painel da credencial, e vale na hora. Um
fromfora do conjunto →422 domain_not_allowed_for_credential(no SMTP,550 5.7.1): o domínio está em ordem, é esta credencial que não o usa. Rotacionar herda a escolha. - Revogar é imediato — a chave revogada para de autenticar na chamada seguinte. Rotacionar não é. A rotação emite a chave nova e deixa a anterior valendo por uma janela de carência (padrão 72 h; o painel diz o prazo exato no momento em que você rotaciona), para a sua aplicação trocar o segredo sem cair. É deliberado: sem a carência, «rotacionar» seria só «interromper», e o resultado prático seria ninguém rotacionar nunca. A consequência é a que importa — se a chave vazou, rotacionar não fecha o buraco: revogue. Rotacione quando o segredo ainda é só seu; revogue quando ele deixou de ser.
- Nunca exponha a chave no front-end nem em repositório.
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"] }| Escopo | Libera | Endpoints |
|---|---|---|
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 · GET /inbound/threads · GET /inbound/threads/:id · GET /inbound/stats
|
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
|
reply:inbound |
Responder e-mails recebidos (inbound) |
POST /inbound/:uid/reply
|
write:inbound |
Criar e alterar caixas de recepção (rotas e canais) |
POST /inbound/:uid/release · GET /inbound/routes · POST /inbound/routes · GET /inbound/routes/:id · PATCH /inbound/routes/:id · DELETE /inbound/routes/:id · DELETE /inbound/routes/:id/channels/:assocId
|
approve:holds |
Aprovar e rejeitar rascunhos aguardando aprovação |
GET /holds · POST /messages/:uuid/approve · POST /messages/:uuid/reject
|
read:inbound-policies |
Ler políticas da recepção |
GET /inbound-policies · GET /inbound-policies/decisions · GET /inbound-policies/:id · POST /inbound-policies/simulate
|
write:inbound-policies |
Criar, editar, ordenar e remover políticas da recepção (criar e editar exigem também «Ler e-mails recebidos») |
POST /inbound-policies · PUT /inbound-policies/:id · POST /inbound-policies/:id/pause · POST /inbound-policies/:id/unpause · POST /inbound-policies/reorder · DELETE /inbound-policies/:id
|
read:logs |
Ler o registro de chamadas da API |
GET /logs · GET /logs/:id
|
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 painel | Na 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 < ownerorganização: member < admin < ownerpessoal (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:
- Papel não enfraquece chave. Não existe «chave de
viewer». Uma chave comsendna mão de alguém que éviewerna conta continua enviando e-mail: a chave não sabe quem está com ela, e nunca perguntou. - Papel não fortalece chave. Ser
ownernão acrescenta escopo nenhum à chave que você criou. Faltou escopo, é403 insufficient_scope— inclusive para o dono da conta, inclusive com a chave dele. Esse403nunca quer dizer «seu papel é baixo demais»; ele quer dizer «esta chave não tem este escopo», e a resposta nomeia qual. - Tirar o acesso de alguém ao painel não revoga chave nenhuma. É a consequência que morde, então ela vai sem eufemismo: uma credencial não guarda quem a criou, e por isso nada cascateia quando um acesso é retirado. Quem saiu continua com o segredo, e o segredo continua valendo em nome da conta. Ao remover uma pessoa, revogue as chaves que estiveram na mão dela — em Credenciais, e revogue, não rotacione: a rotação deixa a anterior viva pela carência.
- Nenhuma chamada do
/v1enxerga duas contas. Não há parâmetro que peça isso, nem header, nem um modo «organização» — e conta suspensa não arrasta a vizinha: o403 account_suspendedé sobre a conta daquela chave, e só. Se você opera várias contas, são várias chaves, uma por conta.
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.
/v1/sendescopo sendEnfileira 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çalhoTo:e no envelope.cc— string ou lista. Vai no cabeçalhoCc: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 com400 bad_request, dizendo o limite e o que veio. Vale também para o assunto vindo de template.htmle/outext— ao menos um é obrigatório.templateId— id numérico ou onamede um template publicado. Mutuamente exclusivo comsubject/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 detemplateId(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-Abusee qualquer um com prefixoX-Oveyon-— são ignorados se enviados.idempotencyKey— string sua para deduplicar; alternativa ao headerIdempotency-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).sandbox—true(ou headerX-Oveyon-Sandbox: 1) aceita e congela sem entregar.
Destinatários, cota e cobrança
- Cada destinatário é uma unidade. Um envio com
to+ 2cc+ 1bccconsome 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
toebccvira uma entrega — e é cobrado uma vez. Vale a primeira ocorrência (toantes deccantes debcc); 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
idsó e não teria como dizer «foi para 3 dos 5». - O
idé da mensagem. O desfecho (entregue, quicou) é por destinatário e aparece emdeliveries[]noGET /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"— aidempotencyKeyjá tinha sido usada; devolve oidoriginal.
Erros
400bad_request—from/toausentes ou inválidos, semhtmlnemtext;too_many_attachments/bad_attachment.400too_many_recipients— mais de 5 endereços somandoto+cc+bcc. Trazlimitereceived.400bad_recipient— um dos endereços não é válido. Trazfield(to/cc/bcc) e cita o endereço na mensagem. Endereço inválido nunca é descartado em silêncio.401unauthorized·403insufficient_scope,key_disabled(chave desligada no painel — religue-a ou use outra),sending_disabled,account_suspendedouaccount_unknown— os dois últimos são estado da conta, não limite: não retente.413attachments_too_large— anexos acima de 15 MB;payload_too_large— o corpo inteiro da requisição acima de 25 MB (comlimit, em bytes).503injection_failed— a mensagem não entrou na fila; a cota é devolvida. Repita com a mesmaidempotencyKey.500noPOST /send— nã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 comidempotencyKey(a chave deduplica com segurança); sem a chave, retentar pode duplicar a mensagem — confirme antes peloGET /v1/messages.422domain_not_verified·invalid_recipient_domain·recipient_suppressed— os dois últimos trazemrecipientdizendo qual endereço causou.422domain_on_hold— o envio do domínio está travado para revisão: domínio registrado há poucos dias, listado na Spamhaus DBL, ou sinalizado na triagem de reputação. A mensagem diz qual dos três e o que fazer; no SMTP a recusa é550 5.7.1. Quem libera é o nosso time.422domain_not_allowed_for_credential— a chave está limitada a domínios selecionados da conta e o domínio dofromnão é um deles. Não é problema do domínio (ele está verificado): ajuste o conjunto da chave em Credenciais, ou use outra chave. No SMTP a recusa é550 5.7.1.400template_conflict—templateIdjunto desubject/html/text·400template_var_missing— variável exigida pelo template ausente dodata(trazvariable) ·422template_not_founde os demais erros de template. Nenhum deles consome cota nem queima aidempotencyKey.422unsub_footer_multi_recipient— esta chave envia com footer/List-Unsubscribeautomá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 trazpolicy.422policy_refused— uma política de envio sua recusou a mensagem. A resposta trazpolicy(o nome da política que decidiu) erecipient(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.429quota_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)
- Criar e editar sempre produzem rascunho — nada muda no envio até você publicar.
- A versão publicada é imutável: editar cria a vN+1 como rascunho; a vN continua exatamente como estava. Template em produção nunca muda por baixo de você.
- Publicar torna a versão a corrente do envio. Para voltar, publique de novo uma versão anterior (
{"version": N}) — o ponteiro anda nos dois sentidos, a linha nunca muda. - O envio pode fixar
version; rascunho nunca é servido, nem fixando a versão dele.
Sintaxe — o catálogo é fechado, e é contrato
| Bloco | O 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. |
- Nada além disso existe — helper, partial, comentário e afins são recusados na gravação (
400 template_parse_error, citando a tag). O catálogo cresce por decisão nossa, nunca por aceitação silenciosa. - Estrito por padrão: variável referenciada e ausente do
datarecusa o envio com400 template_var_missing— nunca um{{buraco}}visível no inbox. O opt-out é por variável, com| "padrão". - O dado nunca vira template: um valor contendo
{{outravar}}sai como texto literal — a substituição é de passo único, nada é re-interpretado. - Tetos: 256KB por campo na gravação; na expansão, 1MB de saída e 10.000 iterações somadas de
{{#each}}— estourou, o envio é recusado com erro nomeado. - Mensagem enviada por template carrega
templateId/templateVersionnoGET /v1/messagese no detalhe — o rastro de qual molde/versão gerou o quê.
/v1/templatesescopo read:templatesLista os templates da conta: id, name, currentVersion (a publicada; null = nunca publicado), latestVersion e drafts.
/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).
/v1/templatesescopo write:templatesCria 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\" }}."
}'/v1/templates/:refescopo write:templatesEdita o conteúdo (subject, html, text): com rascunho vivo, atualiza o rascunho; sem, cria a próxima versão como rascunho. A publicada nunca é tocada. O name é a identidade do template e não muda aqui (um name no corpo é ignorado). Responde como a criação: {"id", "name", "version", "status": "draft", "vars": [{"name", "kind", "required", "default"}]}. Em vars, o name é a raiz do caminho — a chave que vai no data: {{pedido.total}} declara pedido; kind diz o uso (var impressa, flag no {{#if}}, list no {{#each}}).
/v1/templates/:ref/publishescopo write:templatesSem corpo (ou {}): publica o rascunho mais novo. Com {"version": N}: torna aquela versão a corrente — inclusive uma já publicada (rollback). N é inteiro ≥ 1 (uma string só de dígitos também vale); qualquer outra coisa — true, [3], "0x3" — é 400 bad_request, nunca um palpite.
curl -X POST https://api.oveyon.com/v1/templates/pedidoChegou/publish \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" -d '{}'/v1/templates/:refescopo write:templatesRemove o template e todas as versões e responde {"ok": true, "id": 12} (404 template_not_found se ele não existe). 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
400template_invalid(trazfield) ·template_parse_error(traztag) ·template_source_too_large— recusas de gravação. ·bad_request— no publish,versionque não é inteiro ≥ 1.400template_var_missing(trazvariable) — no envio, dado incompleto para o template.404template_not_found— inexistente, de outra conta, sem versão publicada, ou aversionpedida não está publicada. NoPOST /v1/senda mesma condição sai como422.409template_name_taken·template_no_draft.422template_var_invalid·template_each_not_list(trazemvariable) ·template_output_too_large·template_too_many_iterations(trazemlimit) — recusas de render no envio. Ramifique sempre noerror. ·template_too_much_work(o render excedeu 500.000 nós visitados — laços vezes tamanho do corpo)
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.
/v1/messagesescopo read:messagesLista as mensagens, mais nova primeiro, com paginação por cursor (estável sob inserção concorrente).
Query
limit— 1 a 100 (default 25).cursor— onext_cursorda 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» ébouncedoufailed, e responder isso comstatusexige saber o agrupamento de cor e fazer duas chamadas.devolvidas—bounced,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.bloqueadas—suppressed. Paramos antes de tentar (supressão ou regra sua).fila—accepted,queued,deferred. Ainda vai sair sozinha.held—frozen. Retida, esperando decisão.entregues—delivered.
400 bad_outcome.recipient— destinatário exato. Procura em todos os destinatários do envio (to,ccebcc), 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, nunca403.credential— id numérico da chave.from/to— intervalo ISO 8601 (YYYY-MM-DDou 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",
"log_window": { "days": 15, "since": "2026-07-06T13:02:11.000Z" }
}O id público é sempre o uuid. next_cursor: null ⇒ última página.
Janela do registro do plano. A lista mostra os envios aceitos nos últimos N dias do seu plano (Free 3, Starter 15, Plus 20, Growth 30, Business 45, Scale 60, Enterprise 365); log_window diz quantos dias e desde quando (null = sem janela). Mensagem retida aparece até a decisão. from anterior à janela não é erro: a página começa onde o registro começa. As estatísticas (GET /v1/stats) são agregados e não seguem a janela. A plataforma guarda o registro por um período de retenção próprio, mais longo que a janela de qualquer plano (ou até o fim da sua janela, se ela for maior); depois dele a mensagem é apagada e passa a responder 404 not_found, com ou sem janela.
/v1/statsescopo read:statsRollup diário pré-computado, com taxas calculadas no servidor. Default: últimos 30 dias.
Query
group_by—day(default) ·domain·credential.from/to— intervalo ISO 8601.breakdown—provider,reasone/oucredential, separados por vírgula. Acrescenta a chavebreakdownà 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: emprovideré a família (Google,Microsoft…) ou o próprio domínio quando não é um provedor conhecido; emreasoné o código do motivo; emcredentialés:<id>para credencial SMTP ek:<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 é ovalue.breakdown=credentialegroup_by=credentialnão respondem à mesma pergunta.group_bydevolve uma série diária e enxerga somente chaves de API; obreakdowndevolve o total da janela e inclui também as credenciais SMTP. Se você envia por SMTP, é obreakdownque enxerga esse tráfego."value": "-"emcredentialé 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 comsentda série.Outrosemprovideré a cauda dos destinos fora dos 50 maiores, somada sobre a janela inteira — nunca uma soma de recortes por dia.reasonconta 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
totalmenor que a soma desentda série — ospctsão sobre ototalda 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.
/v1/messages/:uuidescopo read:messagesDetalhe de uma mensagem: status, timeline de eventos e eventos de tracking (open/click). Fora do escopo do tenant → 404 not_found. Mensagem sua aceita antes da janela do registro do plano → 404 outside_log_window, com a janela no message.
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.
Registro de chamadas
Toda chamada autenticada que as chaves da conta fazem à API fica registrada por 90 dias: a rota, o status, a duração, o IP, o user-agent e os corpos do pedido e da resposta. São os dados da tela Registro de chamadas do painel, que também explica cada erro e prepara o texto para colar numa IA.
- Os corpos são redigidos antes de gravar. Cabeçalho não é guardado — nem o
Authorization. Campo com nome de segredo (password,secret,token…) e valor com cara de chave (ov_…,Bearer …) viram«redigido»; o corpo da mensagem e o anexo (html,text,content…) viram o tamanho, e cada variável de template (data) guarda o nome e perde o valor. Das rotas de conteúdo da recepção e das duas rotas do registro, a resposta não é guardada: só o tamanho dela. - Teto de 32 KB por corpo. Acima disso o texto é cortado,
truncatedvemtruee o que sobrou pode não ser mais JSON válido. - O que fica de fora: a chamada recusada antes de a chave ser identificada (chave inválida, limite por IP) — não há conta a quem atribuí-la. A recusa de uma chave boa entra: desligada (
key_disabled), conta suspensa (account_suspended), IP fora da amarração (ip_not_allowed). - A gravação é assíncrona: a chamada aparece aqui alguns segundos depois de responder.
/v1/logsescopo read:logsLista as chamadas, mais nova primeiro, com paginação por cursor.
Query
limit— 1 a 100 (default 25).cursor— onext_cursorda página anterior.status—success,error, uma classe (2xx,4xx,5xx) ou um código exato (422). Fora disso,400 bad_status.credential— o id de uma chave da conta.from/to— intervalo ISO 8601.route— o padrão da rota, com ou sem o método:POST /v1/send,/v1/messages/:uuid. Malformado,400 bad_route.sdk— a família detectada no user-agent (curl,node,python…;none= sem user-agent). Fora da lista,400 bad_sdk.
Exemplo
curl "https://api.oveyon.com/v1/logs?status=error&route=/v1/send&limit=25" \
-H "Authorization: Bearer $OVEYON_API_KEY"Resposta
{
"data": [
{
"id": 48213,
"createdAt": "2026-10-08T14:03:27.481Z",
"method": "POST",
"route": "/v1/send",
"path": "/v1/send",
"status": 403,
"durationMs": 21,
"credential": { "id": 12, "name": "backend", "prefix": "ov_3f9a1" },
"ip": "203.0.113.24",
"userAgent": "curl/8.5.0",
"sdk": { "name": "curl", "version": "8.5.0" },
"error": "insufficient_scope",
"messageId": null
}
],
"next_cursor": "bDoxNzU5OTMyMjA3NDgxLjQ4MjEz"
}messageId liga a chamada à mensagem que ela criou ou consultou — o mesmo id de GET /v1/messages/:uuid. Mande um User-Agent que diga quem chama (meu-app/1.4): é por ele que sdk e o filtro do painel separam as suas integrações.
/v1/logs/:idescopo read:logsUma chamada, com os corpos do pedido e da resposta como foram guardados (texto). Id de outra conta, malformado ou que já passou da retenção → 404 not_found.
Resposta
{
"id": 48213,
"createdAt": "2026-10-08T14:03:27.481Z",
"method": "POST",
"route": "/v1/send",
"path": "/v1/send",
"status": 403,
"durationMs": 21,
"credential": { "id": 12, "name": "backend", "prefix": "ov_3f9a1" },
"ip": "203.0.113.24",
"userAgent": "curl/8.5.0",
"sdk": { "name": "curl", "version": "8.5.0" },
"error": "insufficient_scope",
"messageId": null,
"query": null,
"request": {
"body": "{\"from\":\"app@example.com\",\"to\":\"customer@example.com\",\"html\":{\"omitted\":\"content\",\"length\":5120},\"headers\":{\"X-Api-Key\":\"«redigido»\"}}",
"bytes": 5302,
"truncated": false
},
"response": {
"body": "{\"error\":\"insufficient_scope\",\"required\":\"send\",\"have\":[\"read:messages\"]}",
"bytes": 74,
"truncated": false
}
}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.
/v1/disposablesem autenticaçãoAceita 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,falseounull(quandounknown).list.updatedAt— quando a nossa base foi carregada pela última vez, elist.domainsquantos 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_listednã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.comnão herda o veredito deexemplo.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.domainsnunca 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.e-mailbox.com.
# Confira a propagação antes de pedir a verificação no painel:
dig +short MX suaempresa.com
# esperado: 10 mx1.e-mailbox.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.e-mailbox.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 só 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:
- Exato —
suporteaceitasuporte@suaempresa.come mais nada. Letras, números e. _ % + -, até 64 caracteres, sem@. - Catch-all —
*@suaempresa.comaceita qualquer endereço do domínio. Um por domínio. Prático para testar, mas ele também aceita o lixo que varredores mandam paraadmin@,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:inboundna chave (veja o aviso logo abaixo). - Ser avisado — webhook
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 owner — por 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
- Um e-mail, vários destinatários. Uma mensagem que chegou para
suporte@evendas@na mesma transação é uma mensagem com dois itens emrecipients. Nunca achatamos isso num campo só — se o seu código lê só o primeiro, ele vai perder o segundo. (No webhook é o espelho disso: a mesma mensagem vira dois eventos, um por destinatário.) - A cópia expira. O campo
expiresAtvai em toda resposta e diz até quando guardamos o original. Passado o prazo, a mensagem continua listada (assunto, remetente, manifesto de anexos), masexpiresAtviranulle as rotas de conteúdo —/content,/raw,/attachments— passam a responder404. Se você precisa do e-mail para sempre, baixe e guarde do seu lado. - Toda mensagem chega com um score de spam.
spamScore(0-100) é a nossa heurística interna, calculada quando a mensagem entra — quanto maior, mais cara de spam. Dois avisos:nullsignifica não calculado (mensagem recebida antes de o recurso existir, ou falha nossa ao calcular), o que não é o mesmo que0— zero é «olhamos e está limpa». E o score é sinal, não veredito: nós nunca recusamos nem descartamos por causa dele; se você quiser filtrar, a régua é sua. No webhook o mesmo valor vai no corpo (message.spamScore) e no headerx-spam-scoredo POST. - E toda mensagem chega com um veredito de segurança de agente.
agentSafetyresponde a uma pergunta que nenhum dos campos acima responde: esta mensagem carrega instruções escritas para a máquina que vai lê-la? É a diferença entre «isto parece spam» e «isto está tentando dar ordens ao seu agente». Detectamos texto escondido do humano e visível ao parser — o bloco Unicode Tags (cópias invisíveis do ASCII, sem uso legítimo em e-mail),display:none, comentário de HTML, sobrescrita de direção — e o julgamos junto do que ele MANDA fazer. O veredito lê o que o seu agente lê: o assunto, o remetente com o nome de exibição (que chega a você emfromName), o corpo e os anexos de texto; e procura a instrução depois de tirar tudo o que o Unicode declara ignorável (hífen condicional, seletor de variação, largura zero…), para que esses caracteres não quebrem a frase nem empurrem a instrução para fora do trecho analisado. Rodapé oculto sem instrução não acusa; instrução oculta acusa. Links da mensagem também entram no mesmo veredito, julgados contra a base do Safe Browsing.verdictéclean,suspiciousoudangerous;scorevai de 0 a 100 (cortes em 25 e 60). Os mesmos dois avisos do spamScore valem aqui:nullé não avaliado, que não éclean; e o veredito é sinal, não recusa — nós não devolvemos a mensagem para o remetente por causa dele, e a régua de o que fazer com ela é sua. Com uma exceção que você liga: cada caixa pode pedir que mensagensdangeroussejam seguradas em vez de entregues. Enquanto uma mensagem está segurada ela não gera webhook nem reenvio, aparece na Recepção como «segurada para revisão», e sai de lá quando você clicar em «Soltar» — nesse momento o despacho acontece normalmente, com o veredito junto. Se a caixa não pediu isso (o padrão), o comportamento é o do parágrafo acima: entregamos e você decide. No webhook vêm também ossignals, com o motivo de cada ponto — inclusive o texto que estava escondido, decodificado, para você poder ver com os próprios olhos o que o seu agente leria. E o veredito declara o que olhou.agentSafety.coveragevem junto dele (lista, detalhe e webhook):bodySampled— o motor viu cabeça e cauda (128 KiB) em vez do corpo inteiro, ou um anexo de texto cortado no teto, ou um cabeçalho absurdo que cortamos antes de ler (campo acima de 64 KiB, bloco acima de 512 KiB, ou de 5 mil linhas no cabeçalho da mensagem e mil numa parte — nenhum correio real chega perto), ou uma mensagem com mais de mil partes (lemos as primeiras mil);sanitized— o texto que entregamos (corpo, assunto ou nome do remetente) teve caracteres escondidos removidos (bloco Tags, largura zero, sobrescrita de direção; a cópia/rawos mantém, para perícia);unscannedAttachments— quantos anexos o motor não leu por não serem texto (PDF, imagem, planilha, zip): extraia antes de dar a um modelo. Mensagem anexada (.eml) é decodificada e entra no veredito; anexo de texto entra seja qual for o rótulo. Ossignalstambém vêm noGET /v1/inbound/{id}, e?agent_safety=dangerous(oususpicious,dangerous;none= ainda sem veredito) filtra a caixa por ele.coverage: nullé uma mensagem de antes de a cobertura ser gravada. - E se o link armar DEPOIS, nós voltamos atrás. Um link pode estar limpo na hora em que a mensagem chega e virar malicioso seis horas depois — quando o domínio é comprometido, ou quando quem montou o ataque liga a carga. Nenhuma verificação feita no aceite resolve isso, porque ela julga o que a mensagem era. Cada caixa liga o reexame para si (é uma opção do endereço, e vem desligada): quando ligado, reexaminamos os links das mensagens recebidas nos últimos 3 dias e, se um deles passar a constar em lista de ameaça, disparamos o evento
inbound.retro_flaggedno seu webhook: «a mensagem X, que entregamos como limpa, ficou maliciosa», com os links em questão. Três coisas que valem dizer: a mensagem continua entregue — nós não a recolhemos nem reescrevemos o corpo, e o desfecho dela no histórico não muda; o aviso sai uma vez só por mensagem; e oeventIdé próprio, então o seu dedupe não vai confundir a retratação com uma repetição do aviso de chegada. Diferente do resto do mercado, nós não trocamos o link do seu e-mail por um nosso para decidir no clique — o corpo sai como chegou, e a retratação é um aviso, não um sequestro do link. O preço dessa escolha é honesto: quem já clicou não foi protegido por nós, foi informado, e o seu agente decide o que fazer com a informação. O corpo do evento é este:{ "event": "inbound.retro_flagged", "eventId": "9f1c…", // único por POST — deduplique por ele "timestamp": "2026-09-08T11:04:22.117Z", "message": { "id": "a3e1…", "subject": "Nota fiscal 4471", "fromHeader": "fin@cliente.com" }, "recipient": { "id": "7c22…", "to": "suporte@seudominio.com" }, "retro": { "reason": "link_listed_after_delivery", "message": "Um ou mais links desta mensagem passaram a constar em lista de ameaça DEPOIS de nós a entregarmos.", "links": [{ "url": "http://…", "threatTypes": ["MALWARE"] }] } }Ele vai só por webhook: Telegram e reenvio são superfícies de leitura humana, e uma retratação que chega como mensagem de chat é ruído sem ação possível — quem consome isto é o seu agente.
- E se a caixa segura pelo veredito, você fica sabendo na hora. Caixas com «Segurar o que parecer perigoso» ligado não recebem o corpo de uma mensagem com veredito
dangerous: ela fica retida para revisão humana, e no mesmo instante você recebeinbound.quarantinedno webhook (e um aviso no Telegram/Slack, se a caixa tiver). Nunca retemos sem avisar. O mesmo evento sai quando uma das suas políticas da recepção pede para segurar:reason: "policy"e o nome da regra emquarantine.policy(na quarentena de segurança,policyvemnull). A retenção é por caixa: numa mensagem para duas caixas, só a cópia da caixa que pediu fica segurada — a outra é entregue, a mensagem vem comoutcome: "partial"e cada item derecipientstraz o desfecho da sua cópia. Caixa que só encaminha para uma pessoa também segura: o aviso vai por e-mail ao endereço do encaminhamento (sem ele, pelo Telegram da conta; sem este, por e-mail aos donos). Responder por API quando TODAS as cópias estão retidas devolve409 message_quarantinedaté alguém soltá-las; com parte entregue, a resposta sai pela caixa entregue.{ "event": "inbound.quarantined", "eventId": "2b7d…", // único por POST — deduplique por ele "timestamp": "2026-09-16T18:40:07.221Z", "message": { "id": "a3e1…", "subject": "Liberação de pagamento", "fromHeader": "financeiro@acme-pagamentos.com" }, "recipient": { "id": "7c22…", "to": "suporte@seudominio.com" }, "quarantine": { "reason": "agent_safety_dangerous", "heldAt": "2026-09-16T18:40:07.221Z", "expiresAt": null, "agentSafety": { "verdict": "dangerous", "score": 65 }, "policy": null, "release": { "panel": "https://app.oveyon.com/app/recepcao/entregas", "note": "O corpo está segurado, não entregue. A resposta por API devolve 409 message_quarantined até que uma pessoa solte a mensagem no painel." } } } - Políticas da recepção: SE isto chegar, ENTÃO faça aquilo.
/v1/inbound-policies(escoposread:inbound-policiesewrite:inbound-policies; criar e editar exigem tambémread:inbound, porque uma regra sobre o conteúdo, ativada, revela em quais mensagens casou) guarda até 50 regras por conta, avaliadas de cima para baixo para cada destinatário de toda mensagem recebida.mailboxesdiz para quais caixas a regra vale (null= todas; uma lista de ids deGET /v1/inbound/routes= só estas): cada destinatário é julgado pelas regras da caixa dele e pelas de todas, na ordem única da conta — uma mensagem para suporte@ e vendas@ pode ser segurada para uma e entregue para a outra. Se as caixas escolhidas forem apagadas, a regra passa a valer para nenhuma (a lista volta vazia), nunca para todas. As condições enxergam o que só a recepção vê:agent_safety.verdicteagent_safety.score, o corpo (body, texto ou HTML sem tags),dmarc/spf/dkim, anexos (attachment.type,attachment.name,attachment.count),size_kb,listed_link, a conversa (thread.new,thread.replies), remetente, destinatário, assunto, hora e dia da semana no fuso da conta. Operadoresequals,contains,starts_with,ends_with,matches(glob com*— nunca regex),in,at_least,at_most; campo vazio (sem veredito, por exemplo) nunca casa. O texto (assunto, corpo, nome de anexo) é comparado como o agente o lê: sem os caracteres que o Unicode declara ignoráveis (largura zero, hífen condicional, seletores de variação, bidi…) e em NFKC —faturaem largura total éfatura, dos dois lados; um valor feito só de caracteres ignoráveis é vazio (empty_value). O corpo é julgado sobre a amostra de cabeça e cauda recortada depois dessa limpeza, e todo anexo que a borda aceita (até 1.000 partes) é julgado. Ações:holdsegura para uma pessoa decidir (e avisa, como a quarentena),muteguarda sem despachar nem avisar,restrict_channelsentrega só aos canais escolhidos,passencerra a cadeia — etagenotify(Telegram da conta) anotam e seguem. O aviso donotifyé de melhor esforço: sai depois do aceite, com três tentativas, e um reinício no meio pode perdê-lo — o feed registrano_chatounotify_failedquando dá para saber. Se a quarentena de segurança da caixa segura a mensagem, ela vem antes: a política dehold/muteque também casou aparece no feed com o motivomailbox_quarantine(o aviso é o da quarentena, sem o prazo e fora do teto da política). A política nasce pausada: confira no simulador (POST /v1/inbound-policies/simulate, com o id de uma recebida ou uma mensagem sintética, e um rascunho opcional — ele responde por destinatário emrecipients) e então/unpause. Enquanto a trava da plataforma estiver desligada,hold,muteerestrict_channelsviram a etiquetawould_<ação>:<nome>— você vê o que a regra faria antes de ela valer; o mesmo acontece quando uma política chega a 200 mensagens seguradas. E nunca retemos às escuras: umholdque não consegue avisar em canal nenhum (no_notice), ou umrestrict_channelscujos canais não estão ligados à caixa (no_channel), entrega a mensagem normalmente, com a etiqueta e o motivo no feed. Toda recebida trazpolicy(ação efetiva, nome e etiquetas) na lista, no detalhe e no webhook — a da primeira caixa na mensagem, a de cada caixa emrecipients[].policye no webhook daquela caixa; o que a política segurou você solta como a quarentena (painel ouPOST /v1/inbound/{id}/release). O feed (GET /v1/inbound-policies/decisions, 90 dias) diz qual regra casou em qual mensagem, para qual caixa (recipient) e com que ação de fato. O teto de seguradas por política conta mensagens, não cópias.POST /v1/inbound-policies { "name": "PDF suspeito", "action": "hold", "conditions": [ { "field": "agent_safety.verdict", "op": "at_least", "value": "suspicious" }, { "field": "attachment.type", "op": "equals", "value": "application/pdf" } ] } 201 { "id": 12, "position": 3, "paused": true, "version": 1, … } POST /v1/inbound-policies { "name": "DMARC falhou: só o humano", "action": "restrict_channels", "conditions": [ { "field": "dmarc", "op": "equals", "value": "fail" } ], "actionParams": { "channels": [ { "type": "telegram" } ] } } POST /v1/inbound-policies/simulate { "message": "a3e1…", "policyId": 12 } 200 { "holdEnabled": false, "matched": [ { "id": 12, "name": "PDF suspeito", "action": "hold" } ], "result": { "action": "tag", "policy": "PDF suspeito", "degradedReason": "hold_disabled", "tags": ["would_hold:PDF suspeito"] }, "seen": { … } }Sem
policynempolicyId, o simulador avalia só as políticas ativas — a recém-criada (pausada) fica de fora;policyIdavalia uma política salva mesmo pausada, epolicyavalia um rascunho não salvo (os dois juntos são 400). Simular sobre uma recebida real lê a caixa: a chave precisa também deread:inbound; toda simulação — sintética ou não — conta no limite por chave de leitura da caixa (120/min por padrão), e o 429 dele respondeRetry-After: 60: acima dos 30 s que os SDKs oficiais esperam por padrão, então eles não repetem sozinhos — o 429 chega ao seu código, com oRetry-Afterem mãos; espere e chame de novo, ou suba o teto de espera do SDK. O corpo das rotas de políticas tem teto de 256 KB (413 payload_too_large, comlimit: 262144). Pela mesma razão da leitura da caixa, o feed só mostra assunto e remetente para chaves comread:inbound. Numa recebida real o simulador relê a mensagem guardada pelo mesmo leitor do aceite — a mesma amostra do corpo, o remetente, o assunto e os nomes de anexo inteiros — e julga olisted_linkque o aceite gravou (nullquando o aceite não conseguiu julgar todos os links; mensagens anteriores a 27/09/2026 só guardam o «sim»); já prevêno_notice/no_channelpelas caixas da mensagem; a conversa é lida como está agora. Sem a cópia (retenção vencida), vale o que ficou gravado, e o que foi cortado na gravação não casa pelo lado perdido. Uma política salva que o motor não lê mais (policyId) responde409 inbound_policy_unreadable, com o motivo emreason: ela está sendo pulada no aceite até ser editada.O que as condições enxergam, com precisão: o
bodyé julgado no texto e no HTML sem tags (casa se qualquer um casar), depois da mesma limpeza do veredito (caracteres invisíveis, bidi e de tag somem); as listas (recipient,attachment.*) são julgadas nos 100 primeiros itens; um endereço com mais de 320 caracteres guarda o fim (o domínio), e nele sóends_with,containse padrões começando em*casam;listed_linké nulo — e não casa — quando a checagem não julgou todos os links. Confie com medida:sender,subjectethread.*são o que o remetente diz;dmarc/spf/dkim,agent_safety.*elisted_linksão medições nossas. A etiqueta de ensaio é semprewould_<ação>:<nome>com o nome da ação da API (would_hold,would_mute,would_restrict_channels): quem integra deve tratá-la como a intenção da regra. O que uma políticaholdsegurou corre a retenção da caixa (o prazo vem emquarantine.expiresAtno aviso e aparece no painel); sem soltar até lá, a cópia é apagada, e soltar ou responder passam a devolver410 copy_expired. O aviso denotifyvai aos chats de Telegram da conta; se nenhum recebeu, o feed diz por quê (no_chatounotify_failed). - O agente propõe, uma pessoa decide (hold). Mande
hold: truenoPOST /v1/sendou no/replye a mensagem é aceita, assinada e guardada sem sair: o 202 respondestatus: "held"com as URLs de decisão.holdNote(até 500 caracteres) é o que o agente diz ao aprovador;holdTtlé o prazo em segundos (24 h por padrão, 7 dias no máximo). A pessoa decide no portal (Mensagens → Aprovações), no Telegram da conta ou pela API:GET /v1/holdslista,POST /v1/messages/{id}/approveenvia agora sem reassinar (e conta aquecimento),POST /v1/messages/{id}/rejectnão envia e devolve a cota. Tudo com o escopoapprove:holds— a chave que envia não é a que aprova. A primeira decisão vence; a segunda recebe409 already_decided. Vencido o prazo, o rascunho expira e não sai (webhookhold_expired). Os quatro eventos (held,approved,rejected,hold_expired) são opt-in por endpoint e levam metadados e a URL, nunca o corpo. Teto: 500 rascunhos e 200 MB por conta (429 hold_limit). A política de envioreter_para_aprovacaoproduz o mesmo rascunho a partir de uma regra sua.POST /v1/send { "from": "vendas@seudominio.com", "to": "cliente@acme.com", "subject": "Proposta 4471", "text": "…", "hold": true, "holdTtl": 7200, "holdNote": "Desconto acima de 15% — precisa de alguém do comercial" } 202 { "id": "9f2c…", "status": "held", "hold": { "expiresAt": "2026-09-23T16:02:11.000Z", "note": "…", "requestedBy": "api:ovy_a1b2", "approve": "/v1/messages/9f2c…/approve", "reject": "/v1/messages/9f2c…/reject" } } POST /v1/messages/9f2c…/reject { "reason": "valor errado na proposta" } 200 { "id": "9f2c…", "status": "rejected", "decidedAt": "…", "decidedBy": "api:ovy_c3d4", "reason": "valor errado na proposta" } - Soltar é gesto humano — pelo painel ou pela API.
POST /v1/inbound/{id}/release(escopowrite:inbound, nunca o da chave que responde) despacha agora as cópias seguradas da mensagem, e o payload de cada uma trazmessage.quarantine: quando ela foi segurada, por quê, quando e por onde foi solta — e o veredito junto, porque soltar não é absolver. Sem corpo solta TODAS as cópias seguradas;{ "recipient": "vendas@…" }solta só a daquela caixa (a resposta diz quais saíram emrecipientse quantas seguem presas emstillHeld).409 not_quarantinedse nenhuma cópia está segurada (recipient_not_quarantinedse é a daquela caixa que não está);422 recipient_not_in_messagese a mensagem não foi para essa caixa;410 copy_expiredse a cópia já expirou. Os números ficam emGET /v1/inbound/stats(por dia ou por destinatário, até 92 dias): recebidas, avaliadas, limpas, suspeitas, perigosas, seguradas e soltas — os mesmos do cartão «Segurança de agente» da Recepção, contados uma vez só.POST /v1/inbound/a3e1…/release 200 { "id": "a3e1…", "status": "released", "deliveries": 1, "noChannel": false, "releasedAt": "2026-09-16T19:02:11.000Z", "releasedBy": "api:ovy_a1b2" } GET /v1/inbound/stats?group_by=day&from=2026-09-01&to=2026-09-16 200 { "groupBy": "day", "data": [ { "day": "2026-09-16", "received": 41, "evaluated": 41, "clean": 39, "suspicious": 1, "dangerous": 1, "quarantined": 1, "released": 1 }, … ] } - A conversa é um objeto. Cada recebida traz
thread.id(na lista, no detalhe e no webhook): mensagens agrupadas porIn-Reply-To/Referencesdentro da sua conta, incluindo as respostas que você mandou pela OVEYON (portal, Telegram, Slack ou API) — quando o cliente responde à sua resposta, ela cai na mesma conversa. Nunca por assunto.GET /v1/inbound/threadslista as conversas por última atividade (cursor opaco),GET /v1/inbound/threads/{id}devolve as recebidas em ordem e as respostas enviadas, eGET /v1/inbound?thread=<id>filtra a caixa por uma conversa.thread: nullé uma mensagem de antes do agrupamento ou uma que o aceite não conseguiu resolver — nunca uma recusa.GET /v1/inbound/threads/2b7d… { "id": "2b7d…", "subject": "pedido 4471", "firstAt": "2026-09-16T14:02:11.000Z", "lastAt": "2026-09-16T18:40:07.221Z", "messageCount": 2, "replyCount": 1, "messages": [ { "id": "a3e1…", "subject": "Pedido 4471", "thread": { "id": "2b7d…" }, … }, { "id": "c9f0…", "subject": "Re: Pedido 4471", … } ], "replies": [ { "id": "bbcd…", "at": "2026-09-16T15:10:00.000Z", "origin": "api", "from": "suporte@seudominio.com", "to": "cliente@acme.com", "subject": "Re: Pedido 4471", "preview": "Olá! Segue a nota fiscal…" } ] } - O agente responde por API.
POST /v1/inbound/{id}/reply(escoporeply:inbound) manda uma resposta em texto da caixa que recebeu para o Reply-To/From do original, comIn-Reply-ToeReferencespreenchidos por nós — a resposta cai na mesma conversa do lado de lá e na sua (thread). Conta uma unidade de cota e consome aquecimento como qualquer correio que sai em nome do domínio; oiddevolvido é o da mensagem enviada, para você acompanhar emGET /v1/messages/{id}e nos webhooks de entrega.idempotencyKey(ou o header) devolve o mesmo id em vez de responder duas vezes. Guarda de laço, porque o caso é agente contra agente: a resposta sai comAuto-Submitted: auto-replied; responder a correio automático (Auto-Submitted, Precedence bulk/list/junk, remetente nulo) é422 auto_submitted; no máximo 5 respostas por mensagem e 10 respostas automáticas por conversa por hora (429). Mensagem segurada responde409 message_quarantinedcom a pontuação e o link de soltar — soltar é gesto humano, nunca ferramenta do modelo. Com várias caixas e só parte das cópias segurada, a resposta sai pela primeira caixa cuja cópia foi entregue;fromescolhe outra caixa da mensagem (422 from_not_recipientse ela não recebeu a mensagem;409 recipient_quarantinedse a cópia dela está segurada). Suas listas de bloqueio/permissão de destinatário valem aqui também (422). A chave limitada a domínios selecionados só responde por caixa de um domínio permitido — fora deles,422 domain_not_allowed_for_credential, sem consumir aidempotencyKey. O detalhe da recebida trazreplies[].POST /v1/inbound/a3e1…/reply { "text": "Olá! A nota fiscal 4471 foi reemitida e segue em anexo no portal.", "idempotencyKey": "ticket-8812-r1" } 202 { "id": "bbcd…", "status": "accepted", "from": "suporte@seudominio.com", "to": "cliente@acme.com", "subject": "Re: Pedido 4471", "thread": { "id": "2b7d…" }, "url": "/v1/messages/bbcd…" } - E o detalhe conta por onde a mensagem saiu — até a última palavra do outro lado.
deliveries[]traz um item por (destinatário, canal,kind): webhook, reenvio por e-mail, Telegram, Slack. Dois fatos, dois campos, de propósito:statusé a nossa metade —deliveredquer dizer que o seu endpoint respondeu 2xx, que o chat aceitou o aviso, ou que a cópia reenviada entrou na nossa fila de saída; não que a caixa do outro lado a tenha. No reenvio,destinationé a palavra do MX de destino quando a cópia chegou lá:accepted,deferredoubounced, com a resposta SMTP crua (response), quem respondeu (host) e quando (at) — o texto que você cola num chamado com o provedor.nullnos outros canais, ou enquanto ainda não se sabe. Leia os dois antes de dizer «chegou»: um reenviodeliveredcomdestination.status = "bounced"saiu daqui e não chegou a ninguém.kinddistingue o que foi despachado para o mesmo par: a mensagem na chegada (received), o aviso de quarentena (quarantined) ou o da retro-caça de link (retro). O que fica de fora: a configuração do canal (URL, segredo, chat) — é do canal, eGET /v1/inbound/routes/{id}já a serve, sem segredo. Três limites, ditos em voz alta:destinationsó existe quando a cópia saiu por um dos nossos nós de disparo (a rota normal; uma cópia que o master entrega direto, em contingência, ficanull); o veredito é o síncrono — o bounce assíncrono (a caixa aceita e devolve um DSN depois) não entra aqui; eacceptedé o que o MX disse, não o que a caixa mostra: o Gmail, por exemplo, aceita com 250 e descarta em silêncio — sem Spam, sem Lixeira — uma cópia cujoMessage-IDaquela conta já viu (supressão de duplicata), e o reenvio preserva o Message-ID de propósito, porque é ele que mantém a conversa e a assinatura DKIM do remetente. Medido em 22/09/2026: quatro cópias aceitas, nenhuma visível, até o Message-ID mudar. - A caixa nasce por API.
POST /v1/inbound/routes(escopowrite:inbound) criasuporte@seudominio.com— ou o pega-tudo*@seudominio.com— num domínio da sua conta e, na mesma chamada, pendura os avisos: um webhook novo ({ "type": "webhook", "url": … }, com a prova de posse da URL e o segredo devolvido uma vez) e/ou canais que já existem no domínio ({ "channelId": … }).GETlista e detalha (com os canais, nunca com segredo),PATCHliga/desliga e acrescenta canais,DELETEremove a caixa ou, porassocId, só um vínculo. A caixa criada por máquina nasce sem escolhas de segurança: entrega tudo com o veredito; segurar é escolha que se liga no painel. Tetos: 200 caixas por domínio e 1000 por conta (422).createdBydiz quem criou (api:<prefixo da chave>), e o painel mostra o mesmo.POST /v1/inbound/routes { "domain": "seudominio.com", "matchType": "exact", "localPart": "suporte", "channels": [ { "type": "webhook", "url": "https://app.acme.com/hooks/oveyon", "mode": "full" } ] } 201 { "id": 318, "address": "suporte@seudominio.com", "active": true, "inboundEnabled": true, "createdBy": "api:ovy_a1b2", "channels": [ { "assocId": 902, "channelId": 77, "type": "webhook", "destination": "https://app.acme.com/hooks/oveyon", "mode": "full", "active": true } ], "webhook": { "url": "https://app.acme.com/hooks/oveyon", "mode": "full", "secret": "…64 hex, mostrado uma vez…" } } - O texto novo, sem o citado. No webhook (modos
full) e noGET /v1/inbound/{id}/content, além detextvemreplyText: só o que a pessoa escreveu desta vez, sem o «Em 16/09, Fulano escreveu:», as linhas com>, o bloco De/Enviado/Para/Assunto do Outlook, a mensagem original/encaminhada e a assinatura--. É isso que se dá a um modelo: a conversa inteira já está na thread, e reprocessá-la a cada volta custa tokens e confusão.replyStrippeddiz se algo foi removido ereplyMarkerso quê. Sem nada a cortar,replyTexté igual atext. Português e inglês; resposta intercalada (sua resposta entre as linhas citadas) fica inteira. - Isto é conteúdo de terceiros. Corpo, HTML, nome e bytes de anexo vieram de quem enviou, não de nós. Trate tudo como dado hostil: nunca injete o
htmlno seu DOM sem sanitizar, e nunca use ofilenamede um anexo para montar caminho em disco. - Nem toda mensagem listada foi entregue. Cada item traz
outcome:"accepted"quando chegou a pelo menos um destinatário,"quarantined"quando todas as cópias estão seguradas para revisão,"partial"quando umas cópias foram entregues e outras estão seguradas (cada item derecipientsdiz o seu:outcome,heldByepolicy), ou"blocked"quando chegou, foi guardada e não foi entregue a ninguém — com o porquê emblockedReason. Uma mensagemblockednão disparou webhook, não tem destinatários (recipientsvem[]) e não foi cobrada: não conta na sua cota. Ela continua legível pelas rotas de conteúdo até oexpiresAt, como qualquer outra.- Ramifique por
outcome, nunca porblockedReason. A lista de motivos cresce — hoje só existe"inbound_disabled"(a recepção do domínio estava desligada quando a mensagem chegou); amanhã podem nascer outros. Código que enumera motivos passa a responder errado no dia em que o motivo seguinte aparecer. Trate qualqueroutcomediferente de"accepted"e de"partial"como «não entregue a ninguém», e oblockedReasoncomo texto para log e diagnóstico. - Quem já integrou não quebra. Os dois campos são novos e nada mudou de tipo ou sumiu. Se o seu código percorre
recipients, uma mensagemblockedsimplesmente não gera nenhuma iteração — o comportamento correto, sem alterar uma linha. - Quer só o que foi entregue?
?outcome=acceptedna listagem. Sem o parâmetro a lista traz os dois desfechos, de propósito: filtrar por omissão esconderia de você um e-mail que chegou de verdade.
- Ramifique por
/v1/inboundescopo read:inboundLista as mensagens recebidas, mais nova primeiro, com paginação por cursor.
Query
limit— 1 a 100 (default 25).cursor— onext_cursorda página anterior. O cursor desta rota é de um tipo próprio: reaproveitar aqui um cursor de/v1/messagesdevolve400 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 — ofromNamenã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 emdomain; o filtro aceita de volta o que a resposta mostrou). Domínio que não é seu devolve lista vazia, nunca403: 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_attachments—trueoufalse(também aceita1/0eyes/no).outcome— filtra pelo desfecho (accepted,blocked). Vocabulário aberto, pelo mesmo motivo dodmarc: 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.agent_safety—clean,suspicious,dangerousounone(= ainda sem veredito); vírgula combina (suspicious,dangerous). Vocabulário fechado: valor fora dele é400 bad_agent_safety.from/to— intervalo de recebimento, ISO 8601 (YYYY-MM-DDou 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,
// O veredito de segurança de agente, com o que ele OLHOU (coverage).
"agentSafety": { "verdict": "clean", "score": 0,
"coverage": { "bodySampled": false, "sanitized": false, "unscannedAttachments": 1 } },
"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"
}/v1/inbound/:uidescopo read:inboundDetalhe 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" }
],
// POR ONDE SAIU, E O QUE ACONTECEU. Um item por (destinatário, canal,
// kind — a chegada, o aviso de quarentena ou o da retro-caça de link).
// `status` é a NOSSA metade: `delivered` = seu endpoint respondeu 2xx, o
// chat aceitou, ou a cópia reenviada entrou na nossa fila de saída — não
// que a caixa do outro lado a tenha. No reenvio, `destination` é a palavra
// do MX de destino (accepted | deferred | bounced) com a resposta SMTP
// crua; `null` nos outros canais ou enquanto não se sabe. Leia os dois
// antes de dizer «chegou».
"deliveries": [
{ "id": "d999cc6d-…", "recipient": { "id": "d83ea519-…", "to": "suporte@suaempresa.com" },
"channel": "webhook", "kind": "received", "status": "delivered", "detail": "HTTP 200", "attempts": 1,
"createdAt": "2026-09-04T11:02:41.515Z", "completedAt": "2026-09-04T11:02:42.101Z",
"destination": null },
{ "id": "ca0e76d8-…", "recipient": { "id": "d83ea519-…", "to": "suporte@suaempresa.com" },
"channel": "forward", "kind": "received", "status": "delivered",
"detail": "reenviado para caixa@gmail.com (aceito pela fila de saída)", "attempts": 1,
"createdAt": "2026-09-04T11:02:41.518Z", "completedAt": "2026-09-04T11:02:48.079Z",
"destination": { "status": "accepted", "host": "gmail-smtp-in.l.google.com",
"response": "250 2.0.0 OK 1790113210 d9443c01a7336… - gsmtp",
"at": "2026-09-04T11:02:50.428Z" } }
],
"links": {
"self": "/v1/inbound/b71e0c34-…",
"content": "/v1/inbound/b71e0c34-…/content",
"raw": "/v1/inbound/b71e0c34-…/raw"
}
}/v1/inbound/:uid/contentescopo read:inboundO 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.
O texto vem higienizado. Caracteres invisíveis de contrabando — bloco Unicode Tags, largura-zero, sobrescrita de direção — são removidos de text e html — e também do assunto e do nome do remetente (fromName), no webhook e na API — antes de responder, e sanitized diz quando isso aconteceu. Eles não têm uso legítimo em e-mail e servem para dizer ao seu agente algo que a pessoa lendo a mensagem não vê. Se você precisa do original byte a byte — para reverificar o DKIM, para perícia, ou porque quer ver o que chegou no fio —, use /raw: ele nunca é alterado.
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,
"sanitized": 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.
/v1/inbound/:uid/rawescopo read:inboundO .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.
/v1/inbound/:uid/attachments/:nescopo read:inboundOs 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
404 not_found— a mensagem não existe, ou não é da sua conta, ou o uid está malformado. A resposta é idêntica nos três casos, de propósito: distinguir seria contar a quem tenta adivinhar se o identificador dele acertou.400 bad_cursor/bad_date/bad_domain/bad_dmarc/bad_has_attachments/bad_outcome/bad_agent_safety— filtro fora do formato.403 insufficient_scope— a chave não temread:inbound.429 rate_limited— além do teto por IP, estas rotas têm um teto por chave (padrão 120 req/min): são as únicas do/v1que leem bytes do disco a cada chamada. A resposta diz o limite; respeite oretry-after.
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. No painel: Domínios → abra o domínio → aba Recepção → «Canais de aviso deste domínio» → + Novo canal — ou, no Configurar de um endereço, «Avisar por webhook». Você informa a URL e escolhe o modo; o segredo de assinatura é gerado por nós e aparece na criação — e, quando você precisar conferir, em «Mostrar segredo», no painel do canal. Cada exibição fica na trilha de auditoria da conta.
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. Pela API, o webhook nasce preso a uma caixa: o POST /v1/inbound/routes cria o endereço já com ele, e o PATCH /v1/inbound/routes/{id} acrescenta um a uma caixa que já existe — com a mesma prova de posse da URL, e o segredo vem uma vez, na resposta (a caixa nasce por API); depois, só o painel o mostra. Telegram, Slack, encaminhamento e o canal solto, sem caixa, são só no 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:
- corpo cru:
res.send(body.challenge) - JSON:
res.json({ challenge: body.challenge })
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 de criado o canal), 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.
message.idé o mesmo nos três eventos — é o e-mail.recipienteeventIdsão diferentes — é a entrega.- Se o seu código chaveia por
message.id, ele vai sobrescrever dois. Chaveie poreventId.
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,
"agentSafety": { "verdict": "clean", "score": 0, "signals": [],
"coverage": { "bodySampled": false, "sanitized": false, "unscannedAttachments": 1 } },
"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
| Modo | O que vai no POST | Bom 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.
degraded—truequando saiu menos do que você pediu. É o único campo que você precisa checar.requestedModeemode— o que foi pedido no cadastro e o que de fato saiu. Pediufull+attachmentse recebeumode: "full"? Os bytes ficaram de fora; o manifesto e as URLs continuam lá.reason— frase legível com tudo o que se perdeu, não só o último motivo (dois problemas na mesma mensagem viram dois trechos separados por;). Énullquando nada degradou.maxBytes— o teto em vigor para este POST.attachmentsInline—truesó quando os bytes vieram emattachments[].content. É tudo ou nada: nunca metade dos anexos embutidos.attachmentsListedvs.attachmentCount— quantos anexos vieram no manifesto e quantos a mensagem tem no total. Diferentes? A cauda da lista não coube; os que faltam estão na API.textTruncated/htmlTruncated— aquele campo veio cortado (ou removido). Detalhe, não substituto: quando um deles étrue,degradedtambém é.
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:
x-oveyon-event— o nome do evento (inbound.received).x-oveyon-timestamp— epoch em segundos.x-oveyon-signature—sha256=+ HMAC-SHA256 do segredo do canal sobretimestamp + "." + corpo-cru.
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).
| Tentativa | Quando | Acumulado desde a 1ª |
|---|---|---|
1 | assim que o evento entra na fila (o worker roda a cada 10 s) | — |
2 | 30 segundos depois da falha anterior | ~30 s |
3 | 2 minutos | ~2,5 min |
4 | 10 minutos | ~12,5 min |
5 | 1 hora | ~1h12 |
6 | 6 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 manifesto — ord, 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"- Por que não vêm sempre inline: base64 infla o arquivo em cerca de um terço, e um anexo de 15 MB viraria um POST de ~20 MB que nós tentaríamos até seis vezes, com 10 s de timeout. A maioria dos servidores recusa um corpo desse tamanho antes mesmo de conferir a assinatura — e aí o anexo derruba o aviso inteiro, não só a si mesmo.
- O modo
full+attachmentsembute os bytes só enquanto o POST inteiro couber no teto (256 KB por padrão; o valor daquele POST está emtruncation.maxBytes). Não coube? Os anexos passam a ir por link,attachmentsInlinevemfalse,reasonexplica, e as URLs continuam válidas. - É tudo ou nada. Nunca chegam alguns anexos embutidos e outros por link — ou o lote inteiro cabe, ou nenhum vem com
content. - O endereço do anexo é o
ord, nunca ofilename: dois anexos podem ter o mesmo nome, e o nome pode vir vazio ou com caminho dentro.
⚠ 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.
- Nunca renderize o
htmlsem sanitizar. Injetá-lo no seu DOM, num painel de atendimento ou num e-mail que você reenvia é XSS na sua origem, com a sessão do seu usuário. Passe por um sanitizador (DOMPurify e afins) ou use só otext. - Nunca use o
filenamepara montar caminho em disco. Ele pode conter../, barra, caractere nulo ou nome de arquivo do sistema. É path traversal servido de bandeja. Salve peloord(ou por um id seu) e guarde o nome original só como rótulo de exibição. - Não confie no
contentTypedeclarado pelo remetente, e não sirva o anexo de volta como documento na sua origem. Se precisar disponibilizar download, forceContent-Disposition: attachmenteX-Content-Type-Options: nosniff— é o que fazemos nas nossas rotas. - Leia
message.authenticationantes de acreditar em quem assina o e-mail.fromHeaderé o que o remetente declarou; SPF, DKIM e DMARC são o que se pôde provar. Fluxo que aciona algo importante a partir de um e-mail deve exigirdmarc: "pass"— sem isso, o «de: chefe@suaempresa.com» é digitável por qualquer um. - O
fromNameé o campo mais fácil de forjar do e-mail inteiro. Ele é o nome de exibição que o remetente escreveu — não precisa registrar domínio parecido nem quebrar nada: basta digitar. Um e-mail com"fromName": "Banco do Brasil"e"fromHeader": "x@dominio-qualquer.top"é o phishing mais comum que existe, e ele passa por SPF e DKIM sem problema (o domínio dele é legítimo — só não é o que o nome sugere). Exiba o nome se quiser, mas decida pelo endereço, nunca pelo nome.
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.
/v1/suppressionsescopo read:suppressionsLista com filtros e paginação por offset.
Query
email— busca por substring.reason—hard_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
}/v1/suppressionsescopo write:suppressionsAdiciona 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" }'/v1/suppressions/:emailescopo write:suppressionsRemove uma supressão (e-mail URL-encoded no path). Resposta 200 { deleted }.
- Removível apenas
manualehard_bounce. complainteunsubscribe→403 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.
/v1/policiesescopo read:policiesLista 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.
/v1/policiesescopo write:policiesCria 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.
/v1/policies/:idescopo write:policiesRemove uma entrada (o id vem do create/list). A mutação vale em segundos em todas as portas — API, SMTP e MX.
/v1/credentials/ip-rulesescopo read:policies/v1/credentials/ip-rulesescopo write:policies/v1/credentials/ip-rules/:idescopo write:policiesAmarraçã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" }'/v1/credentials/ip-rules/pauseescopo write:policies/v1/credentials/ip-rules/unpauseescopo write:policiesPausar 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-rulestrazpausedepausedAtem cada linha — faixa listada compaused: truenão está valendo. unpausepode devolverwarning.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:policiesnas 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
422 recipient_blocked— destinatário na sua block list; o campoentrynomeia a entrada que casou.422 recipient_not_allowed— sua allow list está ativa e o destinatário não está nela. A chamada é recusada INTEIRA (mesmo contrato da supressão).
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
name— até 120 caracteres. É ele que aparece na recusa e no histórico, e ele fica congelado no rastro: renomear a política não reescreve o que ela já decidiu.conditions— de 1 a 5, e todas precisam casar (E). Cada uma é{ campo, op, valor }. Uma política sem condição casaria com TODAS as suas mensagens, e por isso a lista vazia é recusada.action— uma só, do catálogo abaixo.actionParams— só para as ações que declaram parâmetro. Hoje sólimitar_hora, que exige{ "max": N }. Parâmetro a mais, a menos ou desconhecido é400.position— a ordem de julgamento, de cima para baixo. Ela é atribuída por nós (criar sempre põe no fim) e se muda por/reorder.
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
campo | op | valor | Observação |
|---|---|---|---|
destinatario | igual · contem · termina_em | texto (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). |
remetente | igual · contem · termina_em | texto (até 320) | O from que você escreveu, não o envelope reescrito. |
assunto | contem · igual | texto (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. |
credencial | e_id | { "tipo": "api"|"smtp", "id": N } | O tipo é obrigatório: chave de API e credencial SMTP têm espaços de id separados. |
credencial | tier_e | texto | Ex.: transactional. |
hora | entre | { "de": "22:00", "ate": "06:00" } | de inclusivo, ate exclusivo; de > ate vira a meia-noite. No fuso da sua conta (ajustável em Conta; padrão America/Sao_Paulo). |
Ações
recusar— a mensagem não é aceita:422 policy_refusedna API,550 5.7.1no SMTP. Não consome cota — e não por estorno: o motor roda antes do contador.reter— a mensagem é aceita (202/250) e fica congelada, sem sair. Só o suporte solta retenção de política de envio — não há rota nem botão de liberar para você. Como ela não devolve erro nenhum, o jeito de vê-la pela API é o feed de decisões.exigir_footer— obriga o rodapé de descadastro. Na API, com mais de um destinatário na mesma chamada, o envio é recusado com422 unsub_footer_multi_recipient: o link é individual.forcar_transacional— carimba a mensagem como transacional (rótulo e contabilidade). Hoje não muda o IP de saída, porque o rodízio por tier está desligado nesta plataforma.seguir_fluxo— não faz nada e encerra a avaliação das políticas. É a exceção que se põe ACIMA de uma regra mais ampla. Não isenta de listas, supressão nem cota.limitar_hora— ver a seção própria logo abaixo.
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.
- Conta por destinatário, e não por chamada — a mesma unidade da cota e do teto por credencial. Um envio para 5 pessoas gasta 5.
- Conta no aceite, nunca na checagem: mensagem recusada por cota, por rate ou por falha de injeção depois da política não consome o teto (e no SMTP, o que for recusado após o DATA é devolvido ao contador).
- Folga conhecida e limitada: a checagem é uma por mensagem e a contagem é N. Com
max: 100, 99 contados e uma chamada de 5 destinatários, a chamada passa e o balde termina em 104. Como o teto de destinatários por chamada é 5, o excesso máximo é de 4, uma vez por janela — a chamada seguinte já é barrada. É deliberado: apertá-lo exigiria reservar antes do aceite, trocando um excesso de 4 por reservas órfãs. - Estourado:
429 policy_rate_limitedcomRetry-After(epolicy,limit,used,retryAfterno corpo). No SMTP, um4.7.1que manda tentar de novo — nunca um 5xx, porque a janela vira sozinha. - O rastro da recusa é uma linha por política por hora, não uma por tentativa: uma rajada contra o próprio teto encheria o feed sem dizer nada de novo.
/v1/send-policiesescopo read:send-policiesA 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 pausedAt — pausada continua na lista e não vale.
/v1/send-policies/:idescopo read:send-policiesUma política. Id inexistente e id de outra conta respondem o mesmo 404 send_policy_not_found.
/v1/send-policiesescopo write:send-policiesNasce 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 conta → 422 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.
/v1/send-policies/:idescopo write:send-policiesSubstituiçã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.
/v1/send-policies/:id/pauseescopo write:send-policies/v1/send-policies/:id/unpauseescopo write:send-policiesLigar e desligar sem apagar. Ativar é sempre um gesto explícito: nenhuma política passa a julgar por ter sido salva.
/v1/send-policies/reorderescopo write:send-policiesCorpo { "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.
/v1/send-policies/:idescopo write:send-policiesRemove 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.
/v1/send-policies/decisionsescopo read:send-policiesO 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
422 policy_refused— recusada por uma política sua. O corpo nomeia a política (policy) e o destinatário que casou (recipient). Não consome cota.429 policy_rate_limited— o teto por hora de uma política sua foi atingido. Honre oRetry-After: a janela vira sozinha.422 unsub_footer_multi_recipient— uma política exige rodapé e a chamada tem mais de um destinatário.
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
kind | Faixa | O que o rollup publica |
|---|---|---|
nps | 0 a 10 (onze links) | nps = %promotores (9–10) − %detratores (0–6), arredondado; mais promotores, detratores, neutros, media e distribuicao. |
csat | 1 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 null — nunca 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.
- Primeiro voto vale. Clicar outra nota depois não troca em silêncio: a página mostra a nota registrada e pede uma confirmação. É o que protege o seu número do scanner de link corporativo, que clica os onze links do NPS em ordem — com «último vale», todo destinatário atrás de um scanner viraria a nota do último link sem abrir o e-mail.
- Clique de robô não vota. Proxies de imagem e scanners conhecidos são reconhecidos e ignorados; a página responde igual para eles (nada que os ensine a se disfarçar), e o humano que abrir o mesmo link depois vota normalmente.
- O comentário é opcional e chega depois da nota, na mesma página. Por isso você recebe mais de um
survey.responsepor resposta — ver o webhook abaixo. - Encaminhamento: o link pertence ao destinatário original. Se ele encaminhar o e-mail, o voto de quem clicar conta para ele. É a limitação honesta do modelo, e todo o mercado vive com ela.
- A língua é a da sua conta — a mesma dos e-mails que a plataforma manda para você: português para conta do Brasil, inglês para as demais. Ela vale para o texto fixo do e-mail (o aviso de que responder não vota, as pontas da escala), para a página de voto e para a pergunta padrão. O que você escreve — assunto, pergunta, nome do remetente — sai como você escreveu.
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.
/v1/surveysescopo read:surveysTodas as suas pesquisas. Sem paginação: o teto da conta é 50 e a resposta traz max.
/v1/surveys/:idescopo read:surveysA 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.
/v1/surveysescopo write:surveysCria 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"
}'kind—npsoucsat. 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, na língua da sua conta (a do NPS é a que torna o seu número comparável com o do mercado). Gravada, ela é texto seu como qualquer outro: nada a reescreve depois.throttleDays— 7 a 3650, padrão 90. Texto ou número fora da faixa é400, nunca «caiu no padrão».brandColor(#rrggbb) elogoUrl(https absoluto) — a marca na página de voto.
Teto de 50 pesquisas por conta → 422 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.
/v1/surveys/:idescopo write:surveysSubstituiçã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.
/v1/surveys/:idescopo write:surveysApaga 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.
/v1/surveys/:id/sendescopo send:surveysDispara 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 (200status: "duplicate") e nenhum segundo e-mail. Sem ela, um retry de rede vira um segundo e-mail para a mesma pessoa — ou, pior, um429de 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, domain_not_allowed_for_credential… Os próprios da pesquisa são dois: 429 recipient_recently_surveyed (anti-fadiga) e 422 survey_inactive (a pesquisa está com active: false).
/v1/surveys/:id/responsesescopo read:surveysAs 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.
/v1/webhooksescopo manage:webhooksCria 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.
/v1/webhooksescopo manage:webhooksLista seus endpoints. O secret nunca é ecoado (aparece mascarado).
/v1/webhooks/:idescopo manage:webhooksAtualiza 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 }'/v1/webhooks/:idescopo manage:webhooksRemove 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).
/v1/domainsescopo write:domainsCadastra 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 da sua marca)" },
{ "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.422 domain_limit_reached— o plano chegou ao teto de domínios (no Free, 1). Remova um domínio que não usa mais ou fale com o suporte para aumentar o limite. A recusa vem antes da triagem de reputação: quem está no teto não gasta consulta.
/v1/domainsescopo read:domainsLista seus domínios e o estado de verificação (spf / dkim / dmarc).
/v1/domains/:id/verifyescopo write:domainsDispara 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": "nenhum registro _dmarc" }
}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.
A mesma régua vale para todo campo que é frase para pessoa: o detail das entregas da recepção e dos sinais do veredito de segurança, o label do detalhamento de /v1/stats, o purpose e o detail da verificação dos registros de domínio, o statusDetail das mensagens enviadas e a ajuda de signature na criação de um webhook. Webhook não tem quem chame: o texto do corpo (truncation.reason, retro.message, quarantine.release.note, o detail dos sinais) segue o país da conta — Brasil em português, o resto em inglês.
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ódigo | Significado | error típico |
|---|---|---|
200 | OK / duplicata idempotente | duplicate |
201 | Criado | — |
202 | Aceito (envio no spool) | — |
400 | Requisição malformada | invalid_json, bad_request, bad_cursor, bad_date, bad_status, bad_outcome, bad_events, bad_group_by, bad_domain, bad_route, bad_sdk, too_many_attachments |
401 | Chave inválida/revogada | unauthorized |
403 | Sem permissão / bloqueado | insufficient_scope, key_disabled, sending_disabled, removal_blocked, account_suspended, account_unknown, ip_not_allowed |
404 | Não encontrado / fora do escopo / rota que não existe | not_found |
409 | Conflito | domain_exists, domain_taken |
413 | Payload grande demais | payload_too_large (com limit), attachments_too_large, body_too_long |
415 | Tipo de conteúdo não aceito | unsupported_media_type |
422 | Regra de negócio | domain_not_verified, domain_not_allowed_for_credential, invalid_recipient_domain, recipient_suppressed, unsafe_url, policy_refused |
429 | Limite atingido | rate_limited, too_many_auth_failures, quota_exceeded, service_quota_exceeded, warmup_cap_reached, queue_full |
500 / 503 | Erro interno / injeção indisponível / base não carregada / amarração de IP da chave indisponível | internal_error, injection_failed, record_failed, list_unavailable, ip_rules_unavailable (com Retry-After: 30) |
O que é recusado antes da rota
Algumas respostas nascem antes de a rota rodar, e valem para toda operação — por isso a OpenAPI as declara em todas:
400 invalid_json— comContent-Type: application/json, o corpo tem de ser JSON válido e não pode vir vazio. Sem corpo, não mande o cabeçalho.413 payload_too_large— o corpo passou do limite daquela rota, informado emlimit(em bytes): 25 MB no geral, 256 KB nas rotas de políticas. O corpo é lido até o limite antes de a chave ser validada.415 unsupported_media_type— mande o corpo comoapplication/json.500 internal_error— falha nossa. O detalhe fica nos nossos logs, nunca no corpo. Repita leituras; repita uma escrita só se ela for idempotente ou levar chave de idempotência.503 ip_rules_unavailable— a chave tem faixas de IP amarradas e a conferência não pôde ser feita agora. É cautela, não recusa: repita depois doRetry-After.404 not_found— também para rota ou método que não existe; sem chave, a resposta é401.
Rate limit, cota e rampa
- Por IP — limite de requisições/min e de falhas de auth/min. Estourou →
429com headerRetry-After: 60. Aguarde e repita. - Cota do tenant —
429 quota_exceededcomwindow(diária|mensal),used/limiteretryAfterno corpo. Semwindow, oquota_exceededé o limite por minuto da conta: reduza o ritmo e retente. Cota por serviço (planos) —service_quota_exceeded, comused/quota/effectiveCapno corpo. - Conta bloqueada —
403 account_suspended(conta suspensa) e403 account_unknown(conta não encontrada). Não são limite e não voltam sozinhos: são403, e não429, justamente para que o seu backoff não retente — nenhuma espera resolve. Fale com o suporte. - Rampa (warmup) — teto diário por domínio enquanto a reputação aquece:
warmup_cap_reached. Retente mais tarde. - Backpressure — sistema saturado:
queue_full. Recuo exponencial e nova tentativa.
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.