mails-ai Events API
Typed events (incl. classifier output) and the SSE live stream.
Typed events (incl. classifier output) and the SSE live stream.
Every API here is available over the APIs.io API and to AI agents over MCP.
One button, every client — Claude, Cursor, VS Code and the rest.
https://apis.io/mcp
find_apisBrowse and filter every API in the catalog.get_api_artifactsOne API's artifacts, grouped by type.get_openapiThe primary OpenAPI for this API.find_similar_apisAPIs that look like this one.apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.resolveTurn a domain, URL or GitHub org into the provider it belongs to.find_cohortsEvery scored population of providers in the catalog.curl "https://apis.io/api/v1/apis/mails-ai:mails-ai-events-api"
curl "https://apis.io/api/v1/apis?limit=25"
Discovery needs no key. Ratings and market analysis are Pro.
Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.
A second provider on the same verified email joins the account you already have.
openapi: 3.2.0
info:
title: Mails.ai Events API
version: 0.2.0
description: 'The Mails.ai API gives AI agents a real email identity: send and receive
transactional email. Mail received within your plan''s limits is scanned for prompt injection and
reaches you as a typed event. With classification turned on (a paid-plan feature), new mail can also
be read for intent; a reply in a thread never is.
## Authentication
All endpoints take a Bearer API key:
```
Authorization: Bearer mk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Keys carry one or more scopes — `send`, `read`, `draft` (writes drafts, but can neither send nor
schedule one) and `manage` — and a mode: **live** (`mk_live_…`) or **test** (`mk_test_…`).
## Test mode
A `mk_test_` key exercises the entire API — real validation, persisted resources, real
`message.sent`/`*.received` events and webhooks — without sending real email or incurring cost. Build and
test your whole integration on a test key, then flip one character to `mk_live_` to go live.
`POST /v1/test/inbound` (test key only) lets you simulate inbound mail end-to-end.
## Idempotency
Send endpoints accept an `Idempotency-Key` header; a repeated key replays the stored response instead of
sending twice, with an `Idempotent-Replay: true` header.
## Pagination
List endpoints are cursor-paginated: pass `limit` (1–100) and the `next_cursor` from the previous page.
## Errors
Errors use a stable envelope: `{ "error": { "type", "code", "message", "param", "request_id" } }`. Every
response carries an `X-Request-Id` header for support correlation.'
contact:
name: Mails.ai Support
url: https://mails.ai
email: support@mails.ai
license:
name: Proprietary
identifier: LicenseRef-Proprietary
servers:
- url: https://api.mails.ai
description: Production
security:
- bearerAuth: []
tags:
- name: Events
description: Typed events (incl. classifier output) and the SSE live stream.
paths:
/v1/events:
get:
tags:
- Events
summary: List events
operationId: listEvents
description: '**Requires scope:** `read`.'
parameters:
- name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 25
description: Page size (1–100).
- name: cursor
in: query
required: false
schema:
type: string
description: Opaque cursor from a previous page's `next_cursor`.
- name: event_type
in: query
required: false
schema:
type: string
description: Filter by exact event type.
- name: agent_id
in: query
required: false
schema:
type: string
description: Filter by agent id or name.
- name: since
in: query
required: false
schema:
type: string
format: date-time
description: 'Only events created at or after this time: an ISO 8601 time such as `2026-10-05T07:00:00Z` or `2026-10-05T16:00:00+09:00`, or a date (`2026-10-05`, midnight UTC). A time with no offset is UTC, and its seconds and milliseconds may be left out. Any other value answers `400 invalid_param_value`. The event stream''s `since` is different: a position the stream gave, or an event id.'
responses:
'200':
description: A page of typed events.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/Event'
description: The items on this page, at most `limit` of them.
has_more:
type: boolean
description: 'True when more items follow this page: pass `next_cursor` as `cursor` to get them.'
next_cursor:
type: string
description: Pass as `cursor` to fetch the next page. Absent on the last page.
required:
- data
- has_more
'400':
description: Invalid request — malformed JSON or a field failed validation. Also `feature_not_enabled` when this server has a feature switched off (custom domains before they are available).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Missing or invalid API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the required scope (`insufficient_scope`), or the token belongs to an app connected by sign-in and this operation is closed to connected apps (`connected_app_not_allowed`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: 'Conflict — duplicate resource, an in-progress idempotent request, a draft or scheduled message that started sending while it was being changed, or a thread deleted for good while one of its messages is being sent (`duplicate_resource`); or an agent name more than one agent has (`agent_name_conflict`: use the agent''s id); or a status change on an archived agent, since archiving cannot be undone (`agent_archived`).'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
x-required-scope: read
/v1/events/{id}:
get:
tags:
- Events
summary: Retrieve an event
operationId: getEvent
description: '**Requires scope:** `read`.'
parameters:
- name: id
in: path
required: true
schema:
type: string
description: Event id (`evt_…`).
responses:
'200':
description: The event, including full classifier detail.
content:
application/json:
schema:
$ref: '#/components/schemas/Event'
'401':
description: Missing or invalid API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the required scope (`insufficient_scope`), or the token belongs to an app connected by sign-in and this operation is closed to connected apps (`connected_app_not_allowed`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Resource not found in this workspace, or the agent a send names is archived (`agent_archived`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
x-required-scope: read
/v1/events/{id}/redeliver:
post:
tags:
- Events
summary: Redeliver an event
operationId: redeliverEvent
description: '**Requires scope:** `manage`. Re-dispatches a stored event to all matching webhook endpoints. Needs a key that is not tied to an agent, since an endpoint can receive every agent''s mail (`403 insufficient_scope`).'
parameters:
- name: id
in: path
required: true
schema:
type: string
description: Event id.
responses:
'202':
description: Redelivery scheduled.
content:
application/json:
schema:
type: object
properties:
event_id:
type: string
description: 'The event that was sent again: the id in the path.'
webhook_endpoint_ids:
type: array
items:
type: string
description: 'Ids of the endpoints it was sent to: every active endpoint whose `event_types` take this event. Empty when there is none.'
scheduled_at:
type: string
format: date-time
description: When the redelivery finished (UTC). Each endpoint has been tried once by then, and one that failed is retried like any delivery.
required:
- event_id
- webhook_endpoint_ids
'401':
description: Missing or invalid API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the required scope (`insufficient_scope`), or the token belongs to an app connected by sign-in and this operation is closed to connected apps (`connected_app_not_allowed`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: Resource not found in this workspace, or the agent a send names is archived (`agent_archived`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: Rate or quota limit exceeded. The body's `resets_at` and `retry_after_seconds` say when the request can succeed. A `Retry-After` header is sent only when that is 60 seconds or less; a longer wait (an hourly, daily or monthly cap) sends none, so retry at `resets_at` or raise the limit instead of sleeping.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
x-required-scope: manage
/v1/events/stream:
get:
tags:
- Events
summary: Stream events (SSE)
operationId: streamEvents
description: '**Requires scope:** `read`. Server-Sent Events live tail. Each event is a frame `id: `, `event: `, `data: `. The stream also says where it is, in an `id:` line with no data (`id: evt_…~`), when the connection opens and after each batch of events. That is its position, and what to resume from: a browser''s EventSource sends it back as `Last-Event-ID` by itself, and any other client passes the last one it read as `?since=`. Without `since` or a `Last-Event-ID` header the stream starts at the newest event: it sends what happens from then on, never the workspace''s history; `since=0` replays the whole history first. The connection self-closes after ~50s; reconnect to continue. Events arrive in id order, except one whose row was written late: it is sent after newer ones rather than skipped, and a reconnect from the position still gets it. Delivery is at least once: an event can arrive a second time after a reconnect, so de-duplicate by its `id`. Resuming from a bare event id (`since=evt_…`) starts after that id: it does not get an event written late with a smaller id, and after such an event it sends the newer ones again.'
parameters:
- name: since
in: query
required: false
schema:
type: string
description: 'Where to resume: the last position the stream gave (`evt_…~<number>`), or an event id (`evt_…`) to start after that id. Not a time. `0` replays the whole history. Falls back to the `Last-Event-ID` header; with neither, or empty, the stream starts at the newest event.'
- name: event_types
in: query
required: false
schema:
type: string
description: Comma-separated event-type filter. `*` for all.
responses:
'200':
description: An SSE stream (`text/event-stream`).
content:
text/event-stream:
schema:
type: string
description: SSE frames; the `data:` line is a JSON event payload.
'401':
description: Missing or invalid API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the required scope (`insufficient_scope`), or the token belongs to an app connected by sign-in and this operation is closed to connected apps (`connected_app_not_allowed`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
x-required-scope: read
components:
schemas:
Error:
type: object
properties:
error:
type: object
properties:
type:
type: string
enum:
- invalid_request_error
- authentication_error
- permission_error
- rate_limit_error
- resource_error
- payment_error
- abuse_error
- api_error
description: High-level error category.
code:
type: string
enum:
- invalid_field
- missing_field
- missing_param
- invalid_param_value
- method_not_allowed
- invalid_idempotency_key
- duplicate_resource
- slug_invalid
- slug_reserved
- slug_taken
- agent_name_conflict
- body_too_large
- missing_authorization
- missing_api_key
- invalid_api_key
- expired_api_key
- revoked_api_key
- invalid_access_token
- email_not_verified
- insufficient_scope
- connected_app_not_allowed
- sso_required
- workspace_mismatch
- agent_not_owned
- rate_limit_exceeded
- daily_limit_exceeded
- hourly_limit_exceeded
- monthly_limit_exceeded
- agent_not_found
- agent_paused
- agent_archived
- resource_not_found
- recipient_suppressed
- recipient_not_allowed
- domain_not_verified
- ses_role_not_assumable
- ses_role_external_id_not_required
- ses_permission_denied
- ses_setup_failed
- ses_message_rejected
- ses_events_not_confirmed
- payment_required
- payment_method_failed
- subscription_canceled
- billing_state_inconsistent
- free_tier_exceeded
- plan_limit_exceeded
- cold_email_prohibited
- agent_suspended
- new_workspace_fanout_exceeded
- resource_cap_exceeded
- workspace_cap_exceeded
- feature_not_enabled
- test_mode_required
- live_mode_required
- unknown_test_address
- workspace_not_approved
- spam_pattern_detected
- classifier_rejected
- high_injection_score
- complaint_threshold_exceeded
- bounce_threshold_exceeded
- internal_error
- internal_server_error
- upstream_error
- upstream_unavailable
- service_unavailable
description: Stable machine-readable error code.
message:
type: string
description: Human-readable explanation.
param:
type:
- string
- 'null'
description: The offending request field, when applicable.
request_id:
type: string
description: Echo of the X-Request-Id header for support correlation.
errors:
type: array
items:
$ref: '#/components/schemas/FieldError'
description: On a validation failure, EVERY offending field (not just the first). The top-level code/message/param mirror errors[0] for backward compatibility.
resets_at:
type:
- string
- 'null'
format: date-time
description: 'On every 429: when the limit lets the request through again (UTC). Null when waiting will not help, for a cap that only an upgrade or a deletion lifts.'
retry_after_seconds:
type:
- integer
- 'null'
description: 'On every 429: the seconds until `resets_at`, null when it is null. On any other error that says how long to wait, such as a 503 `upstream_unavailable`: the seconds its `Retry-After` header gives.'
upgrade_url:
type: string
format: uri
description: 'On every 402: the dashboard page where the plan can be changed, `https://app.mails.ai/billing`.'
required:
- type
- code
- message
description: 'The error, as every error response carries it: `type`, `code`, `message`, `param` and `request_id`, plus `errors` on a validation failure, `resets_at` and `retry_after_seconds` on a 429, `retry_after_seconds` on any other error with a `Retry-After`, and `upgrade_url` on a 402. A failed item in a batch send is the same error, and `retry_after_seconds` is how it says how long to wait, since an item has no headers of its own.'
required:
- error
FieldError:
type: object
properties:
code:
type: string
enum:
- invalid_field
- missing_field
- missing_param
- invalid_param_value
- method_not_allowed
- invalid_idempotency_key
- duplicate_resource
- slug_invalid
- slug_reserved
- slug_taken
- agent_name_conflict
- body_too_large
- missing_authorization
- missing_api_key
- invalid_api_key
- expired_api_key
- revoked_api_key
- invalid_access_token
- email_not_verified
- insufficient_scope
- connected_app_not_allowed
- sso_required
- workspace_mismatch
- agent_not_owned
- rate_limit_exceeded
- daily_limit_exceeded
- hourly_limit_exceeded
- monthly_limit_exceeded
- agent_not_found
- agent_paused
- agent_archived
- resource_not_found
- recipient_suppressed
- recipient_not_allowed
- domain_not_verified
- ses_role_not_assumable
- ses_role_external_id_not_required
- ses_permission_denied
- ses_setup_failed
- ses_message_rejected
- ses_events_not_confirmed
- payment_required
- payment_method_failed
- subscription_canceled
- billing_state_inconsistent
- free_tier_exceeded
- plan_limit_exceeded
- cold_email_prohibited
- agent_suspended
- new_workspace_fanout_exceeded
- resource_cap_exceeded
- workspace_cap_exceeded
- feature_not_enabled
- test_mode_required
- live_mode_required
- unknown_test_address
- workspace_not_approved
- spam_pattern_detected
- classifier_rejected
- high_injection_score
- complaint_threshold_exceeded
- bounce_threshold_exceeded
- internal_error
- internal_server_error
- upstream_error
- upstream_unavailable
- service_unavailable
description: Stable machine-readable code for this field.
message:
type: string
description: Human-readable explanation for this field.
param:
type:
- string
- 'null'
description: The offending request field (dot-path), or null.
required:
- code
- message
- param
Event:
type: object
properties:
id:
type: string
description: Event id (`evt_…`). A webhook delivery carries it as `id` and in the `X-Mails-Event-Id` header, the same on every retry and redelivery.
type:
type: string
description: 'Event type. The types emitted: message.sent, message.scheduled, message.delivered, message.delayed, message.bounced, message.complained, message.failed, message.received, message.received.unauthenticated, message.received.over_limit, reply.received, draft.scheduled, draft.sending, draft.sent, draft.failed, suppression.added, webhook.test, thread.created, thread.updated, thread.deleted. `message.sent` is raised for every message that goes out, a scheduled draft''s included (which raises `draft.sent` too). `message.scheduled` is raised for every message scheduled for later (by a send, a batch item or a reply), and again when one is rescheduled, with the new `scheduled_at` and the `previous_scheduled_at` it replaced. `message.delivered` is raised once per recipient when the receiving side reports the delivery, and at once for a send recorded as `delivered` when it was sent. `message.delayed` reports a transient bounce (the message may still be delivered), once per recipient however often the delay is reported; a webhook endpoint gets it, like the thread events, only when it lists it by name. `message.received.over_limit` reports mail received while the workspace was over a limit; its data says which (`over_limit`), whether the text was kept (`content_kept`), and who sent it (`from` and `envelope_from`, as on the inbound events), and carries no subject or body: the injection scan did not read that mail. For the same reason `thread.created` for received mail gives its `subject` only when the scan read the mail and did not quarantine it, and null otherwise.'
workspace_id:
type: string
description: The workspace the event belongs to (`wsk_…`).
agent_id:
type: string
description: The agent the event is about (`agt_…`). Absent when it has none, as on `webhook.test`.
thread_id:
type: string
description: The thread the message behind the event belongs to (`thrd_…`), when it has one.
source_message_id:
type: string
description: 'The message the event is about: the sent message (`msg_…`) on events about a send, the received one (`rcv_…`) on inbound events. Absent on events that carry no message, such as the thread events and `webhook.test`.'
intent:
type: string
description: 'What an inbound message wants, on `message.received`, `reply.received` and `message.received.unauthenticated` only. Fixed values when it was not read for intent: `general_reply` for a reply in a thread, `unknown` when the rules layer settled it, `unclassified` when the agent''s `classify_inbound` is off or the classifier could not run.'
urgency:
type: number
description: Urgency score 0–1 (higher = more time-sensitive).
injection_score:
type: number
description: 'Prompt-injection score 0–1 (inbound events). The scan reads the subject and the first 100,000 characters of the body: the openings of its text part, of its HTML part''s text and of the text that HTML hides come first, then the rest of each. Anything past 100,000 characters is not scanned.'
sender_reputation:
type: number
description: Trust score for the sender of an inbound message, 0–1 (higher = more trusted), on the same events as `intent`. The classifier's estimate when it read the message for intent; otherwise a preset, such as 0.3 for a sender that failed authentication.
entities:
type: object
additionalProperties: true
description: Extracted entities as key→value pairs (object) — present when intent classification ran.
injection_categories:
type: array
items:
type: string
description: Prompt-injection categories flagged — single-event GET only.
classifier_model:
type: string
description: 'What scored the message: a model name; a value starting `rules-layer` when the rules layer settled the intent; `injection-scan-only`; or `scan-unavailable` when the scan could not run. On every event read and in webhook deliveries.'
quarantined:
type: boolean
description: 'Inbound events only: the firewall''s verdict, true when the message''s injection score crossed the quarantine threshold. `injection_score` is the evidence; this is the decision.'
test_mode:
type: boolean
description: 'True when a test key (`mk_test_…`) or test mail raised the event, and on every `webhook.test` event. `thread.updated` and `thread.deleted` take the thread''s own mode instead: true when the thread holds no live mail.'
data:
type: object
additionalProperties: true
description: 'Full typed-event payload (shape varies by type). Inbound events (`message.received`, `reply.received`, `message.received.unauthenticated`) carry `from`, the first mailbox of the email''s From header, which the checks below are about (it is proven only when `dmarc` is `pass`; when no raw email reached us it is read from the From header our mail server passed on, and the envelope sender stands in only when there was none), and `envelope_from`, the envelope sender (SMTP MAIL FROM), where bounces go and which a sending service often sets to its own bounce address: replies go to the Reply-To or to `from`, never there. They carry the sender''s authentication as we checked it from the email itself, never from a header in it: `dkim` (`pass` when a DKIM signature verifies, whichever domain signed it; `fail`; `none` for an unsigned email; or `unknown` when it could not be checked), `dkim_domain` (the domain of a passing DKIM signature that matches the domain of the From header, or null), `dmarc` (`none` when the From domain publishes no DMARC record; otherwise `pass` when a DKIM signature aligned with the From domain verifies, and `unknown`, never `fail`, when none does: SPF is not checked, and a sender that relies on SPF alone is not evidence of forgery) and `spf` (`unknown`: mail reaches us through Cloudflare, and the sending server''s address is not known). `message.received.unauthenticated` replaces `message.received` or `reply.received` when no DKIM signature aligned with the From domain verifies and the From domain''s DMARC policy is quarantine or reject: those domains promise signed mail, so this is the forged-sender case, and `auth_failed` is true. Every other sender gets no such event. `spf_pass` and `dkim_pass` are the same as true, false or null. Mail forwarded to an agent (forwarding mode) is judged the same way, since a forward that leaves the message as it was keeps the sender''s signature (its `envelope_from` is the forwarding service''s own address); `arc` is the ARC chain as we checked it (`pass`, `fail`, `none` or `unknown`), and `trusted_forwarder` names the mail service (`google_workspace`, `microsoft_365`, `fastmail` or `zoho`) whose ARC seal, on a chain that validates, reports that DMARC passed for the From domain when the mail reached it, or null. Such mail never gets `message.received.unauthenticated`, even when the forward broke the sender''s signature; `dkim`, `dkim_domain` and `dmarc` still say only what we verified ourselves.'
created_at:
type: string
format: date-time
description: When the event was recorded (UTC); the `since` filter of `GET /v1/events` compares against it.
required:
- id
- type
- workspace_id
- test_mode
- created_at
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: mk_live_<32 hex>, mk_test_<32 hex>, or moa_<48 hex>
description: 'A Mails.ai API key, or an OAuth access token (`moa_…`, valid for 1 hour) from signing in through an app such as the CLI, Claude or ChatGPT. OAuth discovery: https://api.mails.ai/.well-known/oauth-authorization-server and https://api.mails.ai/.well-known/oauth-protected-resource. An access token''s OAuth scope maps onto these API scopes: `full_access` = `send` + `read` + `manage` (not `draft`: the draft routes take those three too), `send` = `send` + `read`, `read` = `read`. Scopes (`send`/`read`/`manage`/`draft`) are noted per operation via `x-required-scope`; when more than one scope opens an operation (the draft routes also take `draft`), `x-accepted-scopes` lists them all. `inbox` appears there too, on the triage routes (changing and trashing threads, labels, cancelling a scheduled send): only a signed-in dashboard session holds it (a workspace Member''s), never an API key or OAuth token, and `x-session-scopes` names it.'