mails-ai Events API

Typed events (incl. classifier output) and the SSE live stream.

Operations 4

GET /v1/events List events #
GET /v1/events/{id} Retrieve an event #
POST /v1/events/{id}/redeliver Redeliver an event #
GET /v1/events/stream Stream events (SSE) #

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • 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.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/mails-ai:mails-ai-events-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Discovery needs no key. Ratings and market analysis are Pro.

Get an API key

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 Specification

mails-ai-events-api-openapi.yml Raw ↑
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.'