OVEYONAPI documentation base https://api.oveyon.com/v1 OpenAPI 3.1

HTTP API

Integrate your system with OVEYON without leaving the dashboard. REST over HTTPS, JSON in both directions, API key authentication.

Quick start Authentication Send email Templates Query API logs Disposable domain Receive email Inbound webhook Suppressions Allow/block rules Send policies Surveys (NPS/CSAT) Sending webhooks Domains Errors & limits

Base URL

The API runs on a dedicated host, kept separate from the dashboard as a security decision (origin separation). Every call uses this base:

https://api.oveyon.com/v1

Quick start

From zero to your first send in three steps.

export OVEYON_API_KEY="ov_yourkeyhere"

curl -X POST https://api.oveyon.com/v1/send \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Sales <sales@yourcompany.com>",
    "to": "customer@example.com",
    "subject": "Welcome",
    "html": "<h1>Hello!</h1><p>Your account is ready.</p>"
  }'

Expected response: 202 with { "id": "<uuid>", "status": "accepted" }. That id is what you use to look up the message later.

Authentication

Every request carries the key in the Authorization header, using the Bearer scheme.

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

Permissions (scopes)

A key only reaches what its scopes grant. Scope missing for the route you called → 403 insufficient_scope (the response says which scope was missing):

HTTP/1.1 403 Forbidden
{ "error": "insufficient_scope", "required": "read:stats", "have": ["send"] }
ScopeGrantsEndpoints
send Send emails POST /send
read:messages Read messages and timeline GET /messages · GET /messages/:uuid
read:stats Read statistics GET /stats
read:suppressions Read suppressions GET /suppressions
write:suppressions Create and remove suppressions POST /suppressions · DELETE /suppressions/:email
read:domains Read domains GET /domains
write:domains Add and verify domains POST /domains · POST /domains/:id/verify
manage:webhooks Manage webhooks GET /webhooks · POST /webhooks · PATCH /webhooks/:id · POST /webhooks/:id · DELETE /webhooks/:id
read:inbound Read received emails (inbound) GET /inbound · GET /inbound/:uid · GET /inbound/:uid/content · GET /inbound/:uid/raw · GET /inbound/:uid/attachments/:n · GET /inbound/threads · GET /inbound/threads/:id · GET /inbound/stats
read:policies Read policy lists and IP rules GET /policies · GET /credentials/ip-rules
write:policies Create and remove policy lists and IP rules 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 Read email templates GET /templates · GET /templates/:ref
write:templates Create, edit, publish and remove email templates POST /templates · PUT /templates/:ref · POST /templates/:ref/publish · DELETE /templates/:ref
read:send-policies Read sending policies GET /send-policies · GET /send-policies/decisions · GET /send-policies/:id
write:send-policies Create, edit, reorder and remove sending policies 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 Read surveys and responses GET /surveys · GET /surveys/:id · GET /surveys/:id/responses
write:surveys Create, edit and remove surveys POST /surveys · PUT /surveys/:id · DELETE /surveys/:id
send:surveys Send surveys by email POST /surveys/:id/send
reply:inbound Reply to received emails (inbound) POST /inbound/:uid/reply
write:inbound Create and change inbound mailboxes (routes and channels) POST /inbound/:uid/release · GET /inbound/routes · POST /inbound/routes · GET /inbound/routes/:id · PATCH /inbound/routes/:id · DELETE /inbound/routes/:id · DELETE /inbound/routes/:id/channels/:assocId
approve:holds Approve and reject drafts awaiting approval GET /holds · POST /messages/:uuid/approve · POST /messages/:uuid/reject
read:inbound-policies Read inbound policies GET /inbound-policies · GET /inbound-policies/decisions · GET /inbound-policies/:id · POST /inbound-policies/simulate
write:inbound-policies Create, edit, reorder and remove inbound policies (creating and editing also require «Read received emails») POST /inbound-policies · PUT /inbound-policies/:id · POST /inbound-policies/:id/pause · POST /inbound-policies/:id/unpause · POST /inbound-policies/reorder · DELETE /inbound-policies/:id
read:logs Read the API request log GET /logs · GET /logs/:id

Left out of the table, on purpose: /disposable · /openapi.json · /ping requires no authentication — no key, no scope, no quota. Do not look for a checkbox for it in Credentials: there is nothing to grant. It is the only API route like this.

Dashboard roles are not key permissions

This is the confusion that shows up as soon as an account has more than one person on it, and it always errs in the same direction — assuming the key is weaker than it is. So, to spell it out: the dashboard and the key are two permission systems that do not touch each other. Neither one consults the other.

 In the dashboardIn the API
Who acts A person, with a login and a session. A key, and it is a bearer credential: whoever holds the secret can use it.
What decides Their role, on three independent ladders. The key's scopes — the table above, and nothing beyond it.
The ladders account: viewer < admin < owner
organization: member < admin < owner
personal (over yourself): sessao < pessoa
There is no ladder. You either have a scope or you do not.
Reach A person reaches N accounts, with a different role in each, and switches accounts on screen. A key belongs to one account and does not switch. Every read stays confined to that account.

What follows from this — and what you should carry into your code:

Role decides one thing in all of this, and it is on the dashboard side: who gets to press the button. Minting a new secret — creating an API key, creating an SMTP credential, and rotating either one — is reserved for the account owner. Killing and adjusting are open to admin: revoking, disabling, and also editing the name, scopes and limit of a key that already exists — because an access control that forbids reducing exposure is worse than having it turned off. And owner is the ownership of the account itself, not a role that can be granted: if you are admin and the button to create a key is not there, the screen is not broken. None of this changes what the key does once it is issued — which is the subject of the rest of this page.

Send email

Sending goes through the same pipeline as SMTP: idempotency, backpressure, sender and recipient domain, suppression, quota and warm-up ramp — in that order. The 202 only comes back once the message is already in the spool.

POST/v1/sendscope send

Queues a message. Up to 5 recipients per call, counting to + cc + bcc together — for more than that, make more than one call.

Body (JSON)

  • from (required) — "a@b.com" or "Name <a@b.com>". The domain must be verified in this account.
  • to (required) — a string or a list of addresses. Goes in the To: header and in the envelope.
  • cc — a string or a list. Goes in the Cc: header and in the envelope: every recipient sees who is copied.
  • bcc — a string or a list. Goes only in the envelope: it never appears in any header, and no recipient sees who is blind-copied.
  • subject — the subject line. Up to 500 characters: it is the same limit that the message history and the policy audit trail store, so a longer subject would be cut later without you knowing. Above that, the call is refused with 400 bad_request, stating the limit and what it received. This also applies to a subject that comes from a template.
  • html and/or text — at least one is required.
  • templateId — the numeric id or the name of a published template. Mutually exclusive with subject/html/text: with a template, the entire content (subject included) comes from the template — sending both is 400 template_conflict.
  • data — a {variable: value} object for rendering the template. Only meaningful together with templateId (on its own it is ignored).
  • version — an integer ≥ 1: pins a specific published version of the template. Without it, the current one is used.
  • headers — an object of extra headers (e.g. X-Campaign). Headers we control — List-Unsubscribe, X-Report-Abuse and any with the X-Oveyon- prefix — are ignored if sent.
  • idempotencyKey — a string of your own for deduplication; an alternative to the Idempotency-Key header. The same key never produces two messages.
  • attachments — a list of { filename, content (base64), contentType? }. Up to 20 attachments, totaling ≤ 15 MB (after base64 decoding).
  • sandbox — true (or the X-Oveyon-Sandbox: 1 header) accepts and freezes the message without delivering it.

Recipients, quota and billing

  • Each recipient is one unit. A send with to + 2 cc + 1 bcc consumes 4 of your daily and monthly quota, not 1 — that is what actually gets delivered.
  • A cap of 5 addresses per call, counting what you sent across the three fields. Above that: 400 too_many_recipients, with the limit and how many were received in the response.
  • A repeated address is delivered only once. The same address in to and bcc becomes one delivery — and is billed once. The first occurrence wins (to before cc before bcc); the headers stay as you wrote them.
  • All or nothing. If one address is invalid, is suppressed, or the quota does not cover all of them, the whole call is refused and no email goes out. There is no partial delivery: the response carries a single id and would have no way to say «it went to 3 of the 5».
  • The id belongs to the message. The outcome (delivered, bounced) is per recipient and shows up in deliveries[] on GET /v1/messages/:id.

Example

curl -X POST https://api.oveyon.com/v1/send \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-8842" \
  -d '{
    "from": "Support <support@yourcompany.com>",
    "to": "customer@example.com",
    "cc": ["billing@example.com"],
    "bcc": ["archive@yourcompany.com"],
    "subject": "Your receipt",
    "html": "<p>Thanks for your purchase.</p>",
    "text": "Thanks for your purchase.",
    "headers": { "X-Campaign": "receipts" },
    "attachments": [
      { "filename": "receipt.pdf", "content": "JVBERi0xLjQK...", "contentType": "application/pdf" }
    ]
  }'

Success

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

// `recipients` only appears when there is more than one recipient, and it is the number
// of DISTINCT addresses that went into the envelope — the same number you were charged for.
  • 202 · status: "accepted" — a real send, already in the spool.
  • 202 · status: "sandbox" — accepted, frozen, not delivered (sandbox mode).
  • 200 · status: "duplicate" — the idempotencyKey had already been used; returns the original id.

