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 MCP server Disposable domain Receive email Inbound webhook Suppressions Allow/block rules Send policies Surveys (NPS/CSAT) Sending webhooks Domains Marketing (experimental) Errors & limits API changes

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). With the human-approval lock on the key, the scopes that would undo it (approve:holds, write:send-policies, manage:webhooks, write:inbound, write:templates, write:surveys, write:domains, write:contacts, write:campaigns, send:campaigns, write:sequences) answer 403 scope_blocked_by_approval_lock, even when the key holds them:

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 · GET /inbound/instant
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 · POST /inbound/instant
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 · GET /logs/mcp · GET /logs/mcp/:id
read:contacts Read contacts, tags and lists (marketing, experimental) GET /contacts · GET /contacts/find · GET /lists · GET /lists/:id/form
write:contacts Create, edit and delete contacts; subscribe and unsubscribe from lists — on a list with double opt-in, subscribing sends the confirmation email (marketing, experimental) POST /contacts · DELETE /contacts · POST /contacts/properties · POST /contacts/tags · DELETE /contacts/tags · POST /lists/:id/subscribe · POST /lists/:id/unsubscribe · PATCH /lists/:id/form
read:campaigns Read campaigns, their check and their report (marketing, experimental) GET /campaigns · GET /campaigns/:id · POST /campaigns/:id/check · GET /campaigns/:id/report
write:campaigns Create, edit, rehearse and pause campaigns; send the preview to a user of the account (marketing, experimental) POST /campaigns · PATCH /campaigns/:id · POST /campaigns/:id/preview · POST /campaigns/:id/rehearse · POST /campaigns/:id/pause
send:campaigns Approve, schedule, resume, cancel and delete campaigns (marketing, experimental) DELETE /campaigns/:id · POST /campaigns/:id/approve · POST /campaigns/:id/schedule · POST /campaigns/:id/resume · POST /campaigns/:id/activate · POST /campaigns/:id/cancel
read:sequences Read sequences and the funnel of each step (marketing, experimental) GET /sequences · GET /sequences/:id · GET /sequences/:id/report
write:sequences Create, edit, start, pause, stop and delete sequences (marketing, experimental) POST /sequences · PATCH /sequences/:id · DELETE /sequences/:id · POST /sequences/:id/start · POST /sequences/:id/pause · POST /sequences/:id/stop

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. Exactly one address: the display name cannot carry another address.
  • to (required) — a string (one address, or several separated by commas) or a list of addresses; each item is "a@b.com" or "Name <a@b.com>" (see Unambiguous 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). Each value is text (or a list of texts); an object is 400 bad_request. In the address headers (Reply-To, Sender, Cc…) the value is read like to, with groups allowed and every member checked. 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. Up to 190 printable ASCII characters without spaces (! to ~); an integer works too and is read as its decimal text (1234 and "1234" are the same key; 0 counts as no key); anything else is 400 bad_request, with nothing consumed. The same key returns the original id (200 duplicate) for 24 hours from when the message was queued; after that, a retry can create another message.
  • 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.
  • return_path — "sender" or "platform": the envelope sender (Return-Path) of this message. Without the field, the key’s default applies, chosen under Credentials. See Return-Path. Note for SparkPost users: there the field is an address; here an address is 400 bad_request (API changes).

Return-Path (envelope sender)

The Return-Path — the envelope sender, the MAIL FROM — is where non-delivery notices and automatic replies go back to, and it is the address SPF authenticates. Each credential (SMTP or API key) picks one of two modes under Credentials, in the credential’s panel (Overview); in the API, return_path changes only the message of that call.

  • The platform’s ("platform", the default) — recommended for automated sending, transactional and marketing. Each message goes out with a return address of ours, its own: the bounce comes back to us, shows up in deliveries[], fires the bounce webhook, and a recipient that does not exist goes into automatic suppression.
  • The sender itself ("sender") — for a personal mailbox and discussion lists. The envelope is the from address, the same on every message: mailing list subscription confirmation, greylisting and allowlists by envelope sender work as they do with an ordinary mail server. A bounce the destination server sends back goes straight to the sender’s mailbox and the platform does not track it: these messages get no automatic suppression and no bounce webhook. An immediate refusal our server sees during delivery still shows up in the message status — and only reaches the sender by email if the credential has the bounce notice to the sender turned on (Preferences). The GET /v1/messages/:uuid of these messages carries returnPath: "sender" and bounceTracking: false.

The sender mode is for paid plans. On an account without a paid plan, return_path: "sender" answers 403 return_path_requires_paid_plan (nothing is queued, no quota is used) and the dashboard does not turn the mode on. A credential you left on the sender itself whose account went back to a free plan does not fail: it sends with the platform address until the account is back on a paid plan. If our support team turned the mode on for a credential as an exception, it applies on any plan — and the dashboard shows it.

The DNS the sender mode requires. The from domain must authorize us to use its address in the envelope:

  • the domain’s SPF has our include: — the same one the domain page asks you to publish —, before the all. If you already have SPF, add the mechanism to the existing record: one record only, because two v=spf1 on the same name is an error;
  • with the MailChannels relay active on the platform, also the TXT _mailchannels.example.com (on your own domain), carrying our account’s auth=.

The dashboard checks this when you turn the mode on, shows the exact record missing on each domain, and only turns it on with every domain of the credential in order. At send time every message is checked again, and we never switch to the platform address silently: without the authorization the API answers 422 return_path_not_authorized (over SMTP, 550 5.7.1, with the record in the reply), without using quota; DNS that did not answer is 503 return_path_check_unavailable (over SMTP, 451 4.4.3) — try again.

