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). Com a trava de aprovação humana ligada na chave, os escopos que a anulariam (approve:holds, write:send-policies, manage:webhooks, write:inbound, write:templates, write:surveys, write:domains, write:contacts, write:campaigns, send:campaigns, write:sequences) respondem 403 scope_blocked_by_approval_lock, mesmo gravados na chave:
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 · GET /inbound/instant
|
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 · POST /inbound/instant
|
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 · GET /logs/mcp · GET /logs/mcp/:id
|
read:contacts |
Ler contatos, tags e listas (marketing, experimental) |
GET /contacts · GET /contacts/find · GET /lists · GET /lists/:id/form
|
write:contacts |
Criar, editar e apagar contatos; inscrever e descadastrar em listas — na lista com confirmação dupla, inscrever manda o e-mail de confirmação (marketing, experimental) |
POST /contacts · DELETE /contacts · POST /contacts/properties · POST /contacts/tags · DELETE /contacts/tags · POST /lists/:id/subscribe · POST /lists/:id/unsubscribe · PATCH /lists/:id/form
|
read:campaigns |
Ler campanhas, a checagem e o relatório (marketing, experimental) |
GET /campaigns · GET /campaigns/:id · POST /campaigns/:id/check · GET /campaigns/:id/report
|
write:campaigns |
Criar, editar, ensaiar e pausar campanhas; enviar a prévia a um usuário da conta (marketing, experimental) |
POST /campaigns · PATCH /campaigns/:id · POST /campaigns/:id/preview · POST /campaigns/:id/rehearse · POST /campaigns/:id/pause
|
send:campaigns |
Aprovar, agendar, retomar, cancelar e apagar campanhas (marketing, experimental) |
DELETE /campaigns/:id · POST /campaigns/:id/approve · POST /campaigns/:id/schedule · POST /campaigns/:id/resume · POST /campaigns/:id/activate · POST /campaigns/:id/cancel
|
read:sequences |
Ler sequências e o funil de cada passo (marketing, experimental) |
GET /sequences · GET /sequences/:id · GET /sequences/:id/report
|
write:sequences |
Criar, editar, ligar, pausar, encerrar e apagar sequências (marketing, experimental) |
POST /sequences · PATCH /sequences/:id · DELETE /sequences/:id · POST /sequences/:id/start · POST /sequences/:id/pause · POST /sequences/:id/stop
|
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. Um endereço só: o nome de exibição não pode trazer outro endereço.to(obrigatório) — string (um endereço, ou vários separados por vírgula) ou lista de endereços; cada item é"a@b.com"ou"Nome <a@b.com>"(veja Endereço sem ambiguidade). 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). O valor de cada um é texto (ou lista de textos); um objeto é400 bad_request. Nos cabeçalhos de endereço (Reply-To,Sender,Cc…) o valor é lido como oto, com grupo permitido e cada membro conferido. 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. Até 190 caracteres ASCII imprimíveis, sem espaço (de!a~); um número inteiro também vale e vira o texto decimal dele (1234e"1234"são a mesma chave;0é «sem chave»); fora disso,400bad_request, sem consumir nada. A mesma chave devolve oidoriginal (200 duplicate) por 24 h a partir de quando a mensagem entrou na fila; passado isso, uma repetição pode criar outra mensagem.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.return_path—"sender"ou"platform": o endereço de retorno (Return-Path) desta mensagem. Sem o campo, vale o padrão da chave, escolhido em Credenciais. Veja Endereço de retorno. Atenção, quem vem da SparkPost: lá o campo é um endereço; aqui um endereço é400 bad_request(Mudanças na API).
Endereço de retorno (Return-Path)
O Return-Path — o remetente do envelope, o MAIL FROM — é para onde voltam os avisos de não entrega e as respostas automáticas, e é o endereço que o SPF autentica. Cada credencial (SMTP ou chave de API) escolhe um de dois modos em Credenciais, no painel da credencial (Visão geral); na API, return_path muda só a mensagem daquela chamada.
- Da plataforma (
"platform", o padrão) — o recomendado para envio automático, transacional e marketing. Cada mensagem sai com um endereço de retorno nosso, só dela: o bounce volta para nós, aparece emdeliveries[], dispara o webhook de bounce e o destinatário que não existe entra na supressão automática. - O próprio remetente (
"sender") — para caixa pessoal e listas de discussão. O envelope é o endereço dofrom, o mesmo em toda mensagem: a confirmação de inscrição em lista, o greylisting e a allowlist por remetente do envelope funcionam como num servidor de e-mail comum. O bounce que o servidor de destino devolve vai direto para a caixa do remetente e a plataforma não o rastreia: nessas mensagens não há supressão automática nem webhook de bounce. A recusa imediata que o nosso servidor presencia na entrega continua aparecendo no status da mensagem — e só chega por e-mail ao remetente se a credencial tiver o aviso de bounce para o remetente ligado (Preferências). OGET /v1/messages/:uuiddessas mensagens trazreturnPath: "sender"ebounceTracking: false.
O modo do remetente é de plano pago. Numa conta sem plano pago, return_path: "sender" responde 403 return_path_requires_paid_plan (nada entra na fila, nenhuma cota é usada) e o painel não liga o modo. A credencial que você deixou no próprio remetente e cuja conta voltou a um plano gratuito não falha: os envios dela saem com o endereço da plataforma até a conta voltar a um plano pago. Se o nosso suporte ligou o modo numa credencial como exceção, ele vale em qualquer plano — e o painel mostra isso.
O DNS que o modo do remetente exige. O domínio do from tem de nos autorizar a usar o endereço dele no envelope:
- o SPF do domínio tem o nosso
include:— o mesmo que a página do domínio manda publicar —, antes doall. Quem já tem SPF acrescenta o mecanismo ao registro que existe: um registro só, porque doisv=spf1no mesmo nome é erro; - com o relay MailChannels ativo na plataforma, também o TXT
_mailchannels.exemplo.com.br(no seu domínio), com oauth=da nossa conta.
O painel confere isso quando você liga o modo, mostra o registro exato que falta em cada domínio e só liga com todos os domínios da credencial em ordem. No envio cada mensagem é conferida de novo, e nunca trocamos para o endereço da plataforma em silêncio: sem a autorização a API responde 422 return_path_not_authorized (no SMTP, 550 5.7.1, com o registro na frase), sem consumir cota; DNS que não respondeu é 503 return_path_check_unavailable (no SMTP, 451 4.4.3) — tente de novo.
422 { "error": "return_path_not_authorized",
"message": "return_path=sender exige que o SPF de exemplo.com.br tenha include:_spf.… — publique e envie de novo, ou envie com return_path \"platform\"",
"reason": "spf_include_missing", "domain": "exemplo.com.br", "include": "_spf.…" }
reason é spf_include_missing (falta o include, ou não há SPF), spf_multiple (há mais de um registro SPF) ou mailchannels_lockdown_missing — este traz record ({ type, name, value }) no lugar de include. Credencial de relay (smarthost) não usa este recurso: o envelope dela é reescrito em SRS. A resposta pela recepção (POST /v1/inbound/{id}/reply) sai sempre com o endereço da plataforma. A caixa instantânea também: o domínio é da plataforma, não há o que publicar nele — o padrão da chave sai dali com o endereço da plataforma, e pedir return_path: "sender" dali responde 422 return_path_managed_domain.
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).202·status: "accepted"comfrozen: { reason: "policy", policyId }— aceita e congelada por uma política de envio sua com a açãoreter: não entregue, espera o nosso suporte (veja o feed de decisões). Ostatuscontinuaaccepted; quem distingue é o campofrozen.200·status: "duplicate"— aidempotencyKeyjá tinha sido usada; devolve oidoriginal.
Erros
400bad_request—from/toausentes ou inválidos, semhtmlnemtext,return_pathque não seja"sender"nem"platform"(um endereço ali nunca vira envelope),idempotencyKeyfora da forma acima;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.400comreason: "address_not_ascii"— um endereço tem caractere fora do ASCII (veja Endereços só em ASCII). Nos destinatários oerrorébad_recipient; nofrome nos cabeçalhos de endereço deheaders,bad_request. Trazfielde não cita o endereço.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.403return_path_requires_paid_plan—return_path: "sender"numa conta sem plano pago (veja Endereço de retorno). Nada entra na fila: mude de plano, ou envie comreturn_path: "platform".409idempotency_conflict— aidempotencyKeyjá está em uso por outra mensagem da conta: as chaves são comparadas sem distinguir maiúsculas (Pedido-AbCepedido-abcsão a mesma chave). Nada foi enviado; use outra chave.409idempotency_in_progress— outro pedido com a mesmaidempotencyKeyainda está em andamento (não chegou à fila). Nada foi decidido: repita depois doRetry-After(5 s) com a mesma chave — vem200 duplicatese aquele pedido enfileirou a mensagem, ou a resposta normal se ele falhou.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.503return_path_check_unavailable— a mensagem pediu o remetente como Return-Path e o DNS do domínio dofromnão respondeu agora. Não é recusa: nada entrou na fila; repita depois doRetry-After(60 s).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. No SMTP, o endereço suprimido é recusado com550 5.1.1. O endereço pausado por caixa cheia também sai comorecipient_suppressed, comreason: "mailbox_full"eexpiresAt(o fim da pausa) — também quando a pausa é da plataforma e não aparece na sua lista (ver Supressões); no SMTP, a recusa dele é temporária,452 4.2.2, e o seu servidor tenta de novo.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.422managed_domain_requires_paid_plan— ofromé a sua caixa instantânea e a conta está no plano gratuito, onde ela só recebe. O domínio está em ordem: envie pelo seu domínio verificado, ou mude de plano. No SMTP a recusa é550 5.7.1.422return_path_not_authorized— a mensagem sai com o remetente como Return-Path (return_path: "sender", ou o padrão da chave) e o domínio dofromainda não nos autoriza. Trazreason,domaine o que publicar (includeourecord). Não consome cota: publique e envie de novo, ou mande esta mensagem comreturn_path: "platform". No SMTP a recusa é550 5.7.1.422return_path_managed_domain—return_path: "sender"com ofromna sua caixa instantânea: o domínio é da plataforma, não há o que publicar. Trazdomain. Envie comreturn_path: "platform"(ou sem o campo).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). OmatchedBydiz por onde ela casou:recipient(a política tem condição de destinatário) oumessage(ela só olha a mensagem — assunto, remetente, credencial ou hora — e recusa qualquer destinatário). Com condição de hora, vem tambémretryAfter(ISO 8601): o fim da faixa, quando a mesma mensagem volta a passar. 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.
Só na mensagem que saiu com o próprio remetente como Return-Path: returnPath: "sender" e bounceTracking: false — o bounce dela foi para a caixa do remetente e não é rastreado aqui. Nas demais os dois campos não vêm.
Insights da mensagem
No painel, a página de cada mensagem enviada tem a aba Insights: regras de entregabilidade verificadas no que a mensagem guardou. Cada regra sai como precisa de atenção, indo bem ou não deu para verificar — quando o dado não permite, a regra diz por quê em vez de adivinhar.
- DMARC no domínio. O domínio de envio publica
_dmarc. Desde 2024 o Gmail e o Yahoo exigem DMARC de quem envia em volume; comece comp=nonee endureça depois de ler os relatórios. - Versão em texto. A mensagem leva uma parte
text/plainao lado do HTML (multipart/alternative): filtros de spam olham isso, e quem lê em texto puro não recebe uma mensagem vazia. Na API, mandetextjunto comhtml. - Remetente que aceita resposta. O From visível não é «no-reply»: resposta conta como engajamento para os provedores, e o destinatário consegue falar com você.
- Tamanho abaixo de 102 KB. O Gmail corta o corpo acima de ~102 KB e esconde o resto atrás de um link — o rodapé de descadastro e o pixel de abertura costumam ficar no pedaço escondido. Quando a mensagem tem várias partes (anexo, versão texto), o tamanho guardado é o do conjunto e o corpo sozinho não dá para medir.
- Links no domínio do remetente. Os links do corpo apontam para o mesmo domínio registrável do From. Só é verificável quando a checagem de links está ligada na credencial que enviou: é ela que registra os hosts do corpo.
- Domínio próprio de rastreio. Com rastreio de abertura ou clique ligado, o pixel e os links reescritos saem num subdomínio seu (configurado na página do domínio), não no domínio compartilhado da plataforma.
Registro de chamadas
Toda chamada autenticada que as chaves da conta fazem à API fica registrada por 180 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 — inclusive o nome do arquivo — (html,text,content,filename…) viram o tamanho, como o detalhe dos sinais de segurança (signals[].detail, que pode citar texto escondido no e-mail); cada variável de template (data) guarda o nome e perde o valor; e uma URL que pode carregar segredo no caminho vira só o host e o tamanho — o link rastreado da mensagem enviada (tracking[].url) e a URL de destino que você cadastra (channels[].url, e todourlnas rotas de webhooks e de caixas): em Zapier, Make e afins o caminho é o próprio segredo. Das rotas de conteúdo da recepção e das duas rotas do registro, a resposta não é guardada: só o tamanho dela. Nas rotas de marketing, o pedido e a query guardam só os nomes dos campos e os ids — os valores viram{ "omitted": "personal" }— e a resposta de sucesso não é guardada. - 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 —, e o
429do teto geral por chave. 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,
"mcpCallId": null
}
],
"next_cursor": "bDoxNzU5OTMyMjA3NDgxLjQ4MjEz"
}messageId liga a chamada à mensagem que ela criou ou consultou — o mesmo id de GET /v1/messages/:uuid. mcpCallId vem preenchido na chamada que um agente de IA fez pelo servidor MCP: é o callId da mensagem no registro do MCP (GET /v1/logs/mcp). 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,
"mcpCallId": 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
}
}Registro do MCP
O que os agentes de IA fizeram pelo servidor MCP, uma linha por mensagem JSON-RPC: a ferramenta que o agente chamou, por qual aplicativo (ChatGPT, Claude…), de onde, como terminou e quais chamadas à API ela fez. O mesmo prazo, 180 dias, e o mesmo escopo, read:logs. No painel é a aba MCP do Registro de chamadas.
- Este registro não guarda o resultado das ferramentas — o e-mail lido, a lista de mensagens: só o tamanho (
resultBytes). A chamada que a ferramenta faz à API fica no registro de chamadas, redigida como qualquer outra (e a resposta das rotas de conteúdo, comoGET /v1/inbound/:uid/content, nem lá). - Os argumentos são gravados redigidos: só os campos do esquema da ferramenta (outro campo vira
{ "omitted": "unknown_field" }), com as mesmas regras dos corpos acima; teto de 8 KB — o que não cabe no teto como JSON válido vira{ "omitted": "too_large", "bytes": N }, com o tamanho em bytes dos argumentos como chegaram, eargumentsTruncatedvemtrue. - O elo com o registro de chamadas: cada chamada à API que uma ferramenta fez leva em
mcpCallIdocallIdda mensagem — a tela e a API ligam uma à outra, nos dois sentidos. - O que fica de fora: a requisição cuja credencial não foi identificada, como no registro de chamadas (a chave conhecida e recusada — desligada, por exemplo — entra).
/v1/logs/mcpescopo read:logsLista as mensagens, mais nova primeiro, com paginação por cursor.
Query
limit/cursor— como emGET /v1/logs.outcome—ok;tool_error(a ferramenta rodou e devolveu erro — em geral, a resposta de erro da API);rejected(recusada antes de rodar: ferramenta desconhecida, argumento inválido);error(a própria requisição ao MCP falhou);aborted(o cliente desistiu antes da resposta). Fora detools/call,okquer dizer que o pedido foi respondido: um erro do protocolo dentro da resposta (método ou recurso desconhecido) não é lido. Valor fora da lista,400 bad_outcome.tool— o nome da ferramenta (get_inbound_content). Malformado,400 bad_tool.method— o método JSON-RPC (tools/call,tools/list,initialize…). Malformado,400 bad_method.credential— o id de uma credencial da conta (a conexão de um aplicativo ou uma chave).from/to— intervalo ISO 8601.
Exemplo
curl "https://api.oveyon.com/v1/logs/mcp?tool=get_inbound_content&limit=25" \
-H "Authorization: Bearer $OVEYON_API_KEY"Resposta
{
"data": [
{
"id": 9120,
"createdAt": "2026-10-14T03:12:08.204Z",
"callId": "5f0c8e2a9b1d4c7e8a3f6b2d1e9c0a47",
"credential": { "id": 41, "name": "ChatGPT (OAuth)", "prefix": "ov_7d2e1" },
"application": { "name": "ChatGPT", "origin": "chatgpt.com" },
"method": "tools/call",
"tool": "get_inbound_content",
"outcome": "ok",
"error": null,
"status": 200,
"durationMs": 184,
"apiCalls": 1,
"apiStatuses": [200],
"client": null,
"batch": { "size": 1, "index": 0 }
}
],
"next_cursor": "bToxNzYwNDExNTI4MjA0LjkxMjA"
}application é o aplicativo por trás de uma conexão OAuth — o nome que ele declarou e o host de onde vem; null quando a credencial é uma chave usada direto. client é o que o cliente declarou (o clientInfo) e não autentica nada.
/v1/logs/mcp/:idescopo read:logsUma mensagem, com os argumentos como foram guardados e apiLogIds: as chamadas à API que ela fez (os ids de GET /v1/logs/:id). Id de outra conta, malformado ou que já passou da retenção → 404 not_found.
Resposta
{
"id": 9120,
"createdAt": "2026-10-14T03:12:08.204Z",
"callId": "5f0c8e2a9b1d4c7e8a3f6b2d1e9c0a47",
"credential": { "id": 41, "name": "ChatGPT (OAuth)", "prefix": "ov_7d2e1" },
"application": { "name": "ChatGPT", "origin": "chatgpt.com" },
"method": "tools/call",
"tool": "get_inbound_content",
"outcome": "ok",
"error": null,
"status": 200,
"durationMs": 184,
"apiCalls": 1,
"apiStatuses": [200],
"client": null,
"batch": { "size": 1, "index": 0 },
"target": "get_inbound_content",
"httpMethod": "POST",
"rpcId": "7",
"rpcError": null,
"arguments": { "uid": "3b8f1c52-6a0e-4d3b-9f27-0c5e8a1d6b94" },
"argumentsTruncated": false,
"requestBytes": 142,
"resultBytes": 4183,
"apiRoute": "GET /v1/inbound/:uid/content",
"apiError": null,
"ip": "203.0.113.77",
"userAgent": "openai-mcp/1.0.0",
"protocolVersion": "2025-06-18",
"sessionId": null,
"apiLogIds": [48230]
}Servidor MCP (para agentes de IA)
A mesma API como ferramentas que um agente de IA (Claude Code, Cursor, VS Code, Codex, Windsurf…) chama pelo nome, pelo Model Context Protocol. Um endpoint só, Streamable HTTP, sem estado: POST https://mcp.oveyon.com/v1 (o endereço https://api.oveyon.com/v1/mcp também responde, para sempre). A tela MCP do painel imprime a configuração de cada cliente.
- A autenticação é a chave de API, como Bearer:
Authorization: Bearer ov_…. O mesmo portão de toda rota:401sem chave,403parakey_disabled,account_suspendedeip_not_allowed,429comRetry-After. - Uma ferramenta por rota. O servidor lista só as ferramentas que os escopos da chave permitem (mais
whoamiecheck_disposable):send_emailéPOST /v1/sendcom o escoposend,list_inboundéGET /v1/inboundcomread:inbound, e assim por diante — o esquema de entrada de cada ferramenta é o deste documento. - Toda chamada de ferramenta é uma chamada à API, com a mesma chave: escopos, cotas, políticas, idempotência e o registro de chamadas valem sem mudança; no registro elas aparecem como a família
oveyon-mcp(sdk), commcpCallId. A mensagem do agente — a ferramenta, o aplicativo, os argumentos redigidos — fica no registro do MCP. - O erro chega inteiro ao modelo:
error,messageeRetry-Afterno resultado da ferramenta (isError) — é o que faz um agente parar num403e esperar num429. - Sem chave, por OAuth 2.1: o cliente que conecta por login (o conector personalizado do claude.ai, do Claude Desktop e do celular; o ChatGPT) recebe só o endereço. O
401aponta o metadado do recurso (resource_metadata, RFC 9728), que aponta o emissor (/.well-known/oauth-authorization-serverno host do painel); código com PKCES256, registro dinâmico ou por documento de metadado (client_id= URL). A pessoa entra, escolhe as permissões e permite; a conexão vira uma credencial da conta (Credenciais), e o token só vale aqui no servidor MCP. Os tetos são os de uma chave: cada conexão tem o seu (600 req/min), seja qual for o IP de onde a plataforma chama, e a descoberta sem token e o token vencido não contam como falha de autenticação. No/oauth/token, só código ou refresh desconhecido conta, por origem (30/min); acima disso a resposta é429 too_many_requestscomRetry-After, e a renovação válida passa mesmo com a origem no teto, dentro de 120 consultas por minuto. - Nada fica entre uma chamada e outra: sem sessão (GET e DELETE respondem
405);whoamidiz a conta, a chave e as ferramentas; o recursooveyon://guideé o procedimento.
Conectar
claude mcp add --transport http oveyon https://mcp.oveyon.com/v1 --header "Authorization: Bearer $OVEYON_API_KEY"Na mão
curl -X POST https://mcp.oveyon.com/v1 \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'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 cada caixa pode ligar um filtro de spam de verdade. Além do
spamScore(a nossa heurística, de toda mensagem), a caixa pode ligar o filtro de spam da caixa: o rspamd da nossa borda de recepção, que olha conteúdo, reputação de link e de IP e a autenticação. É opt-in por caixa — nasce desligado em toda caixa e só você liga, no painel (Recepção → Caixas → a caixa) ou porPATCH /v1/inbound/routes/{id}com{ "spamFilter": true }; caixa com o filtro desligado nem é varrida. A mensagem passa a trazerspamFilter:status(scanned= tem nota;unavailable= o filtro não respondeu a tempo e a mensagem foi entregue sem nota;skipped= não varrida de propósito, ereasondiz por quê:too_largeoubypass, correio de um dos nossos próprios domínios, numa lista explícita, assinado (DKIM) por esse mesmo domínio e com DMARC aprovado — subdomínio não entra),score(decimal; negativo é cara de legítima; réguas comuns: 6 é provável spam, 15 é spam),action(o que o rspamd sugere — nós nunca executamos) esymbols(as regras que pesaram, da mais pesada para a mais leve; no detalhe e no webhook, fora da lista paginada).nullé «nenhuma caixa desta mensagem pediu o filtro», nunca «limpa». A nota é da cópia que pediu: numa mensagem para duas caixas,recipients[].spamFiltereddiz qual tinha o filtro, e o webhook da caixa que não pediu vem comspamFilter: null. O filtro só anota: nada é recusado, segurado ou descartado por ele. Para agir sobre a nota, use uma política da recepção comspam_filter.score— por exemplo, «pelo menos 6 →tag», ou «pelo menos 15 →hold». - 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. Um sinal não acusa instrução:texto_ilegivel, trecho que não se normaliza com segurança — longo demais sem nenhum caractere ASCII (um parágrafo muito longo em chinês ou japonês corrido, por exemplo) ou com marcas combinantes demais — e é lido em pedaços; sozinho, leva no máximo asuspicious. 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 (e o domínio dele,sender.domain, em queends_withé «o domínio ou um subdomínio dele»), destinatário, assunto, a nota do filtro de spam da caixa (spam_filter.score, decimal — vazia na cópia cuja caixa não ligou o filtro, e campo vazio nunca casa), hora e dia da semana no fuso da conta. Operadoresequals,contains,starts_with,ends_with,matches(glob com*),regex(só no assunto: uma expressão regular rodada num motor nosso de tempo linear — sem retrocesso, então nenhum padrão trava a recepção; até 200 caracteres; referência a grupo como\1, olhar à frente/atrás e(?…)são recusados comregex_unsupported; no máximo 10 por conta,regex_limit),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 — e esse rascunho não consulta o DNS dos destinatários no envio (o domínio escolhido pelo agente não pode levar dado na própria consulta): o domínio é conferido na aprovação, e o que não recebe e-mail responde422 invalid_recipient_domaincomrecipient; o rascunho é encerrado como rejeitado porsystem:recipient-domain, nada sai e a cota volta.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; até 190 caracteres ASCII imprimíveis sem espaço, ou um número inteiro) devolve o mesmo id em vez de responder duas vezes, por 24 h a partir de quando a resposta entrou na fila; com o primeiro pedido ainda em andamento, a repetição ouve409 idempotency_in_progresscomRetry-After. 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), e as políticas de envio nas duas ações que decidem se o correio sai:recusarresponde422 policy_refused, ereter_para_aprovacaotransforma a resposta num rascunho que uma pessoa aprova (status: held) — é assim que a chave marcada «exigir aprovação humana» segura toda resposta de um agente. 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[].Se a chave estiver em sandbox, a resposta é aceita e congelada sem entrega, com
status: "sandbox". Isso prevalece sobreholde políticas de aprovação, sem criar link para aprovar. Nesta rota, o sandbox vem do perfil da chave; campo ou header no pedido não o define. Se o perfil não puder ser verificado, a API devolve503 profile_unavailablee não envia a resposta; tente novamente com a mesmaidempotencyKey.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 a caixa e o filtro de spam dela (spamFilter) 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…" } } - A caixa instantânea: um endereço pronto, sem DNS. Toda conta pode receber no domínio da casa, com o código curto da conta — a conta
80001recebe em80001.oveyon.app.POST /v1/inbound/instant(escopowrite:inbound) sem corpo liga a caixa — o domínio e o pega-tudo*@80001.oveyon.app— e devolve o pega-tudo (201na primeira vez,200depois); comlocalPart, cria a caixa nomeada de um agente,bot@80001.oveyon.app, com o fio, os canais e as políticas dela: a mesma forma, os mesmos tetos e o mesmo webhook na chamada doPOST /v1/inbound/routes.GET /v1/inbound/instant(escoporead:inbound) diz o endereço, as caixas e o estado, sem ligar nada. Criadas, elas são caixas como as outras —PATCHeDELETE /v1/inbound/routes/{id}, o mesmo veredito, a mesma quarentena; desligue o pega-tudo e os endereços sem caixa nomeada voltam a ser recusados na porta (550). Receber vale em todo plano; enviar a partir dela, inclusive responder, só no plano pago: no gratuito,/v1/sende/v1/inbound/{id}/replyrespondem422managed_domain_requires_paid_plan(no SMTP,550 5.7.1). Encaminhar dela segue a regra de toda caixa: só para destino verificado. Cada conta recebe atélimits.perHourmensagens por hora ali — acima disso o MX responde450 4.2.1e o remetente retenta na hora seguinte —, e no plano gratuito o recebido fica o prazo do plano (limits.retentionDays). Enquanto a caixa instantânea não estiver ligada na plataforma, as duas rotas respondem404not_found, como uma rota que não existe. Religar a caixa depois de desligar a recepção dela (o interruptor da página do domínio) espera como esse interruptor: no máximo um ligar a cada 5 s por domínio e 20 por minuto por conta — antes disso,429rate_limitedcomRetry-After(em segundos, também emretryAfter), e nada muda: a recepção segue desligada. Desligar nunca espera.POST /v1/inbound/instant { "localPart": "bot", "channels": [ { "type": "webhook", "url": "https://app.acme.com/hooks/bot" } ] } 201 { "id": 512, "address": "bot@80001.oveyon.app", "domain": "80001.oveyon.app", "matchType": "exact", "active": true, "createdBy": "api:ovy_a1b2", "webhook": { "url": "https://app.acme.com/hooks/bot", "mode": "full", "secret": "…64 hex, mostrado uma vez…" } } GET /v1/inbound/instant 200 { "domain": "80001.oveyon.app", "available": true, "active": true, "catchAll": { "id": 511, "address": "*@80001.oveyon.app", "active": true }, "mailboxes": [ { "id": 512, "address": "bot@80001.oveyon.app", "active": true } ], "sending": { "allowed": false, "reason": "managed_domain_requires_paid_plan" }, "limits": { "perHour": 200, "thisHour": 3, "retentionDays": 3 } } - 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 filtro de spam da CAIXA (opt-in): null = nenhuma caixa pediu.
"spamFilter": { "status": "scanned", "score": -0.4, "action": "no action", "reason": null },
// 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). `contentType` é o rótulo do remetente, cru;
// `detectedType` é o tipo lido dos primeiros bytes do arquivo (null =
// sem assinatura reconhecida), e `typeMismatch` diz quando os dois
// discordam — como no 2º item: um .doc rotulado como imagem.
"attachments": [
{ "ord": 1, "filename": "orcamento.pdf", "contentType": "application/pdf",
"detectedType": "application/pdf", "typeMismatch": false,
"sizeBytes": 14233, "sha256": "9f2c…",
"url": "/v1/inbound/b71e0c34-…/attachments/1" },
{ "ord": 2, "filename": "contrato.doc", "contentType": "image/png",
"detectedType": "application/x-ole-storage", "typeMismatch": true,
"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 geral por chave, estas rotas têm um teto por chave próprio (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,
// O filtro de spam da caixa DESTA cópia (null se ela não pediu).
"spamFilter": { "status": "scanned", "score": -0.4, "action": "no action", "reason": null,
"symbols": [{ "name": "R_DKIM_ALLOW", "score": -0.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`. `detectedType` é o tipo pelos primeiros bytes (null = sem
// assinatura reconhecida); `typeMismatch` avisa quando o rótulo
// (`contentType`) e o conteúdo discordam — não descarte pelo rótulo.
"attachments": [
{ "ord": 1, "filename": "orcamento.pdf", "contentType": "application/pdf",
"detectedType": "application/pdf", "typeMismatch": false,
"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",
"detectedType": "application/pdf", "typeMismatch": false,
"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.
Dois tipos por anexo. contentType é o rótulo do remetente, cru; detectedType é o tipo lido dos primeiros bytes do arquivo (PDF, Office antigo e novo, imagens, compactados, áudio e vídeo, executáveis — null quando não há assinatura reconhecida, como em texto puro). typeMismatch vem true quando os dois existem e são de famílias diferentes — imagem, áudio e vídeo, PDF, PostScript, documento de escritório (Office antigo e novo, OpenDocument, RTF), compactado, executável, texto. Sinônimos do mesmo tipo (image/jpg, o application/x-zip-compressed do Outlook) e o mesmo contêiner (application/zip para um .docx) não contam; rótulo genérico (application/octet-stream) nunca discorda. Um rótulo fora dessas famílias só discorda quando o conteúdo é executável — o executável discorda de todo rótulo que não o declare. Não descarte um anexo pelo rótulo; prefira detectedType quando existir — um .doc já chegou rotulado image/png pelo programa de e-mail de quem mandou, e um filtro «só imagens» o jogaria fora.
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·mailbox_full.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": "lotada@exemplo.com", "reason": "mailbox_full", "source": "dsn",
"domainId": null, "expiresAt": "2026-07-21T10:02:00.000Z", "createdAt": "2026-07-20T10:02:00.000Z" },
{ "email": "invalido@exemplo.com", "reason": "hard_bounce", "source": "bounce",
"domainId": null, "expiresAt": null, "createdAt": "2026-07-20T09:11:00.000Z" }
],
"total": 2, "limit": 100, "offset": 0
}Caixa cheia é uma pausa, não uma supressão. Quando o servidor do destino recusa porque a caixa do destinatário está cheia (5.2.2, o adiamento 4.2.2, ou um 5.2.x que fala da cota ou do armazenamento da caixa), o endereço fica pausado por um prazo contado da recusa (24 horas por padrão; o fim exato é o expiresAt): até lá, os envios novos para ele são recusados — na API com 422 recipient_suppressed, reason: "mailbox_full" e expiresAt; no SMTP com 452 4.2.2, temporário, para o seu servidor tentar de novo. A pausa renova enquanto a caixa seguir cheia e cai antes do prazo quando uma entrega para o endereço é aceita pelo servidor do destino (a mensagem que ficou na fila e saiu depois que a pessoa liberou espaço); depois dela, o próximo envio testa de novo. Quando a recusa veio na própria conversa SMTP com o destino (o caso comum) e a mensagem tinha até 1 MiB, a pausa vale para a plataforma inteira: ela não aparece nesta lista e não pode ser removida por aqui — no painel, a busca pelo endereço exato mostra até quando, e quem a tira antes do prazo é o nosso time. Nela, o expiresAt que você vê (no 422, no 452 e no painel) sai arredondado para a hora cheia seguinte: o exato diria a hora da recusa de outro cliente. A pausa que veio por DSN assíncrono, ou de uma mensagem maior que 1 MiB, é só da sua conta: ela entra nesta lista com reason: "mailbox_full" e expiresAt, e você pode removê-la. A caixa cheia e inativa (o Gmail diz «out of storage space and inactive») não vai esvaziar: ela é tratada como caixa inexistente. Recusa pelo tamanho da mensagem (5.2.3, 5.3.4) não suprime nem pausa ninguém: é falha daquela mensagem. Linha com expiresAt no passado já não barra nada; domainId preenchido quer dizer que a linha vale só para aquele domínio de envio.
/v1/suppressionsescopo write:suppressionsAdiciona um endereço. reason default manual; mailbox_full não é aceito (só a plataforma grava a pausa) e responde 400 bad_request. Resposta 201 { email, reason }. Só endereço ASCII, o IDN em xn-- — fora disso, 400 com reason: "address_not_ascii" (também na remoção).
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
manual,hard_bounceemailbox_full(tirar antes do prazo a pausa da caixa cheia que é da sua conta; a da plataforma não está na sua lista). 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ê. Ela não devolve erro: o202doPOST /v1/sendmantémstatus: "accepted"e trazfrozen: { reason: "policy", policyId }; o histórico fica no 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. A exceção é a trava de aprovação humana de uma chave (a reter_para_aprovacao com a condição única «credencial é a chave N»): ela vale em qualquer posição. Para o envio dessa chave, seguir_fluxo, forcar_transacional, exigir_footer e limitar_hora não decidem: nada dessa chave sai sem uma pessoa. recusar continua barrando em qualquer posição, e reter acima da trava congela a mensagem (só o suporte solta). Na resposta (POST /v1/inbound/{id}/reply), que não aplica reter, a chave travada sempre gera um rascunho para aprovação, a não ser que recusar decida.
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 geral da chave: 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), e diz por onde ela casou emmatchedBy:recipient(ela tem condição de destinatário) oumessage(só olha a mensagem e recusa qualquer destinatário). Com condição de hora, vem tambémretryAfter(ISO 8601): o fim da faixa. Vale para o/v1/sende para a resposta da recepção (POST /v1/inbound/{id}/reply). 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(ou o header; até 190 caracteres ASCII imprimíveis sem espaço, ou um número inteiro — fora disso,400) — 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 geral da chave: 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). O que vem com optional: true não é preciso para enviar: o CNAME de tracking e, no modo de sempre, o SPF da raiz — o e-mail que sai por nós leva no envelope o nosso endereço de retorno, e o destino confere o SPF desse endereço. Quando uma credencial que envia por este domínio usa o próprio remetente como Return-Path (plano pago), o SPF da raiz vem sem optional: aí ele decide o e-mail que sai por nós.
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)", "optional": true },
{ "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.429 rate_limited— no máximo 60 cadastros de domínio por hora por conta (hora corrida, somada à do painel). Conta todo pedido que passa da sintaxe e do teto do plano, criado ou não; o recusado por este limite não conta. Nada foi criado: espere oRetry-After(segundos, também emretryAfter) e chame de novo.
/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.
spfProblem diz por que o SPF publicado quebra, e detail.spf traz a frase de como resolver. Ele não bloqueia o envio: quem barra é o DKIM não verificado. O e-mail que sai por nós leva no envelope o nosso endereço de retorno, e o destino confere o SPF desse endereço, não o do seu domínio. Por isso, quando o SPF do domínio quebra, o code é a causa (too_many_dns_lookups, too_many_void_lookups, multiple_spf_records, invalid_syntax, include_without_spf, too_many_mx_hosts, too_many_addresses) e o severity é "warning": seu envio por nós passa no SPF, e quem pode falhar são os outros serviços que enviam com o domínio (o e-mail da sua empresa, por exemplo). É "error" só quando uma credencial que envia por este domínio usa o próprio remetente como Return-Path (recurso de plano pago): aí o destino confere o SPF do domínio também no seu envio por nós, e ele falha. Com code lookups_over_limit é sempre "warning": seu envio por nós passa, mas o registro inteiro faz mais consultas ao DNS que o limite, e outros remetentes podem falhar. O custo vem em lookups (o total, contando include repetido), limit, exact (false quando a contagem parou num teto nosso e lookups é o piso) e terms (o custo de cada item do registro). Com record_too_large, um registro da cadeia (o do domínio, ou o de um include ou redirect) passa de 4096 bytes: ele não é avaliado — o SPF não conta como verificado, sem passar nem falhar — e o detail.spf diz qual encurtar. Sem nada a dizer, é null.
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" },
"spfProblem": null
}429 rate_limited— no máximo 60 verificações ao vivo por minuto por conta (minuto corrido, somado ao do painel: o «Verificar agora», o ↻ da lista de domínios, o assistente e a troca do modo DKIM). Conta só a verificação de um domínio seu (o404não conta), e o recusado por este limite também não. Nada foi conferido nem gravado: espere oRetry-After(segundos, também emretryAfter) e chame de novo.
Marketing (experimental)
Contatos, propriedades, tags, listas de newsletter, campanhas e sequências. Experimental: o módulo é ligado conta a conta, e sem ele toda rota desta seção responde 403 module_disabled. Escopos read:contacts (ler) e write:contacts (gravar — e, na lista com confirmação dupla, inscrever manda à pessoa o e-mail de confirmação, que conta na cota de envio da conta).
O modelo
- Um contato por e-mail na conta: o endereço é normalizado antes de comparar (em minúsculas; das pontas sai só o branco ASCII — espaço, TAB, LF, VT, FF, CR), e a mesma pessoa em três listas é um contato só, com uma inscrição por lista. Espaço Unicode na ponta (NBSP, U+3000…) não é aparado: o endereço é recusado com
400 invalid_email. O endereço é só ASCII (veja Endereços só em ASCII): o domínio internacionalizado (IDN) entra só em punycode —ana@xn--exmple-cua.com;ana@exämple.comé recusado com400 invalid_emailereason: "address_not_ascii". O mesmo vale para achar, etiquetar, descadastrar e apagar um contato: ele só existe na forma ASCII, e só ela é procurada. - A lista é de uma marca (o domínio de envio dela) e tem o próprio remetente. Cada inscrição tem estado próprio:
pending,subscribed(já confirmada) ouunsubscribed. - Sair da lista não é supressão. Quem se descadastra da newsletter continua recebendo o e-mail transacional da sua conta.
- Quem saiu pela própria mão só volta pela própria mão. Saiu pelo link de descadastro ou pela central de preferências, marcou um e-mail como spam ou teve o endereço devolvido: a API não reinscreve —
409 unsubscribed_by_contact,409 complainedou409 bounced. - Propriedades (
attributes): nomes no formato^[a-z][a-zA-Z0-9_]{0,63}$, nuncaemail,firstNameoulastName; o tipo fica registrado pelo primeiro valor (ou porPOST /v1/contacts/properties). Tetos: 100 nomes por conta (422 too_many_properties), 8 KB por contato (422 attributes_too_large) e 1 KB por valor (400 invalid_property_value). - Apagar tira o contato de toda lista na hora; 30 dias depois os dados dele são apagados de vez (
purgeAfter). Fica só um hash do endereço, para uma reimportação não devolver à lista quem tinha saído. Recriar o mesmo e-mail depois começa do zero (201): nada do contato anterior volta — nome, propriedades, tags e inscrições —, e os descadastros que a pessoa fez continuam valendo. - Corpo fechado: campo desconhecido é
400 bad_requestcomfield(o nome só volta quando parece nome de campo: sem espaço e sem arroba). Contato, lista ou tag de outra conta respondem igual ao inexistente (404). - Escritas ao mesmo tempo: se escritas simultâneas — de qualquer origem, não só desta conta — seguram o contato (ou a mesma tag nova) por alguns segundos, a escrita desiste em vez de esperar mais —
503 contact_busycomRetry-After(5 s, também emretryAfter). Repetir é seguro. - O registro de chamadas destas rotas guarda os nomes dos campos, os ids e o erro; os valores (e-mail, nome, propriedades, IP de consentimento) aparecem só como
{ "omitted": "personal" }.
/v1/contactsescopo write:contactsCria ou atualiza pelo e-mail: 201 quando criou, 200 quando atualizou. attributes é mesclado — a chave que você não manda fica como estava, e null apaga o valor. Campos: email (obrigatório), firstName e lastName (até 100 caracteres), attributes e externalId (até 64).
curl -X POST https://api.oveyon.com/v1/contacts \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "ana@example.com", "firstName": "Ana",
"attributes": { "plan": "pro", "points": 120 } }'HTTP/1.1 201 Created
{
"contact": {
"id": 1001, "email": "ana@example.com", "firstName": "Ana", "lastName": null,
"attributes": { "plan": "pro", "points": 120 },
"source": "api", "externalId": null,
"createdAt": "2026-10-17T12:00:00.000Z", "updatedAt": "2026-10-17T12:00:00.000Z"
},
"created": true
}/v1/contactsescopo read:contactsDo mais antigo ao mais novo, com next_cursor. Filtros: q (começo do e-mail), listId (e, com ele, status) e tagId; limit até 100 (padrão 50).
/v1/contacts/findescopo read:contacts?email= — o contato com tags e lists (cada inscrição com status, datas e unsubscribeReason). Apagado ou inexistente: 404 contact_not_found.
/v1/contactsescopo write:contacts?email= ou ?id= — um dos dois. Resposta 200 { id, deleted, lists, purgeAfter }.
/v1/contacts/propertiesescopo write:contacts{ name, type }, com type = string · number · boolean · date. Mesmo nome e mesmo tipo: 200, sem mudança; outro tipo: 409 property_type_conflict.
/v1/contacts/tagsescopo write:contacts{ email | id, tags }, de 1 a 20 tags. A tag que ainda não existe é criada; o nome não diferencia maiúsculas (fica a primeira grafia).
/v1/contacts/tagsescopo write:contacts?email= ou ?id=, e tag (repita o parâmetro para tirar várias). A tag que o contato não tem é ignorada.
/v1/listsescopo read:contactsAs listas da conta, com remetente, fuso, doubleOptIn, formEnabled (o interruptor do formulário público) e counts por estado — só de contatos não apagados, os números da tela de Audiência (o contato apagado sai de toda lista na hora e não conta mais, nem como unsubscribed).
/v1/lists/:id/subscribeescopo write:contactsCria ou atualiza o contato (os mesmos campos do upsert) e o inscreve. Na lista com confirmação dupla (double opt-in) a inscrição fica pending e a pessoa recebe o e-mail de confirmação, do remetente da lista, com o link que a inscreve: no máximo um por lista e caixa de entrada a cada 24 horas (outra chamada dentro dessa janela não manda outro), cinco por caixa por hora e três por caixa por dia na plataforma inteira, e nenhum para endereço suprimido. A caixa ignora o +tag do endereço e, no Gmail, os pontos. Sem confirmação dupla, subscribed na hora. Também fica pending, em qualquer lista, quem já estava pending nela (preencheu o seu formulário e ainda não confirmou): só a confirmação da própria pessoa ou a central de preferências a tornam subscribed — nunca a API nem a importação. consent (opcional) guarda a prova de quem se inscreveu: ip e source (até 190) da pessoa, não de quem chama a API. Recusada (409), a chamada não muda nada — nem o contato.
curl -X POST https://api.oveyon.com/v1/lists/7/subscribe \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "ana@example.com",
"consent": { "ip": "203.0.113.9", "source": "https://example.com/newsletter" } }'{ "contactId": 1001, "listId": 7, "status": "pending", "changed": true, "contactCreated": false }/v1/lists/:id/formescopo read:contactsO formulário de inscrição da lista, em HTML para colar no seu site (text/html, o mesmo da tela da lista): posta no host de rastreio da marca (/f/<publicKey>), com o campo email, os campos e as tags que a lista aceita, um campo escondido contra robôs (website) e, quando a lista tem, o Turnstile da Cloudflare. Rótulos na língua da lista. O redirect (opcional) só leva a um endereço do mesmo domínio registrável do domínio da lista (o domínio da lista é o de envio: a lista de news.suaempresa.com aceita www.suaempresa.com e suaempresa.com); outro domínio é recusado. A resposta do formulário é a mesma para todo endereço — ela não diz se a pessoa já está na lista. O formulário público só existe em lista com confirmação dupla: sem ela, 409 form_requires_double_opt_in (inscreva pelo seu servidor, com o subscribe).
/v1/lists/:id/formescopo write:contactsO interruptor do formulário público da lista — o mesmo da tela: {"enabled": false} o desliga na hora (ele responde a página de sempre a todo envio e não grava nada, e os e-mails de confirmação do formulário que ainda estavam na fila não saem — o link deles morre; os da API saem) e true o liga de novo. Devolve listId e formEnabled. Não muda o HTML do formulário; para invalidar o HTML que já está no ar, troque o endereço do formulário na tela da lista.
/v1/lists/:id/unsubscribeescopo write:contacts{ email | id }. O motivo gravado é api: a API pode reinscrever quem a API tirou. status vem null quando o contato nunca esteve na lista.
Campanhas
Uma campanha é uma edição para uma lista: a marca, o remetente, o rodapé e a língua vêm da lista. O rodapé de toda mensagem de campanha leva o endereço postal da lista (a lei americana de e-mail comercial o exige), o link de descadastro e a central de preferências; sem o endereço nada sai — o check acusa list_missing_postal_address, e a campanha que já está saindo espera com o mesmo motivo em pauseReason e continua sozinha quando ele volta. A plataforma compõe a mensagem final: o rodapé entra como o último elemento do corpo, com estilo inline próprio, e o resultado é lido de novo e conferido; o HTML do layout ou do template que não o deixa entrar (um frameset, ou um aninhamento muito além do que um e-mail usa) aparece no check como footer_not_guaranteed e nada sai. O e-mail composto, com os links rastreados, tem teto de 100 KB medidos como ele sai — a parte HTML codificada em quoted-printable ou base64, que é mais longa que o HTML (acentos e emojis pesam até cerca de 2,4 vezes os seus bytes): o Gmail corta a mensagem por volta de 102 KB, e o que some é o fim — o rodapé. Acima dele o check traz o mesmo footer_not_guaranteed com o motivo em problemReasons ({"footer_not_guaranteed": "html_too_large"}; o not_ready leva o mesmo campo), e nada sai — reduza o HTML. O CSS do layout ou do template que esconderia o rodapé também não passa: no <html> e no <body> só entram cor, fundo, fonte, texto, margem, borda e largura; em qualquer elemento, nada de position: absolute, fixed ou sticky, deslocamento, z-index positivo, margem negativa, transform ou outline largo; e a folha de fora (@import, <link rel="stylesheet">) não dá para conferir. O check traz footer_not_guaranteed com o motivo em problemReasons — css_on_html_body, css_positioning, css_negative_margin, css_transform, css_outline, css_external_stylesheet, css_unsupported_rule, css_outlook_hide — e a tela diz o que tirar. Escopos read:campaigns (ler, conferir e o relatório), write:campaigns (criar, editar, ensaiar, a prévia e pausar) e send:campaigns (aprovar, agendar, retomar, cancelar e apagar). Com a trava de aprovação humana ligada na chave, write:campaigns e send:campaigns não valem: a campanha sai para a lista inteira sem rascunho.
- Os estados:
draft→approved(aprovar nunca envia) →scheduled(agendar é o gesto que dispara) →running→done;running⇄paused; qualquer um →cancelled. Editar uma campanha aprovada ou agendada a devolve adraft(returnedToDraft): a aprovação vale para o que foi conferido. - A aprovação congela o que a campanha herda: a versão do template (ou do layout da lista) que foi conferida é a que sai, mesmo que uma mais nova seja publicada depois — também no envio que se espalha por dias. E o remetente, o «responder para», o endereço postal, a língua e o layout da lista ficam gravados: se a lista mudar algum deles depois da aprovação, agendar (ou a agendada, na hora de sair) devolve a campanha a
drafte o dono é avisado —409 changedcomreturnedToDraft: true. - Conferir antes: o
checkdevolveproblems(vazio =ready), a audiência e a prontidão da marca (readiness), item a item, com quem resolve cada um:account(você),staff(a nossa equipe de suporte) ouautomatic. Aprovar ou agendar com problema →422 not_readycom os mesmosproblems. A audiência é conferida dentro do teto de 30 contagens por conta por minuto e de um tempo máximo: sem resposta, o problema éaudience_check_inconclusive(comaudienceErrorrate_limited,filter_too_slowoubusy, quando há conferências de audiência demais da conta ao mesmo tempo) — nunca «vazia», nunca «pronta»: confira de novo em um minuto. Nadaily_capacity,capacityelimitedBycontam só o limite diário da marca e os tetos da própria conta (aquecimento do domínio, cotas, ritmo), e oestimatedDayssai só deles: a folga da capacidade de envio de marketing compartilhada da plataforma não é divulgada (a campanha que espera por ela pausa comip_capacitye continua sozinha). - O custo do conteúdo: converter e compilar uma campanha que mudou — e criar ou editar uma — gasta uma unidade por 256 KB de conteúdo, até 60 por conta por minuto; acima,
429 rate_limitedcomRetry-After. A campanha que não mudou é conferida de um cache e não gasta. - A marca aquece em dias: nas primeiras semanas a edição se espalha por dias, dos inscritos mais recentes para os mais antigos (
estimatedDays). Uma pausa pelos limiares de devolução ou de reclamação segura a marca inteira, e só o suporte retoma:409 paused_for_review. Os mesmos limiares valem para a soma das campanhas da marca nas últimas 24 horas: a marca é segurada mesmo que nenhuma campanha sozinha passe deles (dividir a audiência em campanhas pequenas não escapa). A edição também respeita a capacidade de envio de marketing de hoje: quando ela acaba, a campanha espera compauseReasonip_capacitye continua sozinha quando houver vaga. - O filtro de audiência é TEXTO:
audienceFilteré o JSON do filtro dentro de uma string, até 64 KB — o tamanho é conferido antes da leitura (400 filter_too_large). O corpo inteiro tem teto de 320 KB (413 payload_too_large). - A prévia só vai para o e-mail verificado de um usuário ativo da conta (
400 preview_recipient_not_allowed; comreason: address_not_asciiquando o endereço tem caractere fora do ASCII), até 20 por dia e 60 tentativas por dia — as recusadas e as que não saem contam também (429 preview_limit). - O envio semanal: uma campanha
kindmodelé o modelo de um envio recorrente — não envia, não é aprovada nem agendada. Com arecurrence(weekdayde 1 a 7, segunda = 1;timeHH:MM;leadDaysde 1 a 6), no fusotimezonedo modelo, oactivateliga a recorrência (running): a cada semana nasce emdrafta edição (kindedition, comparentIdeeditionDate),leadDaysantes do horário. Nada sai sem a aprovação daquela edição, e aprovar a edição já a agenda para o horário dela. A que não foi aprovada até o horário viraskippede o dono é avisado; ela só sai comsendNow, até a próxima edição (409 edition_supersededdepois). A edição anterior nunca é reenviada: se ainda estiver saindo quando a seguinte começar, o resto dela é encerrado (errorCodesupersededno relatório).pauseeresumedesligam e religam a recorrência;cancelencerra o modelo e as edições dele em rascunho. Uma variável da AWeber colada ({!firstname_fix}) deixa a campanha não pronta (aweber_merge_tag): troque pela sintaxe{{firstName}}com um padrão. - O corpo em HTML: a campanha aceita
contentHtmlno lugar decontentMd. O HTML passa por uma lista branca (p,h1ah4,strong,em,code,pre,a,img,ul,ol,li,blockquote,hr,br) e entra no layout da lista, com o rodapé de sempre. Um link comclass="ovy-botao"sai como botão. Scripts, CSS, tabelas e formulários saem, e a resposta diz o que saiu emcontentRemoved. Grava-se o HTML já limpo, que é o que a leitura devolve. Vale uma fonte só: mandenullna outra (400 invalid_content). - O HTML volta normalizado: a leitura devolve o
contentHtmlda lista branca, não o que foi mandado.bvirastrong,iviraem,divvirap,h5eh6viramh4; os brancos repetidos viram um, e o branco da ponta de um link, negrito ou itálico vai para fora dele (um negrito dentro de outro vira um só);{,}e'saem em entidade ({,},'), como&,<e>. Na tag de variável, o padrão volta com&,<e>em entidade:{{firstName|"Tom & Jerry"}}volta{{firstName|"Tom & Jerry"}}, e o e-mail mostra «Tom & Jerry». Reenviar o valor como veio dá o mesmo resultado. - Escritas ao mesmo tempo: se escritas simultâneas — de qualquer origem, não só desta conta — seguram a campanha por alguns segundos, a alteração desiste em vez de esperar mais —
503 campaign_busycomRetry-After(5 s, também emretryAfter). Nada mudou; repetir é seguro. - Campanha de outra conta responde igual à inexistente (
404 campaign_not_found). O registro de chamadas destas rotas guarda os ids, os estados e a agenda; o assunto, o conteúdo, o nome, o filtro e otoda prévia aparecem só como{ "omitted": "personal" }.
/v1/campaignsescopo write:campaignsCria em draft (201). Campos: listId e name (obrigatórios), subject, previewText, contentMd (Markdown, até 256 KB), contentHtml (HTML, até 256 KB) ou templateId e templateVersion, fromName, fromLocal, replyTo, audienceFilter, excludeListIds, audienceCap (só reduz a audiência) e timezone. Corpo fechado: campo desconhecido é 400 bad_request com field. O fromLocal e o replyTo são só ASCII (domínio internacional só na forma xn--): fora disso, 400 invalid_sender com reason: address_not_ascii e o field, sem gravar nada. Até 500 campanhas vivas por conta (as canceladas e as concluídas não contam): acima, 422 campaign_limit com limit. E até 200 campanhas criadas por conta por dia (UTC; as canceladas e as apagadas contam): acima, 422 campaign_limit com limit e window day. Com kind model e recurrence, nasce o modelo de um envio semanal; kind edition é 400 bad_request.
curl -X POST https://api.oveyon.com/v1/campaigns \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "listId": 7, "name": "October edition",
"subject": "Hi {{firstName|\"there\"}}",
"contentMd": "# News\n\nRead [the post](https://example.com/post).",
"audienceFilter": "{\"all\":[{\"kind\":\"tag\",\"op\":\"has\",\"tag\":\"vip\"}]}" }'/v1/campaignsescopo read:campaignsA mais nova primeiro, sem o conteúdo, com next_cursor. Filtros: status, listId, kind e parentId (as edições de um modelo); limit até 100 (padrão 50).
/v1/campaigns/:idescopo read:campaignsA campanha inteira, com contentMd, contentHtml e audienceFilter.
/v1/campaigns/:idescopo write:campaignsSó os campos enviados mudam (null limpa). Aprovada, agendada ou vencida (skipped) volta a draft; a resposta é { campaign, returnedToDraft }. Fora desses estados: 409 invalid_state.
/v1/campaigns/:idescopo send:campaignsSó draft ou cancelled que nunca saiu de verdade (204). A que já foi enviada é histórico: cancele, não apague (409 invalid_state). O modelo com edições também não sai: encerre-o.
/v1/campaigns/:id/checkescopo read:campaignsNão muda nada. { "probe": true } roda agora as conferências externas da marca (o certificado e o CAA do host de rastreio, o SPF do endereço de retorno) — até 20 por conta a cada 10 minutos; sem ele, vale o último resultado. Os números da nossa infraestrutura não aparecem.
HTTP/1.1 200 OK
{
"problems": ["tracking_host_without_tls"],
"ready": false,
"audience": 58210, "audienceError": null,
"schedule": { "dailyLimit": 10000, "sentToday": 0, "warmed": false, "estimatedDays": 4 },
"readiness": [
{ "name": "tracking_host_tls", "state": "blocking", "owner": "automatic", "effect": "blocking",
"detail": "certificate_invalid", "numbers": null },
{ "name": "marketing_ips", "state": "ok", "owner": "staff", "effect": "blocking",
"detail": "has_ips", "numbers": null },
{ "name": "daily_capacity", "state": "ok", "owner": "staff", "effect": "warning",
"detail": "brand_limit", "numbers": { "capacity": 10000, "limitedBy": "brand", "brandRemaining": 10000 } }
]
}/v1/campaigns/:id/previewescopo write:campaigns{ to, contactId }: a campanha como sai, com o assunto prefixado na língua da lista ([Teste] em português, [Test] em inglês), para to — um usuário da conta. contactId (opcional) só preenche as variáveis: o contato não recebe nada. Resposta 202 { messageId }.
/v1/campaigns/:id/rehearseescopo write:campaignsTudo menos o envio: o retrato da audiência, o recheque de cada destinatário e a renderização. Quantos quiser, um por vez na conta (409 rehearsal_in_progress). Resposta 202 { runId }; o resultado sai em rehearsal do relatório.
/v1/campaigns/:id/approveescopo send:campaignsSó draft. Confere tudo — inclusive as conferências externas da marca — e aprova o que conferiu, sem enviar — e congela a versão do template (ou do layout) conferida. 409 changed: a campanha (ou a lista) mudou durante a conferência. A edição de um envio semanal é aprovada e agendada para o horário dela no mesmo gesto (scheduled, com scheduledAt); a que pulou só sai com { "sendNow": true }, até a próxima edição. O modelo nunca é aprovado.
/v1/campaigns/:id/scheduleescopo send:campaigns{ scheduledAt, timezone }: "now", um instante ISO 8601 com fuso, ou uma hora local sem fuso, lida em timezone — de 5 minutos atrás até um ano à frente. É o gesto que dispara. 409 changed com returnedToDraft: true: a lista mudou depois da aprovação e a campanha voltou a draft.
curl -X POST https://api.oveyon.com/v1/campaigns/42/approve \
-H "Authorization: Bearer $OVEYON_API_KEY"
curl -X POST https://api.oveyon.com/v1/campaigns/42/schedule \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "scheduledAt": "2026-11-03T09:00", "timezone": "America/New_York" }'/v1/campaigns/:id/activateescopo send:campaignsSó o modelo em draft: liga a recorrência (running). Confere a recurrence, o endereço postal e o remetente da lista (422 not_ready). Resposta { id, status, next }: o horário e o dia da próxima edição.
/v1/campaigns/:id/pauseescopo write:campaignsSó running; o que está em voo termina.
/v1/campaigns/:id/resumeescopo send:campaignsSó paused. Pausada pelos limiares de devolução ou de reclamação: 409 paused_for_review — o suporte avalia a marca e retoma. Pausada com sender_changed (o remetente ou o «responder para» da lista mudou depois da aprovação): retomar aceita o remetente atual, e a campanha segue com ele.
/v1/campaigns/:id/cancelescopo send:campaignsDe qualquer estado que não seja final; os ensaios vivos param junto. Resposta { id, status, skipped, runId }: skipped é quem esperava e não recebe. No modelo, vem também editionsCancelled: as edições em rascunho canceladas junto.
/v1/campaigns/:id/reportescopo read:campaignsEnviadas, entregues, devolvidas, reclamações, descadastros, aberturas e cliques, com as taxas (frações de 0 a 1), lidos dos contadores da execução — o relatório não muda quando um contato é apagado. routeFailures são falhas do nosso lado, não devoluções; ipBlocks é a parte delas em que o servidor do destinatário recusou os nossos servidores de envio pela reputação (uma lista de bloqueio como a Spamhaus, ou o S3140 da Microsoft) — não contam contra a marca nem tiram o contato da lista. hardBounced é a parte de bounced que é permanente para o endereço (o contato sai da lista); a recusa pelo tamanho da mensagem ou por caixa cheia nunca é dura e conta só em bounced.
HTTP/1.1 200 OK
{
"campaign": { "id": 42, "name": "October edition", "status": "running" },
"run": { "id": 9, "status": "pending", "rehearsal": false, "audience": 58210, "cap": null,
"sent": 10000, "queued": 48210, "inFlight": 0, "skipped": 0, "failed": 0, "rehearsed": 0,
"pauseReason": "daily_limit", "errorCode": null, "resumeAt": "2026-11-04T05:00:00.000Z",
"startedAt": "2026-11-03T14:00:00.000Z", "finishedAt": null },
"outcome": { "delivered": 9810, "bounced": 150, "hardBounced": 41, "routeFailures": 0, "ipBlocks": 0, "complaints": 2,
"unsubscribes": 23, "opens": 4120, "clicks": 610, "inTransit": 40 },
"rates": { "delivery": 0.981, "bounce": 0.015, "complaint": 0.0002, "unsubscribe": 0.0023,
"open": 0.42, "click": 0.0622 },
"skippedByReason": {}, "tracking": { "opens": true, "clicks": true },
"rehearsal": null, "rehearsals": 1
}Sequências
Uma sequência é uma série de e-mails para uma lista: a pessoa entra na lista (ou recebe uma tag) e recebe e-mail → espera → e-mail… até a saída. O remetente, o rodapé (com o endereço postal da lista), a língua e a marca são os da lista, como nas campanhas, e cada e-mail é Markdown dentro do layout da lista. Cada e-mail é reconferido na hora de sair: quem saiu da lista no meio, foi apagado ou está suprimido não recebe o próximo e sai da sequência. Escopos read:sequences (ler e o funil) e write:sequences (criar, editar, ligar, pausar, encerrar e apagar). Com a trava de aprovação humana na chave, write:sequences não funciona: ligar uma sequência é mandar a todo inscrito novo, sem rascunho.
- O grafo: o formato do Loops —
rootId,nodeDataByIde, em cada nó,typeName,nextNodeIdsepayload. A primeira versão é linear: a raiz é o gatilho (ListTrigger: alguém se inscreveu na lista ou confirmou a inscrição;TagTrigger: alguém inscrito na lista recebeu a tag), depoisSendEmailActioneTimerActionem qualquer ordem, eExitActionno fim — sempre depois de um e-mail. Até 50 nós.BranchNode,AudienceFiltereExperimentBranchNodeainda não existem:400 unsupported_node. O grafo errado é400 invalid_graphcom ofield(o caminho do nó) e oreason. OrootIdnão muda depois que a sequência é criada: o grafo com outra raiz é400 invalid_graphcom oreasonroot_changed, e a mensagem diz a raiz gravada. - As esperas:
amountdeunitm,houd, até um ano; comat(HH:MM, só com dias) a espera termina naquele horário notimezoneda sequência — «3 dias às 09:00» é o 3º dia de calendário às 09:00 de lá, também na troca de horário de verão. - A importação nunca dispara: nem a da AWeber, nem a inscrição com método de importação, nem a tag que a importação aplica. Pausada: quem entra espera. Encerrada: ninguém mais entra. Quem se inscreve com a sequência pausada entra e espera no começo: quando ela for ligada de novo, anda — e as esperas só começam a contar aí.
reEligibledeixa quem terminou entrar de novo no próximo gatilho — nunca enquanto ainda está dentro. - Os estados:
draft→active(startconfere tudo e liga: é o gesto que manda) ⇄paused; qualquer um →stopped(quem esperava sai). Ligar congela o layout da lista que foi conferido e o remetente: se o remetente ou o «responder para» da lista mudar depois, a sequência pausa (sender_changed) e o dono é avisado. O envio que recusa a conta ou o conteúdo também pausa a sequência (pauseReason). - Editar exige a
revisionque você leu (outra →409 changedcom a de agora) e a sequência fora do ar (draftoupaused; no ar, só onamemuda). Quem espera num passo que a edição tirou sai da sequência (revision) quando ela voltar a rodar. - O corpo do e-mail: o
payloaddoSendEmailActionlevacontentMdoucontentHtml, nunca os dois, com a mesma lista branca das campanhas, e volta normalizado como o delas. Em HTML,contentRemovedna resposta diz o que saiu. E-mail escrito em Markdown não tem o campocontentHtml. - Escritas ao mesmo tempo: se escritas simultâneas — de qualquer origem, não só desta conta — seguram a sequência por alguns segundos, a alteração desiste em vez de esperar mais —
503 sequence_busycomRetry-After(5 s, também emretryAfter). Nada mudou; repetir é seguro. - Até 100 sequências vivas por conta e 50 criadas por dia (UTC):
422 sequence_limit. A sequência de outra conta responde igual à inexistente (404 sequence_not_found). O registro de chamadas destas rotas guarda os ids, os estados e a revisão; o nome e o grafo só aparecem como{ "omitted": "personal" }.
/v1/sequencesescopo write:sequencesCria em draft (201). Campos: listId e name (obrigatórios), graph (sem ele: gatilho da lista → um e-mail em branco → saída), timezone (padrão: o da lista) e reEligible. Corpo fechado e de até 576 KB; o conteúdo gasta o mesmo balde das campanhas (429 rate_limited).
curl -X POST https://api.oveyon.com/v1/sequences \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "listId": 7, "name": "Welcome", "timezone": "America/New_York",
"graph": { "rootId": "n1", "nodeDataById": {
"n1": { "typeName": "ListTrigger", "nextNodeIds": ["n2"], "payload": {} },
"n2": { "typeName": "SendEmailAction", "nextNodeIds": ["n3"],
"payload": { "subject": "Welcome, {{firstName|\"friend\"}}", "contentMd": "Thanks for joining." } },
"n3": { "typeName": "TimerAction", "nextNodeIds": ["n4"], "payload": { "amount": 3, "unit": "d", "at": "09:00" } },
"n4": { "typeName": "SendEmailAction", "nextNodeIds": ["n5"],
"payload": { "subject": "Three tips", "contentMd": "1. Read the guide." } },
"n5": { "typeName": "ExitAction", "nextNodeIds": [], "payload": {} } } } }'
curl -X POST https://api.oveyon.com/v1/sequences/12/start \
-H "Authorization: Bearer $OVEYON_API_KEY"/v1/sequencesescopo read:sequencesA mais nova primeiro, sem o grafo, com next_cursor. Filtros: status e listId; limit até 100 (padrão 50).
/v1/sequences/:idescopo read:sequencesA sequência com o graph (normalizado, na ordem do caminho), a revision e o trigger.
/v1/sequences/:idescopo write:sequencesrevision (obrigatório) e o que muda. Resposta: a sequência, com a revisão nova.
/v1/sequences/:idescopo write:sequencesSó a que nunca teve ninguém dentro (204); a que já teve é encerrada, não apagada (409 invalid_state).
/v1/sequences/:id/startescopo write:sequencesDe draft ou paused para active. Confere cada e-mail, o endereço postal da lista e os pré-requisitos da marca: 422 not_ready com problems — cada um com o code (os mesmos nomes das campanhas) e o nodeId do e-mail, quando é de um e-mail. Até 5 sequências ligadas ou pausadas com o mesmo gatilho (a mesma lista, ou a mesma tag) por conta — a pausada também recebe quem entra: ligar a 6ª (ou mudar o gatilho de uma pausada para um que já tem 5) é 409 trigger_limit (com limit) — encerre uma delas, ou use outro gatilho.
/v1/sequences/:id/pauseescopo write:sequencesSó active. Ninguém recebe o próximo e-mail; quem está dentro fica no mesmo passo, e quem se inscrever enquanto isso entra e espera no começo.
/v1/sequences/:id/stopescopo write:sequencesEncerra de vez: quem esperava sai (cancelled diz quantos). Encerrada não volta — crie outra.
/v1/sequences/:id/reportescopo read:sequencesO funil, por nó, na ordem do caminho: quantos chegaram (entered), os e-mails que saíram (sent), quantos saíram da sequência ali (exited) e quantos esperam nele agora (waiting); e os totais. Os contadores não mudam quando um contato é apagado.
HTTP/1.1 200 OK
{
"id": 12, "status": "active", "revision": 3,
"nodes": [
{ "nodeId": "n1", "typeName": "ListTrigger", "entered": 240, "sent": 0, "exited": 0, "waiting": 0 },
{ "nodeId": "n2", "typeName": "SendEmailAction", "entered": 240, "sent": 238, "exited": 2, "waiting": 0 },
{ "nodeId": "n3", "typeName": "TimerAction", "entered": 238, "sent": 0, "exited": 0, "waiting": 61 },
{ "nodeId": "n4", "typeName": "SendEmailAction", "entered": 177, "sent": 171, "exited": 6, "waiting": 0 },
{ "nodeId": "n5", "typeName": "ExitAction", "entered": 171, "sent": 0, "exited": 0, "waiting": 0 }
],
"totals": { "entered": 240, "finished": 171, "exited": 8, "inProgress": 61 },
"exitsByReason": { "unsubscribed": 7, "removed": 1 }
}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, request_too_complex, 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, scope_blocked_by_approval_lock, key_disabled, sending_disabled, removal_blocked, account_suspended, account_unknown, ip_not_allowed, return_path_requires_paid_plan |
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, managed_domain_requires_paid_plan, return_path_not_authorized, return_path_managed_domain, 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 / escritas simultâneas seguraram o contato, a campanha ou a sequência (marketing; de qualquer origem, não só desta conta — nada mudou, repetir é seguro) | internal_error, injection_failed, record_failed, list_unavailable, ip_rules_unavailable (com Retry-After: 30), return_path_check_unavailable (com Retry-After: 60), contact_busy, campaign_busy, sequence_busy (com Retry-After: 5, também em retryAfter) |
Endereços só em ASCII
A plataforma só aceita endereço de e-mail sem acento: o local-part e o domínio em ASCII, sem caractere de controle. Um domínio internacionalizado (IDN) entra só na forma punycode — xn--…, e um xn-- válido; escrito em Unicode, ele é recusado. Nada é convertido por você: trocar a letra acentuada pela sem acento mandaria para outra caixa.
- Na API, toda entrada de endereço recusa com
400,reason: "address_not_ascii"e ofielddo campo. Oerroré o da família do campo:bad_recipientnos destinatários dePOST /v1/send,invalid_emailnos contatos ebad_requestnos demais (ofrom, os cabeçalhos de endereço, as supressões, as pesquisas, as listas allow/block de saída e as políticas de envio). A mensagem não repete o endereço. Ramifique noreason. - Na resposta a uma mensagem recebida (
POST /v1/inbound/:uid/reply), a caixa que recebeu ou o remetente original fora do ASCII viram409 no_mailboxou422 no_reply_address, com o mesmoreason. - Na submissão SMTP,
MAIL FROMeRCPT TOfora do ASCII levam553 5.6.7; oFrome os cabeçalhos de endereço do DATA (Reply-To,Sender,To,Cc…) fora do ASCII levam550 5.6.7— a mensagem não é assinada nem entra na fila. - Nas importações da Central de migração, a linha com endereço fora do ASCII é pulada e contada no relatório — a supressão com o motivo próprio, o contato da AWeber entre os endereços inválidos; o resto do lote entra. A exceção é a saída (descadastro, reclamação ou devolução) com a parte antes do
@em ASCII e o domínio internacional escrito com acento: ela entra na formaxn--do mesmo domínio, para continuar valendo, e o relatório a conta à parte. - Endereço sem ambiguidade. Nos campos de endereço de
POST /v1/send—to,cc,bcc,frome os cabeçalhos de endereço deheaders— o endereço tem de estar escrito por inteiro:"a@b.com"ou"Nome <a@b.com>", um por item (lista, ou separados por vírgula). O que um leitor de e-mail leria de dois jeitos é400: dois endereços separados por espaço, o nome sem<…>, o comentário depois do endereço solto (a@b.com (Nome)), o grupo (Nome: …;) emto/cc/bcc, o item sem endereço numa lista e o@(ou o@de largura cheia) no nome de exibição. O espaço Unicode colado ao endereço (NBSP, U+3000…) éreason: "address_not_ascii". Oerrorébad_recipientnos destinatários ebad_requestnofrome nos cabeçalhos. Nada é reescrito por você: a forma ambígua é recusada inteira. - O que não muda: o nome de exibição pode ter acento (
"Zoë" <zoe@example.com>); a recepção segue aceitando remetente com acento, e a lista allow/block de entrada continua aceitando um padrão com acento para barrá-lo.
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.400 request_too_complex— a estrutura do corpo passou de um teto (comlimit): uma lista com mais de 10.000 itens, mais de 60.000 valores no total (itens de lista e campos de objeto), mais de 64 níveis de aninhamento ou um número ou literal com mais de 32 caracteres. Texto não conta — um anexo grande é um valor só. Divida o trabalho em mais de uma chamada.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. A chave é validada antes de o corpo ser lido: sem chave válida a resposta é 401 (ou 403) e o corpo não é lido.415 unsupported_media_type— mande o corpo comoapplication/json(formulário e texto puro não são aceitos).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 chave — com credencial válida, o teto geral é da chave (ou da conexão OAuth do MCP): 600 req/min por padrão, venha a chamada de qualquer IP. Estourou →
429 rate_limitedcom headerRetry-After: 60. Aguarde e repita. - Por IP — sem credencial válida (sem
Authorization, ou chave desconhecida, desligada ou revogada), o teto é do IP: 600 req/min por padrão. As falhas de credencial — só chave ou token desconhecido ou malformado — têm teto próprio de 20/min por IP (em IPv6, por/48):429 too_many_auth_failures. Não contam como falha a chamada semAuthorization, o token OAuth vencido ou revogado e a chave desligada. Uma chave válida que chega de um IP no teto ainda passa: o IP no teto tem 120 consultas de credencial por minuto, e só acima delas a resposta é429 too_many_auth_failures, até a virada do minuto, para toda credencial que não tenha sido validada nos últimos 5 minutos (a que foi segue passando). O que não tem a forma de uma chave nossa (ov_+ 48 hexadecimais minúsculos) conta como falha sem ser consultado. 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.
Mudanças na API
O que pode mudar a resposta de uma chamada que já funcionava — a entrada mais nova primeiro.
- 03/11/2026 — o SPF acima de 4096 bytes deixa de ser avaliado. Em
POST /v1/domains/:id/verify, um registro da cadeia do SPF (o do domínio, ou o de um include ou redirect) com mais de 4096 bytes não é mais lido: o SPF não conta como verificado,spfProblem.codeérecord_too_largee odetail.spfdiz qual registro encurtar (veja a rota). Antes ele era avaliado inteiro e podia passar. - 03/11/2026 —
this_outside_loopeloop_never_repeatsna conferência das campanhas e das sequências. EmPOST /v1/campaigns/:id/check,problemsganhou dois códigos.this_outside_loop: o conteúdo usa{{this}}ou{{this.nome}}fora de um{{#each}}— ali não há item: sem padrão, o envio pulava quem chegava a esse trecho; com padrão, todo contato recebia o padrão. Use{{nome}}(o motor não lê{{this.nome}}nem dentro de um laço: lá também é{{nome}}).loop_never_repeats: o conteúdo (ou o template) tem um{{#each}}— os campos do contato nunca são listas, e o envio pulava quem tinha o campo (quem não tinha recebia o trecho vazio): tire o laço. Aprovar ou agendar com qualquer um dos dois dá422 not_ready, e os mesmos códigos impedem ligar uma sequência (POST /v1/sequences/:id/start). - 03/11/2026 — teto de verificações de domínio por conta.
POST /v1/domains/:id/verifypassa a responder429 rate_limitedacima de 60 verificações por minuto na conta, somadas às do painel (veja a rota). Antes, só o limite geral da chave valia. Com o429nada é conferido: espere oRetry-After(também emretryAfter) e chame de novo. - 03/11/2026 — o assunto do e-mail de teste da campanha passa a «[Teste]». Em
POST /v1/campaigns/:id/preview, o prefixo do assunto era[Prévia](lista em português) e[Preview](em inglês); agora é[Teste]e[Test](veja a rota). A rota, o corpo, a resposta e os códigos (preview_limit…) não mudam: muda só o texto do assunto. Quem filtra o e-mail de teste pelo prefixo antigo precisa trocar o filtro. - 02/11/2026 — o SPF da raiz com
optional: trueemPOST /v1/domains. No modo de sempre, o registro SPF do domínio passou a vir comoptional: true, como o CNAME de tracking: o e-mail que sai por nós passa no SPF pelo nosso endereço de retorno (veja Cadastrar domínio). Com o Return-Path no próprio remetente (plano pago), ele segue semoptional. O painel mostra o mesmo: «Recomendado» num modo, «Obrigatório» no outro. - 20/10/2026 — Sequências na API (marketing, experimental). Rotas novas em
/v1/sequencese dois escopos novos:read:sequencesewrite:sequences(veja Sequências). Nenhuma chave existente ganha esses escopos — nem a de escopos antigos. Com a trava de aprovação humana ligada na chave,write:sequencesresponde403 scope_blocked_by_approval_lock, como os outros escopos que a anulariam. - 19/10/2026 — Campanhas na API (marketing, experimental). Rotas novas em
/v1/campaignse três escopos novos:read:campaigns,write:campaignsesend:campaigns(veja Campanhas). Nenhuma chave existente ganha esses escopos — nem a de escopos antigos. Com a trava de aprovação humana ligada na chave,write:campaignsesend:campaignsrespondem403 scope_blocked_by_approval_lock, como os outros escopos que a anulariam. - 19/10/2026 — o endereço ambíguo de
POST /v1/sendpassa a400. O que o leitor de endereço reescrevia calado agora é recusado (veja Endereço sem ambiguidade): emto/cc/bcc, nofrome nos cabeçalhos de endereço deheaders, dois endereços separados por espaço (antes um virava o nome e só o outro recebia), o nome sem<…>e o comentário depois do endereço, o grupo emto/cc/bcc(antes os membros sumiam do envelope), o item sem endereço numa lista (antes descartado), o@no nome de exibição e o espaço Unicode colado ao endereço (antes o NBSP no meio da parte local mandava para outra caixa). O espaço Unicode nas pontas deto/cc/bccdeixou de ser aparado: éaddress_not_ascii, como na/v1/suppressions. O valor de um cabeçalho deheaderstem de ser texto ou lista de textos: um objeto é400. Também: as importações gravam a saída com domínio internacional em Unicode na formaxn--, e a submissão SMTP recusa oFrome os cabeçalhos de endereço do DATA fora do ASCII com550 5.6.7. Endereços escritos como"a@b.com"ou"Nome <a@b.com>"não mudam. - 18/10/2026 — só endereço em ASCII. Toda entrada de endereço passou a recusar caractere fora do ASCII com
400ereason: "address_not_ascii"(veja Endereços só em ASCII). O que muda para quem já chamava: um destinatário com acento emPOST /v1/send, que antes era aceito, agora é400 bad_recipient; o domínio internacionalizado de um contato (POST /v1/contacts), que antes era convertido para punycode, agora tem de chegar já emxn--— e achar, etiquetar, descadastrar ou apagar pela grafia Unicode (GET /v1/contacts/finde as outras buscas por e-mail), que antes achava o contato convertido, passa a400 invalid_emailcom o mesmoreason; e a submissão SMTP recusaMAIL FROM/RCPT TOcom acento com553 5.6.7. Endereços ASCII não mudam. - 15/10/2026 —
detectedTypeetypeMismatchno manifesto de anexos. Cada item deattachments— emGET /v1/inbound/:uide no payload do webhookinbound.received— ganhoudetectedType(o tipo lido dos primeiros bytes do arquivo;null= sem assinatura reconhecida) etypeMismatch(truequando o rótulo do remetente e o conteúdo são de famílias diferentes). Quem confere a resposta contra um esquema fechado precisa aceitar os campos novos. Mensagens anteriores a esta data vêm comnullefalse. Veja Anexos. - 14/10/2026 —
mcpCallIdno registro de chamadas. Cada item deGET /v1/logseGET /v1/logs/:idganhou o campomcpCallId(nullfora do servidor MCP), e há duas rotas novas:GET /v1/logs/mcpeGET /v1/logs/mcp/:id(Registro do MCP). Quem confere a resposta contra um esquema fechado precisa aceitar o campo novo. Nos corpos guardados, o nome do arquivo dos anexos e o detalhe dos sinais de segurança passam a aparecer só pelo tamanho. - 13/10/2026 —
return_pathnoPOST /v1/send. O campo passou a existir e aceita só"sender"e"platform"(veja Endereço de retorno). Antes ele era ignorado, como todo campo que a rota não conhece; agora qualquer outro valor responde400 bad_requeste nada é enviado — inclusive um endereço de e-mail. Quem vem da SparkPost, ondereturn_pathé o endereço do envelope: tire o campo (o endereço de retorno da plataforma é o padrão) ou, se o endereço era o próprio remetente, use"sender"(de plano pago, com o include no SPF do domínio).