Errors

  • 400 bad_request — from/to missing or invalid, neither html nor text; too_many_attachments / bad_attachment.
  • 400 too_many_recipients — more than 5 addresses across to+cc+bcc. Includes limit and received.
  • 400 bad_recipient — one of the addresses is not valid. Includes field (to/cc/bcc) and quotes the address in the message. An invalid address is never dropped silently.
  • 401 unauthorized · 403 insufficient_scope, key_disabled (key turned off in the dashboard — turn it back on or use another one), sending_disabled, account_suspended or account_unknown — the last two are account state, not a limit: do not retry.
  • 413 attachments_too_large — attachments above 15 MB; payload_too_large — the whole request body above 25 MB (with limit, in bytes).
  • 503 injection_failed — the message did not make it into the queue; the quota is refunded. Retry with the same idempotencyKey.
  • 500 on POST /send — does not prove the message was not accepted: there is a narrow window in which it has already been accepted when the error goes out. So only retry a send that carries an idempotencyKey (that key deduplicates safely); without it, retrying can duplicate the message — check first with GET /v1/messages.
  • 422 domain_not_verified · invalid_recipient_domain · recipient_suppressed — the last two include recipient, saying which address caused it.
  • 422 domain_on_hold — sending from the domain is held for review: the domain was registered only a few days ago, is listed on the Spamhaus DBL, or was flagged by the reputation screening. The message says which of the three it is and what to do; over SMTP the refusal is 550 5.7.1. Releasing it is up to our team.
  • 422 domain_not_allowed_for_credential — the key is limited to selected domains of the account and the from domain is not one of them. It is not a domain problem (it is verified): adjust the key’s set under Credentials, or use another key. Over SMTP the refusal is 550 5.7.1.
  • 400 template_conflict — templateId together with subject/html/text · 400 template_var_missing — a variable the template requires is missing from data (includes variable) · 422 template_not_found and the other template errors. None of them consumes quota or burns the idempotencyKey.
  • 422 unsub_footer_multi_recipient — this key sends with an automatic footer/List-Unsubscribe, and the unsubscribe link is per recipient. With several recipients it would unsubscribe people who never asked to be, so the send is refused: make one call per recipient. The same code comes back when a send policy requires the footer on a call with several recipients — in that case the response also includes policy.
  • 422 policy_refused — one of your own send policies refused the message. The response includes policy (the name of the policy that decided) and recipient (the recipient that matched it). It does not consume quota: policies are evaluated before the counter. Adjust or pause the policy to send again.
  • 429 quota_exceeded · service_quota_exceeded · warmup_cap_reached · rate_limited · queue_full.

Sandbox (test without delivering)

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@yourcompany.com", "to": "customer@example.com",
        "subject": "Test", "text": "Nothing will be delivered." }'

The sandbox counts toward quota/rate (the message was accepted), but nothing goes out to the destination.

Templates

An email template with variables, stored once and sent with just the data: {"templateId": …, "data": {…}} in POST /v1/send, instead of 40KB of HTML repeated on every call. The look changes in one place, with no redeploy of your system.

The lifecycle: draft → published (immutable)

Syntax — the catalog is closed, and it is a contract

BlockWhat it does
{{ var }}Replaced with the value. In the html body, the value comes out escaped (a name containing <script> becomes text, never code). In text and in the subject, it comes out as received. Dotted paths work: {{ user.email }}.
{{ var | "default" }}An optional variable: when missing (or null), the quoted literal is used.
{{{ var }}}Replaced without escaping — you declare that the value is your own HTML and take on the risk.
{{#if var}} … {{else}} … {{/if}}Conditional section. Missing, null, false, "", 0 and an empty list count as false.
{{#each list}} … {{/each}}Repeats the inner block for each item. {{this}} is the item; on an object item, {{field}} resolves against it (and falls back to the outer context if not found there). A missing list renders nothing.
GET/v1/templatesscope read:templates

Lists the account's templates: id, name, currentVersion (the published one; null = never published), latestVersion and drafts.

GET/v1/templates/:refscope read:templates

:ref is the numeric id or the name — on every route in this section. Returns the template with all its versions (source, status, declared variables).

POST/v1/templatesscope write:templates

Creates the template as draft v1. Body: name (unique in the account; starts with a letter — letters, digits, . - _, up to 120), subject (required, and also a template), and html and/or text. The syntax is validated here: a template error never survives to send time.

curl -X POST https://api.oveyon.com/v1/templates \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "orderArrived",
    "subject": "{{firstName}}, your order has arrived",
    "html": "<p>Hello {{firstName}}, use coupon {{ coupon | \"SAVE10\" }}.</p>",
    "text": "Hello {{firstName}}, use coupon {{ coupon | \"SAVE10\" }}."
  }'
PUT/v1/templates/:refscope write:templates

Edits the content (subject, html, text): if there is a live draft, it updates the draft; if not, it creates the next version as a draft. The published one is never touched. The name is the template's identity and does not change here (a name in the body is ignored). Same response as creation: {"id", "name", "version", "status": "draft", "vars": [{"name", "kind", "required", "default"}]}. In vars, the name is the root of the path — the key that goes in data: {{order.total}} declares order; kind says how it is used (var printed, flag in {{#if}}, list in {{#each}}).

POST/v1/templates/:ref/publishscope write:templates

With no body (or {}): publishes the newest draft. With {"version": N}: makes that version the current one — including one already published (rollback). N is an integer ≥ 1 (a digits-only string is accepted too); anything else — true, [3], "0x3" — is 400 bad_request, never a guess.

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

Deletes the template and all its versions and responds {"ok": true, "id": 12} (404 template_not_found if it does not exist). Messages already sent keep templateId/templateVersion as a historical trail.

End-to-end example

Create → publish → send with just the data. The body on the wire goes out with the variables substituted and the whole pipeline on top (footer, tracking, DKIM — a template skips no safeguard).

curl -X POST https://api.oveyon.com/v1/templates \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "orderArrived",
    "subject": "{{firstName}}, your order has arrived",
    "html": "<p>Hello {{firstName}}, use coupon {{ coupon | \"SAVE10\" }}.</p>",
    "text": "Hello {{firstName}}, use coupon {{ coupon | \"SAVE10\" }}."
  }'
curl -X POST https://api.oveyon.com/v1/templates/orderArrived/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": "Store <sales@yourcompany.com>",
    "to": "customer@example.com",
    "templateId": "orderArrived",
    "data": { "firstName": "Ana" }
  }'

Errors

Query

Listing, aggregate statistics and the timeline of a single message. Every read is confined to the key's tenant — you never see another account's data.

GET/v1/messagesscope read:messages

Lists messages, newest first, with cursor pagination (stable under concurrent inserts).

Query

  • limit — 1 to 100 (default 25).
  • cursor — the next_cursor from the previous page.
  • status — the raw value: accepted · queued · delivered · deferred · bounced · suppressed · failed · frozen.
  • outcome — the grouped outcome, the same five triage buckets the dashboard uses. It exists because the questions people actually ask aren't about a single status: «what came back» is bounced or failed, and answering that with status means knowing the color grouping and making two calls.
    • devolvidas — bounced, failed. The destination refused it, or we gave up delivering it. Don't confuse this with mail refused on the way in at your MX, which is a different resource (GET /v1/inbound) and a different thing: this is what you sent that came back.
    • bloqueadas — suppressed. We stopped before trying (a suppression or one of your rules).
    • fila — accepted, queued, deferred. It will still go out on its own.
    • held — frozen. Held, awaiting a decision.
    • entregues — delivered.
    The five partition the eight statuses: every message falls into exactly one, and the five add up to the total. A value outside the list returns 400 bad_outcome.
  • recipient — exact recipient. Searches all recipients of the send (to, cc and bcc), not just the first: an address that was copied returns the message that went to it.
  • domain — the domain's numeric id or name (it accepts back whatever the response shows). A name that isn't yours returns an empty list, never 403. credential — the key's numeric id.
  • from / to — ISO 8601 range (YYYY-MM-DD or timestamp).
  • event — filters by an event in the timeline (e.g. opened).

Example

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

Response

{
  "data": [
    {
      "id": "3f8a1c2e-9b4d-4e10-8a77-2b0c9d5e1f34",
      "from": "sales@yourcompany.com",
      "to": "customer@example.com",
      "subject": "Welcome",
      "status": "delivered",
      "statusDetail": null,
      "route": "mailchannels",
      "acceptedAt": "2026-07-21T13:02:11.000Z",
      "deliveredAt": "2026-07-21T13:02:14.000Z"
    }
  ],
  "next_cursor": "eyJpZCI6MTg0Mn0",
  "log_window": { "days": 15, "since": "2026-07-06T13:02:11.000Z" }
}

The public id is always the uuid. next_cursor: null ⇒ last page.

Your plan's log window. The list shows the sends accepted in the last N days of your plan (Free 3, Starter 15, Plus 20, Growth 30, Business 45, Scale 60, Enterprise 365); log_window says how many days and since when (null = no window). A held message shows until it's decided. A from earlier than the window is not an error: the page starts where the log starts. Statistics (GET /v1/stats) are aggregates and don't follow the window. The platform keeps the log for a retention period of its own, longer than any plan's window (or until the end of your window, if yours is longer); after it, the message is deleted and answers 404 not_found, window or not.

GET/v1/statsscope read:stats

Precomputed daily rollup, with rates calculated on the server. Default: last 30 days.

Query

  • group_by — day (default) · domain · credential.
  • from / to — ISO 8601 range.
  • breakdown — provider, reason and/or credential, comma-separated. Adds the breakdown key to the response. A value outside this list → 400 bad_breakdown.

Example

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

Response

{
  "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 }
    }
  ]
}

Period facets (breakdown)