422 { "error": "return_path_not_authorized",
      "message": "return_path=sender requires the SPF of example.com to have include:_spf.… — publish it and send again, or send with return_path \"platform\"",
      "reason": "spf_include_missing", "domain": "example.com", "include": "_spf.…" }

reason is spf_include_missing (the include is missing, or there is no SPF), spf_multiple (there is more than one SPF record) or mailchannels_lockdown_missing — this one carries record ({ type, name, value }) instead of include. A relay (smarthost) credential does not use this feature: its envelope is rewritten with SRS. A reply through inbound (POST /v1/inbound/{id}/reply) always goes out with the platform address. So does the instant inbox: the domain is the platform’s, and there is nothing to publish on it — the key’s default sends from it with the platform address, and asking for return_path: "sender" from it answers 422 return_path_managed_domain.

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).
  • 202 · status: "accepted" with frozen: { reason: "policy", policyId } — accepted and frozen by a sending policy of yours with the action reter: not delivered, it waits for our support (see the decisions feed). The status stays accepted; frozen is the field that tells it apart.
  • 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, a return_path that is neither "sender" nor "platform" (an address there never becomes the envelope), an idempotencyKey outside the form above; 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.
  • 400 with reason: "address_not_ascii" — an address has a character outside ASCII (see ASCII-only addresses). On the recipients the error is bad_recipient; on from and on the address headers of headers, bad_request. Includes field and does not quote the address.
  • 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.
  • 403 return_path_requires_paid_plan — return_path: "sender" on an account without a paid plan (see Return-Path). Nothing is queued: upgrade, or send with return_path: "platform".
  • 409 idempotency_conflict — the idempotencyKey is already taken by another message of the account: keys are compared ignoring case (Order-AbC and order-abc are the same key). Nothing was sent; use a different key.
  • 409 idempotency_in_progress — another request with the same idempotencyKey is still in progress (it has not reached the queue yet). Nothing was decided: retry after Retry-After (5 s) with the same key — you get 200 duplicate if that request queued the message, or a normal answer if it failed.
  • 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.
  • 503 return_path_check_unavailable — the message asked for the sender as Return-Path and the DNS of the from domain did not answer just now. It is not a refusal: nothing was queued; retry after Retry-After (60 s).
  • 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. Over SMTP, a suppressed address is refused with 550 5.1.1. An address paused for a full mailbox also comes back as recipient_suppressed, with reason: "mailbox_full" and expiresAt (when the pause ends) — also when the pause is platform-wide and not in your list (see Suppressions); over SMTP its refusal is temporary, 452 4.2.2, and your server tries again.
  • 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.
  • 422 managed_domain_requires_paid_plan — the from is your instant inbox and the account is on the free plan, where it only receives. The domain is fine: send from your verified domain, or upgrade. Over SMTP the refusal is 550 5.7.1.
  • 422 return_path_not_authorized — the message goes out with the sender as Return-Path (return_path: "sender", or the key’s default) and the from domain does not authorize us yet. It carries reason, domain and what to publish (include or record). It does not use quota: publish it and send again, or send this message with return_path: "platform". Over SMTP the refusal is 550 5.7.1.
  • 422 return_path_managed_domain — return_path: "sender" with the from on your instant inbox: the domain is the platform’s, and there is nothing to publish. It carries domain. Send with return_path: "platform" (or without the field).
  • 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). matchedBy says how it matched: recipient (the policy has a recipient condition) or message (it only looks at the message — subject, sender, credential or hour — and refuses any recipient). With an hour condition it also carries retryAfter (ISO 8601): the end of the time window, when the same message passes again. 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": "{{customerName}}, your order has arrived",
    "html": "<p>Hello {{customerName}}, use coupon {{ coupon | \"SAVE10\" }}.</p>",
    "text": "Hello {{customerName}}, 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": "{{customerName}}, your order has arrived",
    "html": "<p>Hello {{customerName}}, use coupon {{ coupon | \"SAVE10\" }}.</p>",
    "text": "Hello {{customerName}}, 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": { "customerName": "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.

Only on a message that went out with the sender itself as Return-Path: returnPath: "sender" and bounceTracking: false — its bounce went to the sender’s mailbox and is not tracked here. On the others the two fields are absent.

Message insights

In the dashboard, the page of every sent message has an Insights tab: deliverability rules checked against what the message kept. Each rule comes out as needs attention, doing well or could not be checked — when the data does not allow it, the rule says why instead of guessing.

  • DMARC on the domain. The sending domain publishes _dmarc. Since 2024 Gmail and Yahoo require DMARC from bulk senders; start with p=none and tighten it after reading the reports.
  • Plain text version. The message carries a text/plain part next to the HTML (multipart/alternative): spam filters look at this, and plain text readers do not get an empty message. In the API, send text along with html.
  • A sender that accepts replies. The visible From is not «no-reply»: replies count as engagement for the providers, and the recipient can talk to you.
  • Size under 102 KB. Gmail clips the body above ~102 KB and hides the rest behind a link — the unsubscribe footer and the open pixel usually end up in the hidden part. When the message has several parts (an attachment, a text version), the stored size is the whole set and the body alone cannot be measured.
  • Your own tracking domain. With open or click tracking on, the pixel and the rewritten links go out on a subdomain of yours (set up on the domain page), not on the shared platform domain.

API logs

Every authenticated call your account's keys make to the API is logged for 180 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,
      "mcpCallId": null
    }
  ],
  "next_cursor": "bDoxNzU5OTMyMjA3NDgxLjQ4MjEz"
}

