HTTP API
Integrate your system with OVEYON without leaving the dashboard. REST over HTTPS, JSON in both directions, API key authentication.
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/v1Quick start
From zero to your first send in three steps.
- 1. Create a key in Credentials. It is shown only once (prefix
ov_…). Store it in a secrets manager. - 2. Verify a domain in Domains — without a verified domain, sending is refused with
422 domain_not_verified. - 3. Send with
POST /send:
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"- Missing, invalid, revoked or expired key →
401 unauthorized. It is always the same response, on purpose: it does not tell anyone whether the key exists or whose it is. - A valid key that was turned off in the dashboard →
403 key_disabled, on any route. The switch is under Credentials → API keys; turning it back on restores the key with the same secret. Do not rotate because of this error — nothing is wrong with the key, it is just turned off; use another key or turn this one back on. - A valid key of a blocked account →
403 account_suspended, and it applies on any route, not just sending. It is not a credential problem: rotating the key or creating another one changes nothing — contact support. Without a valid key for the account, the response is the401above; that is why this403never reveals to anyone that the account exists. - Each key has a name/tag (so you can tell integrations apart) and a set of permissions (scopes). Manage both in Credentials.
- Each key (and each SMTP credential) sends from every domain of the account or only from the selected ones — the choice lives in Credentials, in the credential panel, and applies immediately. A
fromoutside the set →422 domain_not_allowed_for_credential(over SMTP,550 5.7.1): the domain is fine, it is this credential that does not use it. Rotating inherits the choice. - Revoking is immediate — a revoked key stops authenticating on the very next call. Rotating is not. Rotation issues the new key and leaves the previous one still valid for a grace window (default 72 h; the dashboard tells you the exact deadline at the moment you rotate), so your application can swap the secret without going down. This is deliberate: without the grace period, «rotate» would just mean «interrupt», and the practical result would be that nobody ever rotates. The consequence is the one that matters — if the key leaked, rotating does not close the hole: revoke it. Rotate while the secret is still yours alone; revoke once it no longer is.
- Never expose the key in front-end code or in a repository.
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"] }| Scope | Grants | Endpoints |
|---|---|---|
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 dashboard | In 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 < ownerorganization: member < admin < ownerpersonal (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:
- A role does not weaken a key. There is no such thing as a «
viewerkey». A key withsendin the hands of someone who is avieweron the account keeps sending email: the key does not know who is holding it, and never asked. - A role does not strengthen a key. Being
owneradds no scope at all to the key you created. A missing scope is403 insufficient_scope— even for the account owner, even with their own key. That403never means «your role is too low»; it means «this key does not have this scope», and the response names which one. - Removing someone's access to the dashboard does not revoke any key. This is the consequence that bites, so here it is without euphemism: a credential does not record who created it, and so nothing cascades when someone's access is removed. Whoever left still has the secret, and the secret still works on behalf of the account. When you remove a person, revoke the keys that were in their hands — in Credentials, and revoke, do not rotate: rotation keeps the previous key alive through the grace period.
- No
/v1call sees two accounts. There is no parameter that asks for it, no header, no «organization» mode — and a suspended account does not drag its neighbor down:403 account_suspendedis about that key's account, and nothing else. If you operate several accounts, that means several keys, one per account.
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.
/v1/sendscope sendQueues 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 theTo:header and in the envelope.cc— a string or a list. Goes in theCc: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 with400 bad_request, stating the limit and what it received. This also applies to a subject that comes from a template.htmland/ortext— at least one is required.templateId— the numeric id or thenameof a published template. Mutually exclusive withsubject/html/text: with a template, the entire content (subject included) comes from the template — sending both is400 template_conflict.data— a{variable: value}object for rendering the template. Only meaningful together withtemplateId(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 is400 bad_request. In the address headers (Reply-To,Sender,Cc…) the value is read liketo, with groups allowed and every member checked. Headers we control —List-Unsubscribe,X-Report-Abuseand any with theX-Oveyon-prefix — are ignored if sent.idempotencyKey— a string of your own for deduplication; an alternative to theIdempotency-Keyheader. Up to 190 printable ASCII characters without spaces (!to~); an integer works too and is read as its decimal text (1234and"1234"are the same key;0counts as no key); anything else is400bad_request, with nothing consumed. The same key returns the originalid(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 theX-Oveyon-Sandbox: 1header) 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 is400 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 indeliveries[], 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 thefromaddress, 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). TheGET /v1/messages/:uuidof these messages carriesreturnPath: "sender"andbounceTracking: 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 theall. If you already have SPF, add the mechanism to the existing record: one record only, because twov=spf1on 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’sauth=.
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+ 2cc+ 1bccconsumes 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
toandbccbecomes one delivery — and is billed once. The first occurrence wins (tobeforeccbeforebcc); 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
idand would have no way to say «it went to 3 of the 5». - The
idbelongs to the message. The outcome (delivered, bounced) is per recipient and shows up indeliveries[]onGET /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"withfrozen: { reason: "policy", policyId }— accepted and frozen by a sending policy of yours with the actionreter: not delivered, it waits for our support (see the decisions feed). Thestatusstaysaccepted;frozenis the field that tells it apart.200·status: "duplicate"— theidempotencyKeyhad already been used; returns the originalid.
Errors
400bad_request—from/tomissing or invalid, neitherhtmlnortext, areturn_paththat is neither"sender"nor"platform"(an address there never becomes the envelope), anidempotencyKeyoutside the form above;too_many_attachments/bad_attachment.400too_many_recipients— more than 5 addresses acrossto+cc+bcc. Includeslimitandreceived.400bad_recipient— one of the addresses is not valid. Includesfield(to/cc/bcc) and quotes the address in the message. An invalid address is never dropped silently.400withreason: "address_not_ascii"— an address has a character outside ASCII (see ASCII-only addresses). On the recipients theerrorisbad_recipient; onfromand on the address headers ofheaders,bad_request. Includesfieldand does not quote the address.401unauthorized·403insufficient_scope,key_disabled(key turned off in the dashboard — turn it back on or use another one),sending_disabled,account_suspendedoraccount_unknown— the last two are account state, not a limit: do not retry.403return_path_requires_paid_plan—return_path: "sender"on an account without a paid plan (see Return-Path). Nothing is queued: upgrade, or send withreturn_path: "platform".409idempotency_conflict— theidempotencyKeyis already taken by another message of the account: keys are compared ignoring case (Order-AbCandorder-abcare the same key). Nothing was sent; use a different key.409idempotency_in_progress— another request with the sameidempotencyKeyis still in progress (it has not reached the queue yet). Nothing was decided: retry afterRetry-After(5 s) with the same key — you get200 duplicateif that request queued the message, or a normal answer if it failed.413attachments_too_large— attachments above 15 MB;payload_too_large— the whole request body above 25 MB (withlimit, in bytes).503injection_failed— the message did not make it into the queue; the quota is refunded. Retry with the sameidempotencyKey.503return_path_check_unavailable— the message asked for the sender as Return-Path and the DNS of thefromdomain did not answer just now. It is not a refusal: nothing was queued; retry afterRetry-After(60 s).500onPOST /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 anidempotencyKey(that key deduplicates safely); without it, retrying can duplicate the message — check first withGET /v1/messages.422domain_not_verified·invalid_recipient_domain·recipient_suppressed— the last two includerecipient, saying which address caused it. Over SMTP, a suppressed address is refused with550 5.1.1. An address paused for a full mailbox also comes back asrecipient_suppressed, withreason: "mailbox_full"andexpiresAt(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.422domain_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 is550 5.7.1. Releasing it is up to our team.422domain_not_allowed_for_credential— the key is limited to selected domains of the account and thefromdomain 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 is550 5.7.1.422managed_domain_requires_paid_plan— thefromis 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 is550 5.7.1.422return_path_not_authorized— the message goes out with the sender as Return-Path (return_path: "sender", or the key’s default) and thefromdomain does not authorize us yet. It carriesreason,domainand what to publish (includeorrecord). It does not use quota: publish it and send again, or send this message withreturn_path: "platform". Over SMTP the refusal is550 5.7.1.422return_path_managed_domain—return_path: "sender"with thefromon your instant inbox: the domain is the platform’s, and there is nothing to publish. It carriesdomain. Send withreturn_path: "platform"(or without the field).400template_conflict—templateIdtogether withsubject/html/text·400template_var_missing— a variable the template requires is missing fromdata(includesvariable) ·422template_not_foundand the other template errors. None of them consumes quota or burns theidempotencyKey.422unsub_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 includespolicy.422policy_refused— one of your own send policies refused the message. The response includespolicy(the name of the policy that decided) andrecipient(the recipient that matched it).matchedBysays how it matched:recipient(the policy has a recipient condition) ormessage(it only looks at the message — subject, sender, credential or hour — and refuses any recipient). With an hour condition it also carriesretryAfter(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.429quota_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)
- Creating and editing always produce a draft — nothing changes in sending until you publish.
- A published version is immutable: editing creates vN+1 as a draft; vN stays exactly as it was. A template in production never changes out from under you.
- Publishing makes the version the current one for sending. To go back, publish an earlier version again (
{"version": N}) — the pointer moves both ways; the versions themselves never change. - A send can pin
version; a draft is never served, not even by pinning its version.
Syntax — the catalog is closed, and it is a contract
| Block | What 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. |
- Nothing beyond this exists — helpers, partials, comments and the like are refused on save (
400 template_parse_error, naming the tag). The catalog grows by our decision, never by silent acceptance. - Strict by default: a variable that is referenced but missing from
datarefuses the send with400 template_var_missing— never a visible{{gap}}in the inbox. The opt-out is per variable, with| "default". - Data never becomes a template: a value containing
{{othervar}}comes out as literal text — substitution is single-pass, and nothing is re-interpreted. - Caps: 256KB per field on save; on expansion, 1MB of output and 10,000
{{#each}}iterations in total — go over and the send is refused with a named error. - A message sent from a template carries
templateId/templateVersioninGET /v1/messagesand in the message detail — the trail of which template/version produced what.
/v1/templatesscope read:templatesLists the account's templates: id, name, currentVersion (the published one; null = never published), latestVersion and drafts.
/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).
/v1/templatesscope write:templatesCreates 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\" }}."
}'/v1/templates/:refscope write:templatesEdits 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}}).
/v1/templates/:ref/publishscope write:templatesWith 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 '{}'/v1/templates/:refscope write:templatesDeletes 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
400template_invalid(includesfield) ·template_parse_error(includestag) ·template_source_too_large— save-time refusals. ·bad_request— on publish, aversionthat is not an integer ≥ 1.400template_var_missing(includesvariable) — at send time, incomplete data for the template.404template_not_found— does not exist, belongs to another account, has no published version, or the requestedversionis not published. OnPOST /v1/sendthe same condition comes back as422.409template_name_taken·template_no_draft.422template_var_invalid·template_each_not_list(includevariable) ·template_output_too_large·template_too_many_iterations(includelimit) — render refusals at send time. Always branch onerror. ·template_too_much_work(the render exceeded 500,000 visited nodes — loops times body size)
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.
/v1/messagesscope read:messagesLists messages, newest first, with cursor pagination (stable under concurrent inserts).
Query
limit— 1 to 100 (default 25).cursor— thenext_cursorfrom 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» isbouncedorfailed, and answering that withstatusmeans 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.
400 bad_outcome.recipient— exact recipient. Searches all recipients of the send (to,ccandbcc), 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, never403.credential— the key's numeric id.from/to— ISO 8601 range (YYYY-MM-DDor 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.
/v1/statsscope read:statsPrecomputed 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,reasonand/orcredential, comma-separated. Adds thebreakdownkey 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. valueis stable and is what you should branch on: inproviderit's the family (Google,Microsoft…) or the domain itself when it isn't a known provider; inreasonit's the reason code; incredentialit'ss:<id>for an SMTP credential andk:<id>for an API key.labelis text for humans and can change — an API key's name is editable, which is exactly why it isn't thevalue.breakdown=credentialandgroup_by=credentialdon't answer the same question.group_byreturns a daily series and sees only API keys;breakdownreturns the total for the window and also includes SMTP credentials. If you send over SMTP,breakdownis what sees that traffic."value": "-"incredentialis 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.Outrosinprovideris the tail of destinations outside the top 50, summed over the whole window — never a sum of per-day slices.reasoncounts 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
totalsmaller than the sum of the series'sent— thepctvalues are relative to the facet'stotal, 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.
/v1/messages/:uuidscope read:messagesDetail 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 withp=noneand tighten it after reading the reports. - Plain text version. The message carries a
text/plainpart next to the HTML (multipart/alternative): spam filters look at this, and plain text readers do not get an empty message. In the API, sendtextalong withhtml. - 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.
- Links on the sender domain. The links in the body point to the same registrable domain as the From. It can only be checked when the link check is on for the sending credential: that check is what records the hosts in the body.
- 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.
- Bodies are redacted before they are stored. Headers are not stored — not even
Authorization. A field with a secret's name (password,secret,token…) and a value shaped like a key (ov_…,Bearer …) become«redigido»; message bodies and attachments — the file name included — (html,text,content,filename…) become their size, as does the detail of the safety signals (signals[].detail, which can quote text hidden in the email); each template variable (data) keeps its name and loses its value; and a URL whose path can carry a secret becomes just its host and length — the tracked link of a sent message (tracking[].url) and the destination URL you register (channels[].url, and everyurlon the webhook and mailbox routes): on Zapier, Make and the like the path is the secret itself. For the inbound content routes and the two log routes the response is not stored: only its size. On the marketing routes, the request and the query keep only the field names and the ids — the values become{ "omitted": "personal" }— and the success response is not stored. - 32 KB cap per body. Above that the text is cut,
truncatedistrueand what is left may no longer be valid JSON. - What stays out: a call refused before the key is identified (invalid key, per-IP limits) — there is no account to attribute it to —, and the
429of the general per-key limit. A refusal of a good key is logged: switched off (key_disabled), account suspended (account_suspended), IP outside the binding (ip_not_allowed). - Writing is asynchronous: a call shows up here a few seconds after it answers.
/v1/logsscope read:logsLists the calls, newest first, with cursor pagination.
Query
limit— 1 to 100 (default 25).cursor— thenext_cursorof 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.
/v1/logs/:idscope read:logsOne 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.
- This log does not keep tool results — the email read, the list of messages: only their size (
resultBytes). The call a tool makes to the API is in the API logs, redacted like any other (and the response of the content routes, such asGET /v1/inbound/:uid/content, not even there). - Arguments are stored redacted: only the fields of the tool schema (any other field becomes
{ "omitted": "unknown_field" }), under the same rules as the bodies above; 8 KB cap — arguments that do not fit the cap as valid JSON become{ "omitted": "too_large", "bytes": N }, with the size in bytes of the arguments as received, andargumentsTruncatedistrue. - The link with the API logs: each API call a tool made carries the message's
callIdinmcpCallId— the dashboard and the API link one to the other, both ways. - What stays out: a request whose credential could not be identified, as in the API logs (a known key that is refused — switched off, for example — is logged).
/v1/logs/mcpscope read:logsLists the messages, newest first, with cursor pagination.
Query
limit/cursor— as inGET /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). Outsidetools/call,okmeans 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.
/v1/logs/mcp/:idscope read:logsOne 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.
- Authentication is the API key, as a Bearer:
Authorization: Bearer ov_…. The same gate as every route:401without a key,403forkey_disabled,account_suspendedandip_not_allowed,429withRetry-After. - One tool per route. The server lists only the tools the scopes of the key allow (plus
whoamiandcheck_disposable):send_emailisPOST /v1/sendwith the scopesend,list_inboundisGET /v1/inboundwithread:inbound, and so on — the input schema of each tool is the one in this document. - Every tool call is a call to the API, with the same key: scopes, quotas, policies, idempotency and the API logs apply unchanged; in the log they show up as the family
oveyon-mcp(sdk), withmcpCallId. The agent's message — the tool, the app, the redacted arguments — is in the MCP log. - The error reaches the model whole:
error,messageandRetry-Afterin the tool result (isError) — which is what makes an agent stop on a403and wait on a429. - Without a key, by OAuth 2.1: a client that connects by signing in (the custom connector of claude.ai, Claude Desktop and mobile; ChatGPT) gets only the address. The
401points to the resource metadata (resource_metadata, RFC 9728), which points to the issuer (/.well-known/oauth-authorization-serveron the dashboard host); authorization code with PKCES256, dynamic registration or a metadata document (client_id= URL). The person signs in, picks the permissions and allows; the connection becomes a credential of the account (Credentials), and the token is valid only here on the MCP server. The limits are those of a key: each connection has its own (600 req/min), whatever IP the platform calls from, and the discovery without a token and an expired token do not count as authentication failures. On/oauth/token, only an unknown code or refresh token counts, per origin (30/min); above that the answer is429 too_many_requestswithRetry-After, and a valid refresh still gets through with the origin over the limit, within 120 checks per minute. - Nothing is kept between calls: no session (GET and DELETE answer
405);whoamitells the account, the key and the tools; the resourceoveyon://guideis the procedure.
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.
/v1/disposableno authenticationAccepts 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,falseornull(whenunknown).list.updatedAt— when our list was last loaded, andlist.domainshow 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_listedis 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.comdoesn't inherit the verdict ofexample.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.domainsnever 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 —
supportacceptssupport@yourcompany.comand nothing else. Letters, digits and. _ % + -, up to 64 characters, no@. - Catch-all —
*@yourcompany.comaccepts any address on the domain. One per domain. Handy for testing, but it also accepts the junk that scanners send toadmin@,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:inboundscope on the key (see the note right below). - Get notified — the
inbound.receivedwebhook: 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
- One email, several recipients. A message that arrived for
support@andsales@in the same transaction is one message with two items inrecipients. We never flatten that into a single field — if your code reads only the first, it will miss the second. (The webhook mirrors this: the same message becomes two events, one per recipient.) - The copy expires. The
expiresAtfield comes in every response and says until when we keep the original. After that, the message stays listed (subject, sender, attachment manifest), butexpiresAtbecomesnulland the content routes —/content,/raw,/attachments— start answering404. If you need the email forever, download it and keep it on your side. - Every message arrives with a spam score.
spamScore(0-100) is our internal heuristic, computed when the message arrives — the higher it is, the more it looks like spam. Two caveats:nullmeans not computed (a message received before the feature existed, or a failure on our side while computing it), which is not the same as0— zero means «we looked and it's clean». And the score is a signal, not a verdict: we never refuse or discard anything because of it; if you want to filter, where to draw the line is up to you. In the webhook, the same value goes in the body (message.spamScore) and in the POST'sx-spam-scoreheader. - And each mailbox can switch on a real spam filter. Besides
spamScore(our heuristic, on every message), a mailbox can switch on the mailbox spam filter: the rspamd at our receiving edge, which looks at content, link and IP reputation and authentication. It is opt-in per mailbox — off on every mailbox until you switch it on, in the dashboard (Inbound → Mailboxes → the mailbox) or withPATCH /v1/inbound/routes/{id}and{ "spamFilter": true }; a mailbox with the filter off is not even scanned. The message then carriesspamFilter:status(scanned= there is a score;unavailable= the filter did not answer in time and the message was delivered without a score;skipped= not scanned on purpose, andreasonsays why:too_largeorbypass, mail from one of our own domains, on an explicit list, DKIM-signed by that same domain and passing DMARC — subdomains are not on it),score(decimal; negative looks legitimate; common thresholds: 6 is probable spam, 15 is spam),action(what rspamd suggests — we never execute it) andsymbols(the rules that weighed, heaviest first; in the detail and the webhook, not in the paginated list).nullmeans «no mailbox of this message asked for the filter», never «clean». The score belongs to the copy that asked: in a message to two mailboxes,recipients[].spamFilteredsays which one had the filter, and the webhook of the mailbox that did not ask comes withspamFilter: null. The filter only annotates: nothing is refused, held or discarded by it. To act on the score, use an inbound policy withspam_filter.score— for example, «at least 6 →tag», or «at least 15 →hold». - And every message arrives with an agent-safety verdict.
agentSafetyanswers a question none of the fields above answer: does this message carry instructions written for the machine that will read it? It's the difference between «this looks like spam» and «this is trying to give orders to your agent». We detect text hidden from humans but visible to the parser — the Unicode Tags block (invisible copies of ASCII, with no legitimate use in email),display:none, HTML comments, direction overrides — and judge it together with what it TELLS the reader to do. The verdict reads what your agent reads: the subject, the sender including the display name (which reaches you infromName), the body and the text attachments; and it looks for the instruction after stripping everything Unicode declares ignorable (soft hyphen, variation selector, zero width…), so those characters can't break up the sentence or push the instruction out of the analyzed span. A hidden footer with no instruction doesn't trigger it; a hidden instruction does. One signal accuses no instruction:texto_ilegivel, a passage that cannot be normalized safely — too long without a single ASCII character (a very long paragraph of unbroken Chinese or Japanese, for example) or with too many combining marks — and is read in pieces; on its own, it leads at most tosuspicious. The message's links also feed into the same verdict, checked against the Safe Browsing database.verdictisclean,suspiciousordangerous;scoregoes from 0 to 100 (thresholds at 25 and 60). The same two caveats as spamScore apply here:nullmeans not evaluated, which is notclean; and the verdict is a signal, not a refusal — we don't bounce the message back to the sender because of it, and what to do with it is your call. With one exception that you switch on: each mailbox can ask fordangerousmessages to be held instead of delivered. While a message is held it triggers no webhook and no forwarding, shows up in Inbound as «held for review», and comes out of there when you click «Release» — at that moment it's dispatched normally, with the verdict attached. If the mailbox didn't ask for this (the default), the behavior is the one in the paragraph above: we deliver and you decide. The webhook also carries thesignals, with the reason for each point — including the text that was hidden, decoded, so you can see with your own eyes what your agent would read. And the verdict declares what it looked at.agentSafety.coveragecomes with it (list, detail and webhook):bodySampled— the engine saw the head and tail (128 KiB) instead of the whole body, or a text attachment cut at the cap, or an absurd header that we cut before reading (a field over 64 KiB, a block over 512 KiB, or over 5,000 lines in the message header and 1,000 in a part — no real mail comes close), or a message with more than 1,000 parts (we read the first 1,000);sanitized— the text we hand you (body, subject or sender name) had hidden characters removed (Tags block, zero width, direction override; the/rawcopy keeps them, for forensics);unscannedAttachments— how many attachments the engine didn't read because they aren't text (PDF, image, spreadsheet, zip): extract them before handing them to a model. An attached message (.eml) is decoded and goes into the verdict; a text attachment goes in regardless of its label. Thesignalsalso come inGET /v1/inbound/{id}, and?agent_safety=dangerous(orsuspicious,dangerous;none= no verdict yet) filters the inbox by it.coverage: nullis a message from before coverage was recorded. - And if the link is armed LATER, we take it back. A link can be clean when the message arrives and turn malicious six hours later — when the domain gets compromised, or when whoever set up the attack switches on the payload. No check made at arrival solves this, because it judges what the message was. Each mailbox turns re-checking on for itself (it's a setting on the address, and it comes switched off): when it's on, we re-check the links of the messages received in the last 3 days and, if one of them starts showing up on a threat list, we fire the
inbound.retro_flaggedevent on your webhook: «message X, which we delivered as clean, has turned malicious», with the links in question. Three things worth saying: the message stays delivered — we don't recall it or rewrite its body, and its outcome in the history doesn't change; the notice goes out only once per message; and theeventIdis its own, so your dedupe won't mistake the retraction for a repeat of the arrival notice. Unlike the rest of the market, we don't swap the link in your email for one of ours so we can decide at click time — the body goes out as it arrived, and the retraction is a notice, not a hijacking of the link. The price of that choice, stated plainly: whoever already clicked wasn't protected by us, they were informed, and your agent decides what to do with the information. This is the event body:{ "event": "inbound.retro_flagged", "eventId": "9f1c…", // unique per POST — dedupe on it "timestamp": "2026-09-08T11:04:22.117Z", "message": { "id": "a3e1…", "subject": "Invoice 4471", "fromHeader": "fin@customer.com" }, "recipient": { "id": "7c22…", "to": "support@yourdomain.com" }, "retro": { "reason": "link_listed_after_delivery", "message": "One or more links in this message were listed as threats AFTER we delivered it.", "links": [{ "url": "http://…", "threatTypes": ["MALWARE"] }] } }It goes only by webhook: Telegram and forwarding are surfaces for humans to read, and a retraction that arrives as a chat message is noise with no possible action — what consumes this is your agent.
- And if the mailbox holds on the verdict, you find out right away. Mailboxes with «Hold the dangerous ones» switched on don't receive the body of a message with a
dangerousverdict: it's held for human review, and at that same instant you receiveinbound.quarantinedon the webhook (and a notice on Telegram/Slack, if the mailbox has them). We never hold without telling you. The same event goes out when one of your inbound policies asks to hold:reason: "policy"and the rule's name inquarantine.policy(in the security quarantine,policycomes asnull). Holding is per mailbox: in a message to two mailboxes, only the copy of the mailbox that asked for it is held — the other is delivered, the message comes withoutcome: "partial"and each item inrecipientscarries the outcome of its own copy. A mailbox that only forwards to a person holds too: the notice goes by email to the forwarding address (without one, through the account's Telegram; without that, by email to the owners). Replying via API when ALL copies are held returns409 message_quarantineduntil someone releases them; with part of it delivered, the reply goes out from the delivered mailbox.{ "event": "inbound.quarantined", "eventId": "2b7d…", // unique per POST — dedupe on it "timestamp": "2026-09-16T18:40:07.221Z", "message": { "id": "a3e1…", "subject": "Payment release", "fromHeader": "billing@acme-payments.com" }, "recipient": { "id": "7c22…", "to": "support@yourdomain.com" }, "quarantine": { "reason": "agent_safety_dangerous", "heldAt": "2026-09-16T18:40:07.221Z", "expiresAt": null, "agentSafety": { "verdict": "dangerous", "score": 65 }, "policy": null, "release": { "panel": "https://app.oveyon.com/app/recepcao/entregas", "note": "The body is held, not delivered. Reply via API answers 409 message_quarantined until a human releases it in the panel." } } } - Inbound policies: IF this arrives, THEN do that.
/v1/inbound-policies(scopesread:inbound-policiesandwrite:inbound-policies; creating and editing also requireread:inbound, because a rule about content, once active, reveals which messages it matched) stores up to 50 rules per account, evaluated top to bottom for each recipient of every received message.mailboxessays which mailboxes the rule applies to (null= all; a list of ids fromGET /v1/inbound/routes= only these): each recipient is judged by the rules of its own mailbox and by the all-mailbox ones, in the account's single order — a message to support@ and sales@ can be held for one and delivered for the other. If the chosen mailboxes are deleted, the rule applies to none (the list comes back empty), never to all. The conditions see what only inbound sees:agent_safety.verdictandagent_safety.score, the body (body, text or HTML without tags),dmarc/spf/dkim, attachments (attachment.type,attachment.name,attachment.count),size_kb,listed_link, the thread (thread.new,thread.replies), sender (and its domain,sender.domain, whereends_withmeans «the domain or a subdomain of it»), recipient, subject, the score of the mailbox spam filter (spam_filter.score, decimal — empty on a copy whose mailbox did not switch the filter on, and an empty field never matches), hour and day of the week in the account's time zone. Operatorsequals,contains,starts_with,ends_with,matches(a glob with*),regex(subject only: a regular expression run by our own linear-time engine — no backtracking, so no pattern can stall arrival; up to 200 characters; a back-reference such as\1, look-ahead/look-behind and(?…)are refused withregex_unsupported; at most 10 per account,regex_limit),in,at_least,at_most; an empty field (no verdict, for example) never matches. Text (subject, body, attachment name) is compared the way the agent reads it: without the characters Unicode declares ignorable (zero width, soft hyphen, variation selectors, bidi…) and in NFKC — full-widthinvoiceisinvoice, on both sides; a value made only of ignorable characters is empty (empty_value). The body is judged on the head-and-tail sample cut after that cleanup, and every attachment the edge accepts (up to 1,000 parts) is judged. Actions:holdholds for a person to decide (and notifies, like the quarantine),mutestores without dispatching or notifying,restrict_channelsdelivers only to the chosen channels,passends the chain — andtagandnotify(the account's Telegram) annotate and continue. Thenotifynotice is best-effort: it goes out after arrival, with three attempts, and a restart midway can lose it — the feed recordsno_chatornotify_failedwhen that can be known. If the mailbox's security quarantine holds the message, it comes first: thehold/mutepolicy that also matched shows up in the feed with the reasonmailbox_quarantine(the notice is the quarantine's, without the deadline and outside the policy's cap). A policy is born paused: check it in the simulator (POST /v1/inbound-policies/simulate, with the id of a received message or a synthetic message, and an optional draft — it answers per recipient inrecipients) and then/unpause. While the platform switch is off,hold,muteandrestrict_channelsbecome the tagwould_<action>:<name>— you see what the rule would do before it takes effect; the same happens when a policy reaches 200 held messages. And we never hold in the dark: aholdthat can't notify on any channel (no_notice), or arestrict_channelswhose channels aren't attached to the mailbox (no_channel), delivers the message normally, with the tag and the reason in the feed. Every received message carriespolicy(effective action, name and tags) in the list, the detail and the webhook — at message level it's the first mailbox's; each mailbox's is inrecipients[].policyand in that mailbox's webhook; what a policy held you release like the quarantine (dashboard orPOST /v1/inbound/{id}/release). The feed (GET /v1/inbound-policies/decisions, 90 days) says which rule matched which message, for which mailbox (recipient) and with what action in practice. The per-policy cap on held messages counts messages, not copies.POST /v1/inbound-policies { "name": "Suspicious PDF", "action": "hold", "conditions": [ { "field": "agent_safety.verdict", "op": "at_least", "value": "suspicious" }, { "field": "attachment.type", "op": "equals", "value": "application/pdf" } ] } 201 { "id": 12, "position": 3, "paused": true, "version": 1, … } POST /v1/inbound-policies { "name": "DMARC failed: human only", "action": "restrict_channels", "conditions": [ { "field": "dmarc", "op": "equals", "value": "fail" } ], "actionParams": { "channels": [ { "type": "telegram" } ] } } POST /v1/inbound-policies/simulate { "message": "a3e1…", "policyId": 12 } 200 { "holdEnabled": false, "matched": [ { "id": 12, "name": "Suspicious PDF", "action": "hold" } ], "result": { "action": "tag", "policy": "Suspicious PDF", "degradedReason": "hold_disabled", "tags": ["would_hold:Suspicious PDF"] }, "seen": { … } }Without
policyorpolicyId, the simulator evaluates only the active policies — the newly created (paused) one is left out;policyIdevaluates a saved policy even when paused, andpolicyevaluates an unsaved draft (both together is a 400). Simulating against a real received message reads the mailbox: the key also needsread:inbound; every simulation — synthetic or not — counts against the per-key mailbox read limit (120/min by default), and its 429 answersRetry-After: 60: longer than the 30 s the official SDKs wait by default, so they do not retry on their own — the 429 reaches your code, with theRetry-Afterin hand; wait and call again, or raise the SDK's wait cap. The request body of the policy routes is capped at 256 KB (413 payload_too_large, withlimit: 262144). For the same reason as reading the mailbox, the feed only shows subject and sender to keys withread:inbound. On a real received message the simulator re-reads the stored message with the same reader used at arrival — the same body sample, the sender, the subject and the full attachment names — and judges thelisted_linkthat arrival recorded (nullwhen arrival couldn't judge every link; messages from before September 27, 2026 only store the «yes»); it also predictsno_notice/no_channelfrom the message's mailboxes; the thread is read as it is now. Without the copy (retention expired), what was recorded is what counts, and whatever was cut when it was recorded can't match on the lost part. A saved policy the engine can no longer read (policyId) answers409 inbound_policy_unreadable, with the reason inreason: it is being skipped at arrival until it's edited.What the conditions see, precisely:
bodyis judged on the text and on the HTML without tags (it matches if either one matches), after the same cleanup as the verdict (invisible, bidi and tag characters are removed); the lists (recipient,attachment.*) are judged on their first 100 items; an address longer than 320 characters keeps its end (the domain), and on it onlyends_with,containsand patterns starting with*match;listed_linkis null — and doesn't match — when the check didn't judge every link. Calibrate your trust:sender,subjectandthread.*are what the sender says;dmarc/spf/dkim,agent_safety.*andlisted_linkare our own measurements. The dry-run tag is alwayswould_<action>:<name>with the API's action name (would_hold,would_mute,would_restrict_channels): integrators should treat it as the rule's intent. What aholdpolicy held runs on the mailbox retention clock (the deadline comes inquarantine.expiresAtin the notice and shows in the dashboard); if it isn't released by then, the copy is deleted, and releasing or replying start returning410 copy_expired. Thenotifynotice goes to the account's Telegram chats; if none received it, the feed says why (no_chatornotify_failed). - The agent proposes, a person decides (hold). Send
hold: trueonPOST /v1/sendor on/replyand the message is accepted, signed and stored without going out: the 202 answersstatus: "held"with the decision URLs.holdNote(up to 500 characters) is what the agent tells the approver;holdTtlis the deadline in seconds (24 h by default, 7 days at most). The person decides in the portal (Messages → Approvals), in the account's Telegram or through the API:GET /v1/holdslists,POST /v1/messages/{id}/approvesends now without re-signing (and counts toward the warm-up ramp),POST /v1/messages/{id}/rejectdoesn't send and refunds the quota. All with theapprove:holdsscope — the key that sends is not the key that approves. The first decision wins; the second gets409 already_decided. Once the deadline passes, the draft expires and doesn't go out (webhookhold_expired). The four events (held,approved,rejected,hold_expired) are opt-in per endpoint and carry metadata and the URL, never the body. Cap: 500 drafts and 200 MB per account (429 hold_limit). Thereter_para_aprovacaosend policy produces the same draft from a rule of yours — and that draft does not look up the recipients’ DNS at send time (a domain chosen by the agent cannot carry data in the lookup itself): the domain is checked on approval, and one that does not receive mail answers422 invalid_recipient_domainwithrecipient; the draft is closed as rejected bysystem:recipient-domain, nothing goes out and the quota is refunded.POST /v1/send { "from": "sales@yourdomain.com", "to": "customer@acme.com", "subject": "Proposal 4471", "text": "…", "hold": true, "holdTtl": 7200, "holdNote": "Discount above 15% — needs someone from sales" } 202 { "id": "9f2c…", "status": "held", "hold": { "expiresAt": "2026-09-23T16:02:11.000Z", "note": "…", "requestedBy": "api:ovy_a1b2", "approve": "/v1/messages/9f2c…/approve", "reject": "/v1/messages/9f2c…/reject" } } POST /v1/messages/9f2c…/reject { "reason": "wrong amount in the proposal" } 200 { "id": "9f2c…", "status": "rejected", "decidedAt": "…", "decidedBy": "api:ovy_c3d4", "reason": "wrong amount in the proposal" } - Releasing is a human act — through the dashboard or the API.
POST /v1/inbound/{id}/release(scopewrite:inbound, never the one the replying key has) dispatches the message's held copies now, and each one's payload carriesmessage.quarantine: when it was held, why, when and how it was released — and the verdict along with it, because releasing is not absolving. With no body it releases ALL held copies;{ "recipient": "sales@…" }releases only that mailbox's copy (the response says which went out inrecipientsand how many are still held instillHeld).409 not_quarantinedif no copy is held (recipient_not_quarantinedif it's that mailbox's copy that isn't);422 recipient_not_in_messageif the message didn't go to that mailbox;410 copy_expiredif the copy has already expired. The numbers live inGET /v1/inbound/stats(by day or by recipient, up to 92 days): received, evaluated, clean, suspicious, dangerous, held and released — the same ones as the «Agent safety» card in Inbound, counted only once.POST /v1/inbound/a3e1…/release 200 { "id": "a3e1…", "status": "released", "deliveries": 1, "noChannel": false, "releasedAt": "2026-09-16T19:02:11.000Z", "releasedBy": "api:ovy_a1b2" } GET /v1/inbound/stats?group_by=day&from=2026-09-01&to=2026-09-16 200 { "groupBy": "day", "data": [ { "day": "2026-09-16", "received": 41, "evaluated": 41, "clean": 39, "suspicious": 1, "dangerous": 1, "quarantined": 1, "released": 1 }, … ] } - The thread is an object. Every received message carries
thread.id(in the list, the detail and the webhook): messages grouped byIn-Reply-To/Referenceswithin your account, including the replies you sent through OVEYON (portal, Telegram, Slack or API) — when the customer answers your reply, it lands in the same thread. Never by subject.GET /v1/inbound/threadslists threads by last activity (opaque cursor),GET /v1/inbound/threads/{id}returns the received messages in order and the replies sent, andGET /v1/inbound?thread=<id>filters the inbox by one thread.thread: nullis a message from before grouping existed or one that arrival couldn't resolve — never a refusal.GET /v1/inbound/threads/2b7d… { "id": "2b7d…", "subject": "order 4471", "firstAt": "2026-09-16T14:02:11.000Z", "lastAt": "2026-09-16T18:40:07.221Z", "messageCount": 2, "replyCount": 1, "messages": [ { "id": "a3e1…", "subject": "Order 4471", "thread": { "id": "2b7d…" }, … }, { "id": "c9f0…", "subject": "Re: Order 4471", … } ], "replies": [ { "id": "bbcd…", "at": "2026-09-16T15:10:00.000Z", "origin": "api", "from": "support@yourdomain.com", "to": "customer@acme.com", "subject": "Re: Order 4471", "preview": "Hello! Here is the invoice…" } ] } - The agent replies through the API.
POST /v1/inbound/{id}/reply(scopereply:inbound) sends a plain-text reply from the mailbox that received the message to the original's Reply-To/From, withIn-Reply-ToandReferencesfilled in by us — the reply lands in the same conversation on their side and on yours (thread). It counts one quota unit and draws on the warm-up ramp like any mail sent on behalf of the domain; theidreturned is the sent message's, so you can follow it inGET /v1/messages/{id}and in the delivery webhooks.idempotencyKey(or the header; up to 190 printable ASCII characters without spaces, or an integer) returns the same id instead of replying twice, for 24 hours from when the reply was queued; while the first request is still in progress, a retry gets409 idempotency_in_progresswithRetry-After. Loop guard, because the use case is agent versus agent: the reply goes out withAuto-Submitted: auto-replied; replying to automated mail (Auto-Submitted, Precedence bulk/list/junk, null sender) is422 auto_submitted; at most 5 replies per message and 10 automated replies per thread per hour (429). A held message answers409 message_quarantinedwith the score and the release link — releasing is a human act, never a tool for the model. With several mailboxes and only some of the copies held, the reply goes out from the first mailbox whose copy was delivered;frompicks another mailbox of the message (422 from_not_recipientif it didn't receive the message;409 recipient_quarantinedif its copy is held). Your recipient blocklists/allowlists apply here too (422), and so do sending policies, for the two actions that decide whether mail leaves:recusaranswers422 policy_refused, andreter_para_aprovacaoturns the reply into a draft a person approves (status: held) — that is how a key marked «require human approval» holds every reply of an agent. A key limited to selected domains only replies from a mailbox of an allowed domain — outside them,422 domain_not_allowed_for_credential, without consuming theidempotencyKey. The received message's detail carriesreplies[].If the key is in sandbox, the reply is accepted and frozen without delivery, with
status: "sandbox". This takes precedence overholdand approval policies, without creating an approval link. On this endpoint, sandbox comes from the key profile; a request field or header does not set it. If the profile cannot be verified, the API returns503 profile_unavailablewithout sending the reply; retry with the sameidempotencyKey.POST /v1/inbound/a3e1…/reply { "text": "Hello! Invoice 4471 has been reissued and is attached in the portal.", "idempotencyKey": "ticket-8812-r1" } 202 { "id": "bbcd…", "status": "accepted", "from": "support@yourdomain.com", "to": "customer@acme.com", "subject": "Re: Order 4471", "thread": { "id": "2b7d…" }, "url": "/v1/messages/bbcd…" } - And the detail tells you where the message went out — down to the other side's last word.
deliveries[]carries one item per (recipient, channel,kind): webhook, email forwarding, Telegram, Slack. Two facts, two fields, on purpose:statusis our half —deliveredmeans your endpoint answered 2xx, the chat accepted the notice, or the forwarded copy entered our outbound queue; not that the mailbox on the other side has it. For forwarding,destinationis the destination MX's word once the copy reached it:accepted,deferredorbounced, with the raw SMTP response (response), who answered (host) and when (at) — the text you paste into a support ticket with the provider.nullon the other channels, or while it isn't known yet. Read both before saying «it arrived»: adeliveredforward withdestination.status = "bounced"left here and reached no one.kindtells apart what was dispatched for the same pair: the message on arrival (received), the quarantine notice (quarantined) or the retroactive link-check notice (retro). What's left out: the channel's configuration (URL, secret, chat) — it belongs to the channel, andGET /v1/inbound/routes/{id}already serves it, without the secret. Three limits, stated out loud:destinationonly exists when the copy left through one of our sending nodes (the normal route; a copy the master delivers directly, as a fallback, staysnull); the verdict is the synchronous one — an asynchronous bounce (the mailbox accepts and sends back a DSN later) isn't captured here; andacceptedis what the MX said, not what the mailbox shows: Gmail, for example, accepts with 250 and silently discards — no Spam, no Trash — a copy whoseMessage-IDthat account has already seen (duplicate suppression), and forwarding preserves the Message-ID on purpose, because it's what keeps the conversation together and the sender's DKIM signature intact. Measured on September 22, 2026: four copies accepted, none visible, until the Message-ID changed. - Mailboxes can be created through the API.
POST /v1/inbound/routes(scopewrite:inbound) createssupport@yourdomain.com— or the catch-all*@yourdomain.com— on a domain of your account and, in the same call, hooks up the notifications: a new webhook ({ "type": "webhook", "url": … }, with proof of ownership of the URL and the secret returned once) and/or channels that already exist on the domain ({ "channelId": … }).GETlists and shows detail (with the channels, never with the secret),PATCHswitches the mailbox and its spam filter (spamFilter) on/off and adds channels,DELETEremoves the mailbox or, byassocId, just one channel link. A mailbox created by a machine is born without security choices: it delivers everything with the verdict; holding is a choice you switch on in the dashboard. Caps: 200 mailboxes per domain and 1000 per account (422).createdBysays who created it (api:<key prefix>), and the dashboard shows the same.POST /v1/inbound/routes { "domain": "yourdomain.com", "matchType": "exact", "localPart": "support", "channels": [ { "type": "webhook", "url": "https://app.acme.com/hooks/oveyon", "mode": "full" } ] } 201 { "id": 318, "address": "support@yourdomain.com", "active": true, "inboundEnabled": true, "createdBy": "api:ovy_a1b2", "channels": [ { "assocId": 902, "channelId": 77, "type": "webhook", "destination": "https://app.acme.com/hooks/oveyon", "mode": "full", "active": true } ], "webhook": { "url": "https://app.acme.com/hooks/oveyon", "mode": "full", "secret": "…64 hex, shown once…" } } - The instant inbox: an address ready to use, with no DNS. Every account can receive on our domain, under its short account code — account
80001receives at80001.oveyon.app.POST /v1/inbound/instant(scopewrite:inbound) with no body switches the inbox on — the domain and the catch-all*@80001.oveyon.app— and returns the catch-all (201the first time,200after); withlocalPart, it creates an agent's named mailbox,bot@80001.oveyon.app, with its own thread, channels and policies: the same shape, the same caps and the same in-call webhook asPOST /v1/inbound/routes.GET /v1/inbound/instant(scoperead:inbound) tells the address, the mailboxes and the state, without switching anything on. Once created, they are mailboxes like any other —PATCHandDELETE /v1/inbound/routes/{id}, the same verdict, the same quarantine; switch the catch-all off and addresses without a named mailbox are refused at the door again (550). Receiving works on every plan; sending from it, replies included, only on paid plans: on the free plan,/v1/sendand/v1/inbound/{id}/replyanswer422managed_domain_requires_paid_plan(over SMTP,550 5.7.1). Forwarding from it follows the rule of every mailbox: verified destinations only. Each account receives up tolimits.perHourmessages per hour there — above that the MX answers450 4.2.1and the sender retries in the next hour —, and on the free plan received mail is kept for the plan retention (limits.retentionDays). While the instant inbox is not enabled on the platform, both routes answer404not_found, like a route that does not exist. Switching the inbox back on after its receiving was switched off (the switch on the domain page) waits like that switch: at most one switch-on every 5 s per domain and 20 per minute per account — sooner than that,429rate_limitedwithRetry-After(in seconds, also inretryAfter), and nothing changes: receiving stays off. Switching off never waits.POST /v1/inbound/instant { "localPart": "bot", "channels": [ { "type": "webhook", "url": "https://app.acme.com/hooks/bot" } ] } 201 { "id": 512, "address": "bot@80001.oveyon.app", "domain": "80001.oveyon.app", "matchType": "exact", "active": true, "createdBy": "api:ovy_a1b2", "webhook": { "url": "https://app.acme.com/hooks/bot", "mode": "full", "secret": "…64 hex, shown once…" } } GET /v1/inbound/instant 200 { "domain": "80001.oveyon.app", "available": true, "active": true, "catchAll": { "id": 511, "address": "*@80001.oveyon.app", "active": true }, "mailboxes": [ { "id": 512, "address": "bot@80001.oveyon.app", "active": true } ], "sending": { "allowed": false, "reason": "managed_domain_requires_paid_plan" }, "limits": { "perHour": 200, "thisHour": 3, "retentionDays": 3 } } - The new text, without the quoted part. In the webhook (
fullmodes) and inGET /v1/inbound/{id}/content, besidestextyou getreplyText: only what the person wrote this time, without the «On Sep 16, Jane Doe wrote:», the lines starting with>, Outlook's From/Sent/To/Subject block, the original/forwarded message and the--signature. That's what you give a model: the whole conversation is already in the thread, and reprocessing it on every turn costs tokens and causes confusion.replyStrippedsays whether anything was removed andreplyMarkerswhat. With nothing to cut,replyTextequalstext. Portuguese and English; an interleaved reply (your reply between the quoted lines) is kept whole. - This is third-party content. Body, HTML, attachment names and bytes came from the sender, not from us. Treat all of it as hostile data: never inject the
htmlinto your DOM without sanitizing it, and never use an attachment'sfilenameto build a path on disk. - Not every listed message was delivered. Each item carries
outcome:"accepted"when it reached at least one recipient,"quarantined"when all copies are held for review,"partial"when some copies were delivered and others are held (each item inrecipientsstates its own:outcome,heldByandpolicy), or"blocked"when it arrived, was stored and wasn't delivered to anyone — with the reason inblockedReason. Ablockedmessage triggered no webhook, has no recipients (recipientscomes as[]) and was not billed: it doesn't count against your quota. It stays readable through the content routes untilexpiresAt, like any other.- Branch on
outcome, never onblockedReason. The list of reasons grows — today only"inbound_disabled"exists (the domain's inbound was turned off when the message arrived); tomorrow others may appear. Code that enumerates reasons starts answering wrong the day the next reason shows up. Treat anyoutcomeother than"accepted"and"partial"as «not delivered to anyone», andblockedReasonas text for logs and diagnostics. - Existing integrations don't break. Both fields are new and nothing changed type or disappeared. If your code iterates over
recipients, ablockedmessage simply produces no iteration — the correct behavior, without changing a line. - Want only what was delivered?
?outcome=acceptedon the list. Without the parameter the list returns both outcomes, on purpose: filtering by default would hide from you an email that really arrived.
- Branch on
/v1/inboundscope read:inboundLists received messages, newest first, with cursor pagination.
Query
limit— 1 to 100 (default 25).cursor— thenext_cursorfrom the previous page. This route's cursor is of its own type: reusing a/v1/messagescursor here returns400 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 —fromNameis 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 indomain; the filter accepts back what the response showed). A domain that isn't yours returns an empty list, never403: 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—trueorfalse(also accepts1/0andyes/no).outcome— filters by outcome (accepted,blocked). Open vocabulary, for the same reason asdmarc: 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,dangerousornone(= no verdict yet); a comma combines them (suspicious,dangerous). Closed vocabulary: a value outside it is400 bad_agent_safety.from/to— time range of receipt, ISO 8601 (YYYY-MM-DDor 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"
}/v1/inbound/:uidscope read:inboundMessage 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"
}
}/v1/inbound/:uid/contentscope read:inboundThe 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.
/v1/inbound/:uid/rawscope read:inboundThe 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.
/v1/inbound/:uid/attachments/:nscope read:inboundThe 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
404 not_found— the message doesn't exist, or isn't in your account, or the uid is malformed. The response is identical in all three cases, on purpose: telling them apart would tell someone guessing whether their identifier hit.400 bad_cursor/bad_date/bad_domain/bad_dmarc/bad_has_attachments/bad_outcome/bad_agent_safety— a filter in the wrong format.403 insufficient_scope— the key doesn't haveread:inbound.429 rate_limited— on top of the general per-key cap, these routes have their own per-key cap (default 120 req/min): they're the only ones in/v1that read bytes from disk on every call. The response states the limit; honor theretry-after.
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:
- raw body:
res.send(body.challenge) - JSON:
res.json({ challenge: body.challenge })
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.
message.idis the same in all three events — it is the email.recipientandeventIdare different — they are the delivery.- If your code keys by
message.id, it will overwrite two of them. Key byeventId.
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
| Mode | What goes in the POST | Good 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.
degraded—truewhen less went out than you asked for. It is the only field you need to check.requestedModeandmode— what was requested at registration and what actually went out. Asked forfull+attachmentsand gotmode: "full"? The bytes were left out; the manifest and the URLs are still there.reason— a human-readable sentence with everything that was lost, not just the last cause (two problems in the same message become two parts separated by;). It isnullwhen nothing degraded.maxBytes— the cap in effect for this POST.attachmentsInline—trueonly when the bytes came inattachments[].content. It is all or nothing: never half of the attachments embedded.attachmentsListedvs.attachmentCount— how many attachments came in the manifest and how many the message has in total. Different? The tail of the list did not fit; the missing ones are in the API.textTruncated/htmlTruncated— that field came cut (or removed). A detail, not a substitute: when either one istrue,degradedis too.
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:
x-oveyon-event— the event name (inbound.received).x-oveyon-timestamp— epoch in seconds.x-oveyon-signature—sha256=+ HMAC-SHA256 with the channel secret overtimestamp + "." + raw-body.
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).
| Attempt | When | Cumulative since the 1st |
|---|---|---|
1 | as soon as the event enters the queue (the worker runs every 10 s) | — |
2 | 30 seconds after the previous failure | ~30 s |
3 | 2 minutes | ~2.5 min |
4 | 10 minutes | ~12.5 min |
5 | 1 hour | ~1 h 12 min |
6 | 6 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"- Why they do not always come inline: base64 inflates the file by about a third, and a 15 MB attachment would become a ~20 MB POST that we would try up to six times, with a 10 s timeout. Most servers refuse a body that size before even checking the signature — and then the attachment takes down the whole notification, not just itself.
- The
full+attachmentsmode embeds the bytes only as long as the whole POST fits within the cap (256 KB by default; the value for that POST is intruncation.maxBytes). Did not fit? The attachments go by link instead,attachmentsInlinecomes asfalse,reasonexplains, and the URLs remain valid. - It is all or nothing. You never get some attachments embedded and others by link — either the whole batch fits, or none comes with
content. - An attachment's address is its
ord, never itsfilename: two attachments can have the same name, and the name can come empty or with a path inside it.
⚠ 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.
- Never render the
htmlwithout sanitizing it. Injecting it into your DOM, into a helpdesk panel or into an email you resend is XSS on your origin, with your user's session. Run it through a sanitizer (DOMPurify and the like) or use only thetext. - Never use the
filenameto build a path on disk. It can contain../, a slash, a null character or a system file name. That is path traversal served on a platter. Save by theord(or by an id of your own) and keep the original name only as a display label. - Do not trust the
contentTypedeclared by the sender, and do not serve the attachment back as a document on your origin. If you need to offer a download, forceContent-Disposition: attachmentandX-Content-Type-Options: nosniff— that is what we do on our routes. - Read
message.authenticationbefore believing who signed the email.fromHeaderis what the sender declared; SPF, DKIM and DMARC are what could be proven. A flow that triggers something important from an email should requiredmarc: "pass"— without it, the «From: boss@yourcompany.com» can be typed by anyone. - The
fromNameis the easiest field to forge in the entire email. It is the display name the sender wrote — there is no need to register a lookalike domain or break anything: just type it. An email with"fromName": "Bank of America"and"fromHeader": "x@random-domain.top"is the most common phishing there is, and it passes SPF and DKIM without a problem (its domain is legitimate — it just is not the one the name suggests). Display the name if you like, but decide by the address, never by the name.
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.
/v1/suppressionsscope read:suppressionsList 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.
/v1/suppressionsscope write:suppressionsAdds 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" }'/v1/suppressions/:emailscope write:suppressionsRemoves a suppression (email URL-encoded in the path). Response 200 { deleted }.
- Only
manual,hard_bounceandmailbox_fullare removable (lifting early your account’s own full-mailbox pause; a platform-wide one is not in your list). complaintandunsubscribe→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.
/v1/policiesscope read:policiesLists 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.
/v1/policiesscope write:policiesCreates 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.
/v1/policies/:idscope write:policiesRemoves an entry (the id comes from create/list). The change takes effect within seconds on every entry point — API, SMTP and MX.
/v1/credentials/ip-rulesscope read:policies/v1/credentials/ip-rulesscope write:policies/v1/credentials/ip-rules/:idscope write:policiesNetwork 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" }'/v1/credentials/ip-rules/pausescope write:policies/v1/credentials/ip-rules/unpausescope write:policiesPause 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-rulescarriespausedandpausedAton each row — a range listed withpaused: trueis not in effect.unpausemay returnwarning.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:policieson 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
422 recipient_blocked— recipient on your block list; theentryfield names the entry that matched.422 recipient_not_allowed— your allow list is active and the recipient is not on it. The WHOLE call is refused (same contract as suppression).
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
name— up to 120 characters. It is what appears in the refusal and in the history, and it is frozen in the audit trail: renaming the policy does not rewrite what it has already decided.conditions— 1 to 5, and all of them must match (AND). Each one is{ campo, op, valor }. A policy with no condition would match ALL of your messages, which is why an empty list is rejected.action— exactly one, from the catalog below.actionParams— only for actions that declare a parameter. Today that is onlylimitar_hora, which requires{ "max": N }. An extra, missing or unknown parameter is a400.position— the evaluation order, top to bottom. We assign it (creating always puts the policy at the end), and you change it with/reorder.
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
campo | op | valor | Notes |
|---|---|---|---|
destinatario | igual · contem · termina_em | text (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). |
remetente | igual · contem · termina_em | text (up to 320) | The from you wrote, not the rewritten envelope. |
assunto | contem · igual | text (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. |
credencial | e_id | { "tipo": "api"|"smtp", "id": N } | tipo is required: API keys and SMTP credentials have separate id spaces. |
credencial | tier_e | text | E.g. transactional. |
hora | entre | { "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
recusar— the message is not accepted:422 policy_refusedin the API,550 5.7.1in SMTP. It does not consume quota — and not because of a refund: the engine runs before the counter.reter— the message is accepted (202/250) and stays frozen, without going out. Only our support team can release a send-policy hold — there is no route or button for you to release it. It returns no error: the202ofPOST /v1/sendkeepsstatus: "accepted"and carriesfrozen: { reason: "policy", policyId }; the history is in the decisions feed.exigir_footer— makes the unsubscribe footer mandatory. In the API, with more than one recipient in the same call, the send is refused with422 unsub_footer_multi_recipient: the link is per recipient.forcar_transacional— stamps the message as transactional (label and accounting). Today it does not change the outbound IP, because per-tier rotation is turned off on this platform.seguir_fluxo— does nothing and ends policy evaluation. It is the exception you place ABOVE a broader rule. It does not exempt the message from lists, suppression or quota.limitar_hora— see its own section right below.
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.
- Counts per recipient, not per call — the same unit as the quota and the per-credential cap. A send to 5 people uses 5.
- Counts on acceptance, never on the check: a message refused by quota, by rate limit or by an injection failure after the policy does not consume the cap (and in SMTP, whatever is refused after DATA is returned to the counter).
- Known, bounded slack: the check happens once per message and the count is N. With
max: 100, 99 counted and a call with 5 recipients, the call goes through and the bucket ends at 104. Since the cap on recipients per call is 5, the maximum overshoot is 4, once per window — the next call is already blocked. This is deliberate: tightening it would require reserving before acceptance, trading an overshoot of 4 for orphaned reservations. - When exceeded:
429 policy_rate_limitedwithRetry-After(andpolicy,limit,used,retryAfterin the body). In SMTP, a4.7.1that tells the client to try again — never a 5xx, because the window rolls over on its own. - The refusal trail is one row per policy per hour, not one per attempt: a burst against the cap itself would flood the feed without saying anything new.
/v1/send-policiesscope read:send-policiesThe 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.
/v1/send-policies/:idscope read:send-policiesOne policy. A nonexistent id and an id from another account get the same 404 send_policy_not_found.
/v1/send-policiesscope write:send-policiesBorn 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.
/v1/send-policies/:idscope write:send-policiesFull 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.
/v1/send-policies/:id/pausescope write:send-policies/v1/send-policies/:id/unpausescope write:send-policiesTurn a policy on and off without deleting it. Activating is always an explicit step: no policy starts deciding just because it was saved.
/v1/send-policies/reorderscope write:send-policiesBody { "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.
/v1/send-policies/:idscope write:send-policiesRemoves 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.
/v1/send-policies/decisionsscope read:send-policiesWhat 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
422 policy_refused— refused by one of your policies. The body names the policy (policy) and the recipient that matched (recipient), and says how it matched inmatchedBy:recipient(it has a recipient condition) ormessage(it only looks at the message and refuses any recipient). With an hour condition it also carriesretryAfter(ISO 8601): the end of the time window. This holds for/v1/sendand for the inbound reply (POST /v1/inbound/{id}/reply). Does not consume quota.429 policy_rate_limited— the hourly cap of one of your policies was reached. Honor theRetry-After: the window rolls over on its own.422 unsub_footer_multi_recipient— a policy requires the footer and the call has more than one recipient.
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
kind | Range | What the rollup publishes |
|---|---|---|
nps | 0 to 10 (eleven links) | nps = %promoters (9–10) − %detractors (0–6), rounded; plus promotores, detratores, neutros, media and distribuicao. |
csat | 1 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.
- The first vote counts. Clicking a different score later does not silently replace it: the page shows the recorded score and asks for confirmation. That is what protects your number from corporate link scanners, which click all eleven NPS links in order — under a «last vote counts» rule, every recipient behind a scanner would end up with the score of the last link without ever opening the email.
- Bot clicks do not vote. Known image proxies and scanners are recognized and ignored; the page responds to them the same way (nothing that would teach them to disguise themselves), and the human who opens the same link later votes normally.
- The comment is optional and arrives after the score, on the same page. That is why you receive more than one
survey.responseper response — see the webhook below. - Forwarding: the link belongs to the original recipient. If they forward the email, the vote of whoever clicks counts as theirs. That is the honest limitation of the model, and the whole industry lives with it.
- The language is your account’s — the same as the emails the platform sends you: Portuguese for an account from Brazil, English for all others. It applies to the fixed text of the email (the notice that replying does not vote, the two ends of the scale), to the voting page and to the default question. What you write — subject, question, sender name — goes out as you wrote it.
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.
/v1/surveysscope read:surveysAll your surveys. No pagination: the account cap is 50 and the response carries max.
/v1/surveys/:idscope read:surveysThe 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.
/v1/surveysscope write:surveysCreates 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—npsorcsat. 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 a400, never «fell back to the default».brandColor(#rrggbb) andlogoUrl(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.
/v1/surveys/:idscope write:surveysFull 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.
/v1/surveys/:idscope write:surveysDeletes 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.
/v1/surveys/:id/sendscope send:surveysSends 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 is400) — the same key returns the same send (200status: "duplicate") and no second email. Without it, a network retry becomes a second email to the same person — or, worse, an anti-fatigue429against 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).
/v1/surveys/:id/responsesscope read:surveysThe 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.
/v1/webhooksscope manage:webhooksCreates 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.
/v1/webhooksscope manage:webhooksLists your endpoints. The secret is never echoed back (it appears masked).
/v1/webhooks/:idscope manage:webhooksUpdates 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 }'/v1/webhooks/:idscope manage:webhooksRemoves 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).
/v1/domainsscope write:domainsAdds 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 forRetry-After(seconds, also inretryAfter) and call again.
/v1/domainsscope read:domainsLists your domains and their verification status (spf / dkim / dmarc).
/v1/domains/:id/verifyscope write:domainsRuns 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 (a404does not), and a request refused by this limit does not count either. Nothing was checked or stored: wait forRetry-After(seconds, also inretryAfter) 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
- One contact per email address in the account: the address is normalized before matching (lowercase; only ASCII whitespace — space, TAB, LF, VT, FF, CR — is trimmed from the ends), and the same person on three lists is a single contact, with one subscription per list. A Unicode space at either end (NBSP, U+3000…) is not trimmed: the address is refused with
400 invalid_email. The address is ASCII only (see ASCII-only addresses): an internationalized domain (IDN) enters only in punycode —ana@xn--exmple-cua.com;ana@exämple.comis refused with400 invalid_emailandreason: "address_not_ascii". The same holds when finding, tagging, unsubscribing and deleting a contact: the contact exists only in its ASCII form, and that is the only form looked up. - A list belongs to one brand (its sending domain) and has its own sender. Each subscription has its own state:
pending,subscribed(already confirmed) orunsubscribed. - Leaving a list is not a suppression. Whoever unsubscribes from the newsletter keeps receiving your account’s transactional email.
- Whoever left on their own only comes back on their own. They left through the unsubscribe link or the preference center, marked an email as spam, or their address bounced: the API does not subscribe them again —
409 unsubscribed_by_contact,409 complainedor409 bounced. - Properties (
attributes): names in the form^[a-z][a-zA-Z0-9_]{0,63}$, neveremail,firstNameorlastName; the type is registered by the first value (or byPOST /v1/contacts/properties). Limits: 100 names per account (422 too_many_properties), 8 KB per contact (422 attributes_too_large) and 1 KB per value (400 invalid_property_value). - Deleting takes the contact off every list at once; 30 days later their data is erased for good (
purgeAfter). Only a hash of the address stays, so a re-import does not put back on the list someone who had left. Re-creating the same address later starts from zero (201): nothing of the previous contact comes back — name, properties, tags and subscriptions —, and the unsubscribes the person made still hold. - Closed body: an unknown field is
400 bad_requestwithfield(the name only comes back when it looks like a field name: no spaces and no at sign). A contact, list or tag of another account answers the same as a nonexistent one (404). - Concurrent writes: when concurrent writes — from any source, not only this account — hold the contact (or the same new tag) for a few seconds, the write gives up instead of waiting longer —
503 contact_busywithRetry-After(5 s, also inretryAfter). Retrying is safe. - The request log of these routes keeps the field names, the ids and the error; the values (email address, name, properties, consent IP) only show as
{ "omitted": "personal" }.
/v1/contactsscope write:contactsCreates 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
}/v1/contactsscope read:contactsOldest first, with next_cursor. Filters: q (start of the email address), listId (and, with it, status) and tagId; limit up to 100 (default 50).
/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.
/v1/contactsscope write:contacts?email= or ?id= — exactly one. Response 200 { id, deleted, lists, purgeAfter }.
/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.
/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).
/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.
/v1/listsscope read:contactsThe 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).
/v1/lists/:id/subscribescope write:contactsCreates 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 }/v1/lists/:id/formscope read:contactsThe 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).
/v1/lists/:id/formscope write:contactsThe 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.
/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.
- The states:
draft→approved(approving never sends) →scheduled(scheduling is the gesture that sends) →running→done;running⇄paused; any of them →cancelled. Editing an approved or scheduled campaign takes it back todraft(returnedToDraft): an approval is for what was checked. - Approving freezes what the campaign inherits: the version of the template (or of the list layout) that was checked is the one that goes out, even if a newer one is published later — also in a send spread over days. And the list’s sender, reply-to, postal address, language and layout are recorded: if the list changes any of them after the approval, scheduling (or the scheduled campaign, when its time comes) takes the campaign back to
draftand the owner is notified —409 changedwithreturnedToDraft: true. - Check first: the
checkreturnsproblems(empty =ready), the audience and the brand readiness (readiness), item by item, with who fixes each one:account(you),staff(our support team) orautomatic. Approving or scheduling with a problem →422 not_readywith the sameproblems. The audience is checked within the limit of 30 counts per account per minute and a time limit: without an answer, the problem isaudience_check_inconclusive(withaudienceErrorrate_limited,filter_too_sloworbusy, when too many audience checks of the account run at the same time) — never «empty», never «ready»: check again in a minute. Indaily_capacity,capacityandlimitedBycount only the brand’s daily limit and your account’s own caps (domain warmup, quotas, rate), andestimatedDayscomes from them alone: the headroom of the platform’s shared marketing sending capacity is not disclosed (a campaign waiting for it pauses withip_capacityand resumes on its own). - The cost of the content: converting and compiling a campaign that changed — and creating or editing one — spends one unit per 256 KB of content, up to 60 per account per minute; above that,
429 rate_limitedwithRetry-After. A campaign that did not change is checked from a cache and spends nothing. - The brand warms up over days: in the first weeks an edition is spread over days, from the most recent subscribers to the oldest (
estimatedDays). A pause by the bounce or complaint thresholds holds the whole brand, and only our support resumes it:409 paused_for_review. The same thresholds apply to the brand’s campaigns added up over the last 24 hours: the brand is held even if no single campaign goes over them (splitting the audience into small campaigns does not escape it). An edition also respects today’s marketing sending capacity: when it runs out, the campaign waits withpauseReasonip_capacityand resumes by itself when there is room. - The audience filter is TEXT:
audienceFilteris the JSON of the filter inside a string, up to 64 KB — the size is checked before it is read (400 filter_too_large). The whole body is capped at 320 KB (413 payload_too_large). - The preview only goes to the verified email of an active user of the account (
400 preview_recipient_not_allowed; withreason: address_not_asciiwhen the address has a character outside ASCII), up to 20 a day and 60 attempts a day — the refused ones and the ones that do not go out count too (429 preview_limit). - The weekly send: a campaign of
kindmodelis the template of a recurring send — it does not send and is never approved or scheduled. With itsrecurrence(weekdayfrom 1 to 7, Monday = 1;timeHH:MM;leadDaysfrom 1 to 6), in the model’stimezone,activateturns the recurrence on (running): each week the edition is created as adraft(kindedition, withparentIdandeditionDate),leadDaysbefore its time. Nothing goes out without the approval of that edition, and approving the edition already schedules it for its time. One that was not approved by its time becomesskippedand the owner is notified; it only goes out withsendNow, until the next edition (409 edition_supersededafter that). The previous edition is never sent again: if it is still going out when the next one starts, the rest of it is ended (errorCodesupersededin the report).pauseandresumeturn the recurrence off and on;cancelends the model and its draft editions. A pasted AWeber variable ({!firstname_fix}) leaves the campaign not ready (aweber_merge_tag): use the{{firstName}}syntax with a default instead. - HTML body: a campaign takes
contentHtmlinstead ofcontentMd. The HTML goes through a whitelist (p,h1toh4,strong,em,code,pre,a,img,ul,ol,li,blockquote,hr,br) and goes inside the list layout, with the usual footer. A link withclass="ovy-botao"goes out as a button. Scripts, CSS, tables and forms are removed, and the answer says what was removed incontentRemoved. The HTML is stored already clean, and that is what reading returns. One source only: sendnullon the other (400 invalid_content). - The HTML comes back normalized: reading returns the whitelisted
contentHtml, not what was sent.bbecomesstrong,ibecomesem,divbecomesp,h5andh6becomeh4; repeated whitespace becomes one space, and the whitespace at the edge of a link, bold or italic moves outside it (bold inside bold becomes one);{,}and'come out as entities ({,},'), like&,<and>. In a variable tag, the default comes back with&,<and>as entities:{{firstName|"Tom & Jerry"}}comes back as{{firstName|"Tom & Jerry"}}, and the email shows “Tom & Jerry”. Sending the value back as it came gives the same result. - Concurrent writes: when concurrent writes — from any source, not only this account — hold the campaign for a few seconds, the change gives up instead of waiting longer —
503 campaign_busywithRetry-After(5 s, also inretryAfter). Nothing changed; retrying is safe. - A campaign of another account answers the same as a nonexistent one (
404 campaign_not_found). The request log of these routes keeps the ids, the states and the schedule; the subject, the content, the name, the filter and the previewtoonly show as{ "omitted": "personal" }.
/v1/campaignsscope write:campaignsCreates 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\"}]}" }'/v1/campaignsscope read:campaignsNewest first, without the content, with next_cursor. Filters: status, listId, kind and parentId (the editions of a model); limit up to 100 (default 50).
/v1/campaigns/:idscope read:campaignsThe whole campaign, with contentMd, contentHtml and audienceFilter.
/v1/campaigns/:idscope write:campaignsOnly 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.
/v1/campaigns/:idscope send:campaignsOnly 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.
/v1/campaigns/:id/checkscope read:campaignsChanges 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 } }
]
}/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 }.
/v1/campaigns/:id/rehearsescope write:campaignsEverything 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.
/v1/campaigns/:id/approvescope send:campaignsOnly 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.
/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" }'/v1/campaigns/:id/activatescope send:campaignsOnly 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.
/v1/campaigns/:id/pausescope write:campaignsOnly running; what is in flight finishes.
/v1/campaigns/:id/resumescope send:campaignsOnly 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.
/v1/campaigns/:id/cancelscope send:campaignsFrom 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.
/v1/campaigns/:id/reportscope read:campaignsSent, 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.
- The graph: the Loops format —
rootId,nodeDataByIdand, on each node,typeName,nextNodeIdsandpayload. The first version is linear: the root is the trigger (ListTrigger: someone subscribed to the list or confirmed the subscription;TagTrigger: someone subscribed to the list got the tag), thenSendEmailActionandTimerActionin any order, andExitActionat the end — always right after an email. Up to 50 nodes.BranchNode,AudienceFilterandExperimentBranchNodedo not exist yet:400 unsupported_node. A wrong graph is400 invalid_graphwith thefield(the path of the node) and thereason. TherootIddoes not change after the sequence is created: a graph with another root is400 invalid_graphwith thereasonroot_changed, and the message says the stored root. - The waits:
amountofunitm,hord, up to a year; withat(HH:MM, days only) the wait ends at that time in the sequence’stimezone— «3 days at 09:00» is the 3rd calendar day at 09:00 there, also across daylight saving changes. - An import never triggers it: not the AWeber import, not a subscription with the import method, not a tag the import applies. Paused: whoever enters waits. Stopped: nobody else enters. Whoever subscribes while the sequence is paused enters and waits at the start: when it is started again they go on — and the waits only start counting then.
reEligiblelets someone who finished enter again on the next trigger — never while still in it. - The states:
draft→active(startchecks everything and turns it on: it is the gesture that sends) ⇄paused; any of them →stopped(whoever was waiting leaves). Starting freezes the list layout it checked and the sender: if the list sender or reply-to changes later, the sequence pauses (sender_changed) and the owner is notified. Sending that refuses the account or the content also pauses the sequence (pauseReason). - Editing needs the
revisionyou read (another one →409 changedwith the current one) and the sequence off (draftorpaused; while active only thenamechanges). Whoever waits at a step the edit removed leaves the sequence (revision) when it runs again. - Email body: the
payloadof aSendEmailActiontakescontentMdorcontentHtml, never both, with the whitelist of campaigns, and comes back normalized like theirs. In HTML,contentRemovedin the answer says what was removed. An email written in Markdown has nocontentHtmlfield. - Concurrent writes: when concurrent writes — from any source, not only this account — hold the sequence for a few seconds, the change gives up instead of waiting longer —
503 sequence_busywithRetry-After(5 s, also inretryAfter). Nothing changed; retrying is safe. - Up to 100 live sequences per account and 50 created per day (UTC):
422 sequence_limit. A sequence of another account answers the same as a nonexistent one (404 sequence_not_found). The request log of these routes keeps the ids, the states and the revision; the name and the graph only show as{ "omitted": "personal" }.
/v1/sequencesscope write:sequencesCreates 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"/v1/sequencesscope read:sequencesNewest first, without the graph, with next_cursor. Filters: status and listId; limit up to 100 (default 50).
/v1/sequences/:idscope read:sequencesThe sequence with its graph (normalized, in the order of the path), the revision and the trigger.
/v1/sequences/:idscope write:sequencesrevision (required) and what changes. Response: the sequence, with the new revision.
/v1/sequences/:idscope write:sequencesOnly one that never had anyone in it (204); one that did is stopped, not deleted (409 invalid_state).
/v1/sequences/:id/startscope write:sequencesFrom 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.
/v1/sequences/:id/pausescope write:sequencesOnly 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.
/v1/sequences/:id/stopscope write:sequencesStops it for good: whoever was waiting leaves (cancelled says how many). A stopped one does not come back — create another.
/v1/sequences/:id/reportscope read:sequencesThe 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.
| Status | Meaning | Typical error |
|---|---|---|
200 | OK / idempotent duplicate | duplicate |
201 | Created | — |
202 | Accepted (spooled for sending) | — |
400 | Malformed request | invalid_json, request_too_complex, bad_request, bad_cursor, bad_date, bad_status, bad_outcome, bad_events, bad_group_by, bad_domain, bad_route, bad_sdk, too_many_attachments |
401 | Invalid/revoked key | unauthorized |
403 | No permission / blocked | insufficient_scope, scope_blocked_by_approval_lock, key_disabled, sending_disabled, removal_blocked, account_suspended, account_unknown, ip_not_allowed, return_path_requires_paid_plan |
404 | Not found / out of scope / route that does not exist | not_found |
409 | Conflict | domain_exists, domain_taken |
413 | Payload too large | payload_too_large (with limit), attachments_too_large, body_too_long |
415 | Unsupported content type | unsupported_media_type |
422 | Business rule | domain_not_verified, domain_not_allowed_for_credential, managed_domain_requires_paid_plan, return_path_not_authorized, return_path_managed_domain, invalid_recipient_domain, recipient_suppressed, unsafe_url, policy_refused |
429 | Limit reached | rate_limited, too_many_auth_failures, quota_exceeded, service_quota_exceeded, warmup_cap_reached, queue_full |
500 / 503 | Internal 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.
- On the API, every address input refuses with
400,reason: "address_not_ascii"and thefieldit came in. Theerroris the family of the field:bad_recipienton the recipients ofPOST /v1/send,invalid_emailon contacts andbad_requeston the others (thefrom, the address headers, suppressions, surveys, outbound allow/block lists and sending policies). The message does not repeat the address. Branch on thereason. - When replying to a received message (
POST /v1/inbound/:uid/reply), a receiving mailbox or an original sender outside ASCII become409 no_mailboxor422 no_reply_address, with the samereason. - On SMTP submission, a
MAIL FROMorRCPT TOoutside ASCII gets553 5.6.7; theFromand the address headers of the DATA (Reply-To,Sender,To,Cc…) outside ASCII get550 5.6.7— the message is neither signed nor queued. - On imports from the Migration Center, a row with an address outside ASCII is skipped and counted in the report — a suppression with its own reason, an AWeber contact among the invalid addresses; the rest of the batch comes in. The exception is an exit (unsubscribe, complaint or bounce) whose part before the
@is ASCII and whose international domain is written with accents: it comes in as thexn--form of the same domain, so it keeps applying, and the report counts it separately. - Unambiguous addresses. In the address fields of
POST /v1/send—to,cc,bcc,fromand the address headers inheaders— the address must be written in full:"a@b.com"or"Name <a@b.com>", one per item (a list, or comma-separated). What a mail reader could read two ways is400: two addresses separated by a space, a name without<…>, a comment after a bare address (a@b.com (Name)), a group (Name: …;) into/cc/bcc, an item with no address in a list, and an@(or the full-width@) in the display name. A Unicode space next to the address (NBSP, U+3000…) isreason: "address_not_ascii". Theerrorisbad_recipienton the recipients andbad_requeston thefromand the headers. Nothing is rewritten for you: the ambiguous form is refused whole. - What does not change: the display name may have accents (
"Zoë" <zoe@example.com>); inbound keeps accepting senders with accents, and the inbound allow/block list keeps accepting an accented pattern to block them.
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:
400 invalid_json— withContent-Type: application/json, the body must be valid JSON and cannot be empty. If there is no body, don't send the header.400 request_too_complex— the structure of the body exceeded a limit (withlimit): a list with more than 10,000 items, more than 60,000 values in total (list items and object fields), more than 64 levels of nesting or a single number or literal longer than 32 characters. Text does not count — a large attachment is a single value. Split the work into more than one call.413 payload_too_large— the body exceeded that route's limit, reported inlimit(in bytes): 25 MB in general, 256 KB on the policy routes. The key is validated before the body is read: without a valid key the answer is 401 (or 403) and the body is not read.415 unsupported_media_type— send the body asapplication/json(form and plain-text bodies are not accepted).500 internal_error— a failure on our side. The detail stays in our logs, never in the body. Retry reads; retry a write only if it is idempotent or carries an idempotency key.503 ip_rules_unavailable— the key has IP ranges bound to it and the check could not be performed right now. This is caution, not a denial: retry afterRetry-After.404 not_found— also for a route or method that does not exist; without a key, the response is401.
Rate limit, quota and warm-up ramp
- Per key — with a valid credential, the general limit belongs to the key (or to the MCP OAuth connection): 600 req/min by default, whatever IP the call comes from. Exceeded →
429 rate_limitedwith theRetry-After: 60header. Wait and retry. - Per IP — without a valid credential (no
Authorization, or an unknown, disabled or revoked key), the limit belongs to the IP: 600 req/min by default. Credential failures — only an unknown or malformed key or token — have their own limit of 20/min per IP (per/48in IPv6):429 too_many_auth_failures. A call withoutAuthorization, an expired or revoked OAuth token and a disabled key do not count as failures. A valid key coming from an IP over the limit still gets through: an IP over the limit has 120 credential checks per minute, and only above them the answer is429 too_many_auth_failures, until the minute turns, for every credential not validated in the last 5 minutes (one that was keeps getting through). Anything without the shape of one of our keys (ov_+ 48 lowercase hexadecimal characters) counts as a failure without being checked. Exceeded →429with theRetry-After: 60header. Wait and retry. - Tenant quota —
429 quota_exceededwithwindow(diária|mensal— Portuguese for daily|monthly, returned as is),used/limitandretryAfterin the body. Withoutwindow,quota_exceededis the account's per-minute limit: slow down and retry. Per-service quota (plans) —service_quota_exceeded, withused/quota/effectiveCapin the body. - Blocked account —
403 account_suspended(suspended account) and403 account_unknown(account not found). These are not limits and do not clear on their own: they are403, not429, precisely so that your backoff does not retry — no amount of waiting fixes them. Contact support. - Warm-up ramp — a daily cap per domain while its reputation warms up:
warmup_cap_reached. Retry later. - Backpressure — system saturated:
queue_full. Back off exponentially and retry.
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.
- 2026-11-03 — SPF records over 4096 bytes are no longer evaluated. On
POST /v1/domains/:id/verify, a record in the SPF chain (the domain’s own, or an include or redirect target) over 4096 bytes is no longer read: the SPF does not count as verified,spfProblem.codeisrecord_too_largeanddetail.spfsays which record to shorten (see the route). Before, it was evaluated in full and could pass. - 2026-11-03 —
this_outside_loopandloop_never_repeatsin the campaign and sequence checks. InPOST /v1/campaigns/:id/check,problemsgained two codes.this_outside_loop: the content uses{{this}}or{{this.nome}}outside a{{#each}}— there is no item there: without a default, the send skipped whoever reached that part; with one, every contact got the default. Use{{nome}}(the engine does not read{{this.nome}}even inside a loop: there it is{{nome}}too).loop_never_repeats: the content (or the template) has a{{#each}}— contact fields are never lists, and the send skipped every contact that had the field (a contact without it got that part empty): remove the loop. Approving or scheduling with either of them gives422 not_ready, and the same codes keep a sequence from starting (POST /v1/sequences/:id/start). - 2026-11-03 — a per-account limit on domain checks.
POST /v1/domains/:id/verifynow answers429 rate_limitedabove 60 checks per minute on the account, counted together with the dashboard’s (see the route). Before, only the key’s general limit applied. With the429nothing is checked: wait forRetry-After(also inretryAfter) and call again. - 2026-11-03 — the subject of the campaign test email is now prefixed “[Test]”. On
POST /v1/campaigns/:id/preview, the subject prefix was[Preview](English list) and[Prévia](Portuguese); it is now[Test]and[Teste](see the route). The route, the body, the response and the codes (preview_limit…) do not change: only the subject text does. If you filter the test email by the old prefix, update the filter. - 2026-11-02 — the domain’s SPF with
optional: trueonPOST /v1/domains. In the default mode, the domain’s SPF record now comes withoptional: true, like the tracking CNAME: mail sent through us passes SPF through our return address (see Add a domain). With the sender itself as the Return-Path (paid plan), it still comes withoutoptional. The dashboard shows the same: «Recommended» in one mode, «Required» in the other. - 2026-10-20 — Sequences in the API (marketing, experimental). New routes under
/v1/sequencesand two new scopes:read:sequencesandwrite:sequences(see Sequences). No existing key gets these scopes — not even one with legacy scopes. With the human-approval lock on the key,write:sequencesanswers403 scope_blocked_by_approval_lock, like the other scopes that would undo it. - 2026-10-19 — Campaigns in the API (marketing, experimental). New routes under
/v1/campaignsand three new scopes:read:campaigns,write:campaignsandsend:campaigns(see Campaigns). No existing key gets these scopes — not even one with legacy scopes. With the human-approval lock on the key,write:campaignsandsend:campaignsanswer403 scope_blocked_by_approval_lock, like the other scopes that would undo it. - 2026-10-19 — an ambiguous address on
POST /v1/sendis now400. What the address reader used to rewrite silently is now refused (see Unambiguous addresses): into/cc/bcc, in thefromand in the address headers ofheaders, two addresses separated by a space (before, one became the name and only the other received), a name without<…>and a comment after the address, a group into/cc/bcc(before, its members vanished from the envelope), an item with no address in a list (before, dropped), an@in the display name, and a Unicode space next to the address (before, an NBSP inside the local part sent to another mailbox). A Unicode space at the ends ofto/cc/bccis no longer trimmed: it isaddress_not_ascii, as on/v1/suppressions. A value inheadersmust be text or a list of texts: an object is400. Also: imports store an exit with an international domain written in Unicode in itsxn--form, and SMTP submission refuses aFromor an address header of the DATA outside ASCII with550 5.6.7. Addresses written as"a@b.com"or"Name <a@b.com>"do not change. - 2026-10-18 — ASCII-only addresses. Every address input now refuses characters outside ASCII with
400andreason: "address_not_ascii"(see ASCII-only addresses). What changes for existing callers: a recipient with accents onPOST /v1/send, accepted before, is now400 bad_recipient; the internationalized domain of a contact (POST /v1/contacts), converted to punycode before, now has to arrive already asxn--— and finding, tagging, unsubscribing or deleting it by the Unicode spelling (GET /v1/contacts/findand the other lookups by email), which used to find the converted contact, is now400 invalid_emailwith the samereason; and SMTP submission refuses an accentedMAIL FROM/RCPT TOwith553 5.6.7. ASCII addresses do not change. - 2026-10-15 —
detectedTypeandtypeMismatchin the attachment manifest. Each item ofattachments— inGET /v1/inbound/:uidand in theinbound.receivedwebhook payload — gaineddetectedType(the type read from the first bytes of the file;null= no recognized signature) andtypeMismatch(truewhen the sender's label and the content belong to different families). If you check the response against a closed schema, accept the new fields. Messages received before this date come withnullandfalse. See Attachments. - 2026-10-14 —
mcpCallIdin the API logs. Each item ofGET /v1/logsandGET /v1/logs/:idgained the fieldmcpCallId(nulloutside the MCP server), and there are two new routes:GET /v1/logs/mcpandGET /v1/logs/mcp/:id(MCP log). If you check the response against a closed schema, accept the new field. In the stored bodies, attachment file names and the detail of safety signals now show only by their size. - 2026-10-13 —
return_pathonPOST /v1/send. The field now exists and only accepts"sender"and"platform"(see Return-Path). It used to be ignored, like any field the route does not know; now any other value answers400 bad_requestand nothing is sent — an email address included. Coming from SparkPost, wherereturn_pathis the envelope address: drop the field (the platform return address is the default) or, if the address was the sender itself, use"sender"(paid plans, with the include in the domain's SPF).