Where you send to (provider), why it didn't arrive (reason) and who sent the most (credential) — the same three blocks as the Analytics screen.

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": "company.com",   "label": "company.com",   "count": 519,  "pct": 12.3 },
        { "value": "Outros",        "label": "Others",        "count": 275,  "pct": 6.5 }
      ]
    },
    "reason": {
      "total": 63,
      "items": [
        { "value": "invalid_recipient", "label": "Address or domain does not exist", "count": 41, "pct": 65.1 },
        { "value": "spam_content",      "label": "Spam or content filter",           "count": 14, "pct": 22.2 },
        { "value": "mailbox_full",      "label": "Recipient mailbox full",           "count": 8,  "pct": 12.7 }
      ]
    },
    "credential": {
      "total": 4210,
      "items": [
        { "value": "s:12", "label": "support@company.com",  "count": 2180, "pct": 51.8 },
        { "value": "k:3",  "label": "production",           "count": 1602, "pct": 38.1 },
        { "value": "s:9",  "label": "invoices@company.com", "count": 375,  "pct": 8.9 },
        { "value": "-",    "label": "Unidentified source",  "count": 53,  "pct": 1.3 }
      ]
    }
  }
}
  • A facet is the total for the window, not a daily series, and it is always the account-wide aggregate — it does not follow group_by.
  • value is stable and is what you should branch on: in provider it's the family (Google, Microsoft…) or the domain itself when it isn't a known provider; in reason it's the reason code; in credential it's s:<id> for an SMTP credential and k:<id> for an API key. label is text for humans and can change — an API key's name is editable, which is exactly why it isn't the value.
  • breakdown=credential and group_by=credential don't answer the same question. group_by returns a daily series and sees only API keys; breakdown returns the total for the window and also includes SMTP credentials. If you send over SMTP, breakdown is what sees that traffic.
  • "value": "-" in credential is a message with no recorded origin (old history and internal injection). It shows up instead of being dropped so the facet's sum keeps matching the series' sent.
  • Outros in provider is the tail of destinations outside the top 50, summed over the whole window — never a sum of per-day slices.
  • reason counts deliveries with a permanent refusal from the destination. Messages held for review and addresses on your suppression list are not included: in those two cases the destination never got to refuse anything.
  • Window still being computed: the facet is written on the day of the send, and a period that hasn't been fully rebuilt yet comes back with a total smaller than the sum of the series' sent — the pct values are relative to the facet's total, not to the period. That's how you detect it: compare the two. The rebuild is automatic, covers the same 90 days as the longest period offered, and runs every hour; you don't need to request anything.
GET/v1/messages/:uuidscope read:messages

Detail of one message: status, event timeline and tracking events (open/click). Outside your tenant → 404 not_found. A message of yours accepted before your plan's log window → 404 outside_log_window, with the window stated in message.

Example

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

Response

{
  "id": "3f8a1c2e-9b4d-4e10-8a77-2b0c9d5e1f34",
  "status": "delivered",
  "statusDetail": null,
  "route": "mailchannels",
  "from": "sales@yourcompany.com",
  "to": "customer@example.com",
  "subject": "Welcome",
  "sizeBytes": 4821,
  "acceptedAt": "2026-07-21T13:02:11.000Z",
  "deliveredAt": "2026-07-21T13:02:14.000Z",

  // ONE DELIVERY PER RECIPIENT. `status` and `to` above are the summary and the
  // first address; the outcome for each recipient is here. The summary is
  // pessimistic: if any recipient did not receive it, the message is not "delivered".
  "deliveries": [
    { "id": "d_8f2a…", "to": "customer@example.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": "copy@example.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` say WHICH delivery the event belongs to; null = a
  // message-level event (acceptance, for example), which belongs to no recipient.
  "events": [
    { "event": "accepted",  "deliveryId": null,      "to": null,                   "detail": { "source": "api" }, "at": "2026-07-21T13:02:11.000Z" },
    { "event": "delivered", "deliveryId": "d_8f2a…", "to": "customer@example.com", "detail": {},                  "at": "2026-07-21T13:02:14.000Z" },
    { "event": "bounced",   "deliveryId": "d_1c07…", "to": "copy@example.com",     "detail": { "smtpResponse": "550 5.1.1 User unknown" }, "at": "2026-07-21T13:02:13.000Z" }
  ],
  "tracking": [
    { "type": "open",  "url": null, "host": "email.yourcompany.com", "readMsEstimate": 3200, "at": "..." },
    { "type": "click", "url": "https://shop.yourcompany.com/x", "host": "email.yourcompany.com", "readMsEstimate": null, "at": "..." }
  ]
}

readMsEstimate is a weak approximation (the gap between beacons), not an exact reading time.

API logs

Every authenticated call your account's keys make to the API is logged for 90 days: the route, the status, the duration, the IP, the user-agent and the request and response bodies. It is the data behind the API logs screen of the dashboard, which also explains each error and prepares the text to paste into an AI.

GET/v1/logsscope read:logs

Lists the calls, newest first, with cursor pagination.

Query

  • limit — 1 to 100 (default 25). cursor — the next_cursor of the previous page.
  • status — success, error, a class (2xx, 4xx, 5xx) or an exact code (422). Anything else, 400 bad_status.
  • credential — the id of one key of the account.
  • from / to — ISO 8601 range.
  • route — the route pattern, with or without the method: POST /v1/send, /v1/messages/:uuid. Malformed, 400 bad_route.
  • sdk — the family detected from the user-agent (curl, node, python…; none = no user-agent). Outside the list, 400 bad_sdk.

Example

curl "https://api.oveyon.com/v1/logs?status=error&route=/v1/send&limit=25" \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Response

{
  "data": [
    {
      "id": 48213,
      "createdAt": "2026-10-08T14:03:27.481Z",
      "method": "POST",
      "route": "/v1/send",
      "path": "/v1/send",
      "status": 403,
      "durationMs": 21,
      "credential": { "id": 12, "name": "backend", "prefix": "ov_3f9a1" },
      "ip": "203.0.113.24",
      "userAgent": "curl/8.5.0",
      "sdk": { "name": "curl", "version": "8.5.0" },
      "error": "insufficient_scope",
      "messageId": null
    }
  ],
  "next_cursor": "bDoxNzU5OTMyMjA3NDgxLjQ4MjEz"
}

messageId links the call to the message it created or read — the same id as GET /v1/messages/:uuid. Send a User-Agent that says who is calling (my-app/1.4): it is what sdk and the dashboard filter use to tell your integrations apart.

GET/v1/logs/:idscope read:logs

One call, with the request and response bodies as they were stored (text). An id of another account, malformed or past the retention period → 404 not_found.

Response

{
  "id": 48213,
  "createdAt": "2026-10-08T14:03:27.481Z",
  "method": "POST",
  "route": "/v1/send",
  "path": "/v1/send",
  "status": 403,
  "durationMs": 21,
  "credential": { "id": 12, "name": "backend", "prefix": "ov_3f9a1" },
  "ip": "203.0.113.24",
  "userAgent": "curl/8.5.0",
  "sdk": { "name": "curl", "version": "8.5.0" },
  "error": "insufficient_scope",
  "messageId": null,
  "query": null,
  "request": {
    "body": "{\"from\":\"app@example.com\",\"to\":\"customer@example.com\",\"html\":{\"omitted\":\"content\",\"length\":5120},\"headers\":{\"X-Api-Key\":\"«redigido»\"}}",
    "bytes": 5302,
    "truncated": false
  },
  "response": {
    "body": "{\"error\":\"insufficient_scope\",\"required\":\"send\",\"have\":[\"read:messages\"]}",
    "bytes": 74,
    "truncated": false
  }
}

Disposable domain

Does an address's domain belong to a disposable/temporary email service (Mailinator, 10minutemail and the like)? Use it at sign-up, before accepting an email address that will never be read twice.

This is the only API route that requires no authentication. No Authorization, no key, no account: the data is a public list and nothing of yours is involved in the response. It doesn't consume quota either. In exchange, the cap is per IP and it's tight — see the end of this section.

GET/v1/disposableno authentication

Accepts a full address or a domain. Provide one of the two. No authentication header.

Query

  • email — full address; we extract the domain after the @.
  • domain — the domain itself.
  • Both together, or neither → 400 bad_request. A value that doesn't yield a valid domain → 400 bad_domain.

Example

# no Authorization: this route is public
curl "https://api.oveyon.com/v1/disposable?email=someone@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": "…" }
}

Response fields

  • domain — the domain we actually looked up, already normalized (lowercase, no whitespace, no trailing dot).
  • result — "disposable" · "not_listed" · "unknown". This is the field to automate on.
  • disposable — the same as a boolean: true, false or null (when unknown).
  • list.updatedAt — when our list was last loaded, and list.domains how many domains it has now. They're there so you can decide how much to trust the answer instead of taking it on faith.

If our list isn't loaded

HTTP/1.1 503 Service Unavailable
Retry-After: 3600
{
  "error": "list_unavailable",
  "domain": "mailinator.com",
  "result": "unknown",
  "disposable": null,
  "message": "lookup unavailable: the list has not been loaded on this server yet"
}

// `disposable` comes back null, NEVER false. If our list is not loaded,
// the answer is "we do not know" — saying "not disposable" with nothing to check
// against would be the one mistake this lookup cannot make.

What this lookup doesn't do

  • not_listed is not a certificate of good standing. The data is a blocklist: what it says is «this domain is not on it», not «this domain is trustworthy». A new disposable domain only gets onto the list after it exists.
  • The match is exact, without climbing to the parent domain. mail.example.com doesn't inherit the verdict of example.com. This is deliberate: matching by suffix would manufacture false positives, and the expensive mistake here is blocking a real customer's sign-up.
  • We don't check whether the mailbox exists or whether the address receives mail — only the domain, against the list.
  • The list is updated once a day. A load that arrives truncated or empty is rejected and the previous list is kept — which is why list.domains never plummets from one day to the next.

Limit