messageId links the call to the message it created or read — the same id as GET /v1/messages/:uuid. mcpCallId is set on a call an AI agent made through the MCP server: it is the message's callId in the MCP log (GET /v1/logs/mcp). 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,
  "mcpCallId": null,
  "query": null,
  "request": {
    "body": "{\"from\":\"app@example.com\",\"to\":\"customer@example.com\",\"html\":{\"omitted\":\"content\",\"length\":5120},\"headers\":{\"X-Api-Key\":\"«redigido»\"}}",
    "bytes": 5302,
    "truncated": false
  },
  "response": {
    "body": "{\"error\":\"insufficient_scope\",\"required\":\"send\",\"have\":[\"read:messages\"]}",
    "bytes": 74,
    "truncated": false
  }
}

MCP log

What AI agents did through the MCP server, one entry per JSON-RPC message: the tool the agent called, through which app (ChatGPT, Claude…), from where, how it ended and which API calls it made. The same retention, 180 days, and the same scope, read:logs. In the dashboard it is the MCP tab of API logs.

GET/v1/logs/mcpscope read:logs

Lists the messages, newest first, with cursor pagination.

Query

  • limit / cursor — as in GET /v1/logs.
  • outcome — ok; tool_error (the tool ran and returned an error — usually the API's error answer); rejected (refused before running: unknown tool, invalid argument); error (the MCP request itself failed); aborted (the client gave up before the answer). Outside tools/call, ok means the request was answered: a protocol error inside the answer (an unknown method or resource) is not read. A value outside the list, 400 bad_outcome.
  • tool — the tool name (get_inbound_content). Malformed, 400 bad_tool.
  • method — the JSON-RPC method (tools/call, tools/list, initialize…). Malformed, 400 bad_method.
  • credential — the id of one credential of the account (an app connection or a key).
  • from / to — ISO 8601 range.

Example

curl "https://api.oveyon.com/v1/logs/mcp?tool=get_inbound_content&limit=25" \
  -H "Authorization: Bearer $OVEYON_API_KEY"

Response

{
  "data": [
    {
      "id": 9120,
      "createdAt": "2026-10-14T03:12:08.204Z",
      "callId": "5f0c8e2a9b1d4c7e8a3f6b2d1e9c0a47",
      "credential": { "id": 41, "name": "ChatGPT (OAuth)", "prefix": "ov_7d2e1" },
      "application": { "name": "ChatGPT", "origin": "chatgpt.com" },
      "method": "tools/call",
      "tool": "get_inbound_content",
      "outcome": "ok",
      "error": null,
      "status": 200,
      "durationMs": 184,
      "apiCalls": 1,
      "apiStatuses": [200],
      "client": null,
      "batch": { "size": 1, "index": 0 }
    }
  ],
  "next_cursor": "bToxNzYwNDExNTI4MjA0LjkxMjA"
}

application is the app behind an OAuth connection — the name it declared and the host it comes from; null when the credential is a key used directly. client is what the client declared (its clientInfo) and authenticates nothing.

GET/v1/logs/mcp/:idscope read:logs

One message, with the arguments as they were stored and apiLogIds: the API calls it made (the ids of GET /v1/logs/:id). An id of another account, malformed or past the retention period → 404 not_found.

Response

{
  "id": 9120,
  "createdAt": "2026-10-14T03:12:08.204Z",
  "callId": "5f0c8e2a9b1d4c7e8a3f6b2d1e9c0a47",
  "credential": { "id": 41, "name": "ChatGPT (OAuth)", "prefix": "ov_7d2e1" },
  "application": { "name": "ChatGPT", "origin": "chatgpt.com" },
  "method": "tools/call",
  "tool": "get_inbound_content",
  "outcome": "ok",
  "error": null,
  "status": 200,
  "durationMs": 184,
  "apiCalls": 1,
  "apiStatuses": [200],
  "client": null,
  "batch": { "size": 1, "index": 0 },
  "target": "get_inbound_content",
  "httpMethod": "POST",
  "rpcId": "7",
  "rpcError": null,
  "arguments": { "uid": "3b8f1c52-6a0e-4d3b-9f27-0c5e8a1d6b94" },
  "argumentsTruncated": false,
  "requestBytes": 142,
  "resultBytes": 4183,
  "apiRoute": "GET /v1/inbound/:uid/content",
  "apiError": null,
  "ip": "203.0.113.77",
  "userAgent": "openai-mcp/1.0.0",
  "protocolVersion": "2025-06-18",
  "sessionId": null,
  "apiLogIds": [48230]
}

MCP server (for AI agents)

The same API as tools an AI agent (Claude Code, Cursor, VS Code, Codex, Windsurf…) calls by name, through the Model Context Protocol. One endpoint, Streamable HTTP, no state: POST https://mcp.oveyon.com/v1 (the address https://api.oveyon.com/v1/mcp answers too, for good). The MCP screen of the dashboard prints the configuration of each client.

Connect

claude mcp add --transport http oveyon https://mcp.oveyon.com/v1 --header "Authorization: Bearer $OVEYON_API_KEY"

By hand

curl -X POST https://mcp.oveyon.com/v1 \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

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 MAILBOX spam filter (opt-in): null = no mailbox asked for it.
      "spamFilter": { "status": "scanned", "score": -0.4, "action": "no action", "reason": null },
      // 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). `contentType` is the sender's label, as is;
  // `detectedType` is the type read from the first bytes of the file (null =
  // no recognized signature), and `typeMismatch` says when the two
  // disagree — as in the 2nd item: a .doc labelled as an image.
  "attachments": [
    { "ord": 1, "filename": "quote.pdf", "contentType": "application/pdf",
      "detectedType": "application/pdf", "typeMismatch": false,
      "sizeBytes": 14233, "sha256": "9f2c…",
      "url": "/v1/inbound/b71e0c34-…/attachments/1" },
    { "ord": 2, "filename": "contract.doc", "contentType": "image/png",
      "detectedType": "application/x-ole-storage", "typeMismatch": true,
      "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,
    // The spam filter of THIS copy's mailbox (null if it did not ask).
    "spamFilter": { "status": "scanned", "score": -0.4, "action": "no action", "reason": null,
                    "symbols": [{ "name": "R_DKIM_ALLOW", "score": -0.2 }] },
    "agentSafety": { "verdict": "clean", "score": 0, "signals": [],
                     "coverage": { "bodySampled": false, "sanitized": false, "unscannedAttachments": 1 } },
    "url":    "https://api.oveyon.com/v1/inbound/b71e0c34-…",
    "rawUrl": "https://api.oveyon.com/v1/inbound/b71e0c34-…/raw"
  },

  // 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`. `detectedType` is the type by the first bytes (null = no
  // recognized signature); `typeMismatch` warns when the label
  // (`contentType`) and the content disagree — do not discard by the label.
  "attachments": [
    { "ord": 1, "filename": "quote.pdf", "contentType": "application/pdf",
      "detectedType": "application/pdf", "typeMismatch": false,
      "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",
    "detectedType": "application/pdf", "typeMismatch": false,
    "sizeBytes": 14233, "sha256": "e3b0c442…",
    "url": "https://api.oveyon.com/v1/inbound/b71e0c34-…/attachments/1",
    "content": "JVBERi0xLjQK…" }
]
"truncation": { …, "mode": "full+attachments", "attachmentsInline": true }

// … 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(evt) {
  // 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(evt.eventId);
  if (!isNew) return;              // already processed — answer 2xx and move on

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

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 evt = JSON.parse(raw.toString('utf8'));
  enqueueForProcessing(evt);        // 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; }

$evt = json_decode($raw, true);
enqueueForProcessing($evt);        // 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.

Two types per attachment. contentType is the sender's label, as is; detectedType is the type read from the first bytes of the file (PDF, legacy and modern Office, images, archives, audio and video, executables — null when no signature is recognized, as with plain text). typeMismatch is true when both exist and belong to different families — image, audio and video, PDF, PostScript, office document (legacy and modern Office, OpenDocument, RTF), archive, executable, text. Synonyms of the same type (image/jpg, Outlook's application/x-zip-compressed) and the same container (application/zip for a .docx) do not count; a generic label (application/octet-stream) never mismatches. A label outside these families only mismatches when the content is an executable — an executable mismatches every label that does not declare one. Do not discard an attachment on the label alone; prefer detectedType when it exists — a .doc has already arrived labelled image/png by the sender's email program, and an «images only» filter would have thrown it away.

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 · mailbox_full.
  • limit — ≤ 500 (default 100) · offset.
curl "https://api.oveyon.com/v1/suppressions?reason=hard_bounce&limit=100" \
  -H "Authorization: Bearer $OVEYON_API_KEY"
{
  "suppressions": [
    { "email": "nospace@example.com", "reason": "mailbox_full", "source": "dsn",
      "domainId": null, "expiresAt": "2026-07-21T10:02:00.000Z", "createdAt": "2026-07-20T10:02:00.000Z" },
    { "email": "invalid@example.com", "reason": "hard_bounce", "source": "bounce",
      "domainId": null, "expiresAt": null, "createdAt": "2026-07-20T09:11:00.000Z" }
  ],
  "total": 2, "limit": 100, "offset": 0
}

A full mailbox is a pause, not a suppression. When the destination server refuses because the recipient’s mailbox is full (5.2.2, the 4.2.2 deferral, or a 5.2.x that speaks of the mailbox quota or storage), the address is paused for a period counted from the refusal (24 hours by default; the exact end is expiresAt): until then, new sends to it are refused — in the API with 422 recipient_suppressed, reason: "mailbox_full" and expiresAt; over SMTP with 452 4.2.2, temporary, so your server tries again. The pause is renewed while the mailbox stays full and ends early when a delivery to the address is accepted by the destination server (the message that waited in the queue and went out after the person freed up space); after it, the next send tries again. When the refusal came in the SMTP conversation with the destination itself (the common case) and the message was up to 1 MiB, the pause applies to the whole platform: it is not in this list and cannot be removed here — in the dashboard, searching for the exact address shows until when, and our team is who lifts it early. For it, the expiresAt you see (in the 422, the 452 and the dashboard) is rounded up to the next full hour: the exact one would tell the time of another customer’s refusal. A pause that came from an asynchronous DSN, or from a message larger than 1 MiB, is your account’s only: it shows up in this list with reason: "mailbox_full" and expiresAt, and you can remove it. A mailbox that is full and inactive (Gmail says “out of storage space and inactive”) will not empty: it is treated as a mailbox that does not exist. A refusal for the message size (5.2.3, 5.3.4) neither suppresses nor pauses anyone: it is a failure of that message. A line with expiresAt in the past no longer blocks anything; a filled domainId means the line applies only to that sending domain.

POST/v1/suppressionsscope write:suppressions

Adds an address. reason defaults to manual; mailbox_full is not accepted (only the platform writes the pause) and answers 400 bad_request. Response 201 { email, reason }. ASCII addresses only, the IDN as xn-- — anything else is 400 with reason: "address_not_ascii" (on removal too).

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, hard_bounce and mailbox_full are removable (lifting early your account’s own full-mailbox pause; a platform-wide one is not in your list).
  • 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 exception is a key’s human-approval lock (the reter_para_aprovacao whose only condition is «credential is key N»): it applies in any position. For that key’s sending, seguir_fluxo, forcar_transacional, exigir_footer and limitar_hora do not decide: nothing from that key goes out without a person. recusar still blocks in any position, and reter above the lock freezes the message (only support releases it). On a reply (POST /v1/inbound/{id}/reply), which does not apply reter, the locked key always produces a draft for approval, unless recusar decides.

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 general per-key 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 (or the header; up to 190 printable ASCII characters without spaces, or an integer — anything else is 400) — 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 general per-key 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). What comes with optional: true is not needed to send: the tracking CNAME and, in the default mode, the domain’s own SPF — mail sent through us carries our return address in the envelope, and the receiver checks the SPF of that address. When a credential that sends for this domain uses the sender itself as the Return-Path (paid plan), the domain’s SPF comes without optional: then it decides the mail sent through us.

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)", "optional": true },
    { "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.
  • 429 rate_limited — at most 60 domain registrations per hour per account (a sliding hour, shared with the dashboard). Every request that passes the syntax check and the plan cap counts, created or not; a request refused by this limit does not. Nothing was created: wait for Retry-After (seconds, also in retryAfter) and call again.
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.

spfProblem says why the published SPF breaks, and detail.spf carries the sentence on how to fix it. It does not block sending: what stops it is an unverified DKIM. Mail sent through us carries our return address in the envelope, and the receiver checks the SPF of that address, not your domain’s. So when the domain’s SPF breaks, code is the cause (too_many_dns_lookups, too_many_void_lookups, multiple_spf_records, invalid_syntax, include_without_spf, too_many_mx_hosts, too_many_addresses) and severity is "warning": your mail sent through us passes SPF, and what may fail are the other services that send as the domain (your company email, for example). It is "error" only when a credential that sends for this domain uses the sender itself as the Return-Path (a paid plan feature): then the receiver checks the domain’s SPF on your mail sent through us too, and it fails. With code lookups_over_limit it is always "warning": sending through us passes, but the whole record makes more DNS queries than the limit, and other senders may fail. The cost comes in lookups (the total, counting a repeated include again), limit, exact (false when the count stopped at one of our ceilings and lookups is the floor) and terms (the cost of each item of the record). With record_too_large, a record in the chain (the domain’s own, or an include or redirect target) is over 4096 bytes: it is not evaluated — the SPF does not count as verified, neither passing nor failing — and detail.spf says which one to shorten. With nothing to say, it is null.

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" },
  "spfProblem": null
}
  • 429 rate_limited — at most 60 live checks per minute per account (a sliding minute, shared with the dashboard: «Verify now», the ↻ of the domain list, the onboarding assistant and the DKIM mode switch). Only a check of a domain of yours counts (a 404 does not), and a request refused by this limit does not count either. Nothing was checked or stored: wait for Retry-After (seconds, also in retryAfter) and call again.

