# The machine-readable contract for the customer-facing Send API —
# api.mailway.net (live) / api.mailway.dev (sandbox).
#
# Hand-authored, code-verified: tests/Core/OpenApiContractTest.php makes
# real requests against the routes and validates the responses against
# these schemas on every suite run, so code and spec cannot drift
# silently. When either side changes, change both in the same commit.
#
# Published at https://mailway.net/openapi.yaml — mailway-landing serves
# a vendored copy (public/openapi.yaml); update it in the same wave.
#
# Scope: the bearer-token send surface only. The console API
# (api.console.mailway.net) and the SMTP ingest are internal contracts
# and deliberately not described here; the anonymous landing-form
# endpoints (/v1/consent, /v1/contact, /v1/waitlist) are not part of
# the customer contract either.
openapi: 3.1.0

info:
  title: Mailway Send API
  version: 1.0.0
  summary: One endpoint in front of the email providers you already own.
  description: |
    Mailway is a provider-neutral email gateway: you bring your own
    provider accounts (Amazon SES, SendGrid, Mailgun, Postmark, Brevo,
    Mandrill, Gmail, or any SMTP server), and Mailway routes, fails
    over, archives, and evidences every message.

    A `202` from this API means Mailway **accepted and durably stored**
    the message — it says nothing about delivery. Provider handoffs and
    recipient-server delivery events arrive asynchronously via
    [webhooks](https://mailway.net/docs/webhooks) and the console;
    provider-accepted and delivered are different claims and are never
    blurred.

    The human-readable reference lives at
    [mailway.net/docs/api/send](https://mailway.net/docs/api/send); the
    error-code catalog at
    [mailway.net/docs/api/errors](https://mailway.net/docs/api/errors).
  contact:
    name: Mailway
    url: https://mailway.net/contact

externalDocs:
  description: Send API reference
  url: https://mailway.net/docs/api/send

servers:
  - url: https://api.mailway.net
    description: Production — accepts `mw_live_…` keys only.
  - url: https://api.mailway.dev
    description: >-
      Sandbox — accepts `mw_test_…` keys only. Messages are validated,
      stored, and visible in the console with a sandbox badge, but never
      forwarded automatically (`status: captured`).

security:
  - bearerApiKey: []

paths:
  /v1/send:
    post:
      operationId: sendMessage
      summary: Send a message
      description: |
        Validates, durably stores, and queues one message for sending
        through the project's providers. The key *is* the project
        selection — there is no project field in the body.

        Pass an `Idempotency-Key` header to make retries safe: a
        repeated key returns the **original** message's `202` instead of
        sending again (first body wins; keys are unique per project).
      externalDocs:
        url: https://mailway.net/docs/api/send
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Message'
      responses:
        '202':
          description: >-
            Accepted and durably stored. Not a delivery claim — track
            provider handoff and delivery via webhooks or the console.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendAccepted'
        '401':
          $ref: '#/components/responses/AuthenticationError'
        '402':
          $ref: '#/components/responses/QuotaError'
        '422':
          $ref: '#/components/responses/UnprocessableError'
        '429':
          $ref: '#/components/responses/RateLimitError'

  /v1/send/batch:
    post:
      operationId: sendBatch
      summary: Send up to 100 messages in one request
      description: |
        Each element of `messages` is validated against the exact
        `/v1/send` message schema and is an **independent** send: one
        failing element never sinks the rest, which is why the response
        is `207 Multi-Status`. `data` is an array in request order —
        each slot is either `{id, status}` or the surface's standard
        error envelope for that element. Check per element; don't
        assume all-or-nothing.

        `Idempotency-Key` is not honoured on batch. Each accepted
        element counts against plan quotas.
      externalDocs:
        url: https://mailway.net/docs/api/send#batch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - messages
              properties:
                messages:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    $ref: '#/components/schemas/Message'
      responses:
        '207':
          description: Per-element results, in request order.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchResponse'
        '401':
          $ref: '#/components/responses/AuthenticationError'
        '402':
          $ref: '#/components/responses/QuotaError'
        '422':
          $ref: '#/components/responses/UnprocessableError'
        '429':
          $ref: '#/components/responses/RateLimitError'

# Outbound lifecycle webhooks — the requests Mailway makes TO your
# endpoint. Endpoints and their signing secrets (`whsec_…`) are managed
# in the console; full guide at https://mailway.net/docs/webhooks.
webhooks:
  mailEvent:
    post:
      operationId: mailEvent
      summary: A mail lifecycle event delivered to your endpoint
      description: |
        One envelope for every event type. Events are normalized across
        providers — a `mail.bounced` looks the same whether Amazon SES
        or Mailtrap reported the underlying bounce.

        **Verify before trusting**: every delivery is signed with your
        endpoint's secret. Recompute `HMAC-SHA256("{t}.{raw_body}",
        secret)` over the raw request body bytes, compare against `v1`
        in constant time, and reject stale timestamps (5 minutes is a
        good tolerance).

        Delivery is **at-least-once** — deduplicate on
        `(type, data.mail.uid)`. Failed deliveries retry on a backoff
        ladder (six attempts over roughly nine hours); an endpoint that
        keeps failing for 24 hours is disabled automatically.
      parameters:
        - name: X-Mailway-Signature
          in: header
          required: true
          description: '`t=<unix timestamp>,v1=<hex HMAC-SHA256 of "{t}.{raw_body}">`'
          schema:
            type: string
        - name: X-Mailway-Event
          in: header
          required: true
          description: The event type, mirroring `type` in the body.
          schema:
            type: string
        - name: X-Mailway-Delivery
          in: header
          required: true
          description: The delivery id (`we.…`), stable across retries of one delivery.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookDelivery'
      responses:
        '200':
          description: >-
            Any 2xx within 10 seconds counts as delivered; redirects are
            not followed. Acknowledge fast and process async.

components:
  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      description: |
        Per-project API key, shown once at creation and stored hashed:
        `mw_live_…` (production) or `mw_test_…` (sandbox), 40
        alphanumeric characters after the prefix. Live keys work only on
        `api.mailway.net`, test keys only on `api.mailway.dev` — the
        host binding is deliberate (`mode_mismatch` otherwise), so a
        misconfigured environment variable can't send real mail from a
        test suite.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        12–40 characters of `[A-Za-z0-9_-]` (UUIDs, ULIDs, and base64url
        tokens all pass). Unique per project; a repeated key returns the
        original message's 202 — first body wins, and the window is your
        retention, not a 24-hour timer. Not honoured on `/v1/send/batch`.
      schema:
        type: string
        minLength: 12
        maxLength: 40
        pattern: '^[A-Za-z0-9_-]+$'

  responses:
    AuthenticationError:
      description: >-
        Missing or invalid API key (`missing_api_key`,
        `invalid_api_key`, `key_expired`), or a key used on the wrong
        host (`mode_mismatch`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    QuotaError:
      description: >-
        Plan quota exhausted (`monthly_quota_exceeded`,
        `throughput_quota_exceeded`). A billing state, not a bug —
        retrying won't help until the month rolls over or the plan
        changes.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    UnprocessableError:
      description: >-
        The request can't succeed as sent: field validation
        (`error.details` lists each failing field), a project with no
        providers (`no_providers_configured`), a pin to an unattached
        provider (`routing_provider_not_configured`), a suppressed
        recipient (`suppressed_recipient`), or a plan cap
        (`sender_domain:limit_reached`, `category:limit_reached`). Fix
        the payload or the project; don't retry blindly.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimitError:
      description: >-
        Too many requests (`rate_limited` — honor `Retry-After`), or the
        Free tier's daily ceiling (`daily_send_cap_exceeded` — resets at
        00:00 UTC). Budgets are per key: 600 requests/min on `/v1/send`,
        6/min on `/v1/send/batch` (the same 600-message budget either
        way). Requests without a valid key fall back to a much tighter
        per-IP bucket.
      headers:
        Retry-After:
          description: Seconds to wait before retrying (on `rate_limited`).
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Address:
      description: >-
        An email address in any of three forms: `"a@b.com"`,
        `"Name <a@b.com>"`, or `{"email": "a@b.com", "name": "Name"}`.
      oneOf:
        - type: string
          description: '`a@b.com` or `Name <a@b.com>`'
        - type: object
          required:
            - email
          properties:
            email:
              type: string
              format: email
            name:
              type: string
          additionalProperties: false

    Recipients:
      description: A single address or an array of addresses.
      oneOf:
        - $ref: '#/components/schemas/Address'
        - type: array
          items:
            $ref: '#/components/schemas/Address'

    Attachment:
      type: object
      required:
        - filename
        - content
      properties:
        filename:
          type: string
          maxLength: 255
        content:
          type: string
          contentEncoding: base64
          description: >-
            Base64-encoded bytes. The 10 MB per-attachment / 25 MB
            combined caps are on **decoded** size, so the request body
            is ~33% larger than the number being checked
            (`attachments:too_large`, `attachments:total_too_large`).
        content_type:
          type:
            - string
            - 'null'
          maxLength: 255
        content_id:
          type:
            - string
            - 'null'
          maxLength: 255
          description: >-
            For inline images — reference it from the HTML as
            `cid:<content_id>` and set `disposition: inline`.
        disposition:
          type:
            - string
            - 'null'
          enum:
            - attachment
            - inline
            - null
      additionalProperties: false

    Routing:
      type: object
      description: >-
        Per-message routing overrides. Unknown keys are rejected
        (`routing:unknown_key`). A pin is never silently rerouted — a
        pinned provider that isn't attached to the project is a 422
        (`routing_provider_not_configured`), not a fallback.
      properties:
        provider:
          description: >-
            Pin the send to one provider slug, or an ordered preference
            list (duplicates rejected). The provider must be attached to
            the project.
          oneOf:
            - $ref: '#/components/schemas/ProviderSlug'
            - type: array
              items:
                $ref: '#/components/schemas/ProviderSlug'
        strategy:
          type:
            - string
            - 'null'
          enum:
            - fallback
            - round-robin
            - random
            - free-first
            - null
          description: Override the project's provider-selection strategy for this send.
      additionalProperties: false

    ProviderSlug:
      type: string
      enum:
        - smtp
        - amazon-ses-api
        - amazon-ses-smtp
        - sendgrid-api
        - sendgrid-smtp
        - mailgun-api
        - mailgun-smtp
        - mandrill-api
        - mandrill-smtp
        - postmark-api
        - postmark-smtp
        - brevo-api
        - brevo-smtp
        - gmail-smtp

    ProviderOptions:
      type: object
      description: >-
        Provider-specific extras, allowlist-validated: unknown provider
        slugs (`provider_options:unknown_provider`) and unknown option
        keys (`provider_options:unknown_option`) are rejected, never
        silently dropped. Only header-mappable options are accepted —
        an option Mailway can't actually apply would be a 202 that lies.
      properties:
        amazon-ses-api:
          type: object
          properties:
            configuration_set:
              type: string
              maxLength: 255
          additionalProperties: false
        postmark-api:
          type: object
          properties:
            message_stream:
              type: string
              maxLength: 255
          additionalProperties: false
      additionalProperties: false

    Message:
      type: object
      description: >-
        One message. Unknown top-level keys are rejected
        (`body:unknown_key`) — a typo like `pasued` fails loudly at the
        boundary instead of silently no-opping. At least one of `html`
        or `text` is required (`body:required`); at most 50 recipients
        combined across `to`/`cc`/`bcc` (`recipients:too_many`).
      required:
        - from
        - to
        - subject
      anyOf:
        - required:
            - html
        - required:
            - text
      properties:
        from:
          $ref: '#/components/schemas/Address'
        to:
          $ref: '#/components/schemas/Recipients'
        cc:
          $ref: '#/components/schemas/Recipients'
        bcc:
          $ref: '#/components/schemas/Recipients'
        reply_to:
          $ref: '#/components/schemas/Recipients'
        subject:
          type: string
          maxLength: 998
          description: Required. ≤ 998 characters (the RFC 5322 line limit).
        html:
          type:
            - string
            - 'null'
          maxLength: 2097152
          description: ≤ 2 MB. Send both `html` and `text` when you can.
        text:
          type:
            - string
            - 'null'
          maxLength: 2097152
          description: ≤ 2 MB.
        headers:
          type:
            - object
            - 'null'
          description: >-
            Custom headers (e.g. `List-Unsubscribe`). Transport-critical
            headers (From/To/Subject/Message-ID/DKIM-Signature/…) and
            the `X-Mailway-*` prefix are reserved and rejected
            (`headers:reserved`).
          additionalProperties:
            type: string
            maxLength: 8192
        attachments:
          type:
            - array
            - 'null'
          maxItems: 20
          items:
            $ref: '#/components/schemas/Attachment'
        tags:
          type:
            - array
            - 'null'
          maxItems: 10
          description: >-
            Correlation labels, echoed on every webhook event for this
            mail.
          items:
            type: string
            maxLength: 64
            pattern: '^[A-Za-z0-9_-]+$'
        metadata:
          type:
            - object
            - 'null'
          maxProperties: 10
          description: >-
            Your correlators (order IDs, user IDs), echoed on every
            webhook event. Keys ≤ 40 chars, string values ≤ 500 chars.
          propertyNames:
            maxLength: 40
          additionalProperties:
            type: string
            maxLength: 500
        category:
          type:
            - string
            - 'null'
          minLength: 1
          maxLength: 64
          description: >-
            One per message — the reporting bucket per-category stats
            group by (e.g. "Password reset"). Letters/digits first
            character; letters, digits, space, `_-./:` after.
            Case-sensitive and verbatim. A project holds at most 100
            distinct categories — reusing one is free, a new name past
            the cap is rejected (`category:limit_reached`). Use
            `category` for reporting and `tags` for correlation.
        scheduled_at:
          type:
            - string
            - 'null'
          description: >-
            ISO 8601 instant with offset (e.g. `2026-08-04T09:00:00Z`).
            Must be in the future and at most 72 hours out
            (`scheduled_at:too_soon` / `scheduled_at:too_far`).
        paused:
          type:
            - boolean
            - 'null'
          description: >-
            Accept + store the message but hold it until released in the
            console.
        routing:
          oneOf:
            - $ref: '#/components/schemas/Routing'
            - type: 'null'
        provider_options:
          oneOf:
            - $ref: '#/components/schemas/ProviderOptions'
            - type: 'null'
      additionalProperties: false

    SendAccepted:
      type: object
      required:
        - id
        - status
        - scheduled_at
        - paused
        - category
        - project_id
        - routing
      properties:
        id:
          type: string
          pattern: '^m\.'
          description: >-
            The message's permanent identifier — it threads through the
            console, webhook events, and the public viewer.
        status:
          type: string
          enum:
            - queued
            - scheduled
            - paused
            - captured
          description: >-
            `queued` — accepted for sending now; `scheduled` —
            `scheduled_at` is in the future; `paused` — held (the
            `paused` flag, or the project is paused); `captured` — a
            sandbox send, stored but never auto-forwarded.
        scheduled_at:
          type:
            - string
            - 'null'
          description: Echo of the request field.
        paused:
          type: boolean
          description: >-
            The mail's actual held state — also `true` when the project
            itself is paused, not just when the request asked for it.
        category:
          type:
            - string
            - 'null'
          description: Echo of the request field.
        project_id:
          type: string
          pattern: '^p\.'
        routing:
          type: object
          required:
            - provider
            - strategy
          properties:
            provider:
              description: Echo of the request field.
              oneOf:
                - type: string
                - type: array
                  items:
                    type: string
                - type: 'null'
            strategy:
              type:
                - string
                - 'null'
              description: Echo of the request field.
          additionalProperties: false
      additionalProperties: false

    BatchAccepted:
      type: object
      required:
        - id
        - status
      properties:
        id:
          type: string
          pattern: '^m\.'
        status:
          type: string
          enum:
            - queued
            - scheduled
            - paused
            - captured
      additionalProperties: false

    BatchResponse:
      type: object
      required:
        - data
      properties:
        data:
          type: array
          description: >-
            One slot per input message, in request order — correlate by
            position (`data[i]` ↔ `messages[i]`). Each slot is either an
            accepted send or the surface's standard error envelope
            (persistence failures surface as `api_error` /
            `server_error`: retry just that element).
          items:
            oneOf:
              - $ref: '#/components/schemas/BatchAccepted'
              - $ref: '#/components/schemas/Error'
      additionalProperties: false

    Error:
      type: object
      description: >-
        The surface's one error envelope. Branch on `error.code` — it's
        stable; `error.message` is for humans and may change. Field
        validation carries `error.details` listing every failing field
        (`error.code` mirrors the first).
      required:
        - error
      properties:
        error:
          type: object
          required:
            - type
            - code
            - message
          properties:
            type:
              type: string
              enum:
                - authentication_error
                - quota_error
                - permission_error
                - validation_error
                - rate_limit_error
                - api_error
            code:
              type: string
              description: >-
                Stable, branchable code — e.g. `invalid_api_key`,
                `monthly_quota_exceeded`, `suppressed_recipient`,
                `from:required`, `idempotency_key:invalid`. The full
                catalog: https://mailway.net/docs/api/errors
            message:
              type: string
            details:
              type: array
              description: Present on field-validation failures.
              items:
                type: object
                required:
                  - field
                  - code
                properties:
                  field:
                    type: string
                    description: >-
                      The failing field's dotted path, e.g. `subject`,
                      `to.1`, `attachments.0.filename`.
                  code:
                    type: string
                additionalProperties: false
          additionalProperties: false
      additionalProperties: false

    WebhookDelivery:
      type: object
      description: >-
        One envelope for every event type. `data.mail` is always present
        (except on `webhook.test`); type-specific blocks (e.g. `bounce`)
        appear where relevant. Your `tags` and `metadata` from the send
        are echoed on every event.
      required:
        - id
        - type
        - created_at
        - data
      properties:
        id:
          type: string
          pattern: '^we\.'
          description: >-
            Stable across retries of one delivery; the same underlying
            mail event can be re-emitted with a new id — deduplicate on
            `(type, data.mail.uid)`.
        type:
          type: string
          enum:
            - mail.accepted
            - mail.sent
            - mail.delivered
            - mail.bounced
            - mail.complained
            - mail.failed
            - mail.suppressed
            - webhook.test
          description: >-
            `mail.accepted` — Mailway accepted the message;
            `mail.sent` — a provider accepted the handoff;
            `mail.delivered` — the provider confirmed delivery to the
            recipient's mail server; `mail.bounced` / `mail.complained`
            — the provider reported a bounce / spam complaint;
            `mail.failed` — terminal send failure; `mail.suppressed` —
            blocked by the project's suppression list; `webhook.test` —
            the console's Send-test button.
        created_at:
          type: string
          format: date-time
        data:
          type: object
          properties:
            mail:
              type: object
              properties:
                uid:
                  type: string
                  pattern: '^m\.'
                subject:
                  type: string
                from:
                  type: string
                to:
                  type: array
                  items:
                    type: string
                project:
                  type: object
                  properties:
                    uid:
                      type: string
                      pattern: '^p\.'
                    name:
                      type: string
                tags:
                  type: array
                  items:
                    type: string
                metadata:
                  type: object
              additionalProperties: true
          additionalProperties: true
      additionalProperties: false