Public doesn't mean unlimited — and since there's no key, the cap is per IP: 60 lookups per hour, in a sliding window (it doesn't reset all at once on the hour). Go over it → 429 rate_limited with Retry-After in seconds, already computed for the moment the next slot opens.

If your use case is validating a large list in one go, don't use this endpoint: download the list straight from the public source (disposable-email-domains) and run it locally. It's faster, doesn't depend on us and never runs into any cap.

Receive email

You point a domain's MX at us and create the addresses you want to receive mail on. From then on, everything that arrives is stored and you choose how to consume it: pull it through the routes below, or get notified by webhook. The two coexist — the webhook is the notification, storage is the foundation.

Getting started

Five steps, once per domain. From DNS to the first email read.

Step 1 of 5

Publish your domain's MX. It's the record that tells the world where to send email for @yourcompany.com. Until it exists, nothing arrives — there's nothing we can switch on from our side.

; In the DNS for yourcompany.com — one MX record, priority 10, pointing
; to our inbound host. The trailing dot is part of the record.
yourcompany.com.    IN    MX    10    mx1.e-mailbox.com.

# Check propagation before requesting verification in the dashboard:
dig +short MX yourcompany.com
# expected:  10 mx1.e-mailbox.com.

If the domain already receives email at another provider, changing the MX moves the whole mailbox here. To try it out without touching your main domain, use a subdomain (e.g. inbound.yourcompany.com).

Step 2 of 5

Verify. In Domains, open the domain and click Verify MX. We query DNS on the spot and confirm that it points to mx1.e-mailbox.com. DNS propagation takes from minutes to a few hours; retry as often as you like.

Verification only records a success — a lookup that fails because of slow DNS never undoes an already verified domain.

Step 3 of 5

Turn on inbound in the same place. It's the domain's switch: off, all email to it is refused; on, the addresses from step 4 take effect — and only those.

Turning it on before publishing the MX doesn't break anything, but it doesn't receive anything either. The dashboard warns you when you're in that situation.

Step 4 of 5

Create the address. Two kinds:

  • Exact — support accepts support@yourcompany.com and nothing else. Letters, digits and . _ % + -, up to 64 characters, no @.
  • Catch-all — *@yourcompany.com accepts any address on the domain. One per domain. Handy for testing, but it also accepts the junk that scanners send to admin@, info@ and the like.

An address with no active route is refused during SMTP, with 550 5.1.1 — the sender gets the bounce right away, instead of thinking it was delivered. There is no «accept now, decide later».

Step 5 of 5

Choose what to do with what arrives. Send a test email from one of your own mailboxes and check:

# Send an email from a mailbox of your own to support@yourcompany.com, then list:
curl "https://api.oveyon.com/v1/inbound?limit=5" \
  -H "Authorization: Bearer $OVEYON_API_KEY"
  • Pull — the five routes in this section. They need the read:inbound scope on the key (see the note right below).
  • Get notified — the inbound.received webhook: we call your server for every accepted email, so you don't have to keep polling.

The key needs the scope — and old keys don't get it on their own

These routes require read:inbound, which is a scope of its own: the other reads (read:messages) show what you sent; this one shows what you received — bodies and attachments written by third parties. A key issued before this scope existed does not gain it through a deploy on our side: whoever handed that key to an integrator couldn't consent to a power that didn't exist yet. Tick the scope on the key in Credentials (an account admin action), or issue a new one (an owner action — why). Rotating doesn't fix it — rotation inherits the previous key's permissions, verbatim. Without the scope: 403 insufficient_scope.

Five things before you write code

GET/v1/inboundscope read:inbound

Lists received messages, newest first, with cursor pagination.

Query

  • limit — 1 to 100 (default 25).
  • cursor — the next_cursor from the previous page. This route's cursor is of its own type: reusing a /v1/messages cursor here returns 400 bad_cursor, instead of a silently wrong page.
  • recipient — exact destination address. Searches all recipients of the transaction, not just the first.
  • sender — exact origin address. Matches both the envelope sender (from) and the header sender (fromHeader), because the two diverge in real life. It searches by address, not by name — fromName is deliberately left out here: a display name is chosen by the sender and identifies no one.
  • domain — the name of the domain that received it (the same value the response carries in domain; the filter accepts back what the response showed). A domain that isn't yours returns an empty list, never 403: the response doesn't tell anyone whose domain it is.
  • dmarc — filters by DMARC verdict (pass, fail, none…). The vocabulary is open on purpose — the verdict comes from the authentication library, and a value it doesn't emit yet simply matches no rows.
  • has_attachments — true or false (also accepts 1/0 and yes/no).
  • outcome — filters by outcome (accepted, blocked). Open vocabulary, for the same reason as dmarc: an outcome that doesn't exist yet simply matches no rows, instead of becoming an error. Without the parameter, the list returns all outcomes.
  • agent_safety — clean, suspicious, dangerous or none (= no verdict yet); a comma combines them (suspicious,dangerous). Closed vocabulary: a value outside it is 400 bad_agent_safety.
  • from / to — time range of receipt, ISO 8601 (YYYY-MM-DD or timestamp).

Example

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

Response

{
  "data": [
    {
      "id": "b71e0c34-5a2f-4d18-9c60-77ab31e2d905",
      "receivedAt": "2026-08-05T09:14:02.317Z",
      "from": "customer@example.com",       // envelope (MAIL FROM)
      "fromHeader": "sales@example.com",    // the ADDRESS in the From header
      "fromName": "Example Sales",          // display name, or null
      "subject": "Re: your quote",
      "domain": "yourcompany.com",

      // ONE EMAIL, N RECIPIENTS. We never flatten them into a single field: if the
      // message arrived for support@ and for sales@ in the SAME transaction,
      // both are here, each with its own id.
      "recipients": [
        { "id": "9c1d…", "to": "support@yourcompany.com" },
        { "id": "3af0…", "to": "sales@yourcompany.com" }
      ],

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

      // Internal spam score (0-100; the higher, the more it looks like spam).
      // null = NOT COMPUTED (message from before the feature existed) — it is not 0.
      "spamScore": 2,
      // The agent-safety verdict, with what it LOOKED AT (coverage).
      "agentSafety": { "verdict": "clean", "score": 0,
                       "coverage": { "bodySampled": false, "sanitized": false, "unscannedAttachments": 1 } },

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

      // THE OUTCOME. "accepted" = delivered to at least one recipient.
      "outcome": "accepted",
      "blockedReason": null
    }
  ],
  "next_cursor": "aTo3Nw"
}

The public id is the message's uid; it's what goes in the routes below. next_cursor: null ⇒ last page.

The same item, with a blocked outcome

Same route, same shape — what changes is the outcome. If your code assumes every listed message has a recipient, this is the response that will catch it out. See «not every listed message was delivered».

{
  "id": "c04b19f7-3d5a-4a02-b7e1-6d8f0a2c4419",
  "receivedAt": "2026-08-05T11:02:40.118Z",
  "from": "customer@example.com",
  "subject": "Arrived while it was turned off",
  "domain": "yourcompany.com",

  // EMPTY, not absent: nobody received this message.
  "recipients": [],

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

  // It arrived, was stored, was not delivered and was NOT charged.
  "outcome": "blocked",
  "blockedReason": "inbound_disabled"
}
GET/v1/inbound/:uidscope read:inbound

Message detail: everything the list carries, plus the attachment manifest and the content links. It doesn't return the body — that has its own route.

Example

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

Response

{
  // … every field from the list, plus:

  // ATTACHMENT MANIFEST. `ord` is the ADDRESS of the attachment — stamped when
  // the message came in, stable forever. Download by it, NEVER
  // by `filename` (it came from the sender: it can repeat, be empty or
  // contain a path).
  "attachments": [
    { "ord": 1, "filename": "quote.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" }
  ],

  // WHERE IT WENT OUT, AND WHAT HAPPENED. One item per (recipient, channel,
  // kind — the arrival, the quarantine notice or the retroactive link-check notice).
  // `status` is OUR half: `delivered` = your endpoint answered 2xx, the
  // chat accepted it, or the forwarded copy entered our outbound queue — not
  // that the mailbox on the other end has it. On a forward, `destination` is the verdict
  // of the destination MX (accepted | deferred | bounced) with the raw SMTP
  // response; `null` on the other channels or while it is not known yet. Read both
  // before saying «it arrived».
  "deliveries": [
    { "id": "d999cc6d-…", "recipient": { "id": "d83ea519-…", "to": "support@yourcompany.com" },
      "channel": "webhook", "kind": "received", "status": "delivered", "detail": "HTTP 200", "attempts": 1,
      "createdAt": "2026-09-04T11:02:41.515Z", "completedAt": "2026-09-04T11:02:42.101Z",
      "destination": null },
    { "id": "ca0e76d8-…", "recipient": { "id": "d83ea519-…", "to": "support@yourcompany.com" },
      "channel": "forward", "kind": "received", "status": "delivered",
      "detail": "forwarded to inbox@gmail.com (accepted by the outbound queue)", "attempts": 1,
      "createdAt": "2026-09-04T11:02:41.518Z", "completedAt": "2026-09-04T11:02:48.079Z",
      "destination": { "status": "accepted", "host": "gmail-smtp-in.l.google.com",
                       "response": "250 2.0.0 OK 1790113210 d9443c01a7336… - gsmtp",
                       "at": "2026-09-04T11:02:50.428Z" } }
  ],
  "links": {
    "self":    "/v1/inbound/b71e0c34-…",
    "content": "/v1/inbound/b71e0c34-…/content",
    "raw":     "/v1/inbound/b71e0c34-…/raw"
  }
}
GET/v1/inbound/:uid/contentscope read:inbound

The body already parsed, in text and html — for those who don't want to implement MIME. Each field is cut at 262,144 characters (256 KB), and truncated says when that happened (if you need the full content, use /raw). A hardcoded truncated: false would be decoration waiting for the first large email — this field is measured.

The text comes sanitized. Invisible smuggling characters — the Unicode Tags block, zero-width, direction override — are removed from text and html — and also from the subject and the sender name (fromName), in the webhook and in the API — before responding, and sanitized says when that happened. They have no legitimate use in email and serve to tell your agent something the person reading the message doesn't see. If you need the original byte for byte — to re-verify DKIM, for forensics, or because you want to see what came over the wire — use /raw: it is never altered.

Example

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

Response

{
  "id": "b71e0c34-5a2f-4d18-9c60-77ab31e2d905",
  "text": "Good morning, please find the approved quote attached…",
  "html": "<p>Good morning, please find the approved quote attached…</p>",
  "truncated": false,
  "sanitized": false
}

The html is third-party HTML. It comes back as data, exactly as it arrived. Rendering it without sanitizing is XSS on your origin — run it through a sanitizer (DOMPurify and the like) or use only text.

GET/v1/inbound/:uid/rawscope read:inbound

The original .eml, byte for byte as it arrived over SMTP — with no rewriting at all. It's what you use to parse with your own library, re-verify DKIM or archive.

Example

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

Response: message/rfc822, always as an attachment (Content-Disposition: attachment), no caching. A copy already purged by retention → 404.

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

The bytes of an attachment. :n is the manifest's ord — not the file name.

Example

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

Why by ord: two attachments can have the same name, and the name can come empty or with a path inside it. The ord is assigned by us when the message arrives and never changes. A nonexistent ord → 404.

Everything downloads as an attachment, with nosniff and a restrictive CSP. Types the browser would execute (text/html, image/svg+xml, XML, JavaScript) are served as application/octet-stream on purpose: third-party HTML never becomes a document on our origin or on yours.

Errors on these routes

Inbound webhook

Instead of you asking «did anything arrive?» every minute, we tell you: a signed POST to your server for every accepted email. The event is inbound.received.

«Accepted» is literal: the notification is per recipient, so a message with outcome: "blocked" — one that was delivered to no one — generates no event at all. It is not a condition someone has to remember to maintain: with no recipient, there is no one to fire it for. If inbound on a domain was turned off for a while, whatever arrived there is in GET /v1/inbound, and only there.

Where to turn it on

The inbound webhook is a channel of the address. In the dashboard: Domains → open the domain → Inbound tab → «Notification channels for this domain» → + New channel — or, in an address's Configure, «Notify by webhook». You provide the URL and choose the mode; the signing secret is generated by us and shown at creation — and, whenever you need to check it, under «Show secret», in the channel panel. Every reveal is recorded in the account's audit trail.

It is not the same registration as the sending webhooks — those subscribe to what happened to what you sent (delivered, bounced…), and inbound.received is not in their vocabulary: asking for it in POST /v1/webhooks gets you nowhere. Via the API, the webhook is born attached to a mailbox: POST /v1/inbound/routes creates the address with it already in place, and PATCH /v1/inbound/routes/{id} adds one to a mailbox that already exists — with the same URL ownership proof, and the secret comes back once, in the response (the mailbox is born via API); after that, only the dashboard shows it. Telegram, Slack, forwarding and the standalone channel, with no mailbox, are dashboard-only. Different addresses can have different destinations, and the same address can have more than one webhook (each with its own secret, its own mode and its own attempt count).

The URL goes through the same check as the sending webhooks: an internal or private destination is refused at registration, and the IP is validated at connection time — changing the DNS after registration does not lead us into your network.

Before saving, we hit your URL once

Since August 24, 2026, an inbound webhook URL is only saved after the endpoint answers a challenge. This applies to all three actions: creating the channel, changing the URL of an existing channel, and widening the mode (from «Summary» to «Full», for example). If the challenge does not pass, nothing is saved — and on a re-point, the old URL stays in effect.

The reason is symmetry with the other channels: for forwarding, the mailbox owner confirms by email; on Telegram, the chat_id is never typed in, it comes from a /start carrying a nonce of ours; on Slack, it is OAuth. The webhook was the only one where typing an address was enough to make it take effect — and pointing mail at the endpoint of a third party who never said yes is a way to weaponize us.

What the proof covers, and what it does not: it proves that whoever controls that endpoint agrees to receive. It does not prove that the owner of the system on the other side authorized it — someone pointing at their own server passes the challenge effortlessly. This is deliberate, not a gap to be closed later.

The challenge is an ordinary POST, with the same three signature headers as real deliveries and x-oveyon-event: url_verification. 10 s timeout.

POST https://your-endpoint.example/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://your-endpoint.example/hook",
  "sentAt": "2026-08-24T12:00:00.000Z"
}