Marketing (experimental)

Contacts, properties, tags, newsletter lists, campaigns and sequences. Experimental: the module is enabled account by account, and without it every route in this section answers 403 module_disabled. Scopes read:contacts (read) and write:contacts (write — and, on a list with double opt-in, subscribing sends the person the confirmation email, which counts against the account’s sending quota).

The model

POST/v1/contactsscope write:contacts

Creates or updates by email address: 201 when it created, 200 when it updated. attributes is merged — a key you do not send stays as it was, and null clears the value. Fields: email (required), firstName and lastName (up to 100 characters), attributes and externalId (up to 64).

curl -X POST https://api.oveyon.com/v1/contacts \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "ana@example.com", "firstName": "Ana",
        "attributes": { "plan": "pro", "points": 120 } }'
HTTP/1.1 201 Created
{
  "contact": {
    "id": 1001, "email": "ana@example.com", "firstName": "Ana", "lastName": null,
    "attributes": { "plan": "pro", "points": 120 },
    "source": "api", "externalId": null,
    "createdAt": "2026-10-17T12:00:00.000Z", "updatedAt": "2026-10-17T12:00:00.000Z"
  },
  "created": true
}
GET/v1/contactsscope read:contacts

Oldest first, with next_cursor. Filters: q (start of the email address), listId (and, with it, status) and tagId; limit up to 100 (default 50).

