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.
- Revogar é imediato — a chave revogada para de autenticar na chamada seguinte. Rotacionar não é. A rotação emite a chave nova e deixa a anterior valendo por uma janela de carência (padrão 72 h; o painel diz o prazo exato no momento em que você rotaciona), para a sua aplicação trocar o segredo sem cair. É deliberado: sem a carência, «rotacionar» seria só «interromper», e o resultado prático seria ninguém rotacionar nunca. A consequência é a que importa — se a chave vazou, rotacionar não fecha o buraco: revogue. Rotacione quando o segredo ainda é só seu; revogue quando ele deixou de ser.
- Nunca exponha a chave no front-end nem em repositório.
Permissões (escopos)
A chave só acessa o que os escopos dela liberam. Faltou escopo para a rota chamada → 403 insufficient_scope (a resposta diz qual escopo faltou):
HTTP/1.1 403 Forbidden
{ "error": "insufficient_scope", "required": "read:stats", "have": ["send"] }| Escopo | Libera | Endpoints |
|---|---|---|
send |
Enviar e-mails |
POST /send
|
read:messages |
Ler mensagens e timeline |
GET /messages · GET /messages/:uuid
|
read:stats |
Ler estatísticas |
GET /stats
|
read:suppressions |
Ler supressões |
GET /suppressions
|
write:suppressions |
Criar e remover supressões |
POST /suppressions · DELETE /suppressions/:email
|
read:domains |
Ler domínios |
GET /domains
|
write:domains |
Adicionar e verificar domínios |
POST /domains · POST /domains/:id/verify
|
manage:webhooks |
Gerenciar webhooks |
GET /webhooks · POST /webhooks · PATCH /webhooks/:id · POST /webhooks/:id · DELETE /webhooks/:id
|
read:inbound |
Ler e-mails recebidos (inbound) |
GET /inbound · GET /inbound/:uid · GET /inbound/:uid/content · GET /inbound/:uid/raw · GET /inbound/:uid/attachments/:n
|
read:policies |
Ler listas de política e regras de IP |
GET /policies · GET /credentials/ip-rules
|
write:policies |
Criar e remover listas de política e regras de IP |
POST /policies · DELETE /policies/:id · POST /credentials/ip-rules · DELETE /credentials/ip-rules/:id · POST /credentials/ip-rules/pause · POST /credentials/ip-rules/unpause
|
read:templates |
Ler templates de e-mail |
GET /templates · GET /templates/:ref
|
write:templates |
Criar, editar, publicar e remover templates de e-mail |
POST /templates · PUT /templates/:ref · POST /templates/:ref/publish · DELETE /templates/:ref
|
read:send-policies |
Ler políticas de envio |
GET /send-policies · GET /send-policies/decisions · GET /send-policies/:id
|
write:send-policies |
Criar, editar, ordenar e remover políticas de envio |
POST /send-policies · PUT /send-policies/:id · POST /send-policies/:id/pause · POST /send-policies/:id/unpause · POST /send-policies/reorder · DELETE /send-policies/:id
|
read:surveys |
Ler pesquisas e respostas |
GET /surveys · GET /surveys/:id · GET /surveys/:id/responses
|
write:surveys |
Criar, editar e remover pesquisas |
POST /surveys · PUT /surveys/:id · DELETE /surveys/:id
|
send:surveys |
Disparar pesquisas por e-mail |
POST /surveys/:id/send
|
Fora da tabela, e de propósito: /disposable · /openapi.json · /ping não exige autenticação — nem chave, nem escopo, nem cota. Não procure uma caixinha para ela em Credenciais: não há o que conceder. É a única rota da API nessa condição.
Papel no painel não é permissão de chave
Esta é a confusão que aparece assim que a conta passa a ter mais de uma pessoa, e ela erra sempre para o mesmo lado — o de achar que a chave é mais fraca do que ela é. Então, com todas as letras: o painel e a chave são dois sistemas de permissão que não se tocam. Nenhum consulta o outro.
| No painel | Na API | |
|---|---|---|
| Quem age | Uma pessoa, com login e sessão. | Uma chave, e ela é portadora: quem tem o segredo é quem pode. |
| O que decide | O papel dela, em três escadas independentes. | Os escopos da chave — a tabela acima, e nada além dela. |
| As escadas | conta: viewer < admin < ownerorganização: member < admin < ownerpessoal (sobre si mesmo): sessao < pessoa |
Não há escada. Um escopo você tem ou não tem. |
| Alcance | Uma pessoa alcança N contas, com papel diferente em cada uma, e troca de conta na tela. | Uma chave é de uma conta e não troca. Toda leitura segue cercada por ela. |
O que decorre daí, e é o que você leva para o seu código:
- Papel não enfraquece chave. Não existe «chave de
viewer». Uma chave comsendna mão de alguém que éviewerna conta continua enviando e-mail: a chave não sabe quem está com ela, e nunca perguntou. - Papel não fortalece chave. Ser
ownernão acrescenta escopo nenhum à chave que você criou. Faltou escopo, é403 insufficient_scope— inclusive para o dono da conta, inclusive com a chave dele. Esse403nunca quer dizer «seu papel é baixo demais»; ele quer dizer «esta chave não tem este escopo», e a resposta nomeia qual. - Tirar o acesso de alguém ao painel não revoga chave nenhuma. É a consequência que morde, então ela vai sem eufemismo: uma credencial não guarda quem a criou, e por isso nada cascateia quando um acesso é retirado. Quem saiu continua com o segredo, e o segredo continua valendo em nome da conta. Ao remover uma pessoa, revogue as chaves que estiveram na mão dela — em Credenciais, e revogue, não rotacione: a rotação deixa a anterior viva pela carência.
- Nenhuma chamada do
/v1enxerga duas contas. Não há parâmetro que peça isso, nem header, nem um modo «organização» — e conta suspensa não arrasta a vizinha: o403 account_suspendedé sobre a conta daquela chave, e só. Se você opera várias contas, são várias chaves, uma por conta.
O papel decide uma coisa nesta história, e é do lado do painel: quem consegue apertar o botão. Cunhar segredo novo — criar chave de API, criar credencial SMTP e rotacionar qualquer uma das duas — é gesto de owner da conta. Matar e ajustar ficam com o admin: revogar, desativar, e também editar nome, escopos e limite de uma chave que já existe — porque um controle de acesso que proíbe reduzir exposição está pior que desligado. E owner é a titularidade da conta, não um papel que se concede: se você é admin e o botão de criar chave não está aí, não é falha da tela. Nada disso muda o que a chave faz depois de emitida — que é o assunto do resto desta página.
Enviar e-mail
O envio passa pelo mesmo pipeline do SMTP: idempotência, backpressure, domínio do remetente e do destinatário, supressão, cota e rampa — nessa ordem. O 202 só sai quando a mensagem já está no spool.
/v1/sendescopo sendEnfileira uma mensagem. Até 5 destinatários por chamada, somando to + cc + bcc — para mais que isso, faça mais de uma chamada.
Corpo (JSON)
from(obrigatório) —"a@b.com"ou"Nome <a@b.com>". O domínio precisa estar verificado nesta conta.to(obrigatório) — string ou lista de endereços. Vai no cabeçalhoTo:e no envelope.cc— string ou lista. Vai no cabeçalhoCc:e no envelope: todos os destinatários veem quem está em cópia.bcc— string ou lista. Vai só no envelope: nunca aparece em cabeçalho nenhum, e nenhum destinatário vê quem está em cópia oculta.subject— assunto. Até 500 caracteres: é o mesmo limite que o histórico da mensagem e o rastro de políticas guardam, então um assunto maior seria cortado depois sem você saber. Acima disso a chamada é recusada com400 bad_request, dizendo o limite e o que veio. Vale também para o assunto vindo de template.htmle/outext— ao menos um é obrigatório.templateId— id numérico ou onamede um template publicado. Mutuamente exclusivo comsubject/html/text: com template, o conteúdo inteiro (assunto incluso) vem do molde — mandar os dois é400 template_conflict.data— objeto{variável: valor}para o render do template. Só faz sentido junto detemplateId(sozinho é ignorado).version— inteiro ≥ 1: fixa uma versão publicada específica do template. Sem ele, vale a corrente.headers— objeto de headers extras (ex.:X-Campaign). Headers controlados por nós —List-Unsubscribe,X-Report-Abusee qualquer um com prefixoX-Oveyon-— são ignorados se enviados.idempotencyKey— string sua para deduplicar; alternativa ao headerIdempotency-Key. A mesma chave nunca gera duas mensagens.attachments— lista de{ filename, content (base64), contentType? }. Máx. 20 anexos, somando ≤ 15 MB (base64 já decodificado).sandbox—true(ou headerX-Oveyon-Sandbox: 1) aceita e congela sem entregar.
Destinatários, cota e cobrança
- Cada destinatário é uma unidade. Um envio com
to+ 2cc+ 1bccconsome 4 da sua cota diária e mensal, não 1 — é o que de fato é entregue. - Teto de 5 endereços por chamada, contando o que você mandou nos três campos. Acima disso:
400 too_many_recipients, com o limite e quantos vieram na resposta. - Endereço repetido entrega uma vez só. O mesmo endereço em
toebccvira uma entrega — e é cobrado uma vez. Vale a primeira ocorrência (toantes deccantes debcc); os cabeçalhos continuam como você escreveu. - Tudo ou nada. Se um endereço for inválido, estiver suprimido, ou a cota não cobrir todos, a chamada inteira é recusada e nenhum e-mail sai. Não existe entrega parcial: a resposta traz um
idsó e não teria como dizer «foi para 3 dos 5». - O
idé da mensagem. O desfecho (entregue, quicou) é por destinatário e aparece emdeliveries[]noGET /v1/messages/:id.
Exemplo
curl -X POST https://api.oveyon.com/v1/send \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-8842" \
-d '{
"from": "Suporte <suporte@suaempresa.com>",
"to": "cliente@exemplo.com",
"cc": ["financeiro@exemplo.com"],
"bcc": ["arquivo@suaempresa.com"],
"subject": "Seu recibo",
"html": "<p>Obrigado pela compra.</p>",
"text": "Obrigado pela compra.",
"headers": { "X-Campaign": "recibos" },
"attachments": [
{ "filename": "recibo.pdf", "content": "JVBERi0xLjQK...", "contentType": "application/pdf" }
]
}'Sucesso
HTTP/1.1 202 Accepted
{ "id": "3f8a1c2e-9b4d-4e10-8a77-2b0c9d5e1f34", "status": "accepted", "recipients": 3 }
// `recipients` só aparece quando há mais de um destinatário, e é o número
// de endereços DISTINTOS que entraram no envelope — o mesmo que foi cobrado.202·status: "accepted"— envio real, já no spool.202·status: "sandbox"— aceito, congelado, não entregue (modo sandbox).200·status: "duplicate"— aidempotencyKeyjá tinha sido usada; devolve oidoriginal.
Erros
400bad_request—from/toausentes ou inválidos, semhtmlnemtext;too_many_attachments/bad_attachment.400too_many_recipients— mais de 5 endereços somandoto+cc+bcc. Trazlimitereceived.400bad_recipient— um dos endereços não é válido. Trazfield(to/cc/bcc) e cita o endereço na mensagem. Endereço inválido nunca é descartado em silêncio.401unauthorized·403insufficient_scope,key_disabled(chave desligada no painel — religue-a ou use outra),sending_disabled,account_suspendedouaccount_unknown— os dois últimos são estado da conta, não limite: não retente.413attachments_too_large— anexos acima de 15 MB.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.400template_conflict—templateIdjunto desubject/html/text·400template_var_missing— variável exigida pelo template ausente dodata(trazvariable) ·422template_not_founde os demais erros de template. Nenhum deles consome cota nem queima aidempotencyKey.422unsub_footer_multi_recipient— esta chave envia com footer/List-Unsubscribeautomático, e o link de descadastro é individual. Com vários destinatários ele cancelaria a inscrição de quem não pediu, então o envio é recusado: mande uma chamada por destinatário. O mesmo código sai quando uma política de envio exige o rodapé numa chamada com vários destinatários — nesse caso a resposta também trazpolicy.422policy_refused— uma política de envio sua recusou a mensagem. A resposta trazpolicy(o nome da política que decidiu) erecipient(o destinatário que casou com ela). Não consome cota: as políticas são avaliadas antes do contador. Ajuste ou pause a política para voltar a enviar.429quota_exceeded·service_quota_exceeded·warmup_cap_reached·rate_limited·queue_full.
Sandbox (teste sem entregar)
curl -X POST https://api.oveyon.com/v1/send \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "X-Oveyon-Sandbox: 1" \
-H "Content-Type: application/json" \
-d '{ "from": "a@suaempresa.com", "to": "cliente@exemplo.com",
"subject": "Teste", "text": "Nada será entregue." }'O sandbox conta cota/rate (a mensagem foi aceita), mas nada sai para o destino.
Templates
Molde de e-mail com variáveis, guardado uma vez e disparado com só os dados: {"templateId": …, "data": {…}} no POST /v1/send, em vez de 40KB de HTML repetido a cada chamada. O visual muda num lugar só, sem redeploy do seu sistema.
O ciclo: rascunho → publicada (imutável)
- Criar e editar sempre produzem rascunho — nada muda no envio até você publicar.
- A versão publicada é imutável: editar cria a vN+1 como rascunho; a vN continua exatamente como estava. Template em produção nunca muda por baixo de você.
- Publicar torna a versão a corrente do envio. Para voltar, publique de novo uma versão anterior (
{"version": N}) — o ponteiro anda nos dois sentidos, a linha nunca muda. - O envio pode fixar
version; rascunho nunca é servido, nem fixando a versão dele.
Sintaxe — o catálogo é fechado, e é contrato
| Bloco | O que faz |
|---|---|
{{ var }} | Substitui pelo valor. No corpo html, o valor sai escapado (um nome com <script> vira texto, nunca código). Em text e no subject, sai como veio. Caminho pontilhado funciona: {{ user.email }}. |
{{ var | "padrão" }} | Variável opcional: ausente (ou null) usa o literal entre aspas. |
{{{ var }}} | Substitui sem escape — você declara que o valor é HTML seu e assume o risco. |
{{#if var}} … {{else}} … {{/if}} | Seção condicional. Ausente, null, false, "", 0 e lista vazia contam como falso. |
{{#each lista}} … {{/each}} | Repete o miolo por item. {{this}} é o item; num item-objeto, {{campo}} resolve nele (e sobe para o contexto de fora se não achar). Lista ausente rende vazio. |
- Nada além disso existe — helper, partial, comentário e afins são recusados na gravação (
400 template_parse_error, citando a tag). O catálogo cresce por decisão nossa, nunca por aceitação silenciosa. - Estrito por padrão: variável referenciada e ausente do
datarecusa o envio com400 template_var_missing— nunca um{{buraco}}visível no inbox. O opt-out é por variável, com| "padrão". - O dado nunca vira template: um valor contendo
{{outravar}}sai como texto literal — a substituição é de passo único, nada é re-interpretado. - Tetos: 256KB por campo na gravação; na expansão, 1MB de saída e 10.000 iterações somadas de
{{#each}}— estourou, o envio é recusado com erro nomeado. - Mensagem enviada por template carrega
templateId/templateVersionnoGET /v1/messagese no detalhe — o rastro de qual molde/versão gerou o quê.
/v1/templatesescopo read:templatesLista os templates da conta: id, name, currentVersion (a publicada; null = nunca publicado), latestVersion e drafts.
/v1/templates/:refescopo read:templates:ref é o id numérico ou o name — em todas as rotas desta seção. Devolve o template com todas as versões (fonte, status, variáveis declaradas).
/v1/templatesescopo write:templatesCria o template como rascunho v1. Corpo: name (único na conta; começa com letra — letras, números, . - _, até 120), subject (obrigatório, também é template), e html e/ou text. A sintaxe é validada aqui: erro de template nunca sobrevive até o envio.
curl -X POST https://api.oveyon.com/v1/templates \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "pedidoChegou",
"subject": "{{nome}}, seu pedido chegou",
"html": "<p>Olá {{nome}}, use o cupom {{ cupom | \"SEM10\" }}.</p>",
"text": "Olá {{nome}}, use o cupom {{ cupom | \"SEM10\" }}."
}'/v1/templates/:refescopo write:templatesEdita: com rascunho vivo, atualiza o rascunho; sem, cria a próxima versão como rascunho. A publicada nunca é tocada.
/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).
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. 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.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"
}O id público é sempre o uuid. next_cursor: null ⇒ última página.
/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.
Exemplo
curl https://api.oveyon.com/v1/messages/3f8a1c2e-9b4d-4e10-8a77-2b0c9d5e1f34 \
-H "Authorization: Bearer $OVEYON_API_KEY"Resposta
{
"id": "3f8a1c2e-9b4d-4e10-8a77-2b0c9d5e1f34",
"status": "delivered",
"statusDetail": null,
"route": "mailchannels",
"from": "vendas@suaempresa.com",
"to": "cliente@exemplo.com",
"subject": "Bem-vindo",
"sizeBytes": 4821,
"acceptedAt": "2026-07-21T13:02:11.000Z",
"deliveredAt": "2026-07-21T13:02:14.000Z",
// UMA ENTREGA POR DESTINATÁRIO. `status` e `to` acima são o resumo e o
// primeiro endereço; o desfecho de cada um está aqui. O resumo é
// pessimista: se um destinatário não recebeu, a mensagem não é "delivered".
"deliveries": [
{ "id": "d_8f2a…", "to": "cliente@exemplo.com", "kind": "to", "status": "delivered", "statusDetail": null,
"deliveredAt": "2026-07-21T13:02:14.000Z", "completedAt": "2026-07-21T13:02:14.000Z", "latencyMs": 2900 },
{ "id": "d_1c07…", "to": "copia@exemplo.com", "kind": "bcc", "status": "bounced",
"statusDetail": "550 5.1.1 User unknown", "deliveredAt": null, "completedAt": "2026-07-21T13:02:13.000Z", "latencyMs": 1800 }
],
// `deliveryId`/`to` dizem de QUAL entrega é o evento; null = evento da
// mensagem (aceite, por exemplo), que não pertence a destinatário nenhum.
"events": [
{ "event": "accepted", "deliveryId": null, "to": null, "detail": { "source": "api" }, "at": "2026-07-21T13:02:11.000Z" },
{ "event": "delivered", "deliveryId": "d_8f2a…", "to": "cliente@exemplo.com", "detail": {}, "at": "2026-07-21T13:02:14.000Z" },
{ "event": "bounced", "deliveryId": "d_1c07…", "to": "copia@exemplo.com", "detail": { "smtpResponse": "550 5.1.1 User unknown" }, "at": "2026-07-21T13:02:13.000Z" }
],
"tracking": [
{ "type": "open", "url": null, "host": "email.suaempresa.com", "readMsEstimate": 3200, "at": "..." },
{ "type": "click", "url": "https://loja.suaempresa.com/x", "host": "email.suaempresa.com", "readMsEstimate": null, "at": "..." }
]
}readMsEstimate é uma aproximação fraca (delta entre beacons), não tempo de leitura exato.
Domínio descartável
O domínio de um endereço é de e-mail descartável/temporário (Mailinator, 10minutemail e afins)? Use no cadastro, antes de aceitar um e-mail que nunca vai ser lido duas vezes.
Esta é a única rota da API que não exige autenticação. Sem Authorization, sem chave, sem conta: a base é uma lista pública e não há nada seu envolvido na resposta. Também não consome cota. Em troca, o teto é por IP e é apertado — veja o fim desta seção.
/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.oveyon.com.
# Confira a propagação antes de pedir a verificação no painel:
dig +short MX suaempresa.com
# esperado: 10 mx1.oveyon.com.Se o domínio já recebe e-mail em outro provedor, trocar o MX move a caixa inteira para cá. Para experimentar sem mexer no principal, use um subdomínio (ex.: recebe.suaempresa.com).
Passo 2 de 5
Verifique. Em Domínios, abra o domínio e clique em Verificar MX. Nós consultamos o DNS na hora e confirmamos que ele aponta para mx1.oveyon.com. Propagação de DNS leva de minutos a algumas horas; pode repetir à vontade.
A verificação só grava no positivo — uma consulta que falha por DNS lento nunca desfaz um domínio já verificado.
Passo 3 de 5
Ligue a recepção no mesmo lugar. É o interruptor do domínio: desligado, todo e-mail para ele é recusado; ligado, valem os endereços do passo 4 — e 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. - 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, 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"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.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,
"sizeBytes": 18422,
"attachmentCount": 2,
"expiresAt": "2026-09-04T09:14:02.000Z",
// O DESFECHO. "accepted" = entregue a pelo menos um destinatário.
"outcome": "accepted",
"blockedReason": null
}
],
"next_cursor": "aTo3Nw"
}O id público é o uid da mensagem; é ele que vai nas rotas abaixo. next_cursor: null ⇒ última página.
O mesmo item, com desfecho blocked
Mesma rota, mesma forma — o que muda é o desfecho. Se o seu código pressupõe que toda mensagem listada tem destinatário, é esta a resposta que vai encontrá-lo. Ver «nem toda mensagem listada foi entregue».
{
"id": "c04b19f7-3d5a-4a02-b7e1-6d8f0a2c4419",
"receivedAt": "2026-08-05T11:02:40.118Z",
"from": "cliente@exemplo.com",
"subject": "Chegou enquanto estava desligado",
"domain": "suaempresa.com",
// VAZIO, e não ausente: ninguém recebeu esta mensagem.
"recipients": [],
"attachmentCount": 0,
"expiresAt": "2026-09-04T11:02:40.000Z",
// Chegou, foi guardada, não foi entregue e NÃO foi cobrada.
"outcome": "blocked",
"blockedReason": "inbound_disabled"
}/v1/inbound/:uidescopo read:inboundDetalhe da mensagem: tudo o que a listagem traz, mais o manifesto de anexos e os links de conteúdo. Não devolve o corpo — ele tem rota própria.
Exemplo
curl https://api.oveyon.com/v1/inbound/b71e0c34-5a2f-4d18-9c60-77ab31e2d905 \
-H "Authorization: Bearer $OVEYON_API_KEY"Resposta
{
// … todos os campos da listagem, mais:
// MANIFESTO DOS ANEXOS. O `ord` é o ENDEREÇO do anexo — carimbado quando
// a mensagem entrou, estável para sempre. O download é por ele, NUNCA
// pelo `filename` (que veio de quem enviou: pode repetir, vir vazio ou
// trazer caminho).
"attachments": [
{ "ord": 1, "filename": "orcamento.pdf", "contentType": "application/pdf",
"sizeBytes": 14233, "sha256": "9f2c…",
"url": "/v1/inbound/b71e0c34-…/attachments/1" },
{ "ord": 2, "filename": "logo.png", "contentType": "image/png",
"sizeBytes": 3180, "sha256": "0ab7…",
"url": "/v1/inbound/b71e0c34-…/attachments/2" }
],
"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.
Exemplo
curl https://api.oveyon.com/v1/inbound/b71e0c34-5a2f-4d18-9c60-77ab31e2d905/content \
-H "Authorization: Bearer $OVEYON_API_KEY"Resposta
{
"id": "b71e0c34-5a2f-4d18-9c60-77ab31e2d905",
"text": "Bom dia, segue em anexo o orçamento aprovado…",
"html": "<p>Bom dia, segue em anexo o orçamento aprovado…</p>",
"truncated": false
}O html é HTML de terceiro. Ele volta como dado, exatamente como chegou. Renderizar sem sanitizar é XSS na sua origem — passe por um sanitizador (DOMPurify e afins) ou use só o text.
/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— filtro fora do formato.403 insufficient_scope— a chave não temread:inbound.429 rate_limited— além do teto por IP, estas rotas têm um teto por chave (padrão 120 req/min): são as únicas do/v1que leem bytes do disco a cada chamada. A resposta diz o limite; respeite oretry-after.
Webhook de recepção
Em vez de perguntar «chegou alguma coisa?» a cada minuto, nós avisamos: um POST assinado no seu servidor a cada e-mail aceito. O evento é inbound.received.
«Aceito» é literal: o aviso é por destinatário, então uma mensagem com outcome: "blocked" — que não foi entregue a ninguém — não gera evento nenhum. Não é uma condição que alguém tenha de lembrar de manter: sem destinatário não há por quem disparar. Se a recepção de um domínio ficou desligada por um período, o que chegou nele está em GET /v1/inbound, e só lá.
Onde se liga
O webhook de recepção é um canal do endereço, e o cadastro é no painel: Domínios → abra o domínio → na tabela de endereços de recepção, a linha «Avisar por webhook» logo abaixo do endereço. Você informa a URL e escolhe o modo; o segredo de assinatura é gerado por nós e aparece uma única vez, na criação — copie na hora, porque não há tela que o mostre de novo (perdeu, remova o webhook e crie outro).
Não é o mesmo cadastro dos webhooks de envio — aqueles assinam o que aconteceu com o que você mandou (delivered, bounced…) e inbound.received não está no vocabulário deles: não adianta pedi-lo no POST /v1/webhooks. Também não há rota de API para cadastrar canal de recepção — hoje isso é só painel. Endereços diferentes podem ter destinos diferentes, e o mesmo endereço pode ter mais de um webhook (cada um com o seu segredo, o seu modo e a sua contagem de tentativas).
A URL passa pela mesma checagem dos webhooks de envio: destino interno ou privado é recusado no cadastro, e o IP é validado no momento da conexão — trocar o DNS depois do cadastro não nos leva para dentro da sua rede.
Antes de salvar, nós batemos na sua URL uma vez
Desde 24/08/2026, uma URL de webhook de recepção só é gravada depois que o endpoint responde a um desafio. Vale nos três gestos: ao criar o canal, ao trocar a URL de um canal existente, e ao alargar o modo (de «Resumo» para «Completo», por exemplo). Se o desafio não passa, nada é salvo — e num reaponte a URL antiga continua valendo.
O motivo é a simetria com os outros canais: no encaminhamento, o dono da caixa confirma por e-mail; no Telegram, o chat_id nunca é digitado, ele vem de um /start com nonce nosso; no Slack, é OAuth. O webhook era o único em que bastava digitar um endereço para ele passar a valer — e apontar correio para o endpoint de um terceiro que nunca disse sim é um jeito de nos transformar em instrumento.
O que a prova cobre, e o que ela não cobre: ela prova que quem controla aquele endpoint aceita receber. Ela não prova que o dono do sistema do outro lado autorizou — quem aponta para o próprio servidor passa no desafio sem esforço. Isso é deliberado, não uma lacuna a ser fechada depois.
O desafio é um POST comum, com os mesmos três cabeçalhos de assinatura das entregas de verdade e x-oveyon-event: url_verification. Timeout de 10 s.
POST https://seu-endpoint.exemplo/hook
x-oveyon-event: url_verification
x-oveyon-timestamp: 1756041600
x-oveyon-signature: sha256=…
content-type: application/json
{
"type": "url_verification",
"challenge": "3f9a…64 hex…",
"url": "https://seu-endpoint.exemplo/hook",
"sentAt": "2026-08-24T12:00:00.000Z"
}Para passar, responda 2xx devolvendo o valor de challenge, de uma destas duas formas — as duas são aceitas:
- 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, uma vez), então não há como verificar a assinatura desse primeiro desafio — só o eco é exigido. Numa troca de URL o segredo já é seu, e aí vale verificar a assinatura antes de ecoar, como em qualquer entrega.
E se o destino é um receptor de terceiros que você não programa (n8n, Make, webhook.site)? Ecoar o challenge pode ser impossível ali. Para esse caso existe o caminho alternativo, no painel: quando o seu endpoint aceita o POST (2xx) mas não devolve o desafio, o formulário oferece a opção «aceitar sem prova de leitura» — marque-a e a URL é salva assim mesmo, com uma marca visível no canal. Seja honesto consigo sobre o que se perde: a prova cai de «alguém lê o que chega lá» para «a URL existe e aceita POST». E o atalho só vale para esse desfecho — endpoint que responde erro ou não responde no prazo continua sendo recusado, porque ali não há receptor nenhum, só um endereço quebrado.
app.post('/hook', (req, res) => {
// O desafio vem ANTES de tudo — e sai daqui.
if (req.body && req.body.type === 'url_verification') {
return res.json({ challenge: req.body.challenge });
}
// … daqui para baixo, o inbound.received de verdade
});Um evento por destinatário, por canal
É o ponto que mais confunde quem integra, então vale devagar: um e-mail que chegou para três endereços seus gera três eventos, não um com uma lista.
O motivo é que cada destinatário tem desfecho próprio. Uma transação SMTP é um corpo e N destinatários, mas o aviso de um pode falhar e entrar em retentativa enquanto o do outro já foi entregue no primeiro tiro. Um evento só teria que carregar um status agregado — e status agregado de coisas que terminam diferente é sempre mentira sobre alguma delas. Pelo mesmo motivo, se o endereço tiver dois canais, cada canal tem a sua própria contagem de tentativas: o mesmo destinatário aparece uma vez por canal.
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,
"url": "https://api.oveyon.com/v1/inbound/b71e0c34-…",
"rawUrl": "https://api.oveyon.com/v1/inbound/b71e0c34-…/raw"
},
// A QUEM esta cópia se refere — um OBJETO, não uma string. Um e-mail que
// chegou para suporte@ E vendas@ gera DOIS eventos: mesmo `message.id`,
// `recipient` e `eventId` distintos.
"recipient": { "id": "4d2f77a1-…", "to": "suporte@suaempresa.com" },
// MANIFESTO, no TOPO do corpo (não dentro de `message`): nome, tipo,
// tamanho, sha256 e a URL de onde se buscam os bytes. Vem em TODOS os
// modos, inclusive `summary`. O endereço do anexo é o `ord`, NUNCA o
// `filename`.
"attachments": [
{ "ord": 1, "filename": "orcamento.pdf", "contentType": "application/pdf",
"sizeBytes": 14233, "sha256": "e3b0c442…",
"url": "https://api.oveyon.com/v1/inbound/b71e0c34-…/attachments/1" }
],
// SEMPRE presente — inclusive quando nada foi cortado. Cheque sempre.
"truncation": {
"degraded": false, // saiu menos do que você pediu?
"requestedMode": "full", // o modo cadastrado no canal
"mode": "full", // o modo que de fato saiu
"reason": null, // texto legível quando degradou; null quando não
"maxBytes": 262144, // o teto em vigor para ESTE POST
"attachmentsInline": false, // os bytes vieram embutidos em `content`?
"attachmentsListed": 1, // quantos anexos couberam no manifesto
"attachmentCount": 1, // quantos a mensagem tem, no total
"textTruncated": false,
"htmlTruncated": false
},
// Nos modos `full` e `full+attachments`, e também no TOPO do corpo. São
// `null` quando a mensagem não tem aquela parte; no modo `summary` as duas
// chaves simplesmente não existem.
"text": "Bom dia, segue em anexo o orçamento aprovado…",
"html": "<p>Bom dia, segue em anexo o orçamento aprovado…</p>"
}Três modos — você escolhe quanto volume quer no aviso
| Modo | O que vai no POST | Bom quando |
|---|---|---|
summary |
Remetente, assunto, vereditos de autenticação, as URLs da API e o manifesto dos anexos. Sem text e sem html. |
Você só quer o gatilho e vai buscar o corpo quando (e se) precisar. |
full |
O de cima + text e html. |
O caso comum: dá para processar o e-mail sem uma segunda chamada. |
full+attachments |
O de cima + os bytes dos anexos em base64, em attachments[].content, desde que o POST inteiro caiba no teto. |
Anexos pequenos e previsíveis, e você não quer autenticar um download. |
O manifesto dos anexos vem nos três modos — o que muda de um para o outro é o volume (corpo e bytes), nunca a lista. E o modo que vem marcado por padrão na tela é o full+attachments: quem não escolhe recebe tudo o que couber.
// modo `summary` — sem `text` e sem `html` (as chaves nem aparecem). Tudo o
// mais continua: cabeçalho, vereditos, URLs e o MANIFESTO dos anexos.
{ "event": "inbound.received", "eventId": "…", "schemaVersion": 1,
"message": { … }, "recipient": { "id": "…", "to": "…" },
"attachments": [ { "ord": 1, "filename": "orcamento.pdf", "sha256": "…", "url": "…" } ],
"truncation": { "degraded": false, "requestedMode": "summary", "mode": "summary", … } }
// modo `full+attachments` — cada item do manifesto ganha os BYTES em base64,
// no mesmo formato do `attachments[].content` do POST /send. É TUDO OU NADA:
// ou todos os anexos vêm embutidos, ou nenhum vem.
"attachments": [
{ "ord": 1, "filename": "orcamento.pdf", "contentType": "application/pdf",
"sizeBytes": 14233, "sha256": "e3b0c442…",
"url": "https://api.oveyon.com/v1/inbound/b71e0c34-…/attachments/1",
"content": "JVBERi0xLjQK…" }
]
"truncation": { …, "mode": "full+attachments", "attachmentsInline": true }
// … e quando não coube, o payload DEGRADA e DIZ. Aqui pediram
// `full+attachments` e saiu `full`: o manifesto ficou, os bytes não.
"truncation": {
"degraded": true,
"requestedMode": "full+attachments",
"mode": "full",
"reason": "payload com anexos embutidos passaria de 262144 bytes: vão por link",
"maxBytes": 262144,
"attachmentsInline": false,
"attachmentsListed": 1,
"attachmentCount": 1,
"textTruncated": false,
"htmlTruncated": false
}O teto do POST
Todo aviso tem um teto de tamanho, e ele vale para o corpo inteiro do POST — não só para os anexos. O padrão é 256 KB (262 144 bytes). O valor em vigor para aquele POST vem declarado dentro dele, em truncation.maxBytes: leia dali em vez de fixar o número no seu código.
O teto é aplicado quando o aviso é montado, não na hora de entregar. Um e-mail de 15 MB nunca vira um POST de 20 MB que nós tentaríamos seis vezes: o payload grande não chega nem a existir. Duas consequências práticas para quem integra: text e html entram cada um cortado em no máximo um quarto do teto (o corte é por byte, e nunca parte um caractere no meio), e os bytes de anexo só são embutidos se todos couberem no que sobrar.
Degradação é declarada, nunca silenciosa
Quando o payload não cabe no modo pedido, ele degrada e diz que degradou. O bloco truncation está sempre presente — com degraded: false no caminho normal — justamente para você poder checar sempre, com uma linha só, sem descobrir a existência do assunto no dia do primeiro e-mail grande.
Não existe campo level: o que responde «quanto saiu?» é o par requestedMode / mode, no mesmo vocabulário dos três modos acima.
degraded—truequando saiu menos do que você pediu. É o único campo que você precisa checar.requestedModeemode— o que foi pedido no cadastro e o que de fato saiu. Pediufull+attachmentse recebeumode: "full"? Os bytes ficaram de fora; o manifesto e as URLs continuam lá.reason— frase legível com tudo o que se perdeu, não só o último motivo (dois problemas na mesma mensagem viram dois trechos separados por;). Énullquando nada degradou.maxBytes— o teto em vigor para este POST.attachmentsInline—truesó quando os bytes vieram emattachments[].content. É tudo ou nada: nunca metade dos anexos embutidos.attachmentsListedvs.attachmentCount— quantos anexos vieram no manifesto e quantos a mensagem tem no total. Diferentes? A cauda da lista não coube; os que faltam estão na API.textTruncated/htmlTruncated— aquele campo veio cortado (ou removido). Detalhe, não substituto: quando um deles étrue,degradedtambém é.
O que degradou não se perdeu: está na API, sob o mesmo message.id. Degradar é tirar volume do aviso, nunca do arquivo.
Deduplique por eventId
Nós preferimos entregar duas vezes a perder um aviso. Se o seu servidor processar e o 200 se perder no caminho, a tentativa seguinte traz o mesmo eventId — ele é estável por entrega e sobrevive a retentativa e a reentrega. Guarde-o, e trate repetido como sucesso silencioso. Não deduplique por hash do corpo: uma reentrega remonta o payload, e o que chega pode não ser byte a byte o que chegou antes.
// A MESMA entrega pode bater duas vezes: retentativa depois de um timeout no
// qual você já tinha processado, ou reentrega nossa. `eventId` é estável
// nos dois casos — é a chave de deduplicação.
async function processar(evento) {
// INSERT com chave única no eventId: quem perder a corrida já sabe que
// é repetido, sem depender de um SELECT-antes-do-INSERT (que corre).
const novo = await marcarComoVisto(evento.eventId);
if (!novo) return; // já processado — responda 2xx e siga
if (evento.truncation.degraded) {
// Faltou volume no aviso: busque o que falta na API, pelo message.id.
await baixarDaApi(evento.message.id);
}
await salvar(evento);
}Assinatura (HMAC-SHA256)
Mesmo esquema dos webhooks de envio. Cada POST leva três cabeçalhos:
x-oveyon-event— o nome do evento (inbound.received).x-oveyon-timestamp— epoch em segundos.x-oveyon-signature—sha256=+ HMAC-SHA256 do segredo do canal sobretimestamp + "." + corpo-cru.
O timestamp entra dentro do conteúdo assinado: um POST capturado não pode ser reenviado com carimbo novo sem quebrar a assinatura. Recuse o que chegar com |agora − timestamp| > 300 s, mesmo com HMAC válido — sem essa janela a assinatura sozinha autentica um replay de ontem.
Verificando em Node
const express = require('express');
const crypto = require('crypto');
const app = express();
// O CORPO CRU é o que foi assinado. Capture os bytes ANTES de qualquer parse
// — reserializar o JSON muda um espaço e a assinatura não fecha mais.
app.post('/hooks/oveyon-inbound', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('x-oveyon-timestamp') || '';
const sig = (req.get('x-oveyon-signature') || '').replace(/^sha256=/, '');
const raw = req.body; // Buffer
// Janela anti-replay: 300 s. Fora dela, recuse — mesmo com HMAC válido.
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);
const esperado = crypto.createHmac('sha256', process.env.OVEYON_WEBHOOK_SECRET)
.update(ts + '.').update(raw).digest('hex');
const a = Buffer.from(esperado, 'hex');
const b = Buffer.from(sig, 'hex');
// Comparação em tempo constante (o == vaza o prefixo certo por timing).
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
const evento = JSON.parse(raw.toString('utf8'));
enfileireParaProcessar(evento); // trabalho pesado FORA da requisição
res.sendStatus(200); // 2xx rápido: a tentativa expira em 10 s
});Verificando em PHP
<?php
// O corpo CRU, byte a byte. Nada de json_decode antes de conferir.
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_OVEYON_TIMESTAMP'] ?? '';
$sig = preg_replace('/^sha256=/', '', $_SERVER['HTTP_X_OVEYON_SIGNATURE'] ?? '');
// Janela anti-replay: 300 s.
if ($ts === '' || abs(time() - (int) $ts) > 300) { http_response_code(400); exit; }
$esperado = hash_hmac('sha256', $ts . '.' . $raw, getenv('OVEYON_WEBHOOK_SECRET'));
// hash_equals: comparação em tempo constante.
if (!hash_equals($esperado, $sig)) { http_response_code(401); exit; }
$evento = json_decode($raw, true);
enfileireParaProcessar($evento); // trabalho pesado depois de responder
http_response_code(200);Retentativas
Entrega é 2xx. Qualquer outra coisa conta como falha e entra na fila de retentativa: 4xx, 5xx, timeout de 10 s, conexão recusada — e também 3xx, porque redirecionamento não é seguido (seguir um redirect de endpoint de cliente é como um SSRF entra pela porta da frente).
| Tentativa | Quando | Acumulado desde a 1ª |
|---|---|---|
1 | assim que o evento entra na fila (o worker roda a cada 10 s) | — |
2 | 30 segundos depois da falha anterior | ~30 s |
3 | 2 minutos | ~2,5 min |
4 | 10 minutos | ~12,5 min |
5 | 1 hora | ~1h12 |
6 | 6 horas | ~7h12 |
São 6 tentativas, dentro de uma janela de pouco mais de 7 horas. Esse é o número que importa para quem opera: uma queda de manutenção de duas horas é absorvida sem perder nada; uma queda de um dia inteiro, não.
Esgotadas as seis, a entrega é marcada como falha e não há sétima — a fila para de bater no seu endpoint em vez de martelá-lo para sempre. Nada se perde por isso: o e-mail continua guardado, e GET /v1/inbound é a rede de segurança. Endpoint que ficou fora do ar? Reconcilie listando a janela da queda (?from=…&to=…) e deduplicando pelo que você já tinha — é por isso que a API existe mesmo para quem usa webhook.
Onde você vê isso acontecendo: no painel, na mesma linha em que o webhook foi cadastrado (Domínios → o domínio → o endereço), a coluna Último envio mostra o desfecho de cada aviso e o motivo em texto: pending (vai haver nova tentativa), delivered, failed (as seis se esgotaram) e dropped. Este último é o caso em que não havia mais para onde entregar — o webhook foi removido ou desativado entre a chegada do e-mail e a tentativa. dropped não é falha sua nem nossa, e não tem retentativa.
Responda 2xx rápido e faça o trabalho pesado depois. Um endpoint que processa 12 segundos antes de responder falha por timeout mesmo tendo feito tudo certo — e aí você recebe o mesmo evento seis vezes.
Endpoint atrás de Cloudflare (ou WAF): se você vê 403 nas tentativas e o seu código nunca roda, quem está recusando é a borda, não o seu servidor — bot-fight e regras de WAF adoram matar POST de máquina. Nossas entregas se identificam como User-Agent: OVEYON-Webhook/1.0: crie uma exceção de WAF para esse UA (ou para o caminho do seu webhook) e o problema some. Dica de diagnóstico que vale para tudo: o código que o SEU código devolve você encontra no seu log de aplicação; 403 sem linha de log nenhuma = a borda comeu a requisição antes.
Anexos
O payload carrega o manifesto — ord, nome, tipo, tamanho, sha256 e a URL de cada anexo — nos três modos. Os bytes se buscam pela API, com a sua chave: é a mesma rota da seção anterior.
curl -OJ https://api.oveyon.com/v1/inbound/b71e0c34-5a2f-4d18-9c60-77ab31e2d905/attachments/1 \
-H "Authorization: Bearer $OVEYON_API_KEY"- Por que não vêm sempre inline: base64 infla o arquivo em cerca de um terço, e um anexo de 15 MB viraria um POST de ~20 MB que nós tentaríamos até seis vezes, com 10 s de timeout. A maioria dos servidores recusa um corpo desse tamanho antes mesmo de conferir a assinatura — e aí o anexo derruba o aviso inteiro, não só a si mesmo.
- O modo
full+attachmentsembute os bytes só enquanto o POST inteiro couber no teto (256 KB por padrão; o valor daquele POST está emtruncation.maxBytes). Não coube? Os anexos passam a ir por link,attachmentsInlinevemfalse,reasonexplica, e as URLs continuam válidas. - É tudo ou nada. Nunca chegam alguns anexos embutidos e outros por link — ou o lote inteiro cabe, ou nenhum vem com
content. - O endereço do anexo é o
ord, nunca ofilename: dois anexos podem ter o mesmo nome, e o nome pode vir vazio ou com caminho dentro.
⚠ O que chega no seu endpoint é conteúdo de terceiro
Assunto, corpo, HTML, nome e bytes de anexo foram escritos por quem enviou o e-mail — não por nós, e não por você. Qualquer pessoa na internet pode mandar um e-mail para um endereço seu, e portanto qualquer pessoa na internet escolhe o que vai dentro deste payload. A assinatura HMAC prova que nós enviamos o POST; ela não diz nada sobre o conteúdo dele.
- Nunca renderize o
htmlsem sanitizar. Injetá-lo no seu DOM, num painel de atendimento ou num e-mail que você reenvia é XSS na sua origem, com a sessão do seu usuário. Passe por um sanitizador (DOMPurify e afins) ou use só otext. - Nunca use o
filenamepara montar caminho em disco. Ele pode conter../, barra, caractere nulo ou nome de arquivo do sistema. É path traversal servido de bandeja. Salve peloord(ou por um id seu) e guarde o nome original só como rótulo de exibição. - Não confie no
contentTypedeclarado pelo remetente, e não sirva o anexo de volta como documento na sua origem. Se precisar disponibilizar download, forceContent-Disposition: attachmenteX-Content-Type-Options: nosniff— é o que fazemos nas nossas rotas. - Leia
message.authenticationantes de acreditar em quem assina o e-mail.fromHeaderé o que o remetente declarou; SPF, DKIM e DMARC são o que se pôde provar. Fluxo que aciona algo importante a partir de um e-mail deve exigirdmarc: "pass"— sem isso, o «de: chefe@suaempresa.com» é digitável por qualquer um. - O
fromNameé o campo mais fácil de forjar do e-mail inteiro. Ele é o nome de exibição que o remetente escreveu — não precisa registrar domínio parecido nem quebrar nada: basta digitar. Um e-mail com"fromName": "Banco do Brasil"e"fromHeader": "x@dominio-qualquer.top"é o phishing mais comum que existe, e ele passa por SPF e DKIM sem problema (o domínio dele é legítimo — só não é o que o nome sugere). Exiba o nome se quiser, mas decida pelo endereço, nunca pelo nome.
Supressões
Endereços que você nunca quer atingir. Só as linhas do seu tenant são listadas; as supressões globais da plataforma também bloqueiam, mas não aparecem aqui.
/v1/suppressionsescopo read:suppressionsLista com filtros e paginação por offset.
Query
email— busca por substring.reason—hard_bounce·complaint·manual·unsubscribe.limit— ≤ 500 (default 100) ·offset.
curl "https://api.oveyon.com/v1/suppressions?reason=hard_bounce&limit=100" \
-H "Authorization: Bearer $OVEYON_API_KEY"{
"suppressions": [
{ "email": "invalido@exemplo.com", "reason": "hard_bounce",
"source": "bounce", "createdAt": "2026-07-20T09:11:00.000Z" }
],
"total": 1, "limit": 100, "offset": 0
}/v1/suppressionsescopo write:suppressionsAdiciona um endereço. reason default manual. Resposta 201 { email, reason }.
curl -X POST https://api.oveyon.com/v1/suppressions \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "naoenviar@exemplo.com", "reason": "manual" }'/v1/suppressions/:emailescopo write:suppressionsRemove uma supressão (e-mail URL-encoded no path). Resposta 200 { deleted }.
- Removível apenas
manualehard_bounce. complainteunsubscribe→403 removal_blocked: quem reclamou de spam ou se descadastrou só volta com re-opt-in comprovado.- Inexistente ou fora do escopo →
404 not_found(não distinguimos os dois).
curl -X DELETE https://api.oveyon.com/v1/suppressions/naoenviar%40exemplo.com \
-H "Authorization: Bearer $OVEYON_API_KEY"Regras de envio e recebimento (allow/block)
Quatro listas por conta: allow e block, para outbound (julga o destinatário) e inbound (julga o remetente, no nosso MX). Entrada é um e-mail exato, um domínio exato ou *.dominio.com (cobre subdomínios em qualquer profundidade, nunca o próprio domínio). Semântica: block vence allow; a allow só restringe quando tem pelo menos uma entrada; no block, user+tag@ conta como user@. É a cerca recomendada para caixas operadas por agentes de IA: uma allow list de destinatários limita o estrago de qualquer prompt que dê errado.
/v1/policiesescopo read:policiesLista as entradas (filtros scope e kind). Escopo read:policies.
Cada entrada traz paused (booleano) e pausedAt (carimbo, ou null). Regra pausada continua na lista e não vale — se você só olhar a presença da entrada, vai concluir que ela está bloqueando correio quando ela está dormindo.
Estes dois campos são de leitura aqui: pausar uma entrada de lista é gesto do painel (Regras), e não há rota de API para isso — não procure. Quem tem pausa pela API é a cerca de rede, logo abaixo, que é outro recurso. A assimetria é real e está aqui declarada para você não escrever código contra uma rota que não existe.
/v1/policiesescopo write:policiesCria uma entrada. Escopo write:policies.
curl -X POST https://api.oveyon.com/v1/policies -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" -d '{ "scope": "outbound", "kind": "allow", "pattern": "cliente.com.br" }'Refinamento opcional: domainId (a entrada vale só naquele domínio) e credentialType+credentialId (smtp|apiKey — a cerca de UMA credencial, o desenho por agente). Duplicada responde 409 policy_exists; entrada fora do formato, 400. Teto de 5.000 entradas por lista.
/v1/policies/:idescopo write:policiesRemove uma entrada (o id vem do create/list). A mutação vale em segundos em todas as portas — API, SMTP e MX.
/v1/credentials/ip-rulesescopo read:policies/v1/credentials/ip-rulesescopo write:policies/v1/credentials/ip-rules/:idescopo write:policiesAmarração de rede: CIDRs permitidos por credencial (SMTP ou API key). Opt-in — credencial sem regra autentica de qualquer lugar; com regra, só de dentro dos CIDRs: senha vazada sem o IP certo não autentica (SMTP responde 535 5.7.8; a API, 403 ip_not_allowed nomeando os CIDRs permitidos). Cuidado com IP dinâmico/CGNAT: cadastre a faixa, não o /32 do momento.
curl -X POST https://api.oveyon.com/v1/credentials/ip-rules -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" -d '{ "credentialType": "smtp", "credentialId": 42, "cidr": "203.0.113.0/24" }'/v1/credentials/ip-rules/pauseescopo write:policies/v1/credentials/ip-rules/unpauseescopo write:policiesPausar a cerca sem perder as faixas. Corpo: { "credentialType": "smtp"|"apiKey", "credentialId": … }. Pausada, a credencial volta a autenticar de qualquer IP e as faixas continuam cadastradas — unpause devolve exatamente as mesmas, sem recadastrar nada. É o gesto para testar se a cerca é a causa de um envio que não sai: antes disto o único jeito de desligá-la era apagar as faixas, e apagar perde CIDR, comentário e autoria.
- O
GET /v1/credentials/ip-rulestrazpausedepausedAtem cada linha — faixa listada compaused: truenão está valendo. unpausepode devolverwarning.uncoveredRecentIps: origens que enviaram nos últimos 30 dias e ficam fora das faixas. Elas param de autenticar na hora — a cerca voltou.- Credencial sem nenhuma faixa →
409 no_ip_rules: não há cerca para pausar (ela já autentica de qualquer IP). - Escopo
write:policiesnas duas. Reiniciar o serviço não desfaz a pausa.
curl -X POST https://api.oveyon.com/v1/credentials/ip-rules/pause -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" -d '{ "credentialType": "smtp", "credentialId": 42 }'Erros que o /send passa a devolver
422 recipient_blocked— destinatário na sua block list; o campoentrynomeia a entrada que casou.422 recipient_not_allowed— sua allow list está ativa e o destinatário não está nela. A chamada é recusada INTEIRA (mesmo contrato da supressão).
Políticas de envio
Um motor SE→ENTÃO ordenado avaliado em todo envio, na API e no SMTP autenticado. As regras allow/block acima só enxergam endereço; aqui a condição pode falar de assunto, remetente, credencial e hora do dia — e a ação pode recusar, reter, exigir rodapé, forçar o tier ou limitar por hora. As duas peças são restritivas e ordenadas: uma política nunca reabre o que uma lista, uma supressão ou a cota já fecharam.
A forma de uma política
name— até 120 caracteres. É ele que aparece na recusa e no histórico, e ele fica congelado no rastro: renomear a política não reescreve o que ela já decidiu.conditions— de 1 a 5, e todas precisam casar (E). Cada uma é{ campo, op, valor }. Uma política sem condição casaria com TODAS as suas mensagens, e por isso a lista vazia é recusada.action— uma só, do catálogo abaixo.actionParams— só para as ações que declaram parâmetro. Hoje sólimitar_hora, que exige{ "max": N }. Parâmetro a mais, a menos ou desconhecido é400.position— a ordem de julgamento, de cima para baixo. Ela é atribuída por nós (criar sempre põe no fim) e se muda por/reorder.
Sem regex e sem negação, e isso é decisão de segurança e não falta de tempo: uma expressão escrita pelo cliente e avaliada no caminho de toda mensagem é a definição de superfície de ReDoS. O vocabulário é fechado e está inteiro na tabela abaixo.
Condições — o catálogo fechado
campo | op | valor | Observação |
|---|---|---|---|
destinatario | igual · contem · termina_em | texto (até 320) | Casa se algum destinatário casar. No igual, joao+nota@x.com e joao@x.com são a mesma caixa; no contem/termina_em, não (é o que permite pegar a etiqueta). |
remetente | igual · contem · termina_em | texto (até 320) | O from que você escreveu, não o envelope reescrito. |
assunto | contem · igual | texto (até 500) | Sem sensibilidade a caixa e com acentuação normalizada. Assunto acima de 500 é comparado pelo início e nunca casa igual — o corte não pode virar falso positivo. |
credencial | e_id | { "tipo": "api"|"smtp", "id": N } | O tipo é obrigatório: chave de API e credencial SMTP têm espaços de id separados. |
credencial | tier_e | texto | Ex.: transactional. |
hora | entre | { "de": "22:00", "ate": "06:00" } | de inclusivo, ate exclusivo; de > ate vira a meia-noite. No fuso da sua conta (ajustável em Políticas; padrão America/Sao_Paulo). |
Ações
recusar— a mensagem não é aceita:422 policy_refusedna API,550 5.7.1no SMTP. Não consome cota — e não por estorno: o motor roda antes do contador.reter— a mensagem é aceita (202/250) e fica congelada, sem sair. Só o suporte solta retenção de política de envio — não há rota nem botão de liberar para você. Como ela não devolve erro nenhum, o jeito de vê-la pela API é o feed de decisões.exigir_footer— obriga o rodapé de descadastro. Na API, com mais de um destinatário na mesma chamada, o envio é recusado com422 unsub_footer_multi_recipient: o link é individual.forcar_transacional— carimba a mensagem como transacional (rótulo e contabilidade). Hoje não muda o IP de saída, porque o rodízio por tier está desligado nesta plataforma.seguir_fluxo— não faz nada e encerra a avaliação das políticas. É a exceção que se põe ACIMA de uma regra mais ampla. Não isenta de listas, supressão nem cota.limitar_hora— ver a seção própria logo abaixo.
First-match-wins: a primeira política ativa que casar decide, e a varredura para. Não existe «a mais específica ganha» nem acúmulo de ações. Política pausada não julga nada. Se nenhuma casar, nada acontece: zero evento, zero linha.
O teto por hora (limitar_hora)
Deixa passar até max mensagens por hora de relógio para o que a política descreve, e recusa o excedente até a hora virar. É um teto da fatia, não da conta: o resto do seu tráfego não é afetado.
- Conta por destinatário, e não por chamada — a mesma unidade da cota e do teto por credencial. Um envio para 5 pessoas gasta 5.
- Conta no aceite, nunca na checagem: mensagem recusada por cota, por rate ou por falha de injeção depois da política não consome o teto (e no SMTP, o que for recusado após o DATA é devolvido ao contador).
- Folga conhecida e limitada: a checagem é uma por mensagem e a contagem é N. Com
max: 100, 99 contados e uma chamada de 5 destinatários, a chamada passa e o balde termina em 104. Como o teto de destinatários por chamada é 5, o excesso máximo é de 4, uma vez por janela — a chamada seguinte já é barrada. É deliberado: apertá-lo exigiria reservar antes do aceite, trocando um excesso de 4 por reservas órfãs. - Estourado:
429 policy_rate_limitedcomRetry-After(epolicy,limit,used,retryAfterno corpo). No SMTP, um4.7.1que manda tentar de novo — nunca um 5xx, porque a janela vira sozinha. - O rastro da recusa é uma linha por política por hora, não uma por tentativa: uma rajada contra o próprio teto encheria o feed sem dizer nada de novo.
/v1/send-policiesescopo read:send-policiesA lista completa, na ordem de julgamento, com as pausadas. Sem paginação: o teto da conta é 50 e a resposta traz max. Cada política traz paused (booleano) e pausedAt — pausada continua na lista e não vale.
/v1/send-policies/:idescopo read:send-policiesUma política. Id inexistente e id de outra conta respondem o mesmo 404 send_policy_not_found.
/v1/send-policiesescopo write:send-policiesNasce PAUSADA e no fim da ordem. Nada muda no seu envio até você ativar — é o que dá a você a chance de conferir o que escreveu. Resposta 201 com a política.
curl -X POST https://api.oveyon.com/v1/send-policies \
-H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" \
-d '{
"name": "No maximo 200/h para o dominio do cliente",
"conditions": [{ "campo": "destinatario", "op": "termina_em", "valor": "@cliente.com" }],
"action": "limitar_hora",
"actionParams": { "max": 200 }
}'Teto de 50 políticas por conta → 422 send_policy_limit_reached. Entrada inválida → 400 bad_request, e o campo reason traz o motivo estável (param_obrigatorio, hora_invalida, condicoes_demais…) para você ramificar sem ler a prosa.
/v1/send-policies/:idescopo write:send-policiesSubstituição inteira, não remendo: conditions é a lista completa. Não mexe em posição nem em pausa — são gestos próprios. Salvar uma política ativa muda o julgamento em segundos, nas duas portas.
/v1/send-policies/:id/pauseescopo write:send-policies/v1/send-policies/:id/unpauseescopo write:send-policiesLigar e desligar sem apagar. Ativar é sempre um gesto explícito: nenhuma política passa a julgar por ter sido salva.
/v1/send-policies/reorderescopo write:send-policiesCorpo { "order": [id, id, …] }, a lista completa e na ordem desejada. Como a avaliação é first-match, reordenar muda quem decide sem tocar em política nenhuma.
Ids que não são seus são ignorados (não dão 404: a lista é um desejo, não uma referência), e os que faltarem vão para o fim na ordem antiga — é o que impede uma cópia velha da lista de apagar a posição de uma política criada por outra via. A resposta devolve a lista já reordenada.
/v1/send-policies/:idescopo write:send-policiesRemove a política e reindexa a ordem. O rastro não morre junto: as decisões dela continuam no feed, com o nome que ela tinha, e policyId passa a vir null.
/v1/send-policies/decisionsescopo read:send-policiesO que as suas políticas decidiram — mais novo primeiro. Parâmetros limit (1–200, padrão 50) e before (o id da última linha da página anterior; a resposta traz nextBefore pronto).
Leia este endpoint se você usa reter ou limitar_hora: a primeira aceita a mensagem com 202 e a congela — sem erro nenhum na resposta —, e a segunda registra uma linha por janela. Sem o feed, as duas são invisíveis para quem integra só por API. Retenção de 90 dias (o campo retentionDays confirma).
O teto de escrita destas rotas
As rotas de mutação desta seção (POST, PUT, pause, unpause, reorder, DELETE) têm um teto por chave, somado ao teto por IP: padrão 120 escritas/min. As leituras desta seção não pagam esse teto. Estourou → 429 rate_limited com Retry-After; a mensagem diz o limite.
Ele existe porque cada mutação aqui é mais cara do que parece: ela invalida o cache de avaliação de políticas, o reorder reescreve a ordem inteira e o DELETE ainda solta o ponteiro de 90 dias de rastro. É o mesmo teto que a tela Políticas aplica — nenhum uso humano ou script bem-comportado chega perto dele, já que a conta inteira só pode ter 50 políticas.
Erros que o /send passa a devolver
422 policy_refused— recusada por uma política sua. O corpo nomeia a política (policy) e o destinatário que casou (recipient). Não consome cota.429 policy_rate_limited— o teto por hora de uma política sua foi atingido. Honre oRetry-After: a janela vira sozinha.422 unsub_footer_multi_recipient— uma política exige rodapé e a chamada tem mais de um destinatário.
A mesma política vale na API e no SMTP autenticado. Não vale na porta de recepção (recepção não é envio) nem em correio de terceiro relayado.
Pesquisas (NPS / CSAT)
Dispare a pergunta pelo seu sistema (fechou o ticket, entregou o pedido) e receba a nota de volta por webhook. A pesquisa sai como e-mail normal desta plataforma — domínio verificado, DKIM, regras, supressões, políticas de envio e cota valem todas, sem exceção. A nota é coletada no clique, numa página servida no seu domínio de rastreio.
Os dois tipos, e a faixa de cada um
kind | Faixa | O que o rollup publica |
|---|---|---|
nps | 0 a 10 (onze links) | nps = %promotores (9–10) − %detratores (0–6), arredondado; mais promotores, detratores, neutros, media e distribuicao. |
csat | 1 a 5 (cinco links) | media e distribuicao. nps vem null — CSAT não tem promotor, e inventar um seria uma métrica que ninguém reconhece. |
Toda resposta de pesquisa traz range: { min, max }. Leia dele em vez de cravar 0..10 no seu código. E distribuicao vem sempre com todas as notas da faixa, inclusive as zeradas: um gráfico que some as notas sem voto mente sobre a forma da curva, que é o que se olha primeiro. Sem nenhuma resposta, nps e media vêm null — nunca 0: «NPS 0» é um resultado ruim de verdade, e mostrá-lo onde não há dado seria o produto mentindo com um número plausível.
A nota é o clique — e o corpo do e-mail diz isso
Cada nota é um link próprio, e o clique registra o voto mesmo que a página de agradecimento não carregue: a gravação acontece antes do redirecionamento. Não há votação por responder o e-mail, e o corpo avisa isso em HTML e em texto — sem o aviso, quem responde «9» por instinto sairia achando que votou e a sua pesquisa colheria silêncio.
- Primeiro voto vale. Clicar outra nota depois não troca em silêncio: a página mostra a nota registrada e pede uma confirmação. É o que protege o seu número do scanner de link corporativo, que clica os onze links do NPS em ordem — com «último vale», todo destinatário atrás de um scanner viraria a nota do último link sem abrir o e-mail.
- Clique de robô não vota. Proxies de imagem e scanners conhecidos são reconhecidos e ignorados; a página responde igual para eles (nada que os ensine a se disfarçar), e o humano que abrir o mesmo link depois vota normalmente.
- O comentário é opcional e chega depois da nota, na mesma página. Por isso você recebe mais de um
survey.responsepor resposta — ver o webhook abaixo. - Encaminhamento: o link pertence ao destinatário original. Se ele encaminhar o e-mail, o voto de quem clicar conta para ele. É a limitação honesta do modelo, e todo o mercado vive com ela.
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 (a do NPS é a que torna o seu número comparável com o do mercado).throttleDays— 7 a 3650, padrão 90. Texto ou número fora da faixa é400, nunca «caiu no padrão».brandColor(#rrggbb) elogoUrl(https absoluto) — a marca na página de voto.
Teto de 50 pesquisas por conta → 422 survey_limit_reached. Entrada inválida → 400 bad_request com reason (código estável: kind_invalido, throttle_invalido, from_dominio_alheio, logo_invalido…) e field, para você ramificar sem ler a prosa.
/v1/surveys/:idescopo write:surveysSubstituição inteira, não remendo. kind no corpo é ignorado. Mande "active": false para parar de disparar sem perder nada — é o gesto reversível; apagar é o outro.
/v1/surveys/:idescopo write:surveysApaga as respostas junto — e a resposta diz quantas (responsesDeleted), para você não descobrir o tamanho do que perdeu depois. Para só parar de disparar, use active: false acima.
/v1/surveys/:id/sendescopo send:surveysDispara para um destinatário. Resposta 202 com o id da mensagem — a mesma forma do POST /v1/send, porque é literalmente o mesmo caminho de envio.
curl -X POST https://api.oveyon.com/v1/surveys/7/send \
-H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" \
-d '{
"to": "cliente@exemplo.com",
"meta": { "ticket": 4821, "agente": "bia" },
"idempotencyKey": "ticket-4821"
}'meta— o seu contexto (nº do pedido, id do ticket, quem atendeu). Até 4 KB de JSON. Volta inteiro no webhook e na leitura das respostas: é o que liga a nota ao fato que a motivou, do seu lado.idempotencyKey— a mesma chave devolve o mesmo disparo (200status: "duplicate") e nenhum segundo e-mail. Sem ela, um retry de rede vira um segundo e-mail para a mesma pessoa — ou, pior, um429de anti-fadiga contra o seu próprio disparo de três segundos antes.
Todos os erros do /v1/send valem aqui, com os mesmos códigos: recipient_suppressed, recipient_blocked, policy_refused, quota_exceeded, domain_not_verified… Os próprios da pesquisa são dois: 429 recipient_recently_surveyed (anti-fadiga) e 422 survey_inactive (a pesquisa está com active: false).
/v1/surveys/:id/responsesescopo read:surveysAs notas, mais nova primeiro, com recipient, comment, o seu meta e o rollup no fim. Parâmetros limit (1–200, padrão 50) e before (o id da última linha da página anterior; a resposta traz nextBefore pronto).
Cursor, e não offset: a lista é viva, e com offset uma resposta que chega entre duas páginas empurra a fronteira e faz a última linha da página 1 reaparecer como primeira da página 2. O campo updated diz se aquela nota foi trocada depois — sem ele, uma exportação não sabe se aquele 3 já foi um 9.
O webhook survey.response
Assine survey.response num endpoint de webhook e receba a nota no seu servidor, sem precisar perguntar. Mesma assinatura HMAC e mesma política de retentativa dos outros eventos.
{
"event": "survey.response",
"surveyId": 7,
"surveyName": "NPS pos-atendimento",
"kind": "nps",
"sendId": 91,
"recipient": "cliente@exemplo.com",
"score": 9,
"comment": "Atendimento rapido.",
"updated": true,
"meta": { "ticket": 4821, "agente": "bia" },
"messageId": "8f3c...-uuid-da-mensagem",
"timestamp": "2026-08-25T14:02:11.000Z"
}O payload é o ESTADO ATUAL da resposta, não um delta. Case por sendId e sobrescreva. Você recebe mais de um aviso por resposta quando ela muda: o primeiro no voto (updated: false, comment: null), e outro quando a pessoa escreve o comentário ou troca a nota (updated: true). Não é duplicidade — o comentário sempre chega depois da nota, são dois gestos na página, e com um aviso só o campo mais valioso de um detrator nunca chegaria até você.
Endpoint com escopo de credencial não recebe survey.response: quem votou foi o destinatário, sem chave nenhuma, e mandar o aviso para o canal errado é pior que não mandar.
O teto de escrita destas rotas
As mutações desta seção (POST, PUT, DELETE) e o disparo têm um teto por chave, somado ao teto por IP: padrão 120/min. As leituras não o pagam. Estourou → 429 rate_limited com Retry-After.
O disparo entra nesse teto por um motivo concreto: o link de voto tem de existir antes do corpo do e-mail, então o registro nasce antes de o envio ser julgado e é cancelado quando ele é recusado. Um laço contra um destinatário sempre-recusado ficaria criando e cancelando sem parar.
Webhooks de envio
Receba eventos de entrega no seu servidor. Vários endpoints por conta; cada um assina um subconjunto de eventos: delivered, bounced, opened, clicked, complained, blocked. Estes são os eventos do que você mandou — o aviso de e-mail recebido é outro cadastro, na seção Webhook de recepção.
Timeout e retentativas: cada tentativa espera até 10 segundos pela sua resposta; entrega é 2xx dentro desse prazo. Qualquer outra coisa — 4xx, 5xx, 3xx (redirecionamento não é seguido), timeout, conexão recusada — conta como falha e entra na retentativa: são 6 tentativas no total, com esperas crescentes de 30 s, 2 min, 10 min, 1 h e 6 h (janela de ~7 h), a mesma política e a mesma tabela do webhook de recepção. Esgotadas as seis, a entrega é marcada como falha e não há sétima. Responda 2xx rápido e faça o trabalho pesado depois: um endpoint que processa por 12 s antes de responder falha por timeout mesmo tendo feito tudo certo.
O evento blocked dispara quando as suas regras de envio barram um destinatário (na API, no SMTP ou numa resposta a inbound). O corpo traz reason (block_list ou not_on_allow_list), entry (a entrada que casou, quando é block), address e origin. Endpoint com escopo de credencial não recebe blocked — o bloqueio não carrega credencial.
/v1/webhooksescopo manage:webhooksCria um endpoint. O secret de assinatura é devolvido uma única vez.
curl -X POST https://api.oveyon.com/v1/webhooks \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://suaapp.com/hooks/oveyon",
"events": ["delivered", "bounced", "complained"],
"credential": { "type": "api", "id": 42 } }'{
"id": 7,
"url": "https://suaapp.com/hooks/oveyon",
"events": ["bounced", "complained", "delivered"],
"active": true,
"secret": "3b1f...64hex... (exibido UMA vez)",
"signature": {
"header": "x-oveyon-signature: sha256=HMAC-SHA256(secret, `${timestamp}.${rawBody}`)",
"timestampHeader": "x-oveyon-timestamp (epoch em segundos)",
"verify": "Recompute o HMAC sobre `timestamp + \".\" + corpo-cru` e rejeite se |agora - timestamp| > 300s."
}
}Cada entrega leva x-oveyon-signature (HMAC-SHA256 de timestamp.corpo-cru) e x-oveyon-timestamp. Recompute o HMAC e rejeite se o timestamp divergir mais de 300s de agora. URLs internas/privadas são recusadas na criação (422 unsafe_url).
Escopo por credencial (opcional): com "credential": { "type": "smtp"|"api", "id": … }, o endpoint só recebe eventos de mensagens que entraram por aquela credencial — uma credencial por sistema, um hook por sistema, sem re-filtrar do seu lado. Omitido, o endpoint recebe a conta inteira (comportamento de sempre). A credencial precisa existir e ser sua: id alheio responde 404 credential_not_found. O escopo aparece de volta no GET /v1/webhooks.
/v1/webhooksescopo manage:webhooksLista seus endpoints. O secret nunca é ecoado (aparece mascarado).
/v1/webhooks/:idescopo manage:webhooksAtualiza url, events e/ou active. Também disponível como PATCH (o POST é alias para clientes sem PATCH).
curl -X POST https://api.oveyon.com/v1/webhooks/7 \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "active": false }'/v1/webhooks/:idescopo manage:webhooksRemove um endpoint. Resposta 200 { deleted } · inexistente → 404.
Domínios
Cadastre e verifique os domínios de envio. Um domínio já de outra conta não pode ser recadastrado (anti-sequestro).
/v1/domainsescopo write:domainsCadastra um domínio e devolve os registros DNS a publicar (SPF, DKIM e opcionais).
curl -X POST https://api.oveyon.com/v1/domains \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "domain": "suaempresa.com" }'{
"id": 42,
"domain": "suaempresa.com",
"records": [
{ "type": "TXT", "name": "suaempresa.com", "value": "v=spf1 include:...", "purpose": "SPF (autoriza os IPs de envio)" },
{ "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.
/v1/domainsescopo read:domainsLista seus domínios e o estado de verificação (spf / dkim / dmarc).
/v1/domains/:id/verifyescopo write:domainsDispara a checagem DNS ao vivo e persiste o resultado. Só o domínio da sua conta (senão 404). Se o resolvedor não responder para alguma checagem, ela aparece em inconclusive e o estado anterior daquela checagem é preservado — dúvida não desverifica.
curl -X POST https://api.oveyon.com/v1/domains/42/verify \
-H "Authorization: Bearer $OVEYON_API_KEY"{
"id": 42,
"domain": "suaempresa.com",
"verification": { "spf": true, "dkim": true, "dmarc": false },
"inconclusive": [],
"detail": { "spf": "...", "dkim": "...", "dmarc": "registro DMARC não encontrado" }
}Erros & limites
Erros são JSON com um campo error estável (e, quando útil, message e contexto). Trate pelo código HTTP e pelo error, não pela mensagem.
Idioma da resposta
message é localizada: sai em português para quem chama do Brasil e em inglês para todo o resto do mundo — inclusive quando não conseguimos determinar o país. O país vem do CF-IPCountry da Cloudflare; você não precisa mandar nada.
O error NUNCA muda de idioma. Ele é o contrato de máquina, é idêntico byte a byte em qualquer país, e é nele que a sua integração deve ramificar. O mesmo vale para todo o resto do corpo (required, have, domain, result, disposable, limit…): são dados, não texto.
# mesma chamada, mesmo erro, paises diferentes
# (o CF-IPCountry e injetado pela Cloudflare, voce nao manda nada)
BR { "error": "bad_request", "message": "informe email OU domain, nunca os dois" }
US { "error": "bad_request", "message": "provide email OR domain, never both" }
DE { "error": "bad_request", "message": "provide email OR domain, never both" }
-- { "error": "bad_request", "message": "provide email OR domain, never both" }
// `error` identico nos quatro. So `message` muda.Ou seja: if (body.error === 'rate_limited') — nunca if (body.message === '…'). Uma comparação por texto quebra no dia em que o primeiro cliente seu chamar de outro país.
| Código | Significado | error típico |
|---|---|---|
200 | OK / duplicata idempotente | duplicate |
201 | Criado | — |
202 | Aceito (envio no spool) | — |
400 | Requisição malformada | bad_request, bad_cursor, bad_date, bad_status, bad_outcome, bad_events, bad_group_by, bad_domain, too_many_attachments |
401 | Chave inválida/revogada | unauthorized |
403 | Sem permissão / bloqueado | insufficient_scope, key_disabled, sending_disabled, removal_blocked, account_suspended, account_unknown |
404 | Não encontrado / fora do escopo | not_found |
409 | Conflito | domain_exists, domain_taken |
413 | Payload grande demais | attachments_too_large |
422 | Regra de negócio | domain_not_verified, 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 | internal_error, injection_failed, list_unavailable |
Rate limit, cota e rampa
- Por IP — limite de requisições/min e de falhas de auth/min. Estourou →
429com headerRetry-After: 60. Aguarde e repita. - Cota do tenant —
429 quota_exceededcomwindow(diária|mensal),used/limiteretryAfterno corpo. Semwindow, oquota_exceededé o limite por minuto da conta: reduza o ritmo e retente. Cota por serviço (planos) —service_quota_exceeded, comused/quota/effectiveCapno corpo. - Conta bloqueada —
403 account_suspended(conta suspensa) e403 account_unknown(conta não encontrada). Não são limite e não voltam sozinhos: são403, e não429, justamente para que o seu backoff não retente — nenhuma espera resolve. Fale com o suporte. - Rampa (warmup) — teto diário por domínio enquanto a reputação aquece:
warmup_cap_reached. Retente mais tarde. - Backpressure — sistema saturado:
queue_full. Recuo exponencial e nova tentativa.
Regra de ouro para 429 e 503: recuo exponencial com jitter e retentativa. Erros 4xx de validação (400/422) não devem ser retentados sem corrigir a requisição.