To pass, respond 2xx returning the value of challenge, in one of these two ways — both are accepted:

Handle url_verification before your message logic and return right there: it is not an email, and processing it as if it were creates a phantom delivery in your system. A 200 with an empty body does not pass — that is exactly the case the challenge exists to catch.

At creation you do not know the secret yet (it only appears once the channel has been created), so there is no way to verify the signature of that first challenge — only the echo is required. On a URL change the secret is already yours, and then it is worth verifying the signature before echoing, as with any delivery.

What if the destination is a third-party receiver you do not program (n8n, Make, webhook.site)? Echoing the challenge may be impossible there. For that case there is an alternative path, in the dashboard: when your endpoint accepts the POST (2xx) but does not return the challenge, the form offers the option «accept without proof of reading» — check it and the URL is saved anyway, with a visible mark on the channel. Be honest with yourself about what is lost: the proof drops from «someone reads what arrives there» to «the URL exists and accepts POST». And the shortcut only applies to that outcome — an endpoint that answers with an error or does not answer in time is still refused, because there is no receiver there at all, just a broken address.

app.post('/hook', (req, res) => {
  // The challenge comes BEFORE anything else — and is answered right here.
  if (req.body && req.body.type === 'url_verification') {
    return res.json({ challenge: req.body.challenge });
  }
  // … from here down, the real inbound.received
});

One event per recipient, per channel

This is the point that confuses integrators the most, so let's take it slowly: an email that arrived for three of your addresses generates three events, not one with a list.

The reason is that each recipient has its own outcome. An SMTP transaction is one body and N recipients, but the notification for one may fail and go into retry while the other's was already delivered on the first try. A single event would have to carry an aggregate status — and an aggregate status of things that end differently is always a lie about one of them. For the same reason, if the address has two channels, each channel has its own attempt count: the same recipient appears once per channel.

The payload

POST https://yourapp.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",

  // STABLE ID of this delivery — this recipient, on this channel. Retries and
  // redeliveries repeat the SAME value: it is what you deduplicate on.
  "eventId": "6e5a1b90-3c77-4f02-b1ad-8e4409c2d611",

  // The FORMAT version of this body. It only goes up on a breaking change —
  // store it, and reject what you do not know how to read instead of guessing.
  "schemaVersion": 1,
  "timestamp": "2026-08-05T09:14:03.902Z",

  // The email. Almost the same object as GET /v1/inbound/:uid, with three
  // differences: here you get `messageIdHeader` and `inReplyTo`, the URLs are
  // absolute (`url`/`rawUrl`, not the `links` object), and there is NO `recipients` —
  // this notification is for ONE recipient, and it sits outside, in `recipient`.
  // `message.id` is the uid: it is what goes in the /v1/inbound/* routes.
  "message": {
    "id": "b71e0c34-5a2f-4d18-9c60-77ab31e2d905",
    "receivedAt": "2026-08-05T09:14:02.317Z",
    "domain": "yourcompany.com",
    "from": "customer@example.com",
    "fromHeader": "sales@example.com",
    "fromName": "Sales",
    "subject": "Re: your quote",
    "messageIdHeader": "<a1b2@example.com>",
    "inReplyTo": "<z9@yourcompany.com>",
    "sizeBytes": 18422,
    "attachmentCount": 1,
    "authentication": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },

    // Internal spam score (0-100), the same as in GET /v1/inbound. It also goes
    // in the POST `x-spam-score` header — present ONLY when there is a score; if
    // the field is null, the header simply does not exist. null = not computed
    // (the message predates the feature), which is NOT the same as 0 (= clean).
    "spamScore": 2,
    "agentSafety": { "verdict": "clean", "score": 0, "signals": [],
                     "coverage": { "bodySampled": false, "sanitized": false, "unscannedAttachments": 1 } },
    "url":    "https://api.oveyon.com/v1/inbound/b71e0c34-…",
    "rawUrl": "https://api.oveyon.com/v1/inbound/b71e0c34-…/raw"
  },

  // WHO this copy is for — an OBJECT, not a string. An email that
  // arrived for support@ AND sales@ produces TWO events: same `message.id`,
  // different `recipient` and `eventId`.
  "recipient": { "id": "4d2f77a1-…", "to": "support@yourcompany.com" },

  // MANIFEST, at the TOP of the body (not inside `message`): name, type,
  // size, sha256 and the URL to fetch the bytes from. It comes in ALL
  // modes, including `summary`. The address of an attachment is its `ord`, NEVER
  // its `filename`.
  "attachments": [
    { "ord": 1, "filename": "quote.pdf", "contentType": "application/pdf",
      "sizeBytes": 14233, "sha256": "e3b0c442…",
      "url": "https://api.oveyon.com/v1/inbound/b71e0c34-…/attachments/1" }
  ],

  // ALWAYS present — even when nothing was cut. Always check it.
  "truncation": {
    "degraded": false,          // did you get less than you asked for?
    "requestedMode": "full",    // the mode configured on the channel
    "mode": "full",             // the mode that actually went out
    "reason": null,             // readable text when it degraded; null when not
    "maxBytes": 262144,         // the cap in force for THIS POST
    "attachmentsInline": false, // did the bytes come embedded in `content`?
    "attachmentsListed": 1,     // how many attachments fit in the manifest
    "attachmentCount": 1,       // how many the message has in total
    "textTruncated": false,
    "htmlTruncated": false
  },

  // In `full` and `full+attachments` modes, and also at the TOP of the body. They
  // are `null` when the message has no such part; in `summary` mode the two
  // keys simply do not exist.
  "text": "Good morning, please find the approved quote attached…",
  "html": "<p>Good morning, please find the approved quote attached…</p>"
}

Three modes — you choose how much volume goes into the notification

ModeWhat goes in the POSTGood when
summary Sender, subject, authentication verdicts, the API URLs and the attachment manifest. No text and no html. You only want the trigger and will fetch the body when (and if) you need it.
full The above + text and html. The common case: you can process the email without a second call.
full+attachments The above + the attachment bytes in base64, in attachments[].content, as long as the whole POST fits within the cap. Small, predictable attachments, and you do not want to authenticate a download.

The attachment manifest comes in all three modes — what changes from one to the next is the volume (body and bytes), never the list. And the mode selected by default on screen is full+attachments: if you do not choose, you get everything that fits.

// `summary` mode — no `text` and no `html` (the keys do not even appear). Everything
// else stays: headers, verdicts, URLs and the attachment MANIFEST.
{ "event": "inbound.received", "eventId": "…", "schemaVersion": 1,
  "message": { … }, "recipient": { "id": "…", "to": "…" },
  "attachments": [ { "ord": 1, "filename": "quote.pdf", "sha256": "…", "url": "…" } ],
  "truncation": { "degraded": false, "requestedMode": "summary", "mode": "summary", … } }