GET/v1/contacts/findscope read:contacts

?email= — the contact with tags and lists (each subscription with status, dates and unsubscribeReason). Deleted or nonexistent: 404 contact_not_found.

DELETE/v1/contactsscope write:contacts

?email= or ?id= — exactly one. Response 200 { id, deleted, lists, purgeAfter }.

POST/v1/contacts/propertiesscope write:contacts

{ name, type }, with type = string · number · boolean · date. Same name and same type: 200, unchanged; another type: 409 property_type_conflict.

POST/v1/contacts/tagsscope write:contacts

{ email | id, tags }, from 1 to 20 tags. A tag that does not exist yet is created; the name is case-insensitive (the first spelling stays).

DELETE/v1/contacts/tagsscope write:contacts

?email= or ?id=, and tag (repeat the parameter to remove several). A tag the contact does not have is ignored.

GET/v1/listsscope read:contacts

The account’s lists, with sender, time zone, doubleOptIn, formEnabled (the public form switch) and counts per state — only contacts that are not deleted, the numbers of the Audience screen (a deleted contact leaves every list at once and is no longer counted, not even as unsubscribed).

POST/v1/lists/:id/subscribescope write:contacts

Creates or updates the contact (the same fields as the upsert) and subscribes it. On a list with double opt-in the subscription stays pending and the person gets the confirmation email, from the list’s sender, with the link that subscribes them: at most one per list and mailbox every 24 hours (another call inside that window does not send another one), five per mailbox per hour and three per mailbox per day across the whole platform, and none to a suppressed address. The mailbox ignores the +tag of the address and, on Gmail, the dots. Without double opt-in, subscribed at once. It also stays pending, on any list, when the person was already pending on it (they filled your form and did not confirm yet): only their own confirmation or the preference center makes it subscribed — never the API nor an import. consent (optional) keeps the proof of who subscribed: ip and source (up to 190) of the person, not of whoever calls the API. When refused (409), the call changes nothing — not even the contact.

