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):
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
|
read:policies |
Read policy lists and IP rules |
GET /policies · GET /credentials/ip-rules
|
write:policies |
Create and remove policy lists and IP rules |
POST /policies · DELETE /policies/:id · POST /credentials/ip-rules · DELETE /credentials/ip-rules/:id · POST /credentials/ip-rules/pause · POST /credentials/ip-rules/unpause
|
read:templates |
Read email templates |
GET /templates · GET /templates/:ref
|
write:templates |
Create, edit, publish and remove email templates |
POST /templates · PUT /templates/:ref · POST /templates/:ref/publish · DELETE /templates/:ref
|
read:send-policies |
Read sending policies |
GET /send-policies · GET /send-policies/decisions · GET /send-policies/:id
|
write:send-policies |
Create, edit, reorder and remove sending policies |
POST /send-policies · PUT /send-policies/:id · POST /send-policies/:id/pause · POST /send-policies/:id/unpause · POST /send-policies/reorder · DELETE /send-policies/:id
|
read:surveys |
Read surveys and responses |
GET /surveys · GET /surveys/:id · GET /surveys/:id/responses
|
write:surveys |
Create, edit and remove surveys |
POST /surveys · PUT /surveys/:id · DELETE /surveys/:id
|
send:surveys |
Send surveys by email |
POST /surveys/:id/send
|
reply:inbound |
Reply to received emails (inbound) |
POST /inbound/:uid/reply
|
write:inbound |
Create and change inbound mailboxes (routes and channels) |
POST /inbound/:uid/release · GET /inbound/routes · POST /inbound/routes · GET /inbound/routes/:id · PATCH /inbound/routes/:id · DELETE /inbound/routes/:id · DELETE /inbound/routes/:id/channels/:assocId
|
approve:holds |
Approve and reject drafts awaiting approval |
GET /holds · POST /messages/:uuid/approve · POST /messages/:uuid/reject
|
read:inbound-policies |
Read inbound policies |
GET /inbound-policies · GET /inbound-policies/decisions · GET /inbound-policies/:id · POST /inbound-policies/simulate
|
write:inbound-policies |
Create, edit, reorder and remove inbound policies (creating and editing also require «Read received emails») |
POST /inbound-policies · PUT /inbound-policies/:id · POST /inbound-policies/:id/pause · POST /inbound-policies/:id/unpause · POST /inbound-policies/reorder · DELETE /inbound-policies/:id
|
read:logs |
Read the API request log |
GET /logs · GET /logs/:id
|
Left out of the table, on purpose: /disposable · /openapi.json · /ping requires no authentication — no key, no scope, no quota. Do not look for a checkbox for it in Credentials: there is nothing to grant. It is the only API route like this.
Dashboard roles are not key permissions
This is the confusion that shows up as soon as an account has more than one person on it, and it always errs in the same direction — assuming the key is weaker than it is. So, to spell it out: the dashboard and the key are two permission systems that do not touch each other. Neither one consults the other.
| In the 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.to(required) — a string or a list of 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). 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. The same key never produces two messages.attachments— a list of{ filename, content (base64), contentType? }. Up to 20 attachments, totaling ≤ 15 MB (after base64 decoding).sandbox—true(or theX-Oveyon-Sandbox: 1header) accepts and freezes the message without delivering it.
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).200·status: "duplicate"— theidempotencyKeyhad already been used; returns the originalid.
Errors
400bad_request—from/tomissing or invalid, neitherhtmlnortext;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.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.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.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.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.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). 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": "{{firstName}}, your order has arrived",
"html": "<p>Hello {{firstName}}, use coupon {{ coupon | \"SAVE10\" }}.</p>",
"text": "Hello {{firstName}}, 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": "{{firstName}}, your order has arrived",
"html": "<p>Hello {{firstName}}, use coupon {{ coupon | \"SAVE10\" }}.</p>",
"text": "Hello {{firstName}}, use coupon {{ coupon | \"SAVE10\" }}."
}'curl -X POST https://api.oveyon.com/v1/templates/orderArrived/publish \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" -d '{}'curl -X POST https://api.oveyon.com/v1/send \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Store <sales@yourcompany.com>",
"to": "customer@example.com",
"templateId": "orderArrived",
"data": { "firstName": "Ana" }
}'Errors
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.
API logs
Every authenticated call your account's keys make to the API is logged for 90 days: the route, the status, the duration, the IP, the user-agent and the request and response bodies. It is the data behind the API logs screen of the dashboard, which also explains each error and prepares the text to paste into an AI.
- 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 (html,text,content…) become their size, and each template variable (data) keeps its name and loses its value. For the inbound content routes and the two log routes the response is not stored: only its size. - 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. 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
}
],
"next_cursor": "bDoxNzU5OTMyMjA3NDgxLjQ4MjEz"
}messageId links the call to the message it created or read — the same id as GET /v1/messages/:uuid. Send a User-Agent that says who is calling (my-app/1.4): it is what sdk and the dashboard filter use to tell your integrations apart.
/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,
"query": null,
"request": {
"body": "{\"from\":\"app@example.com\",\"to\":\"customer@example.com\",\"html\":{\"omitted\":\"content\",\"length\":5120},\"headers\":{\"X-Api-Key\":\"«redigido»\"}}",
"bytes": 5302,
"truncated": false
},
"response": {
"body": "{\"error\":\"insufficient_scope\",\"required\":\"send\",\"have\":[\"read:messages\"]}",
"bytes": 74,
"truncated": false
}
}Disposable domain
Does an address's domain belong to a disposable/temporary email service (Mailinator, 10minutemail and the like)? Use it at sign-up, before accepting an email address that will never be read twice.
This is the only API route that requires no authentication. No Authorization, no key, no account: the data is a public list and nothing of yours is involved in the response. It doesn't consume quota either. In exchange, the cap is per IP and it's tight — see the end of this section.
/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 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. 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, recipient, subject, hour and day of the week in the account's time zone. Operatorsequals,contains,starts_with,ends_with,matches(a glob with*— never a regex),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.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) returns the same id instead of replying twice. 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). 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[].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 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 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 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).
"attachments": [
{ "ord": 1, "filename": "quote.pdf", "contentType": "application/pdf",
"sizeBytes": 14233, "sha256": "9f2c…",
"url": "/v1/inbound/b71e0c34-…/attachments/1" },
{ "ord": 2, "filename": "logo.png", "contentType": "image/png",
"sizeBytes": 3180, "sha256": "0ab7…",
"url": "/v1/inbound/b71e0c34-…/attachments/2" }
],
// WHERE IT WENT OUT, AND WHAT HAPPENED. One item per (recipient, channel,
// kind — the arrival, the quarantine notice or the retroactive link-check notice).
// `status` is OUR half: `delivered` = your endpoint answered 2xx, the
// chat accepted it, or the forwarded copy entered our outbound queue — not
// that the mailbox on the other end has it. On a forward, `destination` is the verdict
// of the destination MX (accepted | deferred | bounced) with the raw SMTP
// response; `null` on the other channels or while it is not known yet. Read both
// before saying «it arrived».
"deliveries": [
{ "id": "d999cc6d-…", "recipient": { "id": "d83ea519-…", "to": "support@yourcompany.com" },
"channel": "webhook", "kind": "received", "status": "delivered", "detail": "HTTP 200", "attempts": 1,
"createdAt": "2026-09-04T11:02:41.515Z", "completedAt": "2026-09-04T11:02:42.101Z",
"destination": null },
{ "id": "ca0e76d8-…", "recipient": { "id": "d83ea519-…", "to": "support@yourcompany.com" },
"channel": "forward", "kind": "received", "status": "delivered",
"detail": "forwarded to inbox@gmail.com (accepted by the outbound queue)", "attempts": 1,
"createdAt": "2026-09-04T11:02:41.518Z", "completedAt": "2026-09-04T11:02:48.079Z",
"destination": { "status": "accepted", "host": "gmail-smtp-in.l.google.com",
"response": "250 2.0.0 OK 1790113210 d9443c01a7336… - gsmtp",
"at": "2026-09-04T11:02:50.428Z" } }
],
"links": {
"self": "/v1/inbound/b71e0c34-…",
"content": "/v1/inbound/b71e0c34-…/content",
"raw": "/v1/inbound/b71e0c34-…/raw"
}
}/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 per-IP cap, these routes have a 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,
"agentSafety": { "verdict": "clean", "score": 0, "signals": [],
"coverage": { "bodySampled": false, "sanitized": false, "unscannedAttachments": 1 } },
"url": "https://api.oveyon.com/v1/inbound/b71e0c34-…",
"rawUrl": "https://api.oveyon.com/v1/inbound/b71e0c34-…/raw"
},
// WHO this copy is for — an OBJECT, not a string. An email that
// arrived for support@ AND sales@ produces TWO events: same `message.id`,
// different `recipient` and `eventId`.
"recipient": { "id": "4d2f77a1-…", "to": "support@yourcompany.com" },
// MANIFEST, at the TOP of the body (not inside `message`): name, type,
// size, sha256 and the URL to fetch the bytes from. It comes in ALL
// modes, including `summary`. The address of an attachment is its `ord`, NEVER
// its `filename`.
"attachments": [
{ "ord": 1, "filename": "quote.pdf", "contentType": "application/pdf",
"sizeBytes": 14233, "sha256": "e3b0c442…",
"url": "https://api.oveyon.com/v1/inbound/b71e0c34-…/attachments/1" }
],
// ALWAYS present — even when nothing was cut. Always check it.
"truncation": {
"degraded": false, // did you get less than you asked for?
"requestedMode": "full", // the mode configured on the channel
"mode": "full", // the mode that actually went out
"reason": null, // readable text when it degraded; null when not
"maxBytes": 262144, // the cap in force for THIS POST
"attachmentsInline": false, // did the bytes come embedded in `content`?
"attachmentsListed": 1, // how many attachments fit in the manifest
"attachmentCount": 1, // how many the message has in total
"textTruncated": false,
"htmlTruncated": false
},
// In `full` and `full+attachments` modes, and also at the TOP of the body. They
// are `null` when the message has no such part; in `summary` mode the two
// keys simply do not exist.
"text": "Good morning, please find the approved quote attached…",
"html": "<p>Good morning, please find the approved quote attached…</p>"
}Three modes — you choose how much volume goes into the notification
| 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",
"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(payload) {
// INSERT with a unique key on eventId: whoever loses the race already knows
// it is a duplicate, without relying on a SELECT-before-INSERT (which is racy).
const isNew = await markAsSeen(payload.eventId);
if (!isNew) return; // already processed — answer 2xx and move on
if (payload.truncation.degraded) {
// The notification was trimmed: fetch what is missing from the API, by message.id.
await fetchFromApi(payload.message.id);
}
await save(payload);
}Signature (HMAC-SHA256)
Same scheme as the sending webhooks. Every POST carries three headers:
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 payload = JSON.parse(raw.toString('utf8'));
enqueueForProcessing(payload); // heavy work OUTSIDE the request
res.sendStatus(200); // fast 2xx: the attempt times out after 10 s
});Verifying in PHP
<?php
// The RAW body, byte for byte. No json_decode before verifying.
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_OVEYON_TIMESTAMP'] ?? '';
$sig = preg_replace('/^sha256=/', '', $_SERVER['HTTP_X_OVEYON_SIGNATURE'] ?? '');
// Anti-replay window: 300 s.
if ($ts === '' || abs(time() - (int) $ts) > 300) { http_response_code(400); exit; }
$expected = hash_hmac('sha256', $ts . '.' . $raw, getenv('OVEYON_WEBHOOK_SECRET'));
// hash_equals: constant-time comparison.
if (!hash_equals($expected, $sig)) { http_response_code(401); exit; }
$payload = json_decode($raw, true);
enqueueForProcessing($payload); // heavy work after responding
http_response_code(200);Retries
Delivery means 2xx. Anything else counts as a failure and goes into the retry queue: 4xx, 5xx, a 10 s timeout, connection refused — and also 3xx, because redirects are not followed (following a redirect from a customer endpoint is how an SSRF walks in through the front door).
| 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.
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.limit— ≤ 500 (default 100) ·offset.
curl "https://api.oveyon.com/v1/suppressions?reason=hard_bounce&limit=100" \
-H "Authorization: Bearer $OVEYON_API_KEY"{
"suppressions": [
{ "email": "invalid@example.com", "reason": "hard_bounce",
"source": "bounce", "createdAt": "2026-07-20T09:11:00.000Z" }
],
"total": 1, "limit": 100, "offset": 0
}/v1/suppressionsscope write:suppressionsAdds an address. reason defaults to manual. Response 201 { email, reason }.
curl -X POST https://api.oveyon.com/v1/suppressions \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "donotsend@example.com", "reason": "manual" }'/v1/suppressions/:emailscope write:suppressionsRemoves a suppression (email URL-encoded in the path). Response 200 { deleted }.
- Only
manualandhard_bounceare removable. 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. Since it returns no error at all, the way to see it through the API is 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 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 per-IP cap: default 120 writes/min. The reads in this section do not count against that cap. Exceeded → 429 rate_limited with Retry-After; the message states the limit.
It exists because every mutation here is more expensive than it looks: it invalidates the policy-evaluation cache, reorder rewrites the entire order, and DELETE also clears the policy pointer across 90 days of trail. It is the same cap the Policies screen applies — no human use or well-behaved script comes close to it, since the whole account can only have 50 policies.
Errors that /send now returns
422 policy_refused— refused by one of your policies. The body names the policy (policy) and the recipient that matched (recipient). 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— 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 per-IP cap: default 120/min. Reads do not count against it. Exceeded → 429 rate_limited with Retry-After.
The send counts against this cap for a concrete reason: the voting link has to exist before the email body, so the record is created before the send is evaluated and is canceled when the send is refused. A loop against an always-refused recipient would keep creating and canceling nonstop.
Sending webhooks
Receive delivery events on your server. Multiple endpoints per account; each one subscribes to a subset of events: delivered, bounced, opened, clicked, complained, blocked. These are the events for what you sent — notifications for received email are configured separately, in the Inbound webhook section.
Timeout and retries: each attempt waits up to 10 seconds for your response; delivery means a 2xx within that time. Anything else — 4xx, 5xx, 3xx (redirects are not followed), timeout, connection refused — counts as a failure and is retried: there are 6 attempts in total, with increasing waits of 30 s, 2 min, 10 min, 1 h and 6 h (a ~7 h window), the same policy and the same table as the inbound webhook. Once all six are exhausted, the delivery is marked as failed and there is no seventh. Respond with a 2xx quickly and do the heavy work afterwards: an endpoint that spends 12 s processing before it responds fails on timeout even though it did everything right.
The blocked event fires when your sending rules block a recipient (in the API, over SMTP, or in a reply to an inbound email). The body carries reason (block_list or not_on_allow_list), entry (the entry that matched, when it is a block), address and origin. An endpoint scoped to a credential does not receive blocked — a block carries no credential.
/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).
curl -X POST https://api.oveyon.com/v1/domains \
-H "Authorization: Bearer $OVEYON_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "domain": "yourcompany.com" }'{
"id": 42,
"domain": "yourcompany.com",
"records": [
{ "type": "TXT", "name": "yourcompany.com", "value": "v=spf1 include:...", "purpose": "SPF (authorizes the sending IPs for your brand)" },
{ "type": "CNAME", "name": "s90abc._domainkey.yourcompany.com", "value": "...", "purpose": "DKIM" }
],
"verification": { "spf": false, "dkim": false, "dmarc": false }
}409 domain_exists— it is already yours.409 domain_taken— unavailable (belongs to another account).400 bad_domain— invalid syntax.403 domain_not_allowed_free— Free plan: the domain was refused by reputation screening (Spamhaus Intelligence) before it was created. The response deliberately does not say why; support can see it. Paid plans do not go through this screening.422 domain_limit_reached— the plan has reached its domain cap (1 on Free). Remove a domain you no longer use or contact support to raise the limit. This refusal comes before reputation screening: if you are at the cap, no lookup is spent.
/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.
curl -X POST https://api.oveyon.com/v1/domains/42/verify \
-H "Authorization: Bearer $OVEYON_API_KEY"{
"id": 42,
"domain": "yourcompany.com",
"verification": { "spf": true, "dkim": true, "dmarc": false },
"inconclusive": [],
"detail": { "spf": "...", "dkim": "...", "dmarc": "no _dmarc record" }
}Errors & limits
Errors are JSON with a stable error field (and, when useful, a message and context). Handle them by HTTP status and by error, not by the message.
Response language
message is localized: it comes out in Portuguese for callers from Brazil and in English for the rest of the world — including when we cannot determine the country. The country comes from Cloudflare's CF-IPCountry; you don't need to send anything.
The same rule applies to every field that is a sentence for a person: the detail of inbound deliveries and of agent-safety signals, the label of the /v1/stats breakdown, the purpose and the verification detail of domain records, the statusDetail of sent messages, and the signature help when a webhook is created. A webhook has no caller: the text in its body (truncation.reason, retro.message, quarantine.release.note, the signals' detail) follows the account country — Portuguese for Brazil, English everywhere else.
The error field NEVER changes language. It is the machine contract, it is byte-for-byte identical in every country, and it is what your integration should branch on. The same goes for the rest of the body (required, have, domain, result, disposable, limit…): they are data, not text.
# same call, same error, different countries
# (Cloudflare injects CF-IPCountry; you do not send anything)
BR { "error": "bad_request", "message": "informe email OU domain, nunca os dois" }
US { "error": "bad_request", "message": "provide email OR domain, never both" }
DE { "error": "bad_request", "message": "provide email OR domain, never both" }
-- { "error": "bad_request", "message": "provide email OR domain, never both" }
// `error` is identical in all four. Only `message` changes.In other words: if (body.error === 'rate_limited') — never if (body.message === '…'). A text comparison breaks on the day the first of your customers calls from another country.
| Status | Meaning | Typical error |
|---|---|---|
200 | OK / idempotent duplicate | duplicate |
201 | Created | — |
202 | Accepted (spooled for sending) | — |
400 | Malformed request | invalid_json, bad_request, bad_cursor, bad_date, bad_status, bad_outcome, bad_events, bad_group_by, bad_domain, bad_route, bad_sdk, too_many_attachments |
401 | Invalid/revoked key | unauthorized |
403 | No permission / blocked | insufficient_scope, key_disabled, sending_disabled, removal_blocked, account_suspended, account_unknown, ip_not_allowed |
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, 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 | internal_error, injection_failed, record_failed, list_unavailable, ip_rules_unavailable (with Retry-After: 30) |
What is refused before the route
Some responses are produced before the route runs, and they apply to every operation — which is why the OpenAPI spec declares them on all of them:
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.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 body is read up to the limit before the key is validated.415 unsupported_media_type— send the body asapplication/json.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 IP — a limit on requests/min and on auth failures/min. 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.