// `full+attachments` mode — each manifest item gets its BYTES in base64,
// in the same format as `attachments[].content` in POST /send. It is ALL OR NOTHING:
// either every attachment comes embedded, or none does.
"attachments": [
  { "ord": 1, "filename": "quote.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 }

// … and when it does not fit, the payload DEGRADES and SAYS SO. Here the channel
// asked for `full+attachments` and got `full`: the manifest stayed, the bytes did not.
"truncation": {
  "degraded": true,
  "requestedMode": "full+attachments",
  "mode": "full",
  "reason": "payload with inline attachments would exceed 262144 bytes: sent as links",
  "maxBytes": 262144,
  "attachmentsInline": false,
  "attachmentsListed": 1,
  "attachmentCount": 1,
  "textTruncated": false,
  "htmlTruncated": false
}

The POST cap

Every notification has a size cap, and it applies to the entire POST body — not just the attachments. The default is 256 KB (262,144 bytes). The value in effect for that POST is declared inside it, in truncation.maxBytes: read it from there instead of hardcoding the number in your code.

The cap is applied when the notification is built, not at delivery time. A 15 MB email never becomes a 20 MB POST that we would try six times: the large payload never even comes into existence. Two practical consequences for integrators: text and html are each cut to at most one quarter of the cap (the cut is by byte, and never splits a character in half), and the attachment bytes are only embedded if all of them fit in what is left.

Degradation is declared, never silent

When the payload does not fit in the requested mode, it degrades and says it degraded. The truncation block is always present — with degraded: false on the normal path — precisely so you can always check it, with a single line, instead of finding out it exists on the day of the first large email.

There is no level field: what answers «how much went out?» is the requestedMode / mode pair, in the same vocabulary as the three modes above.

What degraded was not lost: it is in the API, under the same message.id. Degrading takes volume out of the notification, never out of the archive.

Deduplicate by eventId

We would rather deliver twice than lose a notification. If your server processes it and the 200 gets lost on the way, the next attempt carries the same eventId — it is stable per delivery and survives both retry and redelivery. Store it, and treat a repeat as a silent success. Do not deduplicate by a hash of the body: a redelivery rebuilds the payload, and what arrives may not be byte-for-byte what arrived before.

// The SAME delivery can arrive twice: a retry after a timeout in which you had
// already processed it, or a redelivery from us. `eventId` is stable
// in both cases — it is the deduplication key.
async function processEvent(payload) {
  // INSERT with a unique key on eventId: whoever loses the race already knows
  // it is a duplicate, without relying on a SELECT-before-INSERT (which is racy).
  const isNew = await markAsSeen(payload.eventId);
  if (!isNew) return;              // already processed — answer 2xx and move on

  if (payload.truncation.degraded) {
    // The notification was trimmed: fetch what is missing from the API, by message.id.
    await fetchFromApi(payload.message.id);
  }
  await save(payload);
}

Signature (HMAC-SHA256)

Same scheme as the sending webhooks. Every POST carries three headers:

The timestamp goes inside the signed content: a captured POST cannot be resent with a fresh timestamp without breaking the signature. Reject anything that arrives with |now − timestamp| > 300 s, even with a valid HMAC — without that window, the signature alone authenticates a replay from yesterday.

Verifying in Node

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

// The RAW BODY is what was signed. Capture the bytes BEFORE any parsing
// — re-serializing the JSON changes a space and the signature no longer matches.
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

  // Anti-replay window: 300 s. Outside it, reject — even with a valid HMAC.
  if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(400);

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

  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(sig, 'hex');
  // Constant-time comparison (== leaks the correct prefix through timing).
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  const payload = JSON.parse(raw.toString('utf8'));
  enqueueForProcessing(payload);    // heavy work OUTSIDE the request
  res.sendStatus(200);              // fast 2xx: the attempt times out after 10 s
});

Verifying in PHP

<?php
// The RAW body, byte for byte. No json_decode before verifying.
$raw = file_get_contents('php://input');
$ts  = $_SERVER['HTTP_X_OVEYON_TIMESTAMP'] ?? '';
$sig = preg_replace('/^sha256=/', '', $_SERVER['HTTP_X_OVEYON_SIGNATURE'] ?? '');

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

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

// hash_equals: constant-time comparison.
if (!hash_equals($expected, $sig)) { http_response_code(401); exit; }

$payload = json_decode($raw, true);
enqueueForProcessing($payload);    // heavy work after responding
http_response_code(200);

Retries

Delivery means 2xx. Anything else counts as a failure and goes into the retry queue: 4xx, 5xx, a 10 s timeout, connection refused — and also 3xx, because redirects are not followed (following a redirect from a customer endpoint is how an SSRF walks in through the front door).

AttemptWhenCumulative since the 1st
1as soon as the event enters the queue (the worker runs every 10 s)—
230 seconds after the previous failure~30 s
32 minutes~2.5 min
410 minutes~12.5 min
51 hour~1 h 12 min
66 hours~7 h 12 min

That is 6 attempts, within a window of just over 7 hours. That is the number that matters to whoever operates the endpoint: a two-hour maintenance outage is absorbed without losing anything; a full-day outage is not.

Once all six are used up, the delivery is marked as failed and there is no seventh — the queue stops hitting your endpoint instead of hammering it forever. Nothing is lost because of this: the email stays stored, and GET /v1/inbound is the safety net. Endpoint went down? Reconcile by listing the outage window (?from=…&to=…) and deduplicating against what you already had — that is why the API exists even if you use the webhook.

Where you see this happening: in the dashboard, on the same row where the webhook was registered (Domains → the domain → the address), the Last send column shows the outcome of each notification and the reason in text: pending (there will be another attempt), delivered, failed (all six were used up) and dropped. The last one is the case where there was nowhere left to deliver to — the webhook was removed or deactivated between the email's arrival and the attempt. dropped is not a failure on your side or ours, and it is not retried.

Answer 2xx fast and do the heavy work afterwards. An endpoint that processes for 12 seconds before answering fails by timeout even though it did everything right — and then you receive the same event six times.

Endpoint behind Cloudflare (or a WAF): if you see 403 on the attempts and your code never runs, what is refusing is the edge, not your server — bot-fight and WAF rules love to kill machine POSTs. Our deliveries identify themselves as User-Agent: OVEYON-Webhook/1.0: create a WAF exception for that UA (or for your webhook's path) and the problem goes away. A diagnostic tip that applies to everything: the status code YOUR code returns is in your application log; a 403 with no log line at all = the edge ate the request first.

Attachments

The payload carries the manifest — ord, name, type, size, sha256 and the URL of each attachment — in all three modes. The bytes are fetched through the API, with your key: it is the same route as in the previous section.

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

⚠ What reaches your endpoint is third-party content

Subject, body, HTML, attachment names and bytes were written by whoever sent the email — not by us, and not by you. Anyone on the internet can send an email to one of your addresses, and therefore anyone on the internet chooses what goes inside this payload. The HMAC signature proves that we sent the POST; it does not say anything about its content.

Suppressions

Addresses you never want to reach. Only your tenant's rows are listed; the platform's global suppressions also block, but they do not appear here.

GET/v1/suppressionsscope read:suppressions

List with filters and offset pagination.

Query

  • email — substring match.
  • 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": "invalid@example.com", "reason": "hard_bounce",
      "source": "bounce", "createdAt": "2026-07-20T09:11:00.000Z" }
  ],
  "total": 1, "limit": 100, "offset": 0
}
POST/v1/suppressionsscope write:suppressions

Adds an address. reason defaults to manual. Response 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": "donotsend@example.com", "reason": "manual" }'
DELETE/v1/suppressions/:emailscope write:suppressions

Removes a suppression (email URL-encoded in the path). Response 200 { deleted }.

  • Only manual and hard_bounce are removable.
  • complaint and unsubscribe → 403 removal_blocked: a recipient who reported spam or unsubscribed only comes back with a proven re-opt-in.
  • Nonexistent or out of scope → 404 not_found (we do not distinguish the two).
curl -X DELETE https://api.oveyon.com/v1/suppressions/donotsend%40example.com \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Sending and receiving rules (allow/block)

Four lists per account: allow and block, for outbound (judges the recipient) and inbound (judges the sender, at our MX). An entry is an exact email address, an exact domain or *.domain.com (covers subdomains at any depth, never the domain itself). Semantics: block beats allow; the allow list only restricts when it has at least one entry; in the block list, user+tag@ counts as user@. It is the recommended fence for mailboxes operated by AI agents: a recipient allow list limits the damage of any prompt that goes wrong.

GET/v1/policiesscope read:policies

Lists the entries (filters scope and kind). Scope read:policies.

Each entry carries paused (boolean) and pausedAt (timestamp, or null). A paused rule stays on the list and does not apply — if you only look at whether the entry is present, you will conclude it is blocking mail when it is asleep.

These two fields are read-only here: pausing a list entry is a dashboard action (Rules), and there is no API route for it — do not look for one. What does have a pause via the API is the network fence, just below, which is a different resource. The asymmetry is real and is stated here so you do not write code against a route that does not exist.

POST/v1/policiesscope write:policies

Creates an entry. Scope 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": "customer.com" }'

Optional refinement: domainId (the entry applies only to that domain) and credentialType+credentialId (smtp|apiKey — the fence for ONE credential, the per-agent design). A duplicate answers 409 policy_exists; a malformed entry, 400. Cap of 5,000 entries per list.

DELETE/v1/policies/:idscope write:policies

Removes an entry (the id comes from create/list). The change takes effect within seconds on every entry point — API, SMTP and MX.

GET/v1/credentials/ip-rulesscope read:policies
POST/v1/credentials/ip-rulesscope write:policies
DELETE/v1/credentials/ip-rules/:idscope write:policies

Network binding: allowed CIDRs per credential (SMTP or API key). Opt-in — a credential with no rule authenticates from anywhere; with a rule, only from inside the CIDRs: a leaked password without the right IP does not authenticate (SMTP answers 535 5.7.8; the API, 403 ip_not_allowed naming the allowed CIDRs). Watch out for dynamic IPs/CGNAT: register the range, not the /32 you happen to have right now.

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

Pause the fence without losing the ranges. Body: { "credentialType": "smtp"|"apiKey", "credentialId": … }. While paused, the credential authenticates from any IP again and the ranges stay registered — unpause brings back exactly the same ones, without re-registering anything. It is the move for testing whether the fence is the cause of a send that does not go out: before this, the only way to turn it off was to delete the ranges, and deleting loses the CIDR, the comment and the authorship.

  • GET /v1/credentials/ip-rules carries paused and pausedAt on each row — a range listed with paused: true is not in effect.
  • unpause may return warning.uncoveredRecentIps: sources that sent in the last 30 days and fall outside the ranges. They stop authenticating immediately — the fence is back.
  • A credential with no ranges at all → 409 no_ip_rules: there is no fence to pause (it already authenticates from any IP).
  • Scope write:policies on both. Restarting the service does not undo the pause.
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 }'