curl -X POST https://api.oveyon.com/v1/lists/7/subscribe \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "ana@example.com",
        "consent": { "ip": "203.0.113.9", "source": "https://example.com/newsletter" } }'
{ "contactId": 1001, "listId": 7, "status": "pending", "changed": true, "contactCreated": false }
GET/v1/lists/:id/formscope read:contacts

The list’s subscription form, as HTML to paste on your site (text/html, the same as the list screen): it posts to the brand’s tracking host (/f/<publicKey>), with the email field, the fields and tags the list accepts, a hidden field against bots (website) and, when the list has it, Cloudflare Turnstile. Labels in the list’s language. The optional redirect only leads to an address on the same registrable domain as the list’s domain (the list’s domain is its sending domain: a list on news.yourcompany.com takes www.yourcompany.com and yourcompany.com); any other domain is refused. The form answers the same for every address — it does not tell whether the person is already on the list. The public form only exists on a list with double opt-in: without it, 409 form_requires_double_opt_in (subscribe from your server, with subscribe).

PATCH/v1/lists/:id/formscope write:contacts

The switch of the list’s public form — the same as the screen: {"enabled": false} turns it off at once (it answers the usual page to every submission and records nothing, and the form’s confirmation emails still in the queue are not sent — their links die; the API ones are sent) and true turns it back on. Returns listId and formEnabled. It does not change the form HTML; to invalidate the HTML that is already live, change the form address on the list screen.

POST/v1/lists/:id/unsubscribescope write:contacts

{ email | id }. The reason recorded is api: the API can subscribe again someone the API removed. status comes null when the contact was never on the list.

Campaigns

A campaign is one edition for one list: the brand, the sender, the footer and the language come from the list. The footer of every campaign message carries the list’s postal address (US commercial email law requires it), the unsubscribe link and the preference center; without the address nothing goes out — the check reports list_missing_postal_address, and a campaign that is already sending waits with the same reason in pauseReason and resumes on its own once it is back. The platform composes the final message: the footer goes in as the last element of the body, with its own inline style, and the result is parsed again and checked; layout or template HTML that does not let it in (a frameset, or nesting far beyond what an email uses) is reported by the check as footer_not_guaranteed and nothing goes out. The composed email, with its links tracked, is capped at 100 KB measured as it is sent — the HTML part encoded in quoted-printable or base64, which is longer than the HTML (accents and emoji weigh up to about 2.4 times their bytes): Gmail clips a message at about 102 KB, and what disappears is the end — the footer. Above it the check reports the same footer_not_guaranteed with the reason in problemReasons ({"footer_not_guaranteed": "html_too_large"}; not_ready carries the same field), and nothing goes out — reduce the HTML. Layout or template CSS that would hide the footer does not pass either: on <html> and <body> only color, background, font, text, margin, border and width are allowed; on any element, no position: absolute, fixed or sticky, offset, positive z-index, negative margin, transform or wide outline; and an external stylesheet (@import, <link rel="stylesheet">) cannot be checked. The check reports footer_not_guaranteed with the reason in problemReasons — css_on_html_body, css_positioning, css_negative_margin, css_transform, css_outline, css_external_stylesheet, css_unsupported_rule, css_outlook_hide — and the screen says what to remove. Scopes read:campaigns (read, check and the report), write:campaigns (create, edit, rehearse, the preview and pause) and send:campaigns (approve, schedule, resume, cancel and delete). With the human-approval lock on the key, write:campaigns and send:campaigns do not work: a campaign goes to the whole list without a draft.

POST/v1/campaignsscope write:campaigns

Creates it as draft (201). Fields: listId and name (required), subject, previewText, contentMd (Markdown, up to 256 KB), contentHtml (HTML, up to 256 KB) or templateId and templateVersion, fromName, fromLocal, replyTo, audienceFilter, excludeListIds, audienceCap (it only reduces the audience) and timezone. Closed body: an unknown field is 400 bad_request with field. fromLocal and replyTo are ASCII only (an international domain only in its xn-- form): anything else is 400 invalid_sender with reason: address_not_ascii and the field, and nothing is stored. Up to 500 live campaigns per account (cancelled and done ones do not count): above that, 422 campaign_limit with limit. And up to 200 campaigns created per account per day (UTC; cancelled and deleted ones count): above that, 422 campaign_limit with limit and window day. With kind model and recurrence, it is the model of a weekly send; kind edition is 400 bad_request.

curl -X POST https://api.oveyon.com/v1/campaigns \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "listId": 7, "name": "October edition",
        "subject": "Hi {{firstName|\"there\"}}",
        "contentMd": "# News\n\nRead [the post](https://example.com/post).",
        "audienceFilter": "{\"all\":[{\"kind\":\"tag\",\"op\":\"has\",\"tag\":\"vip\"}]}" }'
GET/v1/campaignsscope read:campaigns

Newest first, without the content, with next_cursor. Filters: status, listId, kind and parentId (the editions of a model); limit up to 100 (default 50).

GET/v1/campaigns/:idscope read:campaigns

The whole campaign, with contentMd, contentHtml and audienceFilter.

PATCH/v1/campaigns/:idscope write:campaigns

Only the fields you send change (null clears one). Approved, scheduled or expired (skipped) goes back to draft; the response is { campaign, returnedToDraft }. In any other state: 409 invalid_state.

DELETE/v1/campaigns/:idscope send:campaigns

Only a draft or a cancelled campaign that never really went out (204). One that was sent is history: cancel it, do not delete it (409 invalid_state). A model with editions does not go either: end it.

