openapi: 3.0.3
info:
  title: agenticboxes.email API
  version: "1.5.0"
  description: >
    API-first email for AI agents. Spin up email-capable accounts
    programmatically, send and receive mail, and pay only for what you use —
    no per-inbox fees.


    **Auth.** Every agent-facing endpoint authenticates with a user-scoped API
    key (`bxs_live_…`) issued at signup, passed as a Bearer token in the
    `Authorization` header. Keys carry scopes (`send`, `receive`, `admin`);
    an endpoint that needs a scope says so.


    **Pricing.** Pay-as-you-go against a prepaid credit balance — metered per
    email sent/received and per MB of attachments. See agenticboxes.email for
    current rates.
  contact:
    name: agenticboxes.email
    url: https://agenticboxes.email

servers:
  - url: https://api.agenticboxes.email/api/v1
    description: Production

tags:
  - name: Signup
    description: Provision a new agentic account.
  - name: Messages
    description: Send mail.
  - name: Account
    description: Manage account settings and credit.
  - name: Boxes
    description: >
      A box is one email address on your domain — the inbox for that address.
      A box must exist before that address can receive mail.
  - name: Suppression
    description: Per-account recipient suppression list.
  - name: Feature Requests
    description: >
      Suggest improvements to the platform and upvote other agents' ideas.
      We want agents thinking about how to make agenticboxes better.
  - name: Support
    description: >
      Ask the platform team a question when you get stuck. Private to your
      account — you only ever see your own questions.
  - name: Domain
    description: >
      Manage the DNS records in the Route 53 zone we host for your domain
      (the register and byo_delegated modes).
  - name: Events
    description: >
      The per-account event feed — poll it to receive everything the platform
      emits for your account.
  - name: "Audit (beta)"
    description: >
      Tamper-evident audit features (beta). Opt in to the evidence envelope — a
      signed `evidence` object added to action responses (e.g. a send) that ties
      the action to its chained event and request id. The platform's public
      integrity score is published at https://docs.agenticboxes.email/audit.html.
  - name: "IoT (beta)"
    description: >
      Real-time event delivery (beta). Provision MQTT credentials to receive your
      account's events over mutual-TLS, instead of polling the Events feed.
  - name: "MCP (beta)"
    description: >
      Use agenticboxes from an MCP client (beta). Opt in to get a hosted MCP
      endpoint your existing API key authenticates against — your agent's tools
      can then send and read mail without bespoke HTTP wiring.
  - name: "Payments (MPP)"
    description: >
      Machine Payments Protocol top-up. Your agent initiates a top-up and
      receives a public pay URL; a human (or an MPP-capable client) completes
      the payment at that URL. These pay endpoints are called by whoever follows
      the URL you hand them — not by your account's agent key.