Errors that /send now returns

Send policies

An ordered IF→THEN engine evaluated on every send, through the API and authenticated SMTP. The allow/block rules above only see the address; here a condition can look at the subject, sender, credential and time of day — and the action can refuse, hold, require a footer, force the tier or limit per hour. Both pieces are restrictive and ordered: a policy never reopens what a list, a suppression or the quota has already closed.

The shape of a policy

No regex and no negation, and that is a security decision, not a lack of time: an expression written by the customer and evaluated in the path of every message is the definition of a ReDoS surface. The vocabulary is closed, and all of it is in the table below.

Conditions — the closed catalog

campoopvalorNotes
destinatarioigual · contem · termina_emtext (up to 320)Matches if any recipient matches. With igual, john+note@x.com and john@x.com are the same mailbox; with contem/termina_em, they are not (that is what lets you catch the tag).
remetenteigual · contem · termina_emtext (up to 320)The from you wrote, not the rewritten envelope.
assuntocontem · igualtext (up to 500)Case-insensitive, with accents normalized. A subject longer than 500 is compared by its beginning and never matches igual — the truncation must not turn into a false positive.
credenciale_id{ "tipo": "api"|"smtp", "id": N }tipo is required: API keys and SMTP credentials have separate id spaces.
credencialtier_etextE.g. transactional.
horaentre{ "de": "22:00", "ate": "06:00" }de is inclusive, ate is exclusive; de > ate crosses midnight. In your account's time zone (adjustable under Account; default America/Sao_Paulo).

Actions

First-match-wins: the first active policy that matches decides, and the scan stops. There is no «most specific wins» and no stacking of actions. A paused policy decides nothing. If none matches, nothing happens: zero events, zero rows.

The hourly cap (limitar_hora)

Lets through up to max messages per clock hour for the traffic the policy describes, and refuses the excess until the hour turns over. It is a cap on that slice, not on the account: the rest of your traffic is not affected.

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

The complete list, in evaluation order, paused ones included. No pagination: the account cap is 50 and the response carries max. Each policy carries paused (boolean) and pausedAt — a paused policy stays in the list and does not apply.

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

One policy. A nonexistent id and an id from another account get the same 404 send_policy_not_found.

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

Born PAUSED and last in the order. Nothing changes in your sending until you activate it — that is what gives you the chance to check what you wrote. Response 201 with the policy.

curl -X POST https://api.oveyon.com/v1/send-policies \
  -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" \
  -d '{
        "name": "At most 200/h to the customer domain",
        "conditions": [{ "campo": "destinatario", "op": "termina_em", "valor": "@customer.com" }],
        "action": "limitar_hora",
        "actionParams": { "max": 200 }
      }'

Cap of 50 policies per account → 422 send_policy_limit_reached. Invalid input → 400 bad_request, and the reason field carries the stable cause (param_obrigatorio, hora_invalida, condicoes_demais…) so you can branch without parsing the prose.

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

Full replacement, not a patch: conditions is the complete list. It does not touch position or pause state — those are separate operations. Saving an active policy changes evaluation within seconds, on both entry points.

POST/v1/send-policies/:id/pausescope write:send-policies
POST/v1/send-policies/:id/unpausescope write:send-policies

Turn a policy on and off without deleting it. Activating is always an explicit step: no policy starts deciding just because it was saved.

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

Body { "order": [id, id, …] }, the complete list, in the desired order. Since evaluation is first-match, reordering changes who decides without touching any policy.

Ids that are not yours are ignored (they do not cause a 404: the list is a wish, not a reference), and missing ones go to the end in their previous order — that is what keeps a stale copy of the list from erasing the position of a policy created some other way. The response returns the reordered list.

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

Removes the policy and reindexes the order. The trail does not die with it: its decisions stay in the feed, under the name it had, and policyId comes back as null.

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

What your policies decided — newest first. Parameters limit (1–200, default 50) and before (the id of the last row of the previous page; the response carries nextBefore ready to use).

Read this endpoint if you use reter or limitar_hora: the first accepts the message with 202 and freezes it — with no error at all in the response — and the second records one row per window. Without the feed, both are invisible to anyone who integrates through the API alone. Retention is 90 days (the retentionDays field confirms it).

The write cap on these routes

The mutation routes in this section (POST, PUT, pause, unpause, reorder, DELETE) have a per-key cap, on top of the per-IP cap: default 120 writes/min. The reads in this section do not count against that cap. Exceeded → 429 rate_limited with Retry-After; the message states the limit.

It exists because every mutation here is more expensive than it looks: it invalidates the policy-evaluation cache, reorder rewrites the entire order, and DELETE also clears the policy pointer across 90 days of trail. It is the same cap the Policies screen applies — no human use or well-behaved script comes close to it, since the whole account can only have 50 policies.

Errors that /send now returns

The same policy applies to the API and authenticated SMTP. It does not apply on the inbound entry point (receiving is not sending), nor to relayed third-party mail.

Surveys (NPS / CSAT)

Trigger the question from your own system (ticket closed, order delivered) and get the score back by webhook. The survey goes out as a regular email from this platform — verified domain, DKIM, rules, suppressions, send policies and quota all apply, without exception. The score is collected on the click, on a page served from your tracking domain.

The two kinds, and the range of each

kindRangeWhat the rollup publishes
nps0 to 10 (eleven links)nps = %promoters (9–10) − %detractors (0–6), rounded; plus promotores, detratores, neutros, media and distribuicao.
csat1 to 5 (five links)media and distribuicao. nps comes back null — CSAT has no promoters, and inventing one would be a metric nobody recognizes.

Every response from the survey endpoints carries range: { min, max }. Read it from there instead of hardcoding 0..10 in your code. And distribuicao always comes with every score in the range, including the ones at zero: a chart that drops the scores with no votes lies about the shape of the curve, which is the first thing anyone looks at. With no responses at all, nps and media come back null — never 0: «NPS 0» is a genuinely bad result, and showing it where there is no data would be the product lying with a plausible number.

The score is the click — and the email body says so

Each score is its own link, and the click records the vote even if the thank-you page does not load: the write happens before the redirect. There is no voting by replying to the email, and the body says so in both HTML and plain text — without that notice, someone who replies «9» on instinct would walk away thinking they had voted, and your survey would collect silence.

Anti-fatigue — the rule that protects your list

The same recipient is surveyed only once per window. The window belongs to the ACCOUNT, not to the survey: someone who got your NPS yesterday does not get your CSAT today — the people on your list do not know you have two surveys. The length of the window comes from the survey you are sending (throttleDays, default 90, minimum 7).

A repeated request within the window → 429 recipient_recently_surveyed, with Retry-After (which can be weeks — that is the truthful answer), plus throttleDays and lastSentAt in the body. 429, not 422, and the difference matters: this will go through again once the window expires. A send refused for another reason (suppression, quota, policy) does not consume the window — the attempt is put on record and the address stays free.

GET/v1/surveysscope read:surveys

All your surveys. No pagination: the account cap is 50 and the response carries max.

GET/v1/surveys/:idscope read:surveys

The survey and the rollup in the same call — that is the question you are actually asking («how is my NPS doing?»), and splitting it in two would cost two calls to build one screen. A nonexistent id and an id from another account get the same 404 survey_not_found.

POST/v1/surveysscope write:surveys

Creates the survey definition. Response 201 with the survey.