POST/v1/campaigns/:id/checkscope read:campaigns

Changes nothing. { "probe": true } runs the external checks of the brand now (the certificate and the CAA of the tracking host, the SPF of the return address) — up to 20 per account every 10 minutes; without it, the last result holds. The numbers of our infrastructure are not shown.

HTTP/1.1 200 OK
{
  "problems": ["tracking_host_without_tls"],
  "ready": false,
  "audience": 58210, "audienceError": null,
  "schedule": { "dailyLimit": 10000, "sentToday": 0, "warmed": false, "estimatedDays": 4 },
  "readiness": [
    { "name": "tracking_host_tls", "state": "blocking", "owner": "automatic", "effect": "blocking",
      "detail": "certificate_invalid", "numbers": null },
    { "name": "marketing_ips", "state": "ok", "owner": "staff", "effect": "blocking",
      "detail": "has_ips", "numbers": null },
    { "name": "daily_capacity", "state": "ok", "owner": "staff", "effect": "warning",
      "detail": "brand_limit", "numbers": { "capacity": 10000, "limitedBy": "brand", "brandRemaining": 10000 } }
  ]
}
POST/v1/campaigns/:id/previewscope write:campaigns

{ to, contactId }: the campaign as it goes out, with the subject prefixed in the list language ([Test] in English, [Teste] in Portuguese), to to — a user of the account. contactId (optional) only fills in the variables: the contact receives nothing. Response 202 { messageId }.

POST/v1/campaigns/:id/rehearsescope write:campaigns

Everything except sending: the audience snapshot, the recheck of each recipient and the rendering. As many as you want, one at a time per account (409 rehearsal_in_progress). Response 202 { runId }; the result shows in rehearsal of the report.

POST/v1/campaigns/:id/approvescope send:campaigns

Only a draft. Checks everything — the external checks of the brand included — and approves what it checked, without sending — and freezes the version of the template (or of the layout) that was checked. 409 changed: the campaign (or the list) changed while it was being checked. The edition of a weekly send is approved and scheduled for its time in the same gesture (scheduled, with scheduledAt); one that was skipped only goes out with { "sendNow": true }, until the next edition. A model is never approved.

POST/v1/campaigns/:id/schedulescope send:campaigns

{ scheduledAt, timezone }: "now", an ISO 8601 instant with offset, or a local time without offset, read in timezone — from 5 minutes ago up to a year ahead. This is the gesture that sends. 409 changed with returnedToDraft: true: the list changed after the approval and the campaign went back to draft.

curl -X POST https://api.oveyon.com/v1/campaigns/42/approve \
  -H "Authorization: Bearer $OVEYON_API_KEY"
curl -X POST https://api.oveyon.com/v1/campaigns/42/schedule \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "scheduledAt": "2026-11-03T09:00", "timezone": "America/New_York" }'
POST/v1/campaigns/:id/activatescope send:campaigns

Only a model in draft: turns its recurrence on (running). Checks the recurrence, the postal address and the sender of the list (422 not_ready). Response { id, status, next }: the time and the day of the next edition.

POST/v1/campaigns/:id/pausescope write:campaigns

Only running; what is in flight finishes.

POST/v1/campaigns/:id/resumescope send:campaigns

Only paused. Paused by the bounce or complaint thresholds: 409 paused_for_review — our support reviews the brand and resumes it. Paused with sender_changed (the list sender or reply-to changed after the approval): resuming accepts the current sender, and the campaign goes on with it.

POST/v1/campaigns/:id/cancelscope send:campaigns

From any state that is not final; its running rehearsals stop too. Response { id, status, skipped, runId }: skipped is who was waiting and will not receive it. On a model, editionsCancelled comes too: its draft editions, cancelled with it.

GET/v1/campaigns/:id/reportscope read:campaigns

Sent, delivered, bounced, complaints, unsubscribes, opens and clicks, with the rates (fractions from 0 to 1), read from the counters of the run — the report does not change when a contact is deleted. routeFailures are failures on our side, not bounces; ipBlocks is the part of them where the recipient server refused our sending servers for their reputation (a block list such as Spamhaus, or Microsoft S3140) — they do not count against the brand nor take the contact off the list. hardBounced is the part of bounced that is permanent for the address (the contact leaves the list); a refusal for the message size or for a full mailbox is never hard and counts in bounced only.

HTTP/1.1 200 OK
{
  "campaign": { "id": 42, "name": "October edition", "status": "running" },
  "run": { "id": 9, "status": "pending", "rehearsal": false, "audience": 58210, "cap": null,
           "sent": 10000, "queued": 48210, "inFlight": 0, "skipped": 0, "failed": 0, "rehearsed": 0,
           "pauseReason": "daily_limit", "errorCode": null, "resumeAt": "2026-11-04T05:00:00.000Z",
           "startedAt": "2026-11-03T14:00:00.000Z", "finishedAt": null },
  "outcome": { "delivered": 9810, "bounced": 150, "hardBounced": 41, "routeFailures": 0, "ipBlocks": 0, "complaints": 2,
               "unsubscribes": 23, "opens": 4120, "clicks": 610, "inTransit": 40 },
  "rates": { "delivery": 0.981, "bounce": 0.015, "complaint": 0.0002, "unsubscribe": 0.0023,
             "open": 0.42, "click": 0.0622 },
  "skippedByReason": {}, "tracking": { "opens": true, "clicks": true },
  "rehearsal": null, "rehearsals": 1
}

Sequences

