openapi: 3.0.3
info:
  title: Postscale Email API
  description: |
    Postscale is a transactional email platform with built-in IP warming, deliverability
    monitoring, inbound email processing, and suppression management. This API allows you
    to send emails, manage domains, configure DKIM signing, set up aliases and webhooks,
    track delivery statistics, and more.
  version: 1.0.0
  contact:
    name: Postscale Support
    url: https://postscale.io
    email: support@postscale.io
servers:
  - url: https://api.postscale.io/v1
    description: Production
security:
  - BearerAuth: []
tags:
  - name: Emails
    description: Send and track outbound emails
  - name: Domains
    description: Manage sending and receiving domains
  - name: DKIM
    description: DKIM key generation, rotation, and DNS record management
  - name: Aliases
    description: Create and manage email aliases for forwarding or webhooks
  - name: Inbound
    description: View and manage inbound (received) emails
  - name: Stats
    description: Sending statistics and deliverability metrics
  - name: Warming
    description: IP warming status, control, and history
  - name: Suppressions
    description: Manage organization suppression entries
  - name: Webhooks
    description: Configure webhook endpoints for delivery events
  - name: Templates
    description: Create and manage reusable email templates
  - name: Trust
    description: Live sending review and account trust state
paths:
  # ──────────────────────────────────────────────
  # Trust
  # ──────────────────────────────────────────────
  /v1/trust/review-request:
    get:
      operationId: getLiveSendingReviewRequest
      tags: [Trust]
      summary: Get pending live sending review request
      description: Return the authenticated organization's pending live sending review request, if one exists.
      responses:
        '200':
          description: Pending review request
          content:
            application/json:
              schema:
                type: object
                properties:
                  request:
                    nullable: true
                    allOf:
                      - $ref: '#/components/schemas/OrgReviewRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      operationId: createLiveSendingReviewRequest
      tags: [Trust]
      summary: Request live sending review
      description: Submit business and sending-context details so ops can review a limited organization for live sending.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateOrgReviewRequest'
      responses:
        '201':
          description: Review request created
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  request:
                    $ref: '#/components/schemas/OrgReviewRequest'
        '200':
          description: A review request is already pending
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  request:
                    $ref: '#/components/schemas/OrgReviewRequest'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Live sending is already approved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/InternalError'
  # ──────────────────────────────────────────────
  # Emails
  # ──────────────────────────────────────────────
  /v1/send:
    post:
      operationId: sendEmail
      tags: [Emails]
      summary: Send an email
      description: |
        Queue a single email for delivery. The email is validated, checked against
        suppression lists and warming rate limits, DKIM-signed, and submitted to the
        outbound mail queue. Returns the generated message ID and remaining quota.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendEmailRequest'
            example:
              from: notifications@example.com
              to:
                - user@recipient.com
              subject: Welcome to Postscale
              html_body: "<h1>Welcome!</h1><p>Thanks for signing up.</p>"
              text_body: "Welcome! Thanks for signing up."
              tags:
                - onboarding
              metadata:
                user_id: "usr_12345"
      responses:
        '202':
          description: Email accepted and queued for delivery
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendEmailResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          description: All recipients suppressed
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    example: all_recipients_suppressed
                  suppressed:
                    type: array
                    items:
                      type: string
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/send/batch:
    post:
      operationId: sendBatchEmails
      tags: [Emails]
      summary: Send batch emails
      description: |
        Send up to 100 emails in a single request. Each email is independently
        validated and queued. The response includes per-email results indicating
        success or failure.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - emails
              properties:
                emails:
                  type: array
                  maxItems: 100
                  items:
                    $ref: '#/components/schemas/SendEmailRequest'
      responses:
        '202':
          description: Batch accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendBatchResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/emails:
    get:
      operationId: listEmails
      tags: [Emails]
      summary: List sent emails
      description: Retrieve a paginated list of outbound emails with optional filters.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
        - name: from
          in: query
          description: Filter by sender address
          schema:
            type: string
        - name: to
          in: query
          description: Filter by recipient address
          schema:
            type: string
        - name: status
          in: query
          description: Filter by delivery status
          schema:
            type: string
            enum: [queued, processing, sent, delivered, bounced, deferred, failed]
        - name: tag
          in: query
          description: Filter by tag
          schema:
            type: string
        - name: from_date
          in: query
          description: Start of date range (ISO 8601)
          schema:
            type: string
            format: date-time
        - name: to_date
          in: query
          description: End of date range (ISO 8601)
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: Paginated list of emails
          content:
            application/json:
              schema:
                type: object
                properties:
                  emails:
                    type: array
                    items:
                      $ref: '#/components/schemas/Email'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/emails/{id}:
    get:
      operationId: getEmail
      tags: [Emails]
      summary: Get email details
      description: Retrieve the full details of a single outbound email by ID.
      parameters:
        - $ref: '#/components/parameters/EmailId'
      responses:
        '200':
          description: Email details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Email'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  # ──────────────────────────────────────────────
  # Domains
  # ──────────────────────────────────────────────
  /v1/domains:
    post:
      operationId: createDomain
      tags: [Domains]
      summary: Create domain
      description: |
        Register a new domain for sending or receiving email. The domain starts
        unverified; use the verify endpoint after configuring DNS records.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateDomainRequest'
            example:
              domain: example.com
              type: both
              catch_all_enabled: true
      responses:
        '201':
          description: Domain created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Domain already exists
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      operationId: listDomains
      tags: [Domains]
      summary: List domains
      description: Retrieve all domains for the authenticated organization.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: List of domains
          content:
            application/json:
              schema:
                type: object
                properties:
                  domains:
                    type: array
                    items:
                      $ref: '#/components/schemas/Domain'
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}:
    get:
      operationId: getDomain
      tags: [Domains]
      summary: Get domain
      description: Retrieve a single domain by UUID or domain name.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      responses:
        '200':
          description: Domain details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
    put:
      operationId: updateDomain
      tags: [Domains]
      summary: Update domain
      description: Update domain settings such as type, catch-all, or active status.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateDomainRequest'
      responses:
        '200':
          description: Domain updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Domain'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      operationId: deleteDomain
      tags: [Domains]
      summary: Delete domain
      description: |
        Permanently remove a domain and all associated configuration. This
        action cannot be undone.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      responses:
        '200':
          description: Domain deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/verify:
    post:
      operationId: verifyDomain
      tags: [Domains]
      summary: Verify domain DNS
      description: |
        Trigger a DNS verification check for the domain. Checks ownership TXT,
        MX, SPF, DKIM, DMARC, and return-path records where applicable. The
        domain is marked verified when ownership is confirmed and the records
        required for its type are present: outbound domains require SPF,
        inbound/alias domains require MX, and domains configured for both
        require MX and SPF. Outbound sending also requires verified DKIM. The
        return-path CNAME is recommended for production outbound delayed bounce
        tracking and does not require root MX to point at Postscale.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      responses:
        '200':
          description: Verification results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DomainVerificationResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/dns:
    get:
      operationId: getDNSRecords
      tags: [Domains]
      summary: Get required DNS records
      description: |
        Returns the DNS records that must be configured for this domain's type,
        along with their current verification status. Outbound-only domains do
        not include a required MX record; inbound-capable domains do. Outbound-capable
        domains also include a return-path CNAME used for delayed DSN correlation.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      responses:
        '200':
          description: Required DNS records
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DNSRecordsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  # ──────────────────────────────────────────────
  # DKIM
  # ──────────────────────────────────────────────
  /v1/domains/{id}/dkim:
    post:
      operationId: generateDKIMKey
      tags: [DKIM]
      summary: Generate DKIM key
      description: |
        Generate a new RSA-2048 DKIM key pair for the domain. Returns the
        public key and the DNS TXT record that must be added.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                selector:
                  type: string
                  default: postscale1
                  description: DKIM selector name
                  example: postscale1
      responses:
        '201':
          description: DKIM key generated
          content:
            application/json:
              schema:
                type: object
                properties:
                  key:
                    $ref: '#/components/schemas/DKIMKey'
                  dns_record:
                    $ref: '#/components/schemas/DKIMDNSRecord'
                  message:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      operationId: getDKIMRecords
      tags: [DKIM]
      summary: Get DKIM records
      description: List all DKIM keys and their corresponding DNS records for a domain.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      responses:
        '200':
          description: DKIM keys and DNS records
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain:
                    type: string
                    example: example.com
                  keys:
                    type: array
                    items:
                      $ref: '#/components/schemas/DKIMKey'
                  dns_records:
                    type: array
                    items:
                      $ref: '#/components/schemas/DKIMDNSRecord'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/dkim/rotate:
    post:
      operationId: rotateDKIMKey
      tags: [DKIM]
      summary: Rotate DKIM key
      description: |
        Generate a new DKIM key with a new selector and deactivate the old one.
        Both DNS records should be kept during a transition period to ensure
        in-flight emails can still be verified.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - old_selector
                - new_selector
              properties:
                old_selector:
                  type: string
                  description: Selector of the key to rotate away from
                  example: postscale1
                new_selector:
                  type: string
                  description: Selector for the new key
                  example: postscale2
      responses:
        '200':
          description: Key rotated
          content:
            application/json:
              schema:
                type: object
                properties:
                  new_key:
                    $ref: '#/components/schemas/DKIMKey'
                  dns_record:
                    $ref: '#/components/schemas/DKIMDNSRecord'
                  message:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/dkim/{selector}:
    delete:
      operationId: deactivateDKIMKey
      tags: [DKIM]
      summary: Deactivate DKIM key
      description: |
        Deactivate a DKIM key by selector. The key will no longer be used for
        signing. Remove the DNS record after delivery of in-flight emails completes.
      parameters:
        - $ref: '#/components/parameters/DomainId'
        - name: selector
          in: path
          required: true
          schema:
            type: string
          description: DKIM selector
          example: postscale1
      responses:
        '200':
          description: Key deactivated
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: deactivated
                  message:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  # ──────────────────────────────────────────────
  # Aliases
  # ──────────────────────────────────────────────
  /v1/aliases:
    post:
      operationId: createAlias
      tags: [Aliases]
      summary: Create alias
      description: |
        Create a new email alias. Aliases define how incoming email to a
        specific address is handled (forwarded, sent to a webhook, or both).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAliasRequest'
            example:
              domain_id: "550e8400-e29b-41d4-a716-446655440000"
              address: support
              action: both
              forward_to:
                - team@company.com
              is_masked: false
      responses:
        '201':
          description: Alias created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Alias'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Alias already exists for this address
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      operationId: listAliases
      tags: [Aliases]
      summary: List aliases
      description: Retrieve all aliases for the authenticated organization.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
        - name: domain_id
          in: query
          description: Filter by domain ID
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: List of aliases
          content:
            application/json:
              schema:
                type: object
                properties:
                  aliases:
                    type: array
                    items:
                      $ref: '#/components/schemas/Alias'
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/aliases/{id}:
    get:
      operationId: getAlias
      tags: [Aliases]
      summary: Get alias
      description: Retrieve a single alias by ID.
      parameters:
        - $ref: '#/components/parameters/AliasId'
      responses:
        '200':
          description: Alias details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Alias'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
    patch:
      operationId: updateAlias
      tags: [Aliases]
      summary: Update alias
      description: Partially update an alias configuration.
      parameters:
        - $ref: '#/components/parameters/AliasId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateAliasRequest'
      responses:
        '200':
          description: Alias updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Alias'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      operationId: deleteAlias
      tags: [Aliases]
      summary: Delete alias
      description: Permanently remove an alias.
      parameters:
        - $ref: '#/components/parameters/AliasId'
      responses:
        '200':
          description: Alias deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  # ──────────────────────────────────────────────
  # Inbound
  # ──────────────────────────────────────────────
  /v1/inbound-emails:
    get:
      operationId: listInboundEmails
      tags: [Inbound]
      summary: List inbound emails
      description: Retrieve a paginated list of received inbound emails.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
        - name: domain_id
          in: query
          description: Filter by domain ID
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          description: Filter by processing status
          schema:
            type: string
            enum: [received, processing, delivered, failed]
      responses:
        '200':
          description: List of inbound emails
          content:
            application/json:
              schema:
                type: object
                properties:
                  emails:
                    type: array
                    items:
                      $ref: '#/components/schemas/InboundEmail'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/inbound-emails/{id}:
    get:
      operationId: getInboundEmail
      tags: [Inbound]
      summary: Get inbound email
      description: Retrieve the full details of a single inbound email.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Inbound email ID
      responses:
        '200':
          description: Inbound email details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboundEmail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/inbound-emails/{id}/attachments/{attachment_id}/download:
    get:
      operationId: downloadInboundAttachment
      tags: [Inbound]
      summary: Download inbound attachment
      description: |
        Streams an inbound attachment through Postscale after authenticating
        the caller and verifying organization access to the parent email.
        Attachment `url` values returned by the API point to this endpoint.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Inbound email ID
        - name: attachment_id
          in: path
          required: true
          schema:
            type: string
          description: Attachment ID. Legacy records may use a zero-based attachment index.
      responses:
        '200':
          description: Attachment bytes
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '503':
          description: Storage service not configured
  # ──────────────────────────────────────────────
  # Stats
  # ──────────────────────────────────────────────
  /v1/domains/{id}/stats:
    get:
      operationId: getAggregatedStats
      tags: [Stats]
      summary: Get aggregated stats
      description: |
        Retrieve aggregated sending metrics for a domain over a specified
        period, including delivery rate, bounce rate, complaint rate, and
        engagement metrics.
      parameters:
        - $ref: '#/components/parameters/DomainId'
        - name: days
          in: query
          description: Number of days to aggregate
          schema:
            type: integer
            default: 7
            maximum: 90
      responses:
        '200':
          description: Aggregated statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain_id:
                    type: string
                    format: uuid
                  domain:
                    type: string
                  period_days:
                    type: integer
                  metrics:
                    $ref: '#/components/schemas/WarmingMetrics'
                  today_sent:
                    type: integer
                  this_hour_sent:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/stats/daily:
    get:
      operationId: getDailyStats
      tags: [Stats]
      summary: Get daily stats
      description: Retrieve daily sending statistics for a domain over a specified period.
      parameters:
        - $ref: '#/components/parameters/DomainId'
        - name: days
          in: query
          description: Number of days to return
          schema:
            type: integer
            default: 30
            maximum: 90
      responses:
        '200':
          description: Daily statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain_id:
                    type: string
                    format: uuid
                  domain:
                    type: string
                  days:
                    type: integer
                  stats:
                    type: array
                    items:
                      $ref: '#/components/schemas/DailyStat'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/stats/hourly:
    get:
      operationId: getHourlyStats
      tags: [Stats]
      summary: Get hourly stats
      description: Retrieve hourly sending statistics for a domain.
      parameters:
        - $ref: '#/components/parameters/DomainId'
        - name: hours
          in: query
          description: Number of hours to return
          schema:
            type: integer
            default: 24
            maximum: 168
      responses:
        '200':
          description: Hourly statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain_id:
                    type: string
                    format: uuid
                  domain:
                    type: string
                  hours:
                    type: integer
                  from:
                    type: string
                    format: date-time
                  to:
                    type: string
                    format: date-time
                  stats:
                    type: array
                    items:
                      $ref: '#/components/schemas/HourlyStat'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/stats/isp:
    get:
      operationId: getISPStats
      tags: [Stats]
      summary: Get ISP stats
      description: |
        Retrieve per-ISP sending statistics for a domain, showing delivery
        performance across major mailbox providers (Gmail, Microsoft, Yahoo, etc.).
      parameters:
        - $ref: '#/components/parameters/DomainId'
        - name: days
          in: query
          description: Number of days to aggregate
          schema:
            type: integer
            default: 7
            maximum: 90
      responses:
        '200':
          description: Per-ISP statistics
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain_id:
                    type: string
                    format: uuid
                  domain:
                    type: string
                  days:
                    type: integer
                  isp_stats:
                    type: array
                    items:
                      $ref: '#/components/schemas/ISPStat'
                  known_isps:
                    type: array
                    items:
                      type: string
                    example: [gmail, microsoft, yahoo, apple, other]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  # ──────────────────────────────────────────────
  # Warming
  # ──────────────────────────────────────────────
  /v1/domains/{id}/warming:
    get:
      operationId: getWarmingStatus
      tags: [Warming]
      summary: Get warming status
      description: |
        Retrieve the current IP warming status for a domain, including the
        current phase, rate limits, usage counters, deliverability metrics,
        and advancement eligibility.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      responses:
        '200':
          description: Warming status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WarmingStatus'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/warming/history:
    get:
      operationId: getWarmingHistory
      tags: [Warming]
      summary: Get warming history
      description: Retrieve the phase transition history for a domain.
      parameters:
        - $ref: '#/components/parameters/DomainId'
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
      responses:
        '200':
          description: Warming phase history
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain_id:
                    type: string
                    format: uuid
                  history:
                    type: array
                    items:
                      $ref: '#/components/schemas/WarmingPhaseHistory'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/warming/start:
    post:
      operationId: startWarming
      tags: [Warming]
      summary: Start warming
      description: |
        Begin the IP warming process for a domain. The domain should have SPF
        or DKIM verified before starting. Use the `force` flag to bypass
        prerequisite checks.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                force:
                  type: boolean
                  default: false
                  description: Skip prerequisite checks (SPF/DKIM verification)
      responses:
        '200':
          description: Warming started
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: warming started
                  phase:
                    type: string
                    example: phase_1
        '400':
          description: Prerequisites not met or already warming
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/warming/pause:
    post:
      operationId: pauseWarming
      tags: [Warming]
      summary: Pause warming
      description: Pause the warming process for a domain. Sending limits drop to zero while paused.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  description: Optional reason for pausing
                  example: Investigating high bounce rate
      responses:
        '200':
          description: Warming paused
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: warming paused
                  previous_phase:
                    type: string
                  reason:
                    type: string
        '400':
          description: Domain is not in an active warming phase
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/domains/{id}/warming/resume:
    post:
      operationId: resumeWarming
      tags: [Warming]
      summary: Resume warming
      description: |
        Resume the warming process for a paused domain. The domain returns to
        the phase it was in before being paused.
      parameters:
        - $ref: '#/components/parameters/DomainId'
      responses:
        '200':
          description: Warming resumed
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: warming resumed
                  phase:
                    type: string
        '400':
          description: Domain is not in paused state
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  # ──────────────────────────────────────────────
  # Suppressions
  # ──────────────────────────────────────────────
  /v1/suppressions:
    get:
      operationId: listSuppressions
      tags: [Suppressions]
      summary: List suppressions
      description: |
        Retrieve a paginated list of suppressed email addresses. Addresses are
        automatically added on hard bounces and spam complaints, or can be
        added manually.
      parameters:
        - name: query
          in: query
          schema:
            type: string
          description: Case-insensitive email search
        - name: reason
          in: query
          schema:
            type: string
            enum: [hard_bounce, complaint, manual, unsubscribe]
        - name: scope_type
          in: query
          schema:
            type: string
            enum: [org, domain, stream, template, list]
        - name: scope_id
          in: query
          schema:
            type: string
        - name: source
          in: query
          schema:
            type: string
          description: Source filter, such as manual, unsubscribe, delivery_event, or import:sendgrid
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 200
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Paginated list of suppressions
          content:
            application/json:
              schema:
                type: object
                properties:
                  suppressions:
                    type: array
                    items:
                      $ref: '#/components/schemas/SuppressionEntry'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      operationId: addSuppression
      tags: [Suppressions]
      summary: Add suppression
      description: |
        Manually add an org-wide suppression or scoped unsubscribe entry.
        This endpoint only accepts reason `manual` or `unsubscribe`.
        Use the suppression import preview/commit endpoints for migrated hard
        bounces and complaints.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              properties:
                email:
                  type: string
                  format: email
                  description: Email address to suppress
                  example: user@example.com
                reason:
                  type: string
                  enum: [manual, unsubscribe]
                  default: manual
                scope_type:
                  type: string
                  enum: [org, domain, stream, template, list]
                  default: org
                  description: Only applies to unsubscribe suppressions
                scope_id:
                  type: string
                  description: Required for non-org unsubscribe scopes
      responses:
        '201':
          description: Address suppressed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuppressionEntry'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/suppressions/check:
    get:
      operationId: checkSuppression
      tags: [Suppressions]
      summary: Check suppression
      description: Check whether a specific email address is on the suppression list.
      parameters:
        - name: email
          in: query
          required: true
          schema:
            type: string
            format: email
          description: Email address to check
      responses:
        '200':
          description: Suppression check result
          content:
            application/json:
              schema:
                type: object
                properties:
                  email:
                    type: string
                    example: user@example.com
                  suppressed:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/suppressions/{identifier}:
    delete:
      operationId: removeSuppression
      tags: [Suppressions]
      summary: Remove suppression
      description: |
        Remove a suppression entry. Prefer passing the suppression entry ID so
        only one scoped row is removed. Passing an email address is retained for
        backwards compatibility and removes entries for that email.
      parameters:
        - name: identifier
          in: path
          required: true
          schema:
            type: string
          description: Suppression entry UUID or legacy email address
          example: d290f1ee-6c54-4b01-90e6-d701748f0851
      responses:
        '200':
          description: Suppression removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/imports/suppressions/preview:
    post:
      operationId: previewSuppressionImport
      tags: [Suppressions]
      summary: Preview suppression import
      description: Parse a provider CSV export and create an import preview without writing suppressions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SuppressionImportPreviewRequest'
      responses:
        '201':
          description: Import preview created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImportResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/imports/suppressions/jobs/{id}:
    get:
      operationId: getImportJob
      tags: [Suppressions]
      summary: Get import job
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Import job with items
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImportResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/imports/suppressions/jobs/{id}/commit:
    post:
      operationId: commitImportJob
      tags: [Suppressions]
      summary: Commit import job
      description: Commit valid rows from a previewed suppression import job.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Import job committed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImportResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  # ──────────────────────────────────────────────
  # Webhooks
  # ──────────────────────────────────────────────
  /v1/webhooks:
    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Create webhook
      description: |
        Create a new webhook endpoint that receives email event
        notifications (received, delivered, bounced, deferred, complained).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
            example:
              domain_id: "550e8400-e29b-41d4-a716-446655440000"
              url: "https://example.com/webhooks/postscale"
              match_pattern: "*"
              events:
                - email.received
      responses:
        '201':
          description: Webhook created
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook:
                    $ref: '#/components/schemas/WebhookEndpoint'
                  secret:
                    type: string
                    description: Plaintext HMAC signing secret, returned only once.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhooks
      description: Retrieve all webhook endpoints for the authenticated organization.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: List of webhooks
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookEndpoint'
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/webhooks/{id}:
    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete webhook
      description: Permanently remove a webhook endpoint.
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: Webhook deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: webhook deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/webhooks/{id}/rotate:
    post:
      operationId: rotateWebhookSecret
      tags: [Webhooks]
      summary: Rotate webhook signing secret
      description: |
        Generate a fresh HMAC signing secret for a webhook endpoint and return
        it once. During the optional grace window, deliveries are signed with
        both the old and new secrets so receivers can update without dropping
        events.
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                grace_seconds:
                  type: integer
                  minimum: 0
                  maximum: 604800
                  default: 86400
      responses:
        '200':
          description: Webhook signing secret rotated
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret:
                    type: string
                    description: New plaintext HMAC signing secret, returned only once.
                  grace_seconds:
                    type: integer
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  # ──────────────────────────────────────────────
  # Templates
  # ──────────────────────────────────────────────
  /v1/templates:
    post:
      operationId: createTemplate
      tags: [Templates]
      summary: Create template
      description: Create a reusable email template with variable substitution support.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTemplateRequest'
            example:
              name: Welcome Email
              slug: welcome
              subject: "Welcome, {{name}}!"
              html_body: "<h1>Welcome, {{name}}!</h1><p>Thanks for joining.</p>"
              text_body: "Welcome, {{name}}! Thanks for joining."
      responses:
        '201':
          description: Template created
          content:
            application/json:
              schema:
                type: object
                properties:
                  template:
                    $ref: '#/components/schemas/Template'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Template limit reached for plan
        '409':
          description: A template with this slug already exists
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: List templates
      description: Retrieve all email templates for the authenticated organization.
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: List of templates
          content:
            application/json:
              schema:
                type: object
                properties:
                  templates:
                    type: array
                    items:
                      $ref: '#/components/schemas/Template'
                  total:
                    type: integer
                    description: Total number of templates for this organization
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/templates/{id}:
    get:
      operationId: getTemplate
      tags: [Templates]
      summary: Get template
      description: Retrieve a single email template by UUID or slug.
      parameters:
        - $ref: '#/components/parameters/TemplateId'
      responses:
        '200':
          description: Template details
          content:
            application/json:
              schema:
                type: object
                properties:
                  template:
                    $ref: '#/components/schemas/Template'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
    put:
      operationId: updateTemplate
      tags: [Templates]
      summary: Update template
      description: Partially update an email template. Only provided fields are changed.
      parameters:
        - $ref: '#/components/parameters/TemplateId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateTemplateRequest'
      responses:
        '200':
          description: Template updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  template:
                    $ref: '#/components/schemas/Template'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: A template with this slug already exists
        '500':
          $ref: '#/components/responses/InternalError'
    delete:
      operationId: deleteTemplate
      tags: [Templates]
      summary: Delete template
      description: Permanently remove an email template.
      parameters:
        - $ref: '#/components/parameters/TemplateId'
      responses:
        '200':
          description: Template deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/templates/{id}/preview:
    post:
      operationId: previewTemplate
      tags: [Templates]
      summary: Preview template
      description: |
        Render a template with the provided variables and return the resulting
        HTML and text. Useful for previewing before sending.
      parameters:
        - $ref: '#/components/parameters/TemplateId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                variables:
                  type: object
                  additionalProperties:
                    type: string
                  description: Template variable values
                  example:
                    name: Jane Doe
                    company: Acme Inc
      responses:
        '200':
          description: Rendered template
          content:
            application/json:
              schema:
                type: object
                properties:
                  subject:
                    type: string
                    description: Rendered subject line
                  html:
                    type: string
                    description: Rendered HTML body
                  text:
                    type: string
                    description: Rendered plain text body
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalError'
# ════════════════════════════════════════════════
# Components
# ════════════════════════════════════════════════
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API Key
      description: |
        Authenticate using your API key in the Authorization header:
        `Authorization: Bearer ps_live_...`
  # ────────────────────────────────────────────
  # Parameters
  # ────────────────────────────────────────────
  parameters:
    DomainId:
      name: id
      in: path
      required: true
      description: Domain UUID or domain name
      schema:
        type: string
      example: 550e8400-e29b-41d4-a716-446655440000
    EmailId:
      name: id
      in: path
      required: true
      description: Email UUID
      schema:
        type: string
        format: uuid
    AliasId:
      name: id
      in: path
      required: true
      description: Alias UUID
      schema:
        type: string
        format: uuid
    WebhookId:
      name: id
      in: path
      required: true
      description: Webhook endpoint UUID
      schema:
        type: string
        format: uuid
    TemplateId:
      name: id
      in: path
      required: true
      description: Template UUID or slug
      schema:
        type: string
  # ────────────────────────────────────────────
  # Responses
  # ────────────────────────────────────────────
  responses:
    BadRequest:
      description: Invalid request parameters or body
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: bad_request
              message: "from is required"
    Unauthorized:
      description: Missing or invalid authentication
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: unauthorized
              message: "Invalid or missing API key"
    Forbidden:
      description: Authenticated but not allowed to access this resource
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: forbidden
              message: "access denied"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: not_found
              message: "domain not found"
    RateLimited:
      description: Rate limit exceeded due to warming or ISP limits
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: rate_limit_exceeded
              message:
                type: string
              limit_type:
                type: string
                enum: [daily, hourly, per_minute, isp_hourly]
              limit:
                type: integer
              used:
                type: integer
              retry_after_seconds:
                type: integer
              warming_phase:
                type: string
    InternalError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: internal_error
              message: "An unexpected error occurred"
  # ────────────────────────────────────────────
  # Schemas
  # ────────────────────────────────────────────
  schemas:
    # ── Error ──
    Error:
      type: object
      properties:
        success:
          type: boolean
          example: false
        error:
          type: object
          properties:
            code:
              type: string
              description: Machine-readable error code
              example: bad_request
            message:
              type: string
              description: Human-readable error description
              example: "domain is required"
          required:
            - code
            - message
      required:
        - success
        - error
    # ── Pagination ──
    Pagination:
      type: object
      properties:
        total:
          type: integer
          description: Total number of matching records
          example: 142
        limit:
          type: integer
          description: Maximum records returned per page
          example: 50
        offset:
          type: integer
          description: Number of records skipped
          example: 0
        has_more:
          type: boolean
          description: Whether more records exist beyond this page
          example: true
    # ── Trust Review ──
    CreateOrgReviewRequest:
      type: object
      required:
        - legal_name
        - use_case
        - recipient_source
      properties:
        legal_name:
          type: string
          maxLength: 200
          description: Legal or operating business name
          example: Example Ltd
        website:
          type: string
          maxLength: 500
          description: Public website or product URL
          example: https://example.com
        use_case:
          type: string
          maxLength: 4000
          description: Description of what the account will send and why
        expected_monthly_volume:
          type: integer
          minimum: 0
          default: 1000
        sample_subject:
          type: string
          maxLength: 500
        sample_body:
          type: string
          maxLength: 8000
        recipient_source:
          type: string
          maxLength: 1000
          description: How recipients are collected and why they expect mail
        email_type:
          type: string
          enum: [transactional, marketing, forwarding, security, mixed, other]
          default: transactional
        notes:
          type: string
          maxLength: 2000
    OrgReviewRequest:
      allOf:
        - $ref: '#/components/schemas/CreateOrgReviewRequest'
        - type: object
          properties:
            id:
              type: string
              format: uuid
            org_id:
              type: string
              format: uuid
            requested_by:
              type: string
              format: uuid
            status:
              type: string
              enum: [pending, approved_business, approved_sending, rejected, cancelled]
            reviewed_by:
              type: string
              nullable: true
            reviewed_at:
              type: string
              format: date-time
              nullable: true
            created_at:
              type: string
              format: date-time
            updated_at:
              type: string
              format: date-time
    # ── Domain ──
    Domain:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        domain:
          type: string
          example: example.com
        type:
          type: string
          enum: [inbound, outbound, both, alias]
        verified:
          type: boolean
        ownership_verified:
          type: boolean
          description: Domain ownership TXT record status
        mx_verified:
          type: boolean
        spf_verified:
          type: boolean
        dkim_verified:
          type: boolean
        dmarc_verified:
          type: boolean
        catch_all_enabled:
          type: boolean
        active:
          type: boolean
        verification_token:
          type: string
          nullable: true
          description: TXT record value for domain ownership verification
        last_dns_check:
          type: string
          format: date-time
          nullable: true
        verified_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateDomainRequest:
      type: object
      required:
        - domain
      properties:
        domain:
          type: string
          description: Fully qualified domain name
          example: example.com
        type:
          type: string
          enum: [inbound, outbound, both, alias]
          default: inbound
          description: Domain usage type
        catch_all_enabled:
          type: boolean
          description: Accept email to any address at this domain
    UpdateDomainRequest:
      type: object
      properties:
        type:
          type: string
          enum: [inbound, outbound, both, alias]
        catch_all_enabled:
          type: boolean
        active:
          type: boolean
    DomainVerificationResult:
      type: object
      properties:
        verified:
          type: boolean
          description: Overall verification status
        ownership_verified:
          type: boolean
        mx_verified:
          type: boolean
        spf_verified:
          type: boolean
        dkim_verified:
          type: boolean
        dmarc_verified:
          type: boolean
        verified_at:
          type: string
          format: date-time
        summary:
          type: string
          description: Human-readable summary of verification results
        mx_error:
          type: string
          nullable: true
        mx_records:
          type: array
          nullable: true
          items:
            type: string
        spf_error:
          type: string
          nullable: true
        spf_record:
          type: string
          nullable: true
        dkim_error:
          type: string
          nullable: true
        dmarc_error:
          type: string
          nullable: true
        ownership_error:
          type: string
          nullable: true
        return_path_verified:
          type: boolean
          description: Whether the optional return-path CNAME is verified for delayed bounce tracking
        delayed_bounce_tracking_enabled:
          type: boolean
          description: True when delayed DSNs can be routed to Postscale for this domain
        return_path:
          $ref: '#/components/schemas/ReturnPathDomain'
        return_path_error:
          type: string
          nullable: true
    DNSRecordsResponse:
      type: object
      properties:
        domain:
          type: string
        records:
          type: array
          items:
            $ref: '#/components/schemas/DNSRecord'
        verified:
          type: boolean
        ownership_verified:
          type: boolean
        mx_verified:
          type: boolean
        spf_verified:
          type: boolean
        dkim_verified:
          type: boolean
        dmarc_verified:
          type: boolean
        return_path_verified:
          type: boolean
          description: Whether the return-path CNAME is verified for outbound delayed bounce tracking
        delayed_bounce_tracking_enabled:
          type: boolean
          description: True when delayed DSNs can be routed to Postscale for this domain
        return_path:
          $ref: '#/components/schemas/ReturnPathDomain'
    DNSRecord:
      type: object
      properties:
        type:
          type: string
          enum: [MX, TXT, CNAME]
          example: MX
        name:
          type: string
          example: example.com
        value:
          type: string
          example: mx.postscale.io
        priority:
          type: integer
          example: 10
        ttl:
          type: integer
          example: 3600
        status:
          type: string
          enum: [verified, pending, missing, required, recommended]
          description: Current verification status of this record
    ReturnPathDomain:
      type: object
      properties:
        hostname:
          type: string
          example: ps-bounces.example.com
        cname_target:
          type: string
          example: bounces.postscale.io
        verified:
          type: boolean
        required_for:
          type: string
          example: production_outbound
          description: Production capability that benefits from this record
        delayed_bounce_tracking_enabled:
          type: boolean
          description: True when delayed DSNs can be correlated for this domain
    # ── Email ──
    Email:
      type: object
      properties:
        id:
          type: string
          format: uuid
        message_id:
          type: string
          description: RFC 5322 Message-ID
        from:
          type: string
          description: Sender address
          example: notifications@example.com
        to:
          type: array
          items:
            type: string
          description: Primary recipient addresses
        cc:
          type: array
          items:
            type: string
          nullable: true
        bcc:
          type: array
          items:
            type: string
          nullable: true
        subject:
          type: string
        status:
          type: string
          enum: [queued, processing, sent, delivered, bounced, deferred, failed]
        html_body:
          type: string
          nullable: true
        text_body:
          type: string
          nullable: true
        tags:
          type: array
          items:
            type: string
          nullable: true
        metadata:
          type: object
          additionalProperties:
            type: string
          nullable: true
        recipient_isp:
          type: string
          nullable: true
          description: Detected ISP of primary recipient
        bounce_type:
          type: string
          nullable: true
          enum: [hard, soft]
        bounce_code:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        sent_at:
          type: string
          format: date-time
          nullable: true
        delivered_at:
          type: string
          format: date-time
          nullable: true
    SendEmailRequest:
      type: object
      required:
        - from
        - to
      properties:
        from:
          type: string
          description: Sender email address (must belong to a verified domain)
          example: notifications@example.com
        to:
          type: array
          minItems: 1
          items:
            type: string
          description: Primary recipient addresses
        cc:
          type: array
          items:
            type: string
          description: CC recipients
        bcc:
          type: array
          items:
            type: string
          description: BCC recipients
        subject:
          type: string
          description: Email subject line (required unless using a template)
        html_body:
          type: string
          description: HTML body content (required if no text_body and no template)
        text_body:
          type: string
          description: Plain text body content
        template:
          type: string
          description: Template slug to use instead of inline subject/body (mutually exclusive with subject/html_body/text_body)
          example: order-confirmation
        template_id:
          type: string
          format: uuid
          description: Template UUID to use instead of inline subject/body (mutually exclusive with subject/html_body/text_body)
        variables:
          type: object
          additionalProperties: true
          description: Variables to render into the template
          example:
            first_name: Jane
            order_id: "12345"
        headers:
          type: object
          additionalProperties:
            type: string
          description: Custom email headers
        tags:
          type: array
          items:
            type: string
          description: Tags for categorizing and filtering emails
        metadata:
          type: object
          additionalProperties:
            type: string
          description: Arbitrary key-value metadata attached to the email
        unsubscribe_scope:
          $ref: '#/components/schemas/UnsubscribeScope'
    SendEmailResponse:
      type: object
      properties:
        message_id:
          type: string
          description: Unique message identifier
          example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890@example.com"
        status:
          type: string
          example: queued
        warming_phase:
          type: string
          description: Current warming phase of the sending domain
        remaining_today:
          type: integer
          description: Remaining daily sending quota
        remaining_this_hour:
          type: integer
          description: Remaining hourly sending quota
    SendBatchResponse:
      type: object
      properties:
        queued:
          type: integer
          description: Number of emails successfully queued
        failed:
          type: integer
          description: Number of emails that failed validation
        results:
          type: array
          items:
            type: object
            properties:
              index:
                type: integer
                description: Index in the original request array
              message_id:
                type: string
                description: Assigned message ID (present when queued)
              status:
                type: string
                enum: [queued, failed, suppressed]
              error:
                type: string
                nullable: true
                description: Error message (present when failed)
        warming_phase:
          type: string
        remaining_today:
          type: integer
        remaining_this_hour:
          type: integer
    # ── Inbound Email ──
    InboundEmail:
      type: object
      properties:
        id:
          type: string
          format: uuid
        message_id:
          type: string
        from:
          type: string
        to:
          type: string
        subject:
          type: string
        text:
          type: string
          nullable: true
        html:
          type: string
          nullable: true
        headers:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
              value:
                type: string
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/Attachment'
        status:
          type: string
          enum: [received, processing, delivered, failed]
        spf:
          type: string
          nullable: true
          description: SPF check result
        dkim:
          type: string
          nullable: true
          description: DKIM check result
        spam_score:
          type: number
          nullable: true
          description: Spam score from content analysis
        received_at:
          type: string
          format: date-time
        processed_at:
          type: string
          format: date-time
          nullable: true
    Attachment:
      type: object
      properties:
        id:
          type: string
          description: Stable attachment identifier for Postscale download URLs
        filename:
          type: string
          example: invoice.pdf
        content_type:
          type: string
          example: application/pdf
        size:
          type: integer
          description: File size in bytes
          example: 204800
        content_id:
          type: string
          nullable: true
          description: Content-ID for inline attachments
        url:
          type: string
          nullable: true
          description: Authenticated Postscale download URL
    # ── DKIM ──
    DKIMKey:
      type: object
      properties:
        id:
          type: string
          format: uuid
        domain_id:
          type: string
          format: uuid
        selector:
          type: string
          example: postscale1
        public_key:
          type: string
          description: Base64-encoded public key
        algorithm:
          type: string
          enum: [rsa-sha256, ed25519-sha256]
        key_size:
          type: integer
          example: 2048
        hash_algorithm:
          type: string
          example: sha256
        header_canonicalization:
          type: string
          enum: [simple, relaxed]
        body_canonicalization:
          type: string
          enum: [simple, relaxed]
        signed_headers:
          type: array
          items:
            type: string
          example: [from, to, subject, date, message-id, mime-version, content-type]
        active:
          type: boolean
        primary_key:
          type: boolean
          description: Whether this is the primary signing key
        created_at:
          type: string
          format: date-time
        rotated_at:
          type: string
          format: date-time
          nullable: true
        expires_at:
          type: string
          format: date-time
          nullable: true
        last_used_at:
          type: string
          format: date-time
          nullable: true
    DKIMDNSRecord:
      type: object
      properties:
        key_id:
          type: string
          format: uuid
        domain_id:
          type: string
          format: uuid
        domain:
          type: string
          example: example.com
        selector:
          type: string
          example: postscale1
        dns_name:
          type: string
          description: Full DNS record name
          example: postscale1._domainkey.example.com
        dns_value:
          type: string
          description: DNS TXT record value
          example: "v=DKIM1; k=rsa; p=MIIBIjANBg..."
        active:
          type: boolean
        primary_key:
          type: boolean
    # ── Alias ──
    Alias:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        domain_id:
          type: string
          format: uuid
        address:
          type: string
          description: Local part of the alias (before the @)
          example: support
        domain:
          type: string
          example: example.com
        forward_to:
          type: array
          items:
            type: string
          nullable: true
          description: Addresses to forward incoming email to
        webhook_id:
          type: string
          format: uuid
          nullable: true
          description: Webhook endpoint to notify on incoming email
        action:
          type: string
          enum: [forward, webhook, both, reject]
          description: What to do with incoming email
        is_masked:
          type: boolean
          description: Whether this is a masked/anonymous alias
        masked_description:
          type: string
          nullable: true
          description: Description of what this masked alias is used for
        emails_received:
          type: integer
          description: Total emails received at this alias
        last_email_at:
          type: string
          format: date-time
          nullable: true
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateAliasRequest:
      type: object
      required:
        - domain_id
        - address
      properties:
        domain_id:
          type: string
          format: uuid
          description: Domain to create the alias under
        address:
          type: string
          description: Local part of the alias (without @domain)
          example: support
        forward_to:
          type: array
          items:
            type: string
          description: Addresses to forward email to (required for forward/both actions)
        action:
          type: string
          enum: [forward, webhook, both, reject]
          default: webhook
        webhook_id:
          type: string
          format: uuid
          description: Webhook endpoint to notify
        is_masked:
          type: boolean
          default: false
        masked_description:
          type: string
    UpdateAliasRequest:
      type: object
      properties:
        forward_to:
          type: array
          items:
            type: string
        action:
          type: string
          enum: [forward, webhook, both, reject]
        webhook_id:
          type: string
          format: uuid
          nullable: true
        is_masked:
          type: boolean
        masked_description:
          type: string
        active:
          type: boolean
    # ── Suppression ──
    SuppressionEntry:
      type: object
      properties:
        id:
          type: string
          format: uuid
        email:
          type: string
          format: email
          example: bounced@example.com
        reason:
          type: string
          description: Why the address was suppressed
          enum: [hard_bounce, complaint, manual, unsubscribe]
          example: hard_bounce
        source_message_id:
          type: string
          nullable: true
          description: Message ID that triggered the suppression
        bounce_code:
          type: string
          nullable: true
          description: SMTP bounce code (for bounce suppressions)
          example: "550"
        feedback_type:
          type: string
          nullable: true
          description: Feedback type (for complaint suppressions)
          enum: [abuse, fraud, not-spam, other, virus]
        scope_type:
          type: string
          description: Scope for unsubscribe suppressions; other reasons are normally org-wide
          enum: [org, domain, stream, template, list]
          example: org
        scope_id:
          type: string
          nullable: true
          description: Scope identifier for domain, stream, template, or list scoped entries
        source:
          type: string
          description: Origin of the suppression entry
          example: import:sendgrid
        created_at:
          type: string
          format: date-time
    SuppressionImportPreviewRequest:
      type: object
      required: [provider, content]
      properties:
        provider:
          type: string
          enum: [sendgrid, mailgun, postmark, resend, ses, custom]
          example: sendgrid
        source_filename:
          type: string
          example: bounces.csv
        content:
          type: string
          description: CSV file contents
        default_reason:
          type: string
          enum: [hard_bounce, complaint, manual, unsubscribe]
          default: manual
        default_scope_type:
          type: string
          enum: [org, domain, stream, template, list]
          default: org
        default_scope_id:
          type: string
          description: Default scope identifier for scoped unsubscribe imports
    ImportResult:
      type: object
      properties:
        job:
          $ref: '#/components/schemas/ImportJob'
        items:
          type: array
          items:
            $ref: '#/components/schemas/ImportJobItem'
    ImportJob:
      type: object
      properties:
        id:
          type: string
          format: uuid
        artifact_type:
          type: string
          enum: [suppressions, templates, webhooks, domains]
        provider:
          type: string
        status:
          type: string
          enum: [previewed, completed, failed]
        source_filename:
          type: string
          nullable: true
        total_rows:
          type: integer
        valid_rows:
          type: integer
        invalid_rows:
          type: integer
        duplicate_rows:
          type: integer
        imported_rows:
          type: integer
        skipped_rows:
          type: integer
        error_rows:
          type: integer
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
    ImportJobItem:
      type: object
      properties:
        id:
          type: string
          format: uuid
        job_id:
          type: string
          format: uuid
        row_number:
          type: integer
        artifact_type:
          type: string
        email:
          type: string
          format: email
          nullable: true
        reason:
          type: string
          enum: [hard_bounce, complaint, manual, unsubscribe]
          nullable: true
        scope_type:
          type: string
          enum: [org, domain, stream, template, list]
          nullable: true
        scope_id:
          type: string
          nullable: true
        status:
          type: string
          enum: [valid, invalid, duplicate, imported, skipped, error]
        error:
          type: string
          nullable: true
        raw:
          type: object
          additionalProperties:
            type: string
        created_at:
          type: string
          format: date-time
    UnsubscribeScope:
      type: object
      description: |
        Optional unsubscribe scope for non-transactional mail. If omitted, one-click
        unsubscribes default to org scope. Hard bounces, complaints, and manual
        suppressions remain org-wide.
      properties:
        type:
          type: string
          enum: [org, domain, stream, template, list]
          default: org
        id:
          type: string
          description: Required for stream, template, and list scopes; ignored for org and domain
          example: product-updates
    # ── Warming ──
    WarmingStatus:
      type: object
      properties:
        domain_id:
          type: string
          format: uuid
        domain:
          type: string
          example: example.com
        phase:
          type: string
          enum: [not_started, phase_1, phase_2, phase_3, phase_4, warmed, paused, suspended]
          description: |
            Current warming phase. Phases progress from phase_1 (50-1500/day)
            through phase_4 (100k-200k/day) to fully warmed.
        warming_started_at:
          type: string
          format: date-time
          nullable: true
        phase_started_at:
          type: string
          format: date-time
          nullable: true
        days_in_current_phase:
          type: integer
        current_limits:
          $ref: '#/components/schemas/WarmingLimits'
        today_sent:
          type: integer
          description: Emails sent today
        this_hour_sent:
          type: integer
          description: Emails sent in the current hour
        remaining_today:
          type: integer
          description: Remaining daily quota
        remaining_this_hour:
          type: integer
          description: Remaining hourly quota
        metrics:
          $ref: '#/components/schemas/WarmingMetrics'
        eligible_for_advancement:
          type: boolean
          description: Whether the domain can advance to the next phase
        advancement_blockers:
          type: array
          items:
            type: string
          description: Reasons preventing advancement (empty if eligible)
        next_phase:
          type: string
          nullable: true
        next_phase_limits:
          $ref: '#/components/schemas/WarmingLimits'
          nullable: true
        paused_reason:
          type: string
          nullable: true
    WarmingLimits:
      type: object
      properties:
        daily_limit:
          type: integer
          example: 500
        hourly_limit:
          type: integer
          example: 50
        per_minute_limit:
          type: integer
          example: 5
    WarmingMetrics:
      type: object
      properties:
        period_days:
          type: integer
          example: 7
        total_sent:
          type: integer
        total_delivered:
          type: integer
        total_bounced:
          type: integer
        total_hard_bounce:
          type: integer
        total_complaints:
          type: integer
        delivery_rate:
          type: number
          format: double
          description: Fraction of sent emails that were delivered (0.0 - 1.0)
          example: 0.985
        bounce_rate:
          type: number
          format: double
          example: 0.015
        hard_bounce_rate:
          type: number
          format: double
          example: 0.005
        complaint_rate:
          type: number
          format: double
          example: 0.0002
    WarmingPhaseHistory:
      type: object
      properties:
        id:
          type: string
          format: uuid
        domain_id:
          type: string
          format: uuid
        from_phase:
          type: string
          nullable: true
        to_phase:
          type: string
        reason:
          type: string
          enum: [metrics_passed, metrics_failed, manual_advance, manual_rollback, manual_pause, manual_resume, blocklist, warming_started]
        metrics_snapshot:
          type: object
          nullable: true
          properties:
            bounce_rate:
              type: number
              format: double
            complaint_rate:
              type: number
              format: double
            delivery_rate:
              type: number
              format: double
            daily_volume:
              type: integer
            days_in_phase:
              type: integer
        triggered_by:
          type: string
          description: Who or what triggered the transition
          example: api
        created_at:
          type: string
          format: date-time
    # ── Stats ──
    DailyStat:
      type: object
      properties:
        date:
          type: string
          format: date
        emails_sent:
          type: integer
        emails_delivered:
          type: integer
        emails_bounced:
          type: integer
        hard_bounces:
          type: integer
        soft_bounces:
          type: integer
        complaints:
          type: integer
        delivery_rate:
          type: number
          format: double
          nullable: true
        bounce_rate:
          type: number
          format: double
          nullable: true
        hard_bounce_rate:
          type: number
          format: double
          nullable: true
        complaint_rate:
          type: number
          format: double
          nullable: true
    HourlyStat:
      type: object
      properties:
        hour_bucket:
          type: string
          format: date-time
        emails_sent:
          type: integer
        emails_delivered:
          type: integer
        emails_bounced:
          type: integer
        emails_deferred:
          type: integer
        hard_bounces:
          type: integer
        soft_bounces:
          type: integer
        complaints:
          type: integer
    ISPStat:
      type: object
      properties:
        isp:
          type: string
          example: gmail
        sent:
          type: integer
        delivered:
          type: integer
        bounced:
          type: integer
    # ── Webhook ──
    WebhookEndpoint:
      type: object
      properties:
        id:
          type: string
          format: uuid
        domain_id:
          type: string
          format: uuid
          nullable: true
          description: Scoped to a specific domain, or null for org-wide
        domain:
          type: string
          description: Domain name (for display)
        match_pattern:
          type: string
          description: Address pattern to match (supports * wildcard)
          example: "*"
        webhook_url:
          type: string
          format: uri
          description: URL to POST delivery events to
          example: https://example.com/webhooks/postscale
        event_types:
          type: array
          items:
            type: string
            enum:
              - email.received
              - email.delivered
              - email.bounced
              - email.deferred
              - email.complained
          description: Event types this endpoint subscribes to.
          example:
            - email.received
            - email.delivered
        has_secret:
          type: boolean
          description: Whether this endpoint has an HMAC signing secret configured.
        previous_secret_expires_at:
          type: string
          format: date-time
          nullable: true
          description: End of the previous-secret rotation grace window, if active.
        active:
          type: boolean
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateWebhookRequest:
      type: object
      required:
        - url
      properties:
        domain_id:
          type: string
          format: uuid
          description: Scope webhook to a specific domain
        match_pattern:
          type: string
          default: "*"
          description: Address pattern to match
        url:
          type: string
          format: uri
          description: URL to POST events to
        events:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - email.received
              - email.delivered
              - email.bounced
              - email.deferred
              - email.complained
          description: Event types to subscribe to. Omitted defaults to email.received.
          example:
            - email.received
            - email.delivered
    # ── Template ──
    Template:
      type: object
      properties:
        id:
          type: string
          format: uuid
        org_id:
          type: string
          format: uuid
        name:
          type: string
          description: Display name of the template
          example: Welcome Email
        slug:
          type: string
          description: Unique slug for referencing the template when sending
          example: welcome
        description:
          type: string
          nullable: true
          description: Optional description of the template
        subject:
          type: string
          description: Subject line with optional {{variable}} placeholders
        html_body:
          type: string
          description: HTML body with optional {{variable}} placeholders
        text_body:
          type: string
          description: Plain text body with optional {{variable}} placeholders
        active:
          type: boolean
          description: Whether the template can be used for sending
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreateTemplateRequest:
      type: object
      required:
        - name
        - slug
      properties:
        name:
          type: string
          description: Display name (max 191 characters)
          example: Welcome Email
        slug:
          type: string
          description: Unique slug (lowercase alphanumeric + hyphens, 2-100 chars)
          example: welcome
        description:
          type: string
          description: Optional description
        subject:
          type: string
          description: Subject line template with {{variable}} placeholders
          example: "Welcome, {{name}}!"
        html_body:
          type: string
          description: HTML body template
        text_body:
          type: string
          description: Plain text body template
    UpdateTemplateRequest:
      type: object
      properties:
        name:
          type: string
          description: Display name
        slug:
          type: string
          description: Unique slug
        description:
          type: string
          description: Optional description
        subject:
          type: string
          description: Subject line template
        html_body:
          type: string
          description: HTML body template
        text_body:
          type: string
          description: Plain text body template
        active:
          type: boolean
          description: Whether the template can be used for sending