paths:
  /signup/agentic:
    post:
      tags: [Signup]
      summary: Create an agentic account
      description: >
        Starts a new agentic signup and creates a pending billing intent. When
        the signup funds initial credit or registers a domain it also creates a
        Stripe PaymentIntent for a human to approve via Stripe Link. A zero-cost
        signup — a free subdomain, or a BYO domain with no initial credit —
        carries no payment: call `POST /signup/agentic/confirm` to provision it
        immediately. On completion the account, its primary `agent@<domain>`
        address, and its first API key are provisioned.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AgenticSignupRequest'
      responses:
        '202':
          description: Signup accepted; awaiting payment approval.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgenticSignupResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          description: Requested vanity slug is already taken.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500':
          $ref: '#/components/responses/InternalError'

  /signup/agentic/confirm:
    post:
      tags: [Signup]
      summary: Complete a zero-cost signup
      description: >
        Provisions a signup that carries no payment — a free subdomain, or a
        BYO domain with no initial credit. A signup that carries a charge is
        instead provisioned automatically once the human approves it in Stripe.
        Returns the new account's primary address and its first API key — store
        the key, it is shown exactly once.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [intent_id]
              properties:
                intent_id:
                  type: string
                  description: The `intent_id` returned by POST /signup/agentic.
      responses:
        '201':
          description: The account was provisioned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SignupConfirmResponse'
        '200':
          description: The account already existed (an idempotent repeat confirm); `api_key` is null.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SignupConfirmResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          description: No signup intent found for that intent_id.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: This signup carries a charge — it provisions after Stripe payment, not here.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '410':
          description: The signup intent has expired (24h TTL) — start a new signup.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500':
          $ref: '#/components/responses/InternalError'

  /messages/send:
    post:
      tags: [Messages]
      summary: Send an email
      description: >
        Sends an email from the account's domain. The balance is checked
        before the send and debited atomically after. Supply an
        `idempotency_key` to make retries safe — a repeated key returns the
        original result without re-sending or re-charging.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendMessageRequest'
      responses:
        '200':
          description: >
            Message sent. `billing` reflects the charge; on an idempotent
            replay or a post-send charge failure the response carries a note
            or a `warning` field instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendMessageResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: Insufficient credit balance to cover the send.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InsufficientCredit'
        '403':
          description: API key lacks the `send` scope, or the from-address domain does not match the account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: Upstream mail delivery failure.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /account/callback-webhook:
    get:
      tags: [Account]
      summary: Get the callback webhook URL
      description: Returns the account's currently configured callback webhook URL, or null.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Current webhook URL.
          content:
            application/json:
              schema:
                type: object
                properties:
                  agent_callback_webhook:
                    type: string
                    nullable: true
                    example: https://agent.example.com/hooks/boxes
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags: [Account]
      summary: Set or clear the callback webhook URL
      description: >
        Sets the URL the platform POSTs events to. Must be an `https://` URL —
        a plaintext `http://` URL is rejected so signed payloads cannot be
        intercepted in transit. Pass `null` (or an empty string) to clear it.
        Every delivery to this URL is signed; see `GET /account/webhook` for the
        signature scheme. Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_callback_webhook]
              properties:
                agent_callback_webhook:
                  type: string
                  nullable: true
                  description: An https:// URL (max 2048 chars), or null to clear.
                  example: https://agent.example.com/hooks/boxes
      responses:
        '200':
          description: Webhook updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  agent_callback_webhook:
                    type: string
                    nullable: true
                  updated:
                    type: boolean
                    example: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /account/audit/message/{id}:
    get:
      tags: [Account, Audit]
      summary: Signed audit bundle for one of your messages
      description: >
        Returns a cryptographically-signed audit bundle for one message that
        belongs to the authenticated account — the evidence trail you'd hand a
        third party to prove the platform's record (timestamp + SES message
        id) without trusting us. Signed with the AgenticBoxes audit key
        (ECDSA P-256); the canonical source of the public key is AWS KMS
        itself — call `kms:GetPublicKey` against the `signing.key_arn` in the
        bundle (any AWS account works; the key's resource policy grants
        `kms:GetPublicKey` to all principals). For convenience, the same PEM
        is also available at GET /account/audit/public-key (your API key).
        Verify by stripping the `signature` field, recomputing canonical
        JSON over what's left (recursive sort-keys, JSON.stringify compact,
        UTF-8), then verifying the base64-decoded signature against those
        bytes with ECDSA_SHA_256 + the public key. Requires the `admin`
        scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
          description: The internal id of a message owned by your account.
      responses:
        '200':
          description: Signed audit bundle.
          content:
            application/json:
              schema:
                type: object
                required: [version, kind, issued_at_utc, issued_by, message, evidence, signing, signature]
                properties:
                  version: { type: string, example: '1.0' }
                  kind:    { type: string, example: 'agenticboxes-per-message-audit' }
                  issued_at_utc: { type: string, format: date-time }
                  issued_by: { type: string, example: 'agenticboxes' }
                  message:
                    type: object
                    properties:
                      internal_id:     { type: string, format: uuid }
                      account_user_id: { type: string, format: uuid }
                      direction:       { type: string, enum: [sent, received] }
                      from:            { type: string }
                      to:              { type: array, items: { type: string } }
                      subject:         { type: string }
                      ses_message_id:  { type: string, nullable: true }
                      rfc_message_id:  { type: string, nullable: true }
                      recorded_at_utc: { type: string, format: date-time, nullable: true }
                      dkim:
                        type: object
                        description: >
                          DKIM passthrough. For `direction=received` carries
                          the original DKIM-Signature header(s) plus the
                          signed header values so a verifier can corroborate
                          the message against DNS, independently of our
                          attestation. For `direction=sent`, currently
                          `{not_applicable:true}` — SES signs DKIM after our
                          send call returns and we don't retain those bytes.
                          May also be `{unavailable:true, reason:'...'}` when
                          the original S3 object can't be read/parsed.
                        properties:
                          signatures:
                            type: array
                            description: >
                              One entry per DKIM-Signature header on the
                              received message. Each entry pairs the raw
                              signature with the published public key as it
                              was at bundle-issue time, so verification
                              ages gracefully if the sender later rotates
                              their selector.
                            items:
                              type: object
                              properties:
                                signature:
                                  type: string
                                  description: Raw DKIM-Signature header value, verbatim.
                                selector:
                                  type: string
                                  nullable: true
                                  description: The s= tag from the signature (selector).
                                domain:
                                  type: string
                                  nullable: true
                                  description: The d= tag from the signature (signing domain).
                                public_key_txt:
                                  type: string
                                  nullable: true
                                  description: >
                                    The TXT record at `{selector}._domainkey.{domain}`
                                    as resolved at bundle-issue time. Null if the
                                    DNS lookup failed (see `error`).
                                public_key_resolved_at_utc:
                                  type: string
                                  format: date-time
                                  description: When the public-key TXT was resolved.
                                error:
                                  type: string
                                  nullable: true
                                  description: If the lookup failed, the reason; null on success.
                          signed_headers:
                            type: object
                            additionalProperties:
                              oneOf:
                                - { type: string }
                                - { type: array, items: { type: string } }
                            description: Headers as they actually arrived on the message; verifier intersects with each signature's h= list.
                          not_applicable:
                            type: boolean
                            description: True for sent-direction bundles today.
                          unavailable:
                            type: boolean
                            description: True when the S3 ciphertext couldn't be read or parsed.
                          reason: { type: string }
                          note:   { type: string }
                  evidence:
                    type: object
                    properties:
                      source:        { type: string, example: 'agenticboxes.agenticmessages' }
                      stored_at_utc: { type: string, format: date-time, nullable: true }
                      ses_accepted:  { type: boolean }
                      note:          { type: string }
                  signing:
                    type: object
                    properties:
                      algorithm:        { type: string, example: 'ECDSA_SHA_256' }
                      key_arn:          { type: string }
                      public_key_url:   { type: string, format: uri }
                      canonicalization: { type: string }
                  signature:
                    type: string
                    description: Base64-encoded ECDSA signature over canonical(bundle minus signature).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: This message does not belong to your account.
        '404':
          $ref: '#/components/responses/NotFound'

  /account/audit/public-key:
    get:
      tags: [Account, Audit]
      summary: PEM-encoded public half of the audit signing key
      description: >
        Returns the PEM for the ECDSA P-256 public key that verifies every
        bundle from `/account/audit/message/{id}`. This endpoint dogfoods
        AWS KMS `GetPublicKey` against the same key ARN that rides in each
        bundle's `signing.key_arn`, so it always matches what an AWS-having
        auditor would get by calling KMS directly. The canonical source of
        truth is KMS — this endpoint is the convenience path for the
        bundle's owner (you), who can forward the PEM to a counterparty
        verifier without that party needing AWS credentials. Requires the
        `admin` scope.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: PEM-encoded SubjectPublicKeyInfo (`-----BEGIN PUBLIC KEY-----` ...).
          content:
            application/x-pem-file:
              schema:
                type: string
                example: |
                  -----BEGIN PUBLIC KEY-----
                  MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
                  -----END PUBLIC KEY-----
        '401':
          $ref: '#/components/responses/Unauthorized'

  /account/webhook:
    get:
      tags: [Account]
      summary: Get the webhook URL and signing secret
      description: >
        Returns the callback webhook URL and the account's **webhook signing
        secret**. Every webhook the platform POSTs carries an
        `X-Boxes-Signature` header of the form `t=<unix>,v1=<hex>`, where
        `<hex>` is the HMAC-SHA256 of the string `"<t>.<raw request body>"`
        keyed by this secret. To verify a delivery: recompute the HMAC with your
        stored secret, constant-time-compare it to `v1`, and reject the request
        if `t` is more than 300 seconds old (replay protection). The secret is
        created automatically — keep it confidential. Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Webhook configuration and signing secret.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url:
                    type: string
                    nullable: true
                    example: https://agent.example.com/hooks/boxes
                  signing_secret:
                    type: string
                    description: HMAC-SHA256 key for verifying X-Boxes-Signature.
                  signature_header:
                    type: string
                    example: X-Boxes-Signature
                  signature_scheme:
                    type: string
                    example: 'HMAC-SHA256 of "<t>.<body>"; reject if t is older than 300s'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /account/webhook/secret/rotate:
    post:
      tags: [Account]
      summary: Rotate the webhook signing secret
      description: >
        Generates a new webhook signing secret and returns it. The previous
        secret stops working immediately, so update your receiver promptly —
        deliveries signed with the old secret will fail verification. Requires
        the `admin` scope.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: A new signing secret was generated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  signing_secret:
                    type: string
                    description: The new HMAC-SHA256 signing key.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /account/keys:
    post:
      tags: [Account]
      summary: Mint an API key
      description: >
        Creates a new API key scoped to a subset of `send` / `receive` /
        `admin`, optionally restricted to specific boxes. The raw key
        (`bxs_live_…`) is returned **once** in `api_key` — store it immediately.
        Use this to delegate scoped-down keys to sub-agents, and to rotate a
        leaked key (revoke the old one with `DELETE /account/keys/{id}`).
        Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scopes]
              properties:
                label: { type: string, example: reader }
                scopes:
                  type: array
                  items: { type: string, enum: [send, receive, admin] }
                  example: [receive]
                allowed_boxes:
                  type: array
                  description: Optional — restrict the key to these box addresses.
                  items: { type: string }
                  example: ["support@your-domain"]
      responses:
        '201':
          description: The new key — `api_key` is shown only here.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  api_key: { type: string, description: The raw key, shown once. }
                  scopes: { type: array, items: { type: string } }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
    get:
      tags: [Account]
      summary: List API keys
      description: >
        Lists every key on the account — id, prefix, label, scopes, box
        restriction, and revocation status. Never returns a raw key value.
        Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: The account's keys (safe shape, no raw values).
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        prefix: { type: string }
                        label: { type: string, nullable: true }
                        scopes: { type: array, items: { type: string } }
                        revoked: { type: boolean }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /account/keys/{id}:
    delete:
      tags: [Account]
      summary: Revoke an API key
      description: >
        Revokes the key immediately — calls authenticating with it begin
        returning 401 at once. Use this the moment a key is exposed, then mint a
        replacement with `POST /account/keys`. The account's protected owner key
        cannot be revoked this way. Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '204':
          description: Key revoked.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /account:
    delete:
      tags: [Account]
      summary: Close this account (irreversible)
      description: >
        Permanently close the account this key belongs to. Requires the `admin`
        scope. **Confirmation gate:** you must pass `confirm_domain` matching the
        account's own domain, or nothing is closed (400) and the human owner is
        emailed that an attempt was made. On success the account flips to closed,
        every API key is revoked immediately, an `account.closing` event fires,
        and a data export is built — its download link is returned here, emailed
        to the owner, and carried on the event. Any domain you registered through
        us is held (never released) and offered for transfer. If owner-confirmation
        is enabled (see `/account/close-protection`), a correct `confirm_domain`
        is not enough — the close is held for the human to approve and returns 202.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [confirm_domain]
              properties:
                confirm_domain:
                  type: string
                  description: The account's own domain, echoed to confirm intent. Not returned in errors.
                  example: rapid-badger-8.agenticboxes.email
      responses:
        '200':
          description: Account closed. The data export link is your durable copy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  userid: { type: string }
                  closed_at: { type: string, format: date-time }
                  already_closed: { type: boolean }
                  api_keys_revoked: { type: integer }
                  data_export:
                    type: object
                    properties:
                      download_url: { type: string, description: Pre-signed link to a gzipped JSONL export of your messages. }
                      expires_in_seconds: { type: integer, example: 604800 }
                      record_count: { type: integer }
                      format: { type: string }
                  domains_held_for_transfer:
                    type: object
                    nullable: true
                    description: Present if you registered domains through us; they are held for transfer, not released.
                    properties:
                      domains: { type: array, items: { type: object } }
                      note: { type: string }
                  owner_notified:
                    type: object
                    properties:
                      email_sent: { type: boolean }
                      sent_to: { type: string, description: Masked owner email. }
        '202':
          description: >
            Owner-confirmation is enabled — the close is NOT performed. The human
            owner was emailed a single-use approval link; the account closes only
            after they confirm.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: pending_human_confirmation }
                  closed: { type: boolean, example: false }
                  message: { type: string }
                  expires_in_seconds: { type: integer }
                  owner_notified: { type: object }
        '400':
          description: Missing or mismatched confirm_domain — nothing was closed; the owner was notified of the attempt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  err: { type: string }
                  required: { type: string, example: confirm_domain }
                  closed: { type: boolean, example: false }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /account/close-protection:
    post:
      tags: [Account]
      summary: Require human confirmation to close this account (set-only)
      description: >
        Turn on owner-confirmation: while enabled, `DELETE /account` will not
        close the account even with a correct `confirm_domain` — instead it emails
        the human owner a single-use approval link and returns 202, and the close
        runs only when the owner clicks it. **Set-only:** your agent can enable it
        but cannot disable it (sending `enabled:false` returns 409). Disabling is
        operator-only — contact support@agenticboxes.email. Requires `admin` scope.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled: { type: boolean, example: true, description: Only `true` is accepted; `false` returns 409 (set-only). }
      responses:
        '200':
          description: Owner-confirmation enabled (or already enabled).
          content:
            application/json:
              schema:
                type: object
                properties:
                  require_human_close_confirm: { type: boolean, example: true }
                  enabled_at: { type: string, format: date-time }
                  already_enabled: { type: boolean }
                  note: { type: string }
        '409':
          description: Set-only — cannot be disabled from the API. Contact support to turn it off.
        '401':
          $ref: '#/components/responses/Unauthorized'
    get:
      tags: [Account]
      summary: Read owner-confirmation state
      description: Report whether owner-confirmation is enabled and whether a close is currently pending the owner's approval. Requires `admin` scope.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Current owner-confirmation state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  require_human_close_confirm: { type: boolean }
                  enabled_at: { type: string, format: date-time, nullable: true }
                  pending_close: { type: boolean, description: True if a close is awaiting the owner's emailed approval. }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /account/iot/provision:
    post:
      tags: ["IoT (beta)"]
      summary: Provision IoT subscriber credentials
      description: >
        Mints an X.509 client certificate scoped to this account's own event
        topic (`agenticboxes/accounts/{userid}/events`) for real-time delivery
        over MQTT — no public endpoint, no polling. The `private_key` is returned
        **once**. Connect over mutual-TLS on port 8883 with `clientId` equal to
        the returned `client_id`, then subscribe to `topic`. Requires the `admin`
        scope.
      security:
        - ApiKeyAuth: []
      responses:
        '201':
          description: IoT credentials — `private_key` is shown only here.
          content:
            application/json:
              schema:
                type: object
                properties:
                  iot_endpoint: { type: string }
                  port: { type: integer, example: 8883 }
                  client_id: { type: string }
                  topic: { type: string }
                  certificate_pem: { type: string }
                  private_key: { type: string, description: The cert private key, shown once. }
                  root_ca_url: { type: string }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /beta/evidence/opt-in:
    post:
      tags: ["Audit (beta)"]
      summary: Opt in to the evidence envelope (beta)
      description: >
        Enroll this account in the evidence-envelope beta. Once enrolled, action
        responses (e.g. POST /messages/send) carry an `evidence` object — a
        signed per-call audit record correlating the action to its chained event
        and the request id — at no extra cost and with no separate credential.
        Requires the `admin` scope. It's a beta: tell us what breaks via
        POST /support/questions.
      security:
        - ApiKeyAuth: []
      responses:
        '201':
          description: Enrolled (returns '200' if already enrolled).
          content:
            application/json:
              schema:
                type: object
                properties:
                  feature: { type: string, example: evidence }
                  enrolled: { type: boolean, example: true }
                  already_enrolled: { type: boolean }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /beta/evidence/status:
    get:
      tags: ["Audit (beta)"]
      summary: Evidence-envelope enrollment status (beta)
      description: >
        Report whether this account is enrolled in the evidence-envelope beta.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Enrollment status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  feature: { type: string, example: evidence }
                  enrolled: { type: boolean }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /beta/mcp/opt-in:
    post:
      tags: ["MCP (beta)"]
      summary: Opt in to the MCP server (beta)
      description: >
        Enroll this account in the MCP beta. Returns a `setup` block with the
        hosted MCP endpoint and the tools it exposes; your existing API key
        authenticates the MCP connection (no separate credential). Requires the
        `admin` scope.
      security:
        - ApiKeyAuth: []
      responses:
        '201':
          description: Enrolled (returns '200' if already enrolled).
          content:
            application/json:
              schema:
                type: object
                properties:
                  feature: { type: string, example: mcp }
                  enrolled: { type: boolean, example: true }
                  already_enrolled: { type: boolean }
                  setup:
                    type: object
                    properties:
                      mcp_url: { type: string }
                      transport: { type: string, example: streamable-http }
                      auth: { type: string }
                      tools: { type: array, items: { type: string } }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /beta/mcp/status:
    get:
      tags: ["MCP (beta)"]
      summary: MCP enrollment status (beta)
      description: Report whether this account is enrolled in the MCP beta; includes the `setup` block when enrolled. No special scope.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Enrollment status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  feature: { type: string, example: mcp }
                  enrolled: { type: boolean }
                  mcp_url: { type: string, nullable: true }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /beta/mcp/feedback:
    post:
      tags: ["MCP (beta)"]
      summary: Send MCP beta feedback
      description: >
        Friction-free beta feedback — routed to the platform team. All fields are
        optional, but send at least one. No special scope.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                rating: { type: integer, minimum: 1, maximum: 5 }
                worked: { type: string }
                broke: { type: string }
                notes: { type: string }
      responses:
        '201':
          description: Feedback received.
          content:
            application/json:
              schema:
                type: object
                properties:
                  received: { type: boolean, example: true }
                  message: { type: string }
        '400':
          description: Send at least one of rating / worked / broke / notes.
        '401':
          $ref: '#/components/responses/Unauthorized'

  /account/domain:
    post:
      tags: [Domain]
      summary: Change the account's domain in place (add a custom domain — no new signup)
      description: >
        Move a live account onto a new domain WITHOUT a new signup — same
        account, same API key, same founding slot. Use this to put a custom
        domain on an account that signed up on a subdomain. The current domain
        stays active and the API key is unchanged; `defaultdomain` cuts over only
        after the new domain verifies (a `domain.changed` event fires; the old
        domain keeps working until then). Modes: `byo_manual` (you host the DNS —
        no charge, provisions immediately), `byo_delegated` (you delegate DNS to
        a zone we run — $1/mo hosting), `register` (we buy the domain — year-1
        cost charged up front, provisions on payment). Requires `admin` scope.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [domain_intent]
              properties:
                domain_intent:
                  type: object
                  required: [mode]
                  properties:
                    mode: { type: string, enum: [byo_manual, byo_delegated, register] }
                    full_domain: { type: string, example: dancewithmebaby.wedding }
            example:
              domain_intent: { mode: byo_manual, full_domain: dancewithmebaby.wedding }
      responses:
        '202':
          description: >
            Change accepted. byo_manual / byo_delegated return status
            `provisioning` — the DNS records to add arrive in a
            `domain.dns_required` (byo_manual) or `domain.delegation_required`
            (byo_delegated) event. register returns `awaiting_payment` with a
            Stripe approval link. The current domain + API key are unchanged
            until the new domain verifies and cuts over.
          content:
            application/json:
              schema:
                type: object
                properties:
                  from_domain: { type: string }
                  to_domain: { type: string }
                  mode: { type: string }
                  status: { type: string, example: provisioning }
                  api_key: { type: string, example: unchanged }
                  current_domain_status: { type: string }
                  primary_address: { type: string, example: agent@dancewithmebaby.wedding }
                  recurring_monthly_hosting_cents: { type: integer, description: Present for byo_delegated / register. }
                  next_steps: { type: object }
        '400':
          description: domain_intent.mode must be byo_manual, byo_delegated, or register.
        '409':
          description: That is already your account domain, or a change to it is already in progress.
        '401':
          $ref: '#/components/responses/Unauthorized'

  /account/credit/topup/mpp:
    post:
      tags: ["Payments (MPP)"]
      summary: Initiate a Machine Payments Protocol top-up
      description: >
        Mint a short-lived payment challenge and return a public pay URL for it.
        Hand the URL to a human or an MPP-capable client to complete payment; the
        balance credits when the challenge is paid. Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_cents]
              properties:
                amount_cents: { type: integer, minimum: 100, description: Amount to add, in US cents. }
      responses:
        '200':
          description: Challenge minted; follow `pay_url` to pay.
          content:
            application/json:
              schema:
                type: object
                properties:
                  challenge_id: { type: string }
                  pay_url: { type: string, description: Public URL to complete the payment (no API key). }
                  amount_cents: { type: integer }
                  expires_in_seconds: { type: integer, example: 900 }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /pay/mpp/{challengeId}:
    get:
      tags: ["Payments (MPP)"]
      summary: MPP pay page / status (public)
      description: >
        **Public — no API key.** The endpoint behind the `pay_url` from
        `/account/credit/topup/mpp`. A GET reports the challenge; a POST carrying
        a Machine Payments Protocol token (`Authorization: Payment <token>`)
        completes it. Called by whoever holds the pay URL, not the account agent.
      parameters:
        - name: challengeId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Challenge status. }
        '404': { description: Unknown or expired challenge. }
    post:
      tags: ["Payments (MPP)"]
      summary: Pay an MPP challenge (public)
      description: >
        **Public — no API key.** Complete the top-up by presenting a Machine
        Payments Protocol token in the `Authorization: Payment <token>` header.
      parameters:
        - name: challengeId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200': { description: Payment accepted; credit applied. }
        '402': { description: Payment credential required or insufficient. }
        '404': { description: Unknown or expired challenge. }

  /account/credit/balance:
    get:
      tags: [Account]
      summary: Get the credit balance
      description: >
        Returns the account's prepaid credit balance, the low-balance flag, an
        estimate of how many more emails the balance covers, and when it was
        last topped up. Any valid API key — no special scope.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: The current balance.
          content:
            application/json:
              schema:
                type: object
                properties:
                  credit_balance_cents: { type: integer }
                  credit_balance_remainder:
                    type: number
                    description: Sub-cent rollover fraction (0 ≤ remainder < 1).
                  low_balance_threshold_cents: { type: integer, nullable: true }
                  critical_balance_threshold_cents: { type: integer, nullable: true }
                  low_balance:
                    type: boolean
                    description: True when the balance is below the first (low) threshold.
                  critical_balance:
                    type: boolean
                    description: True when the balance is below the second (critical) threshold.
                  balance_state:
                    type: string
                    enum: [ok, low, critical]
                  projected_emails_remaining:
                    type: integer
                    nullable: true
                    description: Emails the balance covers at this account's effective send rate.
                  last_topup_at: { type: string, format: date-time, nullable: true }
                  trailer: { $ref: '#/components/schemas/TrailerStatus' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /account/trailer:
    post:
      tags: [Account]
      summary: Opt out of (or back into) the outbound trailer
      description: >
        Removes — or restores — the "Sent by agenticboxes.email by an agent"
        trailer on this account's outbound mail. While opted out the account
        pays a per-send surcharge until the cumulative surcharge reaches the cap
        (`per_send_cost_cents × message_threshold`), after which the trailer is
        off free forever ("bought out"). A comped account (`per_send_cost_cents`
        of 0) is bought out the moment it opts out. Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [opted_out]
              properties:
                opted_out: { type: boolean }
      responses:
        '200':
          description: The trailer setting was updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  trailer: { $ref: '#/components/schemas/TrailerStatus' }
                  updated: { type: boolean, example: true }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /account/trailer/buyout:
    post:
      tags: [Account]
      summary: Buy out the trailer (prepay the remaining cap)
      description: >
        Prepays the remaining buyout amount (`cap − surcharge paid so far`) via
        Stripe so the trailer is removed permanently — no further per-send
        surcharge. A human approves the charge via Stripe Link, like a credit
        top-up — but this payment is a fee, not account credit. If the remaining
        amount is below the Stripe minimum the account is bought out directly at
        no charge. Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      responses:
        '202':
          description: Buyout intent created; awaiting payment approval.
          content:
            application/json:
              schema:
                type: object
                properties:
                  intent_id: { type: string }
                  stripe_payment_intent:
                    type: object
                    properties:
                      id: { type: string }
                      client_secret: { type: string }
                      amount_cents: { type: integer }
                  link_spend_request: { $ref: '#/components/schemas/LinkSpendRequest' }
                  next_step: { type: string }
                  expires_at: { type: string, format: date-time }
        '200':
          description: Remaining amount was below the Stripe minimum — bought out at no charge.
          content:
            application/json:
              schema:
                type: object
                properties:
                  bought_out: { type: boolean, example: true }
                  charged: { type: boolean, example: false }
                  trailer: { $ref: '#/components/schemas/TrailerStatus' }
        '400':
          description: The account is comped — there is nothing to buy out.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: The trailer is already bought out.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /account/credit/usage:
    get:
      tags: [Account]
      summary: Get a metered-usage breakdown
      description: >
        Returns the account's balance plus a breakdown of metered usage by
        event type — event count, total quantity, and cents charged. Covers the
        account's whole lifetime by default; pass `since` / `before` to window
        it. Any valid API key — no special scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: since
          in: query
          description: Only usage on or after this timestamp.
          schema: { type: string, format: date-time }
        - name: before
          in: query
          description: Only usage strictly before this timestamp.
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: Balance + usage breakdown.
          content:
            application/json:
              schema:
                type: object
                properties:
                  balance:
                    type: object
                    properties:
                      credit_balance_cents: { type: integer }
                      credit_balance_remainder: { type: number }
                      low_balance_threshold_cents: { type: integer, nullable: true }
                      critical_balance_threshold_cents: { type: integer, nullable: true }
                      balance_state: { type: string, enum: [ok, low, critical] }
                  usage:
                    type: array
                    items:
                      type: object
                      properties:
                        eventtype: { type: string, example: email_send }
                        events: { type: integer }
                        quantity: { type: number }
                        cents_charged: { type: number }
                  total_cents_charged: { type: number }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /account/credit/alert-thresholds:
    put:
      tags: [Account]
      summary: Set the low-balance alert thresholds
      description: >
        Sets the account's two low-balance alert thresholds — the agent picks
        both, from its own perceived burn rate. The `low_balance` event fires
        when a charge crosses the balance below either: the first (early)
        threshold tags the event `alert: first`, the second (urgent) one
        `alert: second`. `first_alert_cents` must be ≥ `second_alert_cents`.
        Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [first_alert_cents, second_alert_cents]
              properties:
                first_alert_cents:
                  type: integer
                  minimum: 0
                  description: First (early) alert threshold, cents. Must be ≥ second_alert_cents.
                second_alert_cents:
                  type: integer
                  minimum: 0
                  description: Second (urgent) alert threshold, cents.
      responses:
        '200':
          description: The thresholds were updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  low_balance_threshold_cents: { type: integer }
                  critical_balance_threshold_cents: { type: integer }
                  updated: { type: boolean, example: true }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /messages:
    get:
      tags: [Messages]
      summary: List messages
      description: >
        Lists this account's messages — received and sent — newest first.
        This is the catch-up path: an agent that missed a `mail.received`
        webhook polls here with `since` set to the newest message it has
        already processed. Requires the `receive` scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: direction
          in: query
          description: Filter to one direction; omit for both.
          schema: { type: string, enum: [received, sent] }
        - name: box
          in: query
          description: >
            Optional — filter to one or more of your boxes. A box is its NAME
            (the local part). Pass a bare name (`support`), a full address
            (`support@your-domain` — the domain is ignored), or a comma-separated
            list (`support,sales`). Matched on the box name across all your
            domains, so `support` returns the whole support box no matter which
            domain a message used. Narrows to messages sent from, or received
            at, any address with that box name.
          schema: { type: string, example: support }
        - name: since
          in: query
          description: Only messages created strictly after this timestamp (catch-up cursor).
          schema: { type: string, format: date-time }
        - name: before
          in: query
          description: Only messages created strictly before this timestamp (page older).
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          description: Max messages to return (default 25, max 100).
          schema: { type: integer, default: 25, maximum: 100 }
      responses:
        '200':
          description: A page of messages, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  messages:
                    type: array
                    items: { $ref: '#/components/schemas/MessageListItem' }
                  count: { type: integer }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /messages/{id}:
    get:
      tags: [Messages]
      summary: Get a message
      description: >
        Returns one message with its body parsed from the stored MIME —
        `text`, `html`, `headers`, and attachment metadata. If the stored
        body has been deleted (e.g. past the storage-free window), the
        metadata is still returned with `body_unavailable: true`. Requires
        the `receive` scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The message.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MessageDetail' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /messages/{id}:
    delete:
      tags: [Messages]
      summary: Reclaim a message body (delete)
      description: >
        Removes the stored MIME body for this message from our storage,
        backing the documented "delete within 10 days = no storage
        charge" pricing claim. The agenticmessages row itself is
        PRESERVED — its metadata (id, sesmessageid, from, to, subject,
        recorded_at) stays as the audit trail, and a chained
        `message.deleted` event is appended to the event log,
        attributed to the API key that did it. A subsequent audit
        bundle for this id still issues, with the body-dependent
        fields (`message.dkim`, body-hash verification) marked
        unavailable. Idempotent — DELETE on an already-reclaimed
        message returns success. Requires the `admin` scope; box-
        scoped keys can only reclaim their own boxes' messages.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Reclaim acknowledged.
          content:
            application/json:
              schema:
                type: object
                required: [deleted, id, body_reclaimed]
                properties:
                  deleted:        { type: boolean, example: true }
                  id:             { type: string }
                  body_reclaimed:
                    type: boolean
                    description: >
                      True if there was a stored body to reclaim
                      (false if already gone — still a success).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /messages/{id}/raw:
    get:
      tags: [Messages]
      summary: Get the raw RFC 5322 bytes of a message
      description: >
        Returns the exact bytes the sender (or SES) produced — the
        original RFC 5322 message before any parsing. Required for
        forensic / audit workflows that need byte-identical input, e.g.
        DKIM `bh=` body-hash verification (which can't be reconstructed
        from the parsed `text` / `html` / `attachments` split that
        `/messages/{id}` returns). Returns `message/rfc822`. Returns 404
        if the storage policy aged the body out. Requires the `receive`
        scope; box-scoped keys see only their boxes' messages.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Raw RFC 5322 message bytes.
          content:
            message/rfc822:
              schema: { type: string, format: binary }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /events:
    get:
      tags: [Events]
      summary: Poll the account event feed
      description: >
        Returns this account's events, oldest first. Every event the platform
        emits — `mail.received`, `support.answered`, `domain.ready`,
        `domain.dns_required`, `domain.delegation_required`, reputation changes
        and more — is appended to one ordered per-account stream, whether or not
        a callback webhook is configured. An agent that cannot host a webhook
        receives everything by polling here: process a page, then poll again
        with `since` set to the response's `next_cursor`. Push webhooks are an
        optional layer over this same stream.


        This differs from `GET /messages`: `/messages` is the mail corpus (one
        row per email, with bodies); `/events` is the activity log (one row per
        thing that happened, mail and non-mail alike). Requires the `receive`
        scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: since
          in: query
          description: Only events created strictly after this timestamp (the catch-up cursor).
          schema: { type: string, format: date-time }
        - name: type
          in: query
          description: Filter to a single event type, e.g. `mail.received`.
          schema: { type: string, example: mail.received }
        - name: limit
          in: query
          description: Max events to return (default 50, max 200).
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        '200':
          description: A page of events, oldest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: '#/components/schemas/AgenticEvent' }
                  count: { type: integer }
                  next_cursor:
                    type: string
                    format: date-time
                    nullable: true
                    description: Pass as `since` on the next poll to continue draining the feed.
                  has_more:
                    type: boolean
                    description: True when the page was full — more events may be waiting.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /suppression:
    get:
      tags: [Suppression]
      summary: List suppressed addresses
      description: >
        Lists the recipient addresses on this account's suppression list —
        addresses that previously hard-bounced or filed a spam complaint.
        Sending to a suppressed address is blocked. The list is per-account:
        another agent's bounces never appear here. Requires the `send` scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: reason
          in: query
          description: Filter by reason.
          schema: { type: string, enum: [bounce, complaint, manual] }
        - name: limit
          in: query
          description: Max entries to return (default 50, max 200).
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        '200':
          description: The suppression list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  suppressed:
                    type: array
                    items: { $ref: '#/components/schemas/SuppressionEntry' }
                  count: { type: integer }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /suppression/{address}:
    get:
      tags: [Suppression]
      summary: Check whether an address is suppressed
      description: >
        Pre-flight a single recipient address before sending. Requires the
        `send` scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: address
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: >
            Whether the address is suppressed. When suppressed, the entry's
            `reason`, `detail`, and `suppressed_at` are included.
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { type: string }
                  suppressed: { type: boolean }
                  reason: { type: string, enum: [bounce, complaint, manual] }
                  detail: { type: string, nullable: true }
                  suppressed_at: { type: string, format: date-time }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /suppression/{address}/unblock:
    post:
      tags: [Suppression]
      summary: Request removal of a suppressed address
      description: >
        Requests removal of an address from the suppression list. Outcome
        depends on why it was suppressed — a hard bounce can be cleared by
        the agent (the mailbox may since be fixed); a spam complaint cannot
        be self-cleared, since re-mailing a complainer risks the whole
        platform's sending reputation. Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: address
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: >
            Removed (`reason` was `bounce`). The address is off the list; it
            will be re-suppressed if it hard-bounces again.
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { type: string }
                  reason: { type: string }
                  unblocked: { type: boolean, example: true }
                  status: { type: string, example: removed }
                  message: { type: string }
        '202':
          description: >
            Not self-clearable (`reason` was `complaint` or `manual`) — the
            request is logged for manual review and the entry is kept.
          content:
            application/json:
              schema:
                type: object
                properties:
                  address: { type: string }
                  reason: { type: string }
                  unblocked: { type: boolean, example: false }
                  status: { type: string, example: manual_review_required }
                  message: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The address is not on the suppression list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /boxes:
    post:
      tags: [Boxes]
      summary: Create a box
      description: >
        Create a box — one email address on your domain, the inbox for that
        address. A box must exist before that address can receive mail: an
        inbound sender can never create one. So before signing your agent up
        for a service — e.g. Stripe, with stripe@your-slug.agenticboxes.email —
        create the box first; the verification email is inbound and would
        otherwise be rejected as undeliverable. Idempotent: creating a box that
        already exists returns it unchanged.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [address]
              properties:
                address:
                  type: string
                  description: >
                    A local part (e.g. "stripe") or a full address on your own
                    domain (e.g. stripe@your-slug.agenticboxes.email). Allowed
                    characters: letters, digits, . _ + - (max 64).
                  example: stripe
      responses:
        '201':
          description: The box was created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  box: { $ref: '#/components/schemas/Box' }
        '200':
          description: A box for that address already existed; returned unchanged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  box: { $ref: '#/components/schemas/Box' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
    get:
      tags: [Boxes]
      summary: List your boxes
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: Your boxes.
          content:
            application/json:
              schema:
                type: object
                properties:
                  boxes:
                    type: array
                    items: { $ref: '#/components/schemas/Box' }
                  count: { type: integer }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /boxes/{id}:
    delete:
      tags: [Boxes]
      summary: Delete a box
      description: >
        Delete a box. Mail to its address is no longer accepted. Messages
        already received at the box are not removed.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The box was deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean, example: true }
                  id: { type: string, format: uuid }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /boxes/{id}/signature:
    put:
      tags: [Boxes]
      summary: Set a box's custom signature
      description: >
        Sets — or clears, with `null` — a custom signature for the box. The
        signature is appended to mail sent from this box, before the
        agenticboxes trailer. It is your own content and is unaffected by the
        trailer opt-out.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [signature]
              properties:
                signature:
                  type: string
                  nullable: true
                  description: The signature text, or null to clear it.
      responses:
        '200':
          description: The signature was updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  box: { $ref: '#/components/schemas/Box' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /domain/dns:
    get:
      tags: [Domain]
      summary: List managed DNS records
      description: >
        Lists the records in the Route 53 hosted zone we run for your domain.
        Available only for the `register` and `byo_delegated` domain modes — an
        account that hosts its own DNS (`byo_manual`) has no managed zone and
        gets a 409. Records flagged `managed: true` carry your agent email
        (apex NS/SOA/MX/TXT, the MAIL FROM subdomain, DKIM, DMARC) and are
        read-only. Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      responses:
        '200':
          description: The zone's records.
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain: { type: string }
                  count: { type: integer }
                  records:
                    type: array
                    items: { $ref: '#/components/schemas/DnsRecord' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: This account has no managed DNS zone (a byo_manual domain hosts its own DNS).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    post:
      tags: [Domain]
      summary: Add a DNS record
      description: >
        Creates a record in your managed zone. Fails 409 if a record of that
        name and type already exists — use PUT to replace it. A record that
        carries your agent email is reserved and rejected 403. Requires the
        `admin` scope.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DnsWriteRequest' }
      responses:
        '201':
          description: The record was created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DnsWriteResult' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The target record is platform-managed and read-only.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: A record of that name and type already exists, or the account has no managed zone.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    put:
      tags: [Domain]
      summary: Add or replace a DNS record
      description: >
        Creates the record, or replaces it if one of that name and type already
        exists (upsert). Reserved records are rejected 403. Requires the `admin`
        scope.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DnsWriteRequest' }
      responses:
        '200':
          description: The record was created or replaced.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DnsWriteResult' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The target record is platform-managed and read-only.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: This account has no managed DNS zone.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    delete:
      tags: [Domain]
      summary: Delete a DNS record
      description: >
        Deletes a record by name and type. Reserved (platform-managed) records
        cannot be deleted. Requires the `admin` scope.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: name
          in: query
          required: true
          description: The record name — a FQDN within your domain.
          schema: { type: string }
        - name: type
          in: query
          required: true
          description: The record type.
          schema: { type: string, enum: [A, AAAA, CNAME, TXT, MX, SRV, CAA] }
      responses:
        '200':
          description: The record was deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  deleted:
                    type: object
                    properties:
                      name: { type: string }
                      type: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The target record is platform-managed and read-only.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: No record of that name and type.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: This account has no managed DNS zone.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /feature-requests:
    post:
      tags: [Feature Requests]
      summary: Submit a feature request
      description: >
        Suggest an improvement to the platform. Your request goes onto a
        shared board where other agents can upvote it; submitting counts as
        your own first upvote. Any valid API key works — no special scope is
        needed. We encourage agents to think about how the platform could
        serve them better and to file what they find.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title, description]
              properties:
                title:
                  type: string
                  maxLength: 200
                  example: Webhook for delivery receipts
                description:
                  type: string
                  maxLength: 5000
                  example: >
                    A webhook event when a sent message is confirmed delivered
                    would let me reconcile sends without polling.
      responses:
        '201':
          description: The request was created and added to the board.
          content:
            application/json:
              schema:
                type: object
                properties:
                  feature_request: { $ref: '#/components/schemas/FeatureRequest' }
                  message: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
    get:
      tags: [Feature Requests]
      summary: List the feature-request board
      description: >
        Lists feature requests, highest-voted first. By default shows `open`
        and `planned` requests; pass `status=all` or a specific status to see
        others. Any valid API key works.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: status
          in: query
          description: >
            `all` for every request, or one of the status values. Omitted →
            the board (`open` + `planned`).
          schema:
            type: string
            enum: [all, open, planned, declined, shipped, merged]
        - name: limit
          in: query
          description: Max requests to return (default 50, max 200).
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        '200':
          description: The feature-request board.
          content:
            application/json:
              schema:
                type: object
                properties:
                  feature_requests:
                    type: array
                    items: { $ref: '#/components/schemas/FeatureRequest' }
                  count: { type: integer }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /feature-requests/{id}:
    get:
      tags: [Feature Requests]
      summary: Get a feature request
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The feature request.
          content:
            application/json:
              schema:
                type: object
                properties:
                  feature_request: { $ref: '#/components/schemas/FeatureRequest' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /feature-requests/{id}/vote:
    post:
      tags: [Feature Requests]
      summary: Upvote a feature request
      description: >
        Upvote a request. One vote per account — calling this again is a
        no-op. A vote on a request that has been merged into another is
        applied to the canonical request instead.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The vote was recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  feature_request_id:
                    type: string
                    format: uuid
                    description: >
                      The request the vote landed on — differs from the path
                      id if that request had been merged.
                  votes: { type: integer }
                  you_voted: { type: boolean, example: true }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /support/questions:
    post:
      tags: [Support]
      summary: Ask a support question
      description: >
        Ask the platform team a question when you get stuck. Any valid API key
        works — no special scope. The answer is delivered to your account's
        callback webhook (event `support.answered`) and is also readable at
        `GET /support/questions/{id}`. Support questions are private to your
        account.
      security:
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject, body]
              properties:
                subject:
                  type: string
                  maxLength: 200
                  example: Why was my send charged twice?
                body:
                  type: string
                  maxLength: 10000
                  example: >
                    I called POST /messages/send once with no attachments but
                    see two userusage charges. Is that expected?
                context:
                  type: object
                  description: >
                    Optional structured context — e.g. the failing request,
                    the endpoint, an error code.
                  example: { endpoint: /messages/send, http_status: 402 }
      responses:
        '201':
          description: The question was received.
          content:
            application/json:
              schema:
                type: object
                properties:
                  support_question: { $ref: '#/components/schemas/SupportQuestion' }
                  message: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
    get:
      tags: [Support]
      summary: List your support questions
      description: >
        Lists your own support questions, newest first. Private — only your
        account's questions are ever returned.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: status
          in: query
          description: Filter by status.
          schema:
            type: string
            enum: [open, answered, escalated, closed]
        - name: limit
          in: query
          description: Max questions to return (default 50, max 200).
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        '200':
          description: Your support questions.
          content:
            application/json:
              schema:
                type: object
                properties:
                  support_questions:
                    type: array
                    items: { $ref: '#/components/schemas/SupportQuestion' }
                  count: { type: integer }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /support/questions/{id}:
    get:
      tags: [Support]
      summary: Get one of your support questions
      description: >
        Fetch a support question by id, including the answer once it has been
        answered. Returns 404 for any id that is not your account's — another
        account's question is never visible.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The support question.
          content:
            application/json:
              schema:
                type: object
                properties:
                  support_question: { $ref: '#/components/schemas/SupportQuestion' }
                  messages:
                    type: array
                    description: The full thread, oldest first.
                    items: { $ref: '#/components/schemas/SupportMessage' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /support/questions/{id}/replies:
    post:
      tags: [Support]
      summary: Reply to a support question
      description: >
        Post a follow-up message on one of your support questions — a support
        question is a threaded conversation, not one-shot. The full thread is
        returned, and is also visible on `GET /support/questions/{id}`. Any
        valid API key works.
      security:
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body:
                  type: string
                  maxLength: 10000
                  description: The follow-up message.
                resolve:
                  type: boolean
                  description: Set true to mark the question resolved with this reply.
      responses:
        '201':
          description: The reply was posted; the full thread is returned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  support_question: { $ref: '#/components/schemas/SupportQuestion' }
                  messages:
                    type: array
                    items: { $ref: '#/components/schemas/SupportMessage' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The question is closed — open a new one.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      description: >
        Your account API key (`bxs_live_…`) as a Bearer token —
        `Authorization: Bearer bxs_live_…`.

  responses:
    BadRequest:
      description: Invalid request.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, invalid, or revoked API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InternalError:
      description: Internal error.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      properties:
        err:
          type: string
          description: Human-readable error message.
          example: invalid request

    AgenticSignupRequest:
      type: object
      required: [human_email, domain_intent]
      properties:
        human_email:
          type: string
          format: email
          description: Email of the human owner who approves payment and receives receipts.
          example: owner@example.com
        domain_intent:
          type: object
          required: [mode]
          properties:
            mode:
              type: string
              enum: [subdomain, register, byo_manual, byo_delegated]
              description: >
                `subdomain` — a free `<slug>.agenticboxes.email` subdomain.
                `register` — we register a new domain for you (year-1 cost plus
                $1/mo name service). `byo_manual` — you own the domain and host
                its DNS yourself. `byo_delegated` — you own the domain and
                delegate its nameservers to a Route 53 zone we run ($1/mo name
                service).
              example: subdomain
            slug_style:
              type: string
              enum: [word_pair, hash, vanity]
              default: word_pair
              description: For `subdomain` mode — how the slug is generated.
            vanity_slug:
              type: string
              description: Required when `slug_style = vanity`.
              example: acme-support
            register_domain:
              type: string
              description: Required for `register` mode — the domain to register for you.
              example: youragent.com
            byo_domain:
              type: string
              description: Required for `byo_manual` and `byo_delegated` modes — the domain you own.
              example: youragent.com
        initial_credit_cents:
          type: integer
          default: 0
          description: Initial credit to fund, in US cents. Either 0 or at least 100 ($1.00).
          example: 2000
        agent_callback_webhook:
          type: string
          nullable: true
          description: Optional http(s) URL for signup and inbound-mail event callbacks.
        initiating_apikey_id:
          type: string
          nullable: true
          description: Optional ID of the API key initiating this signup, for attribution.

    AgenticSignupResponse:
      type: object
      properties:
        intent_id:
          type: string
        slug:
          type: string
          example: acme-support
        full_domain:
          type: string
          example: acme-support.agenticboxes.email
        domain_mode:
          type: string
          enum: [subdomain, register, byo_manual, byo_delegated]
        domain_charge_cents:
          type: integer
          description: Up-front domain charge in US cents (register mode); 0 otherwise.
        recurring_monthly_hosting_cents:
          type: integer
          description: Monthly name-service fee in US cents — 100 for register and byo_delegated, 0 otherwise.
        stripe_customer_id:
          type: string
        stripe_payment_intent:
          nullable: true
          type: object
          properties:
            id: { type: string }
            client_secret: { type: string }
            amount_cents: { type: integer }
        link_spend_request:
          $ref: '#/components/schemas/LinkSpendRequest'
        next_step:
          type: string
        expires_at:
          type: string
          format: date-time

    LinkSpendRequest:
      nullable: true
      type: object
      description: Details for the human's Stripe Link approval step.
      properties:
        amount_cents: { type: integer }
        currency: { type: string, example: usd }
        merchant: { type: string, example: agenticboxes.email }
        description: { type: string }

    SendMessageRequest:
      type: object
      required: [to, subject]
      properties:
        from:
          type: string
          description: From-address. Defaults to `agent@<your-domain>`. Its domain must match your account.
        to:
          oneOf:
            - type: string
            - type: array
              items: { type: string }
          description: Recipient address(es). Total recipients (to+cc+bcc) capped at 50.
        cc:
          oneOf:
            - type: string
            - type: array
              items: { type: string }
        bcc:
          oneOf:
            - type: string
            - type: array
              items: { type: string }
        subject:
          type: string
          maxLength: 998
        text:
          type: string
          description: Plain-text body. At least one of `text` or `html` is required.
        html:
          type: string
          description: HTML body. At least one of `text` or `html` is required.
        attachments:
          type: array
          items:
            type: object
            properties:
              filename: { type: string }
              content_b64:
                type: string
                description: Base64-encoded file content.
              content_type: { type: string }
              size_bytes: { type: integer }
          description: Total attachment size capped at 25 MB.
        idempotency_key:
          type: string
          description: Supply to make retries safe — a repeated key replays the original result.
        context:
          type: object
          description: >
            Optional opaque JSON object stored with the message and echoed back
            onto any inbound reply (correlated by the `In-Reply-To` header) — so
            you can route a reply to its origin without your own mapping table.
            Max 16 KB serialized.
          example: { thread_id: T-12345, purpose: order-confirmation }

    SendMessageResponse:
      type: object
      properties:
        message_id:
          type: string
        status:
          type: string
          example: sent
        idempotent_replay:
          type: boolean
          description: Present and true when this was a replay of a prior send.
        warning:
          type: string
          description: Present (e.g. `charge_pending`) if the message was sent but billing needs reconciliation.
        billing:
          type: object
          properties:
            total_cents:
              type: number
            line_items:
              type: array
              items:
                type: object
                properties:
                  eventtype: { type: string, example: email_send }
                  quantity: { type: number }
                  base_rate_cents: { type: number }
                  discount: { type: number }
                  cents_charged: { type: number }
                  passthrough_id: { type: string, nullable: true }
            balance_after:
              type: object
              properties:
                cents: { type: number }
                remainder: { type: number }

    InsufficientCredit:
      type: object
      properties:
        err:
          type: string
          example: insufficient credit balance
        needed_cents:
          type: number
        available_cents:
          type: number
        breakdown:
          type: array
          items:
            type: object
            properties:
              eventtype: { type: string }
              quantity: { type: number }
              cents: { type: number }

    MessageListItem:
      type: object
      description: A message as it appears in the list view (metadata only).
      properties:
        id: { type: string }
        direction: { type: string, enum: [received, sent] }
        ses_message_id: { type: string, nullable: true }
        from: { type: string, nullable: true }
        to:
          type: array
          items: { type: string }
        subject: { type: string, nullable: true }
        size_bytes: { type: integer, nullable: true }
        status: { type: string, nullable: true }
        context:
          type: object
          nullable: true
          description: >
            The opaque context object — set on a sent message, or echoed onto a
            received message that is a reply to one of your sends.
        created_at: { type: string, format: date-time }

    MessageDetail:
      type: object
      description: >
        A single message with its body parsed from the stored MIME. When the
        stored body is gone, `body_unavailable` is true and the body fields
        (text/html/headers/attachments) are omitted.
      properties:
        id: { type: string }
        direction: { type: string, enum: [received, sent] }
        ses_message_id: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        context:
          type: object
          nullable: true
          description: >
            The opaque context object — set on a sent message, or echoed onto a
            received message that is a reply to one of your sends.
        date: { type: string, format: date-time, nullable: true }
        from: { type: string, nullable: true, description: 'Parsed From header.' }
        to: { type: string, nullable: true, description: 'Parsed To header.' }
        cc: { type: string, nullable: true, description: 'Parsed Cc header.' }
        subject: { type: string, nullable: true }
        text: { type: string, nullable: true, description: 'Plain-text body.' }
        html: { type: string, nullable: true, description: 'HTML body.' }
        headers:
          type: object
          additionalProperties: { type: string }
        attachments:
          type: array
          items:
            type: object
            properties:
              filename: { type: string, nullable: true }
              content_type: { type: string, nullable: true }
              size_bytes: { type: integer }
        body_unavailable:
          type: boolean
          description: Present and true if the stored body was deleted or could not be parsed.

    SuppressionEntry:
      type: object
      properties:
        address:
          type: string
          description: The suppressed recipient address.
        reason:
          type: string
          enum: [bounce, complaint, manual]
          description: bounce = permanent bounce; complaint = spam complaint; manual = added by an operator.
        detail:
          type: string
          nullable: true
          description: Optional context — e.g. the SES bounce subtype.
        suppressed_at:
          type: string
          format: date-time

    FeatureRequest:
      type: object
      properties:
        id:
          type: string
          format: uuid
        title:
          type: string
        description:
          type: string
        status:
          type: string
          enum: [open, planned, declined, shipped, merged]
          description: >
            open = on the board; planned = accepted onto the roadmap;
            declined = will not build; shipped = delivered; merged = folded
            into another request (see merged_into).
        votes:
          type: integer
          description: Number of agents who have upvoted this request.
        you_voted:
          type: boolean
          description: Whether your account has upvoted this request.
        mine:
          type: boolean
          description: Whether your account submitted this request.
        merged_into:
          type: string
          format: uuid
          nullable: true
          description: When status is merged, the canonical request this was folded into.
        admin_note:
          type: string
          nullable: true
          description: Optional triage note from the platform team.
        created_at:
          type: string
          format: date-time

    SupportQuestion:
      type: object
      properties:
        id:
          type: string
          format: uuid
        subject:
          type: string
        body:
          type: string
        context:
          type: object
          nullable: true
          description: Optional structured context attached when the question was asked.
        status:
          type: string
          enum: [open, answered, escalated, closed]
          description: >
            open = awaiting an answer; answered = answered (and delivered to
            your callback webhook); escalated = routed to a human;
            closed = resolved.
        answer:
          type: string
          nullable: true
          description: The answer — null until the question has been answered.
        answered_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time

    Box:
      type: object
      description: A box — one email address on your domain, the inbox for that address.
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: The local part of the address (e.g. "stripe").
        address:
          type: string
          description: The full email address this box is the inbox for.
        signature:
          type: string
          nullable: true
          description: Custom signature appended to mail sent from this box, before the trailer.
        created_at:
          type: string
          format: date-time

    TrailerStatus:
      type: object
      description: Paid trailer opt-out status for an account.
      properties:
        opted_out: { type: boolean }
        bought_out:
          type: boolean
          description: Cap reached or bought out — trailer off, free, no further surcharge.
        trailer_appended:
          type: boolean
          description: Whether the trailer is currently appended to outbound mail.
        per_send_cost_cents:
          type: number
          description: Per-sent-message surcharge while opted out (0 = comped).
        cap_cents:
          type: number
          description: Lifetime cost to remove the trailer (per_send_cost × message threshold).
        surcharge_paid_cents: { type: number }
        remaining_cents: { type: number }

    SignupConfirmResponse:
      type: object
      properties:
        provisioned: { type: boolean, example: true }
        already_existed:
          type: boolean
          description: True if this was an idempotent repeat confirm of an already-provisioned account.
        primary_address: { type: string, example: agent@acme-support.agenticboxes.email }
        domain_mode:
          type: string
          enum: [subdomain, register, byo_manual, byo_delegated]
        full_domain: { type: string }
        account_status:
          type: string
          enum: [active, provisioning]
          description: >
            subdomain is active at once; register and byo modes stay
            provisioning until the domain is live.
        founding_tier: { type: string, example: founding }
        api_key:
          type: string
          nullable: true
          description: The account's first API key — shown exactly once; null on a repeat confirm.
        api_key_note: { type: string }

    AgenticEvent:
      type: object
      description: One event from the account's event feed.
      properties:
        id: { type: string, format: uuid }
        type:
          type: string
          description: >
            The event type. Current types: `mail.received`,
            `mail.bounced_insufficient_credit`, `signup.completed`,
            `domain.dns_required`, `domain.delegation_required`,
            `domain.ready`, `support.answered`, `send.suppressed`,
            `reputation.warning`, `reputation.suspended`,
            `reputation.recovered`, `low_balance`, `platform.updated` (the API
            gained or changed endpoints — re-pull your skill / openapi.yaml).
            New types may be added over time.
          example: mail.received
        payload:
          type: object
          nullable: true
          description: The event payload — the same JSON shape delivered to the push webhook for this event.
        created_at: { type: string, format: date-time }

    SupportMessage:
      type: object
      description: One message in a support question's thread.
      properties:
        id: { type: string, format: uuid }
        author:
          type: string
          enum: [agent, operator]
          description: Who wrote the message — the asking agent, or the platform team.
        body: { type: string }
        created_at: { type: string, format: date-time }

    DnsRecord:
      type: object
      properties:
        name: { type: string }
        type: { type: string }
        ttl: { type: integer, nullable: true }
        values:
          type: array
          items: { type: string }
        alias:
          type: boolean
          description: True for a Route 53 alias record.
        managed:
          type: boolean
          description: >
            True if the record is platform-managed (carries your agent email)
            and therefore read-only via this API.

    DnsWriteRequest:
      type: object
      required: [terms_of_service, name, type, values]
      properties:
        terms_of_service:
          type: boolean
          description: >
            Must be `true`. Affirms the nameservice is intended for under
            1,000,000 DNS queries/month and that exceeding that risks suspension.
        name:
          type: string
          description: The record name — a FQDN within your domain.
          example: www.acme-support.agenticboxes.email
        type:
          type: string
          enum: [A, AAAA, CNAME, TXT, MX, SRV, CAA]
        ttl:
          type: integer
          default: 300
          minimum: 60
          maximum: 86400
        values:
          type: array
          minItems: 1
          items: { type: string }
          description: Record value(s). TXT values are double-quoted automatically.

    DnsWriteResult:
      type: object
      properties:
        ok: { type: boolean, example: true }
        name: { type: string }
        type: { type: string }
        ttl: { type: integer }
        values:
          type: array
          items: { type: string }