A sequence is a series of emails for one list: someone enters the list (or gets a tag) and receives email → wait → email… up to the exit. The sender, the footer (with the list’s postal address), the language and the brand are the list’s, as in campaigns, and each email is Markdown inside the list layout. Each email is rechecked at the moment it goes out: whoever left the list meanwhile, was deleted or is suppressed does not get the next one and leaves the sequence. Scopes read:sequences (read and the funnel) and write:sequences (create, edit, start, pause, stop and delete). With the human-approval lock on the key, write:sequences does not work: starting a sequence sends to every new subscriber, without a draft.

POST/v1/sequencesscope write:sequences

Creates it as draft (201). Fields: listId and name (required), graph (without it: list trigger → one blank email → exit), timezone (default: the list’s) and reEligible. Closed body, up to 576 KB; the content spends the same budget as campaigns (429 rate_limited).

curl -X POST https://api.oveyon.com/v1/sequences \
  -H "Authorization: Bearer $OVEYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "listId": 7, "name": "Welcome", "timezone": "America/New_York",
        "graph": { "rootId": "n1", "nodeDataById": {
          "n1": { "typeName": "ListTrigger", "nextNodeIds": ["n2"], "payload": {} },
          "n2": { "typeName": "SendEmailAction", "nextNodeIds": ["n3"],
                  "payload": { "subject": "Welcome, {{firstName|\"friend\"}}", "contentMd": "Thanks for joining." } },
          "n3": { "typeName": "TimerAction", "nextNodeIds": ["n4"], "payload": { "amount": 3, "unit": "d", "at": "09:00" } },
          "n4": { "typeName": "SendEmailAction", "nextNodeIds": ["n5"],
                  "payload": { "subject": "Three tips", "contentMd": "1. Read the guide." } },
          "n5": { "typeName": "ExitAction", "nextNodeIds": [], "payload": {} } } } }'
curl -X POST https://api.oveyon.com/v1/sequences/12/start \
  -H "Authorization: Bearer $OVEYON_API_KEY"
GET/v1/sequencesscope read:sequences

Newest first, without the graph, with next_cursor. Filters: status and listId; limit up to 100 (default 50).

GET/v1/sequences/:idscope read:sequences

The sequence with its graph (normalized, in the order of the path), the revision and the trigger.

PATCH/v1/sequences/:idscope write:sequences

revision (required) and what changes. Response: the sequence, with the new revision.

DELETE/v1/sequences/:idscope write:sequences

Only one that never had anyone in it (204); one that did is stopped, not deleted (409 invalid_state).

POST/v1/sequences/:id/startscope write:sequences

From draft or paused to active. Checks every email, the list’s postal address and the brand prerequisites: 422 not_ready with problems — each with the code (the same names as campaigns) and the nodeId of the email, when it is an email’s. Up to 5 active or paused sequences with the same trigger (the same list, or the same tag) per account — a paused one also takes whoever enters: starting a 6th (or moving a paused one to a trigger that already has 5) is 409 trigger_limit (with limit) — stop one of them, or use another trigger.

POST/v1/sequences/:id/pausescope write:sequences

Only active. Nobody gets the next email; who is in it stays at the same step, and whoever subscribes meanwhile enters and waits at the start.

POST/v1/sequences/:id/stopscope write:sequences

Stops it for good: whoever was waiting leaves (cancelled says how many). A stopped one does not come back — create another.

GET/v1/sequences/:id/reportscope read:sequences

The funnel, per node, in the order of the path: who reached it (entered), the emails it sent (sent), who left the sequence there (exited) and who waits there now (waiting); and the totals. The counters do not change when a contact is deleted.

HTTP/1.1 200 OK
{
  "id": 12, "status": "active", "revision": 3,
  "nodes": [
    { "nodeId": "n1", "typeName": "ListTrigger", "entered": 240, "sent": 0, "exited": 0, "waiting": 0 },
    { "nodeId": "n2", "typeName": "SendEmailAction", "entered": 240, "sent": 238, "exited": 2, "waiting": 0 },
    { "nodeId": "n3", "typeName": "TimerAction", "entered": 238, "sent": 0, "exited": 0, "waiting": 61 },
    { "nodeId": "n4", "typeName": "SendEmailAction", "entered": 177, "sent": 171, "exited": 6, "waiting": 0 },
    { "nodeId": "n5", "typeName": "ExitAction", "entered": 171, "sent": 0, "exited": 0, "waiting": 0 }
  ],
  "totals": { "entered": 240, "finished": 171, "exited": 8, "inProgress": 61 },
  "exitsByReason": { "unsubscribed": 7, "removed": 1 }
}

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, request_too_complex, bad_request, bad_cursor, bad_date, bad_status, bad_outcome, bad_events, bad_group_by, bad_domain, bad_route, bad_sdk, too_many_attachments
401Invalid/revoked keyunauthorized
403No permission / blockedinsufficient_scope, scope_blocked_by_approval_lock, key_disabled, sending_disabled, removal_blocked, account_suspended, account_unknown, ip_not_allowed, return_path_requires_paid_plan
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, managed_domain_requires_paid_plan, return_path_not_authorized, return_path_managed_domain, 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 unavailable / concurrent writes held the contact, the campaign or the sequence (marketing; from any source, not only this account — nothing changed, retrying is safe)internal_error, injection_failed, record_failed, list_unavailable, ip_rules_unavailable (with Retry-After: 30), return_path_check_unavailable (with Retry-After: 60), contact_busy, campaign_busy, sequence_busy (with Retry-After: 5, also in retryAfter)

ASCII-only addresses

The platform only accepts email addresses without accents: the local part and the domain in ASCII, with no control character. An internationalized domain (IDN) enters only in its punycode form — xn--…, and a valid xn--; written in Unicode, it is refused. Nothing is converted for you: swapping the accented letter for the plain one would send to another mailbox.

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.

API changes

What may change the response of a call that already worked — newest entry first.