curl -X POST https://api.oveyon.com/v1/surveys \
  -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" \
  -d '{
        "kind": "nps",
        "name": "Post-support NPS",
        "subject": "How was your support experience?",
        "fromEmail": "survey@yourcompany.com",
        "fromName": "Team",
        "throttleDays": 90,
        "brandColor": "#0b5fff"
      }'
  • kind — nps or csat. It cannot change after creation: a survey with recorded responses would become a mix of 0–10 and 1–5 ranges in the same rollup, and the resulting number would mean nothing. If you want the other kind, create another survey.
  • fromEmail — must be on a domain in your account, and that is checked here, so the error comes early. The verification (DNS/DKIM) is checked on each send, as with any other send.
  • question — optional. Left empty, it uses the canonical wording for the kind, in your account’s language (the NPS wording is what makes your number comparable to the industry's). Once saved, it is your text like any other: nothing rewrites it later.
  • throttleDays — 7 to 3650, default 90. Text or an out-of-range number is a 400, never «fell back to the default».
  • brandColor (#rrggbb) and logoUrl (absolute https) — your brand on the voting page.

Cap of 50 surveys per account → 422 survey_limit_reached. Invalid input → 400 bad_request with reason (a stable code: kind_invalido, throttle_invalido, from_dominio_alheio, logo_invalido…) and field, so you can branch without parsing the prose.

PUT/v1/surveys/:idscope write:surveys

Full replacement, not a patch. kind in the body is ignored. Send "active": false to stop sending without losing anything — that is the reversible option; deleting is the other one.

DELETE/v1/surveys/:idscope write:surveys

Deletes the responses along with it — and the API response tells you how many (responsesDeleted), so you do not discover the size of what you lost after the fact. To just stop sending, use active: false above.

POST/v1/surveys/:id/sendscope send:surveys

Sends to one recipient. Response 202 with the message id — the same shape as POST /v1/send, because it is literally the same sending path.

curl -X POST https://api.oveyon.com/v1/surveys/7/send \
  -H "Authorization: Bearer $OVEYON_KEY" -H "Content-Type: application/json" \
  -d '{
        "to": "customer@example.com",
        "meta": { "ticket": 4821, "agent": "bea" },
        "idempotencyKey": "ticket-4821"
      }'
  • meta — your context (order number, ticket id, who handled it). Up to 4 KB of JSON. It comes back in full in the webhook and when you read the responses: it is what ties the score to the event that prompted it, on your side.
  • idempotencyKey — the same key returns the same send (200 status: "duplicate") and no second email. Without it, a network retry becomes a second email to the same person — or, worse, an anti-fatigue 429 against your own send from three seconds earlier.

Every /v1/send error applies here, with the same codes: recipient_suppressed, recipient_blocked, policy_refused, quota_exceeded, domain_not_verified, domain_not_allowed_for_credential… Two are specific to surveys: 429 recipient_recently_surveyed (anti-fatigue) and 422 survey_inactive (the survey has active: false).

GET/v1/surveys/:id/responsesscope read:surveys

The scores, newest first, with recipient, comment, your meta and the rollup at the end. Parameters limit (1–200, default 50) and before (the id of the last row of the previous page; the response carries nextBefore ready to use).

A cursor, not an offset: the list is live, and with an offset a response that arrives between two pages shifts the boundary and makes the last row of page 1 reappear as the first row of page 2. The updated field tells you whether that score was changed later — without it, an export cannot tell whether that 3 was once a 9.

The survey.response webhook

Subscribe to survey.response on a webhook endpoint and get the score on your server without having to poll. Same HMAC signature and same retry policy as the other events.

{
  "event": "survey.response",
  "surveyId": 7,
  "surveyName": "Post-support NPS",
  "kind": "nps",
  "sendId": 91,
  "recipient": "customer@example.com",
  "score": 9,
  "comment": "Fast service.",
  "updated": true,
  "meta": { "ticket": 4821, "agent": "bea" },
  "messageId": "8f3c...-message-uuid",
  "timestamp": "2026-08-25T14:02:11.000Z"
}

The payload is the CURRENT STATE of the response, not a delta. Key by sendId and overwrite. You receive more than one notification per response when it changes: the first on the vote (updated: false, comment: null), and another when the person writes the comment or changes the score (updated: true). It is not a duplicate — the comment always arrives after the score, they are two separate actions on the page, and with a single notification the most valuable field from a detractor would never reach you.

An endpoint scoped to a credential does not receive survey.response: the one who voted was the recipient, with no key at all, and sending the notification to the wrong channel is worse than not sending it.

The write cap on these routes

The mutations in this section (POST, PUT, DELETE) and the send have a per-key cap, on top of the per-IP cap: default 120/min. Reads do not count against it. Exceeded → 429 rate_limited with Retry-After.

The send counts against this cap for a concrete reason: the voting link has to exist before the email body, so the record is created before the send is evaluated and is canceled when the send is refused. A loop against an always-refused recipient would keep creating and canceling nonstop.

Sending webhooks

Receive delivery events on your server. Multiple endpoints per account; each one subscribes to a subset of events: delivered, bounced, opened, clicked, complained, blocked. These are the events for what you sent — notifications for received email are configured separately, in the Inbound webhook section.

Timeout and retries: each attempt waits up to 10 seconds for your response; delivery means a 2xx within that time. Anything else — 4xx, 5xx, 3xx (redirects are not followed), timeout, connection refused — counts as a failure and is retried: there are 6 attempts in total, with increasing waits of 30 s, 2 min, 10 min, 1 h and 6 h (a ~7 h window), the same policy and the same table as the inbound webhook. Once all six are exhausted, the delivery is marked as failed and there is no seventh. Respond with a 2xx quickly and do the heavy work afterwards: an endpoint that spends 12 s processing before it responds fails on timeout even though it did everything right.

The blocked event fires when your sending rules block a recipient (in the API, over SMTP, or in a reply to an inbound email). The body carries reason (block_list or not_on_allow_list), entry (the entry that matched, when it is a block), address and origin. An endpoint scoped to a credential does not receive blocked — a block carries no credential.

POST/v1/webhooksscope manage:webhooks

Creates an endpoint. The signing secret is returned only once.

curl -X POST https://api.oveyon.com/v1/webhooks \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://yourapp.com/hooks/oveyon",
        "events": ["delivered", "bounced", "complained"],
        "credential": { "type": "api", "id": 42 } }'
{
  "id": 7,
  "url": "https://yourapp.com/hooks/oveyon",
  "events": ["bounced", "complained", "delivered"],
  "active": true,
  "secret": "3b1f...64hex...  (shown ONCE)",
  "signature": {
    "header": "x-oveyon-signature: sha256=HMAC-SHA256(secret, `${timestamp}.${rawBody}`)",
    "timestampHeader": "x-oveyon-timestamp (epoch seconds)",
    "verify": "Recompute the HMAC over `timestamp + \".\" + raw-body` and reject if |now - timestamp| > 300s."
  }
}

Every delivery carries x-oveyon-signature (HMAC-SHA256 of timestamp.raw-body) and x-oveyon-timestamp. Recompute the HMAC and reject the request if the timestamp is more than 300s away from now. Internal/private URLs are refused at creation (422 unsafe_url).

Per-credential scope (optional): with "credential": { "type": "smtp"|"api", "id": … }, the endpoint only receives events for messages that came in through that credential — one credential per system, one hook per system, no re-filtering on your side. When omitted, the endpoint receives events for the whole account (as it always has). The credential must exist and be yours: an id that is not yours gets 404 credential_not_found. The scope is echoed back in GET /v1/webhooks.

GET/v1/webhooksscope manage:webhooks

Lists your endpoints. The secret is never echoed back (it appears masked).

POST/v1/webhooks/:idscope manage:webhooks

Updates url, events and/or active. Also available as PATCH (the POST is an alias for clients without PATCH).

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

Removes an endpoint. Response 200 { deleted } · nonexistent → 404.

Domains

Add and verify your sending domains. A domain that already belongs to another account cannot be registered again (anti-hijacking).

POST/v1/domainsscope write:domains

Adds a domain and returns the DNS records to publish (SPF, DKIM and optional ones).

curl -X POST https://api.oveyon.com/v1/domains \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "domain": "yourcompany.com" }'
{
  "id": 42,
  "domain": "yourcompany.com",
  "records": [
    { "type": "TXT",   "name": "yourcompany.com", "value": "v=spf1 include:...", "purpose": "SPF (authorizes the sending IPs for your brand)" },
    { "type": "CNAME", "name": "s90abc._domainkey.yourcompany.com", "value": "...", "purpose": "DKIM" }
  ],
  "verification": { "spf": false, "dkim": false, "dmarc": false }
}
  • 409 domain_exists — it is already yours.
  • 409 domain_taken — unavailable (belongs to another account).
  • 400 bad_domain — invalid syntax.
  • 403 domain_not_allowed_free — Free plan: the domain was refused by reputation screening (Spamhaus Intelligence) before it was created. The response deliberately does not say why; support can see it. Paid plans do not go through this screening.
  • 422 domain_limit_reached — the plan has reached its domain cap (1 on Free). Remove a domain you no longer use or contact support to raise the limit. This refusal comes before reputation screening: if you are at the cap, no lookup is spent.
GET/v1/domainsscope read:domains

Lists your domains and their verification status (spf / dkim / dmarc).

POST/v1/domains/:id/verifyscope write:domains

Runs the DNS check live and stores the result. Only for a domain in your account (otherwise 404). If the resolver does not answer for a given check, that check shows up in inconclusive and its previous state is preserved — uncertainty does not unverify.

curl -X POST https://api.oveyon.com/v1/domains/42/verify \
  -H "Authorization: Bearer $OVEYON_API_KEY"
{
  "id": 42,
  "domain": "yourcompany.com",
  "verification": { "spf": true, "dkim": true, "dmarc": false },
  "inconclusive": [],
  "detail": { "spf": "...", "dkim": "...", "dmarc": "no _dmarc record" }
}

Errors & limits

Errors are JSON with a stable error field (and, when useful, a message and context). Handle them by HTTP status and by error, not by the message.

Response language

message is localized: it comes out in Portuguese for callers from Brazil and in English for the rest of the world — including when we cannot determine the country. The country comes from Cloudflare's CF-IPCountry; you don't need to send anything.

The same rule applies to every field that is a sentence for a person: the detail of inbound deliveries and of agent-safety signals, the label of the /v1/stats breakdown, the purpose and the verification detail of domain records, the statusDetail of sent messages, and the signature help when a webhook is created. A webhook has no caller: the text in its body (truncation.reason, retro.message, quarantine.release.note, the signals' detail) follows the account country — Portuguese for Brazil, English everywhere else.

The error field NEVER changes language. It is the machine contract, it is byte-for-byte identical in every country, and it is what your integration should branch on. The same goes for the rest of the body (required, have, domain, result, disposable, limit…): they are data, not text.

# same call, same error, different countries
# (Cloudflare injects CF-IPCountry; you do not send anything)

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` is identical in all four. Only `message` changes.

In other words: if (body.error === 'rate_limited') — never if (body.message === '…'). A text comparison breaks on the day the first of your customers calls from another country.

StatusMeaningTypical error
200OK / idempotent duplicateduplicate
201Created—
202Accepted (spooled for sending)—
400Malformed requestinvalid_json, bad_request, bad_cursor, bad_date, bad_status, bad_outcome, bad_events, bad_group_by, bad_domain, bad_route, bad_sdk, too_many_attachments
401Invalid/revoked keyunauthorized
403No permission / blockedinsufficient_scope, key_disabled, sending_disabled, removal_blocked, account_suspended, account_unknown, ip_not_allowed
404Not found / out of scope / route that does not existnot_found
409Conflictdomain_exists, domain_taken
413Payload too largepayload_too_large (with limit), attachments_too_large, body_too_long
415Unsupported content typeunsupported_media_type
422Business ruledomain_not_verified, domain_not_allowed_for_credential, invalid_recipient_domain, recipient_suppressed, unsafe_url, policy_refused
429Limit reachedrate_limited, too_many_auth_failures, quota_exceeded, service_quota_exceeded, warmup_cap_reached, queue_full
500 / 503Internal error / injection unavailable / list not loaded / key IP binding unavailableinternal_error, injection_failed, record_failed, list_unavailable, ip_rules_unavailable (with Retry-After: 30)

What is refused before the route

Some responses are produced before the route runs, and they apply to every operation — which is why the OpenAPI spec declares them on all of them:

Rate limit, quota and warm-up ramp

Golden rule for 429 and 503: exponential backoff with jitter, then retry. Validation 4xx errors (400/422) should not be retried without fixing the request.