openapi: 3.1.0
info:
  title: MarleyFetch API
  version: 1.0.0
  summary: Send email through your own SMTP account with one HTTPS call.
  description: |
    MarleyFetch is an email API in front of the SMTP account you already have. Every request is
    authenticated with an API key and sent through one of your SMTP connections.

    - Guides and a quickstart: https://docs.marleyfetch.com
    - This spec as a file: https://docs.marleyfetch.com/openapi.yaml
    - Everything in one file for LLMs: https://docs.marleyfetch.com/llms-full.txt

    **Authentication.** Create an API key at https://app.marleyfetch.com/tokens and send it as
    `Authorization: Bearer mf_live_...`.

    **Choosing a sender.** Set `from` to the address of one of your connections. Omit it and your
    default connection is used.
  contact:
    name: MarleyFetch support
    email: support@marleyfetch.com
    url: https://docs.marleyfetch.com
servers:
  - url: https://api.marleyfetch.com/v1
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Email
    description: Send a single email now, or queue it for background delivery.
  - name: Batches
    description: Send one message to many recipients. Pro plan.
  - name: Audiences
    description: Reusable recipient lists with per-member variables. Pro plan.
  - name: Templates
    description: Saved subjects and bodies you can send by ID. Free plan allows 3.

paths:
  /send:
    post:
      tags: [Email]
      operationId: sendEmail
      summary: Send an email
      description: |
        Sends immediately and waits for your SMTP server to accept the message.

        - Up to **10 recipients** across `to`, `cc` and `bcc`. Use `/send/batch` for more.
        - Counts towards your plan's daily limit (Free: 100/day) and the connection's quota.
        - Send an `Idempotency-Key` header to make retries safe: a repeat with the same key returns the
          original result (with `replayed: true`) instead of sending a second email.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            examples:
              text:
                summary: Plain text
                value:
                  from: you@yourcompany.com
                  to: customer@example.com
                  subject: Your order has shipped
                  text_body: Your order is on its way.
              html:
                summary: HTML with cc and bcc
                value:
                  to: [alice@example.com, bob@example.com]
                  cc: manager@example.com
                  bcc: [archive@yourcompany.com]
                  subject: Weekly report
                  html_body: <h1>Weekly report</h1><p>Numbers are up.</p>
                  text_body: Weekly report. Numbers are up.
      responses:
        '200':
          description: The SMTP server accepted the message, or this is a replay of an earlier request with the same `Idempotency-Key`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendResponse'
              examples:
                sent:
                  summary: Sent
                  value:
                    success: true
                    messageId: <send-4821@mid.marleyfetch.com>
                    outboxId: 4821
                    from: you@yourcompany.com
                    message: Email sent successfully
                replayed:
                  summary: Replayed idempotent request
                  value:
                    success: true
                    messageId: <send-4821@mid.marleyfetch.com>
                    outboxId: 4821
                    from: you@yourcompany.com
                    message: Email already sent for this Idempotency-Key
                    replayed: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ConnectionError'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/SendFailed'
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.marleyfetch.com/v1/send \
              -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: order-1042-shipped" \
              -d '{
                "from": "you@yourcompany.com",
                "to": "customer@example.com",
                "subject": "Your order has shipped",
                "text_body": "Your order is on its way."
              }'
        - lang: JavaScript
          label: SDK
          source: |
            import { MarleyFetch } from '@marleyfetch/sdk'

            const mf = new MarleyFetch({ apiKey: process.env.MARLEYFETCH_API_KEY })

            const { messageId } = await mf
              .from('you@yourcompany.com')
              .to('customer@example.com')
              .subject('Your order has shipped')
              .text_body('Your order is on its way.')
              .send()
        - lang: JavaScript
          label: fetch
          source: |
            const res = await fetch('https://api.marleyfetch.com/v1/send', {
              method: 'POST',
              headers: {
                Authorization: `Bearer ${process.env.MARLEYFETCH_API_KEY}`,
                'Content-Type': 'application/json',
              },
              body: JSON.stringify({
                from: 'you@yourcompany.com',
                to: 'customer@example.com',
                subject: 'Your order has shipped',
                text_body: 'Your order is on its way.',
              }),
            })
            if (!res.ok) throw new Error(`MarleyFetch ${res.status}: ${await res.text()}`)
            const { messageId } = await res.json()
        - lang: Python
          source: |
            import os, requests

            res = requests.post(
                "https://api.marleyfetch.com/v1/send",
                headers={"Authorization": f"Bearer {os.environ['MARLEYFETCH_API_KEY']}"},
                json={
                    "from": "you@yourcompany.com",
                    "to": "customer@example.com",
                    "subject": "Your order has shipped",
                    "text_body": "Your order is on its way.",
                },
            )
            res.raise_for_status()
            print(res.json()["messageId"])

  /enqueue:
    post:
      tags: [Email]
      operationId: enqueueEmail
      summary: Queue an email
      description: |
        Accepts the email and returns straight away; a background worker sends it, retrying on
        transient failures. Use it when the caller should not wait on your SMTP server.

        **Pro plan.** There is no status endpoint for a queued email yet: follow it in the
        dashboard's Email Outbox using the returned `outboxId`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EmailRequest'
            example:
              to: customer@example.com
              subject: Welcome aboard
              html_body: <p>Thanks for signing up.</p>
      responses:
        '200':
          description: Queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnqueueResponse'
              example:
                success: true
                message: Email queued successfully
                outboxId: 4822
                from: you@yourcompany.com
                queuedAt: '2026-09-28T10:15:00.000Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ConnectionOrPlanError'
        '429':
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.marleyfetch.com/v1/enqueue \
              -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"to": "customer@example.com", "subject": "Welcome aboard", "html_body": "<p>Thanks for signing up.</p>"}'
        - lang: JavaScript
          label: SDK
          source: |
            await mf.to('customer@example.com')
              .subject('Welcome aboard')
              .html_body('<p>Thanks for signing up.</p>')
              .enqueue()

  /send/batch:
    post:
      tags: [Batches]
      operationId: createBatch
      summary: Send a batch
      description: |
        Sends one message to many recipients, one email per recipient. The request returns as soon
        as the batch is created; poll `status_url` for progress.

        Give it content — inline `template` **or** a saved `template_id` — and recipients —
        `recipients` (up to 1,000 addresses) **or** an `audience_id`. Or send `emails`: up to
        1,000 items, each with its own recipient, subject and body.

        With an audience, `{{variable}}` placeholders in the subject and bodies are filled from each
        member's `payload`. A missing variable renders as an empty string.

        Batches respect your connection's quota and pacing: when the quota runs out, remaining
        emails wait for the next window instead of failing.

        **Pro plan.**
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BatchRequest'
            examples:
              recipients:
                summary: Inline template to a list of addresses
                value:
                  name: September newsletter
                  from: news@yourcompany.com
                  template:
                    subject: What's new in September
                    html_body: <p>Here's what we shipped this month.</p>
                  recipients: [alice@example.com, bob@example.com]
              audience:
                summary: Saved template to an audience, personalised
                value:
                  name: Renewal reminder
                  template_id: 3
                  audience_id: 12
              emails:
                summary: Different content per recipient
                value:
                  name: Order updates
                  emails:
                    - to: alice@example.com
                      subject: Order 1042 has shipped
                      text_body: Your order is on its way.
                    - to: bob@example.com
                      subject: Order 1043 is delayed
                      text_body: Sorry, your order is a day late.
      responses:
        '200':
          description: Batch created and started.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchCreatedResponse'
              example:
                success: true
                batch_id: 77
                total_count: 2
                message: 2 emails queued for batch delivery.
                status_url: /v1/batches/77
        '400':
          description: |
            Invalid body, `BATCH_TOO_LARGE` (more than 1,000 `recipients` or `emails`), or an audience that
            does not exist or has no members.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ValidationErrorResponse'
                  - $ref: '#/components/schemas/Error'
                  - $ref: '#/components/schemas/SimpleError'
              examples:
                tooLarge:
                  value:
                    code: BATCH_TOO_LARGE
                    message: Batch too large. Maximum 1000 recipients per batch allowed.
                emptyAudience:
                  value:
                    error: Audience has no members
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ConnectionOrPlanError'
        '404':
          description: '`template_id` does not exist or belongs to another account.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                code: RESOURCE_NOT_FOUND
                message: Template 3 not found
        '429':
          $ref: '#/components/responses/RateLimited'
      x-codeSamples:
        - lang: cURL
          source: |
            curl https://api.marleyfetch.com/v1/send/batch \
              -H "Authorization: Bearer $MARLEYFETCH_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                "name": "September newsletter",
                "template": {"subject": "What'\''s new in September", "html_body": "<p>Here is what we shipped.</p>"},
                "recipients": ["alice@example.com", "bob@example.com"]
              }'
        - lang: JavaScript
          label: SDK
          source: |
            const { batch_id } = await mf.batch()
              .name('Renewal reminder')
              .template_id(3)
              .audience_id(12)
              .send()

  /batches/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Batches]
      operationId: getBatch
      summary: Get batch status
      responses:
        '200':
          description: Current progress.
          content:
            application/json:
              schema:
                type: object
                required: [success, batch]
                properties:
                  success: { type: boolean, const: true }
                  batch: { $ref: '#/components/schemas/Batch' }
              example:
                success: true
                batch:
                  id: 77
                  name: September newsletter
                  status: running
                  total_count: 500
                  sent_count: 320
                  failed_count: 2
                  progress: 64
                  created_at: '2026-09-28 10:15:00'
                  updated_at: '2026-09-28 10:21:40'
        '400':
          $ref: '#/components/responses/InvalidId'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/BatchNotFound'
    delete:
      tags: [Batches]
      operationId: deleteBatch
      summary: Delete a batch
      description: Deletes a finished batch and its per-recipient records. Only `completed` or `failed` batches can be deleted.
      responses:
        '200':
          $ref: '#/components/responses/Deleted'
        '400':
          description: Invalid ID, or the batch is still `pending` or `running` (`INVALID_STATUS`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                code: INVALID_STATUS
                message: Cannot delete batch that is still in progress
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/BatchNotFound'

  /audiences:
    get:
      tags: [Audiences]
      operationId: listAudiences
      summary: List audiences
      responses:
        '200':
          description: Your audiences, newest first, with member counts.
          content:
            application/json:
              schema:
                type: object
                required: [success, audiences]
                properties:
                  success: { type: boolean, const: true }
                  audiences:
                    type: array
                    items: { $ref: '#/components/schemas/AudienceWithCount' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ProRequired'
    post:
      tags: [Audiences]
      operationId: createAudience
      summary: Create an audience
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 255 }
                description: { type: string, maxLength: 1000 }
            example:
              name: Beta customers
              description: Everyone on the beta programme
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AudienceResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ProRequired'

  /audiences/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Audiences]
      operationId: getAudience
      summary: Get an audience
      responses:
        '200':
          description: The audience with its member count.
          content:
            application/json:
              schema:
                type: object
                required: [success, audience]
                properties:
                  success: { type: boolean, const: true }
                  audience: { $ref: '#/components/schemas/AudienceWithCount' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NotYoursOrProRequired'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [Audiences]
      operationId: updateAudience
      summary: Update an audience
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 255 }
                description: { type: string, maxLength: 1000 }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AudienceResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NotYoursOrProRequired'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Audiences]
      operationId: deleteAudience
      summary: Delete an audience
      responses:
        '200':
          $ref: '#/components/responses/Deleted'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NotYoursOrProRequired'
        '404':
          $ref: '#/components/responses/NotFound'

  /audiences/{id}/members:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Audiences]
      operationId: listAudienceMembers
      summary: List members
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 100, maximum: 1000 }
        - name: offset
          in: query
          schema: { type: integer, default: 0 }
      responses:
        '200':
          description: One page of members.
          content:
            application/json:
              schema:
                type: object
                required: [success, members, pagination]
                properties:
                  success: { type: boolean, const: true }
                  members:
                    type: array
                    items: { $ref: '#/components/schemas/AudienceMember' }
                  pagination:
                    type: object
                    required: [total, limit, offset, hasMore]
                    properties:
                      total: { type: integer }
                      limit: { type: integer }
                      offset: { type: integer }
                      hasMore: { type: boolean }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NotYoursOrProRequired'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [Audiences]
      operationId: addAudienceMembers
      summary: Add members
      description: Adds up to 10,000 members per request. Addresses already in the audience are skipped and counted as `duplicates`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [members]
              properties:
                members:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items:
                    type: object
                    required: [email]
                    properties:
                      email: { type: string, format: email }
                      payload:
                        type: object
                        additionalProperties: true
                        description: Variables for `{{placeholders}}` when this audience is used in a batch.
            example:
              members:
                - email: alice@example.com
                  payload: { first_name: Alice, plan: Pro }
                - email: bob@example.com
                  payload: { first_name: Bob, plan: Free }
      responses:
        '201':
          description: Members added.
          content:
            application/json:
              schema:
                type: object
                required: [success, inserted, duplicates, message]
                properties:
                  success: { type: boolean, const: true }
                  inserted: { type: integer }
                  duplicates: { type: integer }
                  message: { type: string }
              example:
                success: true
                inserted: 2
                duplicates: 0
                message: Added 2 members (0 duplicates skipped)
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NotYoursOrProRequired'
        '404':
          $ref: '#/components/responses/NotFound'

  /audiences/{id}/members/{memberId}:
    parameters:
      - $ref: '#/components/parameters/Id'
      - name: memberId
        in: path
        required: true
        schema: { type: integer }
    delete:
      tags: [Audiences]
      operationId: deleteAudienceMember
      summary: Remove a member
      responses:
        '200':
          $ref: '#/components/responses/Deleted'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NotYoursOrProRequired'
        '404':
          $ref: '#/components/responses/NotFound'

  /templates:
    get:
      tags: [Templates]
      operationId: listTemplates
      summary: List templates
      responses:
        '200':
          description: Your templates.
          content:
            application/json:
              schema:
                type: object
                required: [success, templates]
                properties:
                  success: { type: boolean, const: true }
                  templates:
                    type: array
                    items: { $ref: '#/components/schemas/Template' }
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Templates]
      operationId: createTemplate
      summary: Create a template
      description: Free plans can keep 3 templates; Pro is unlimited.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, subject]
              description: At least one of `text_body` or `html_body` is required.
              properties:
                name: { type: string, minLength: 1, maxLength: 255 }
                subject: { type: string, minLength: 1, maxLength: 998 }
                text_body: { type: string }
                html_body: { type: string }
            example:
              name: Renewal reminder
              subject: '{{first_name}}, your {{plan}} plan renews soon'
              html_body: <p>Hi {{first_name}}, your plan renews next week.</p>
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Your plan's template limit is reached.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  limit: { type: integer }
                  used: { type: integer }
                  upgradeRequired: { type: boolean }
                  message: { type: string }
              example:
                error: Template limit reached
                limit: 3
                used: 3
                upgradeRequired: true
                message: You have 3 templates. Your plan allows 3. Upgrade to Pro for unlimited templates.

  /templates/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      tags: [Templates]
      operationId: getTemplate
      summary: Get a template
      responses:
        '200':
          description: The template.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NotYours'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [Templates]
      operationId: updateTemplate
      summary: Update a template
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 255 }
                subject: { type: string, minLength: 1, maxLength: 998 }
                text_body: { type: string }
                html_body: { type: string }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TemplateResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NotYours'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Templates]
      operationId: deleteTemplate
      summary: Delete a template
      responses:
        '200':
          $ref: '#/components/responses/Deleted'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/NotYours'
        '404':
          $ref: '#/components/responses/NotFound'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        An API key from https://app.marleyfetch.com/tokens, e.g. `Authorization: Bearer mf_live_...`.
        The key is shown once when created. A key can be limited to a single connection.

  parameters:
    Id:
      name: id
      in: path
      required: true
      schema: { type: integer }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string }
      description: |
        Any unique string, e.g. a UUID or `order-1042-shipped`. A retry with the same key returns
        the first request's result instead of sending again. While the first request is still in
        progress, a retry gets `400 IDEMPOTENCY_KEY_IN_FLIGHT`.

  schemas:
    Recipients:
      description: One address, a comma-separated string of addresses, or an array of addresses.
      oneOf:
        - type: string
          examples: ['alice@example.com', 'alice@example.com, bob@example.com']
        - type: array
          items: { type: string, format: email }

    EmailRequest:
      type: object
      required: [to, subject]
      description: At least one of `text_body` or `html_body` is required. Send both for the best deliverability.
      properties:
        from:
          type: string
          format: email
          description: Address of one of your connections. Picks the connection to send through; omit to use your default connection.
        to: { $ref: '#/components/schemas/Recipients' }
        cc: { $ref: '#/components/schemas/Recipients' }
        bcc: { $ref: '#/components/schemas/Recipients' }
        subject: { type: string, minLength: 1 }
        text_body: { type: string }
        html_body: { type: string }

    SendResponse:
      type: object
      required: [success, outboxId, message]
      properties:
        success:
          type: boolean
          description: '`false` only on a replay of an earlier request that failed.'
        messageId:
          type: string
          description: The Message-ID of the sent email.
        outboxId:
          type: integer
          description: ID of the send in your dashboard's Email Outbox.
        from: { type: string, format: email }
        message: { type: string }
        replayed:
          type: boolean
          description: Present and `true` when this response is for an earlier request with the same `Idempotency-Key`.

    EnqueueResponse:
      type: object
      required: [success, message, outboxId, queuedAt]
      properties:
        success: { type: boolean, const: true }
        message: { type: string }
        outboxId:
          type: integer
          description: ID of the send in your dashboard's Email Outbox.
        from: { type: string, format: email }
        queuedAt: { type: string, format: date-time }

    BatchRequest:
      type: object
      description: |
        Provide content (`template` or `template_id`, not both) and recipients (`recipients` or
        `audience_id`) — or an `emails` array, which carries both.
      properties:
        name:
          type: string
          description: Shown in the dashboard. Defaults to `Batch Send <timestamp>`.
        from:
          type: string
          format: email
          description: Address of one of your connections. Omit to use your default connection.
        template:
          type: object
          required: [subject]
          description: Inline content. At least one of `text_body` or `html_body` is required.
          properties:
            subject: { type: string, minLength: 1 }
            text_body: { type: string }
            html_body: { type: string }
        template_id:
          type: integer
          description: ID of a saved template.
        recipients:
          type: array
          maxItems: 1000
          items: { type: string, format: email }
        audience_id:
          type: integer
          description: ID of a saved audience. Members' `payload` fills `{{placeholders}}`.
        emails:
          type: array
          maxItems: 1000
          description: One email per item, each with its own content. Use instead of a template and recipients.
          items:
            type: object
            required: [to, subject]
            additionalProperties: false
            description: A single recipient — no cc or bcc. At least one of `text_body` or `html_body` is required.
            properties:
              to: { type: string, format: email }
              subject: { type: string, minLength: 1 }
              text_body: { type: string }
              html_body: { type: string }

    BatchCreatedResponse:
      type: object
      required: [success, batch_id, total_count, message, status_url]
      properties:
        success: { type: boolean, const: true }
        batch_id: { type: integer }
        total_count: { type: integer }
        message: { type: string }
        status_url:
          type: string
          description: Path to poll with `GET`, relative to `https://api.marleyfetch.com`.

    Batch:
      type: object
      required: [id, name, status, total_count, sent_count, failed_count, progress]
      properties:
        id: { type: integer }
        name: { type: string }
        status:
          type: string
          enum: [pending, running, completed, failed]
        total_count: { type: integer }
        sent_count: { type: integer }
        failed_count: { type: integer }
        progress:
          type: integer
          minimum: 0
          maximum: 100
          description: Percentage of recipients handled, sent or failed.
        created_at: { type: string }
        updated_at: { type: string }

    Audience:
      type: object
      properties:
        id: { type: integer }
        user_id: { type: integer }
        name: { type: string }
        description: { type: [string, 'null'] }
        temporary:
          type: integer
          description: '`1` for the throwaway audiences created behind `recipients` batches.'
        created_at: { type: string }
        updated_at: { type: string }

    AudienceWithCount:
      allOf:
        - $ref: '#/components/schemas/Audience'
        - type: object
          properties:
            member_count: { type: integer }

    AudienceResponse:
      type: object
      required: [success, audience]
      properties:
        success: { type: boolean, const: true }
        audience: { $ref: '#/components/schemas/Audience' }

    AudienceMember:
      type: object
      properties:
        id: { type: integer }
        audience_id: { type: integer }
        email: { type: string, format: email }
        payload:
          type: [object, 'null']
          additionalProperties: true
        created_at: { type: string }

    Template:
      type: object
      properties:
        id: { type: integer }
        user_id: { type: integer }
        name: { type: string }
        subject: { type: string }
        text_body: { type: [string, 'null'] }
        html_body: { type: [string, 'null'] }
        created_at: { type: string }
        updated_at: { type: string }

    TemplateResponse:
      type: object
      required: [success, template]
      properties:
        success: { type: boolean, const: true }
        template: { $ref: '#/components/schemas/Template' }

    Error:
      type: object
      required: [code, message]
      description: The standard error body. Branch on `code`; `message` is for humans.
      properties:
        code:
          type: string
          examples: [RATE_LIMITED]
        message: { type: string }
        details:
          type: object
          additionalProperties: true
          description: Extra context, e.g. `retryAfter` (seconds), `limit`, `used`, `upgradeRequired`.

    SimpleError:
      type: object
      required: [error]
      description: Older error body still returned by authentication and some audience/template checks.
      properties:
        error: { type: string }

    ValidationErrorResponse:
      type: object
      required: [success, error]
      description: The request body failed schema validation.
      properties:
        success: { type: boolean, const: false }
        error:
          type: object
          properties:
            name: { type: string, const: ZodError }
            issues:
              type: array
              items:
                type: object
                properties:
                  path:
                    type: array
                    items: { type: [string, integer] }
                  message: { type: string }
                  code: { type: string }

  responses:
    BadRequest:
      description: The body failed validation, or a request-level rule was broken (e.g. `TOO_MANY_RECIPIENTS`, `IDEMPOTENCY_KEY_IN_FLIGHT`).
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/ValidationErrorResponse'
              - $ref: '#/components/schemas/Error'
          examples:
            validation:
              summary: Schema validation
              value:
                success: false
                error:
                  name: ZodError
                  issues:
                    - code: invalid_type
                      path: [subject]
                      message: Required
            tooManyRecipients:
              summary: Too many recipients
              value:
                code: TOO_MANY_RECIPIENTS
                message: Too many recipients. Maximum 10 allowed for /send. Use /send/batch for 11-1000 recipients.
    InvalidId:
      description: The ID in the path is not a number.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: INVALID_ID, message: Invalid batch ID }
    Unauthorized:
      description: Missing, malformed, revoked or expired API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/SimpleError' }
          examples:
            missing:
              value: { error: Missing or invalid Authorization header }
            invalid:
              value: { error: Invalid or expired API token }
    ConnectionError:
      description: No usable connection for this request.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            noConnection:
              value: { code: NO_CONNECTION, message: No connected account found. Please connect an account first. }
            unknownFrom:
              value: { code: CONNECTION_NOT_FOUND, message: No connected account found for someone@else.com. }
            scopeMismatch:
              value: { code: CONNECTION_SCOPE_MISMATCH, message: This API key is scoped to you@yourcompany.com and cannot send from other@yourcompany.com. }
            paused:
              value: { code: CONNECTION_PAUSED, message: Account you@yourcompany.com is paused. Resume from the dashboard before retrying. }
    ConnectionOrPlanError:
      description: No usable connection, or the feature needs the Pro plan (`FEATURE_NOT_AVAILABLE`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            proRequired:
              value:
                code: FEATURE_NOT_AVAILABLE
                message: Upgrade to Pro to access this feature.
                details: { feature: async_sending, upgradeRequired: true }
            noConnection:
              value: { code: NO_CONNECTION, message: No connected account found. Please connect an account first. }
    ProRequired:
      description: This feature needs the Pro plan.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            code: FEATURE_NOT_AVAILABLE
            message: Upgrade to Pro to access this feature.
            details: { feature: audiences, upgradeRequired: true }
    NotYours:
      description: The resource belongs to another account.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/SimpleError' }
          example: { error: Access denied }
    NotYoursOrProRequired:
      description: The resource belongs to another account, or the feature needs the Pro plan.
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/SimpleError'
              - $ref: '#/components/schemas/Error'
          examples:
            notYours:
              value: { error: Access denied }
            proRequired:
              value:
                code: FEATURE_NOT_AVAILABLE
                message: Upgrade to Pro to access this feature.
                details: { feature: audiences, upgradeRequired: true }
    NotFound:
      description: No such resource.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/SimpleError' }
          example: { error: Audience not found }
    BatchNotFound:
      description: No batch with this ID on your account.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: BATCH_NOT_FOUND, message: Batch not found }
    Deleted:
      description: Deleted.
      content:
        application/json:
          schema:
            type: object
            required: [success, message]
            properties:
              success: { type: boolean, const: true }
              message: { type: string }
    RateLimited:
      description: |
        A limit was hit. `details.retryAfter` (seconds) says when to retry, where known.

        - `DAILY_LIMIT_REACHED` — your plan's daily cap (Free: 100/day). Resets at 00:00 UTC.
        - `QUOTA_EXCEEDED` — the connection's own daily or hourly quota. `details.resetsAt` says when it resets.
        - `RATE_LIMITED` — the connection is pacing sends, or your SMTP provider pushed back.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            rateLimited:
              value:
                code: RATE_LIMITED
                message: Account you@yourcompany.com is pacing sends. Retry in 4s.
                details: { retryAfter: 4 }
            dailyLimit:
              value:
                code: DAILY_LIMIT_REACHED
                message: You've sent 100 emails today. Your plan allows 100 per day. Upgrade to Pro for higher limits.
                details: { limit: 100, used: 100, upgradeRequired: true }
            quota:
              value:
                code: QUOTA_EXCEEDED
                message: 'Daily quota exceeded for you@yourcompany.com. Limit: 500 emails/day.'
                details: { limit: 500, used: 500, resetsAt: '2026-09-29T00:00:00.000Z' }
    SendFailed:
      description: Your SMTP server rejected the message or could not be reached. `message` carries the server's reason. Nothing was sent and no quota was used.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            code: INTERNAL_ERROR
            message: '535 5.7.8 Username and Password not accepted'
