openapi: 3.1.0
info:
  title: Statxt Public API
  description: |
    The Statxt Public API lets third-party developers send SMS messages, manage contacts and
    campaigns, react to events via webhooks, and more.

    **Base URL:** `https://docs.statxt.com/api/v1` (same API as `https://app.statxt.com/api/v1`)

    Authentication is via API key (`Authorization: Bearer intx_public_live_…` or
    `X-Integration-Key` header). Test-mode keys use the `intx_public_test_…` prefix.
    All keys belong to a single organization and have explicit scopes — see the
    `securitySchemes` section.

    All mutating endpoints (`POST`, `PATCH`, `DELETE`) honor an `Idempotency-Key` header
    (24h cache, body-hash protected). All responses include `X-Request-Id`,
    `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers.

    See `lib/open-api/release-hardening.md` and `docs/open-api-spec.md` for the full
    architectural specification.
  version: 1.2.0
  contact:
    name: Statxt Developer Support
    url: https://docs.statxt.com
servers:
  # Prefer docs host first so Scalar "Try it" stays same-origin on docs.statxt.com
  # (avoids empty Response panes from browser CORS blocks against app.statxt.com).
  - url: https://docs.statxt.com/api/v1
    description: Production (docs host — use this in the docs Try it client)
  - url: https://app.statxt.com/api/v1
    description: Production (app host)
  - url: http://localhost:3000/api/v1
    description: Local development

# ── Security ──────────────────────────────────────────────────────────────────
security:
  - bearerAuth: []

# ── Tags (used to group endpoints in docs) ────────────────────────────────────
tags:
  - name: Messages
    description: Send and inspect SMS messages
  - name: Contacts
    description: Manage contact records and bulk imports
  - name: Contact Groups
    description: Organize contacts into addressable groups
  - name: Campaigns
    description: Create, launch, pause, and cancel SMS campaigns
  - name: Templates
    description: Reusable message templates with merge-field tokens
  - name: Phone Numbers
    description: Inspect organization-owned sender numbers
  - name: Premium Numbers
    description: Buy, rotate, delete, and monitor managed premium phone numbers
  - name: Webhooks
    description: Receive real-time event notifications via HTTP callbacks
  - name: Notifications
    description: AI/MCP notification inbox (subscribe + poll; no public callback URL)
  - name: Usage
    description: Read current billing-period usage
  - name: Compliance
    description: Pre-flight DNC, illegal-word, and quiet-hours checks; read and update quiet-hours policy
  - name: Clients
    description: API partner clients (agency/reseller multi-client hierarchy)
  - name: Ten DLC
    description: Per-client 10DLC registration applications

# ── Reusable parameters ───────────────────────────────────────────────────────
paths:

  # ───────────────────────────── Messages ────────────────────────────────────
  /messages:
    get:
      tags: [Messages]
      summary: List messages
      description: Returns recent messages with cursor-based pagination, newest first.
      operationId: listMessages
      security: [{ bearerAuth: [messages:read] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/After'
        - in: query
          name: status
          schema: { $ref: '#/components/schemas/MessageStatus' }
        - in: query
          name: direction
          schema: { type: string, enum: [inbound, outbound] }
      responses:
        '200':
          description: A page of messages
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MessageListResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Messages]
      summary: Send a message
      description: |
        Queues an SMS or MMS for delivery. Include `media_urls` with one or more
        HTTPS image URLs to send picture messaging (MMS). Upload bytes first via
        `POST /media` when you do not already have a public URL. The optional
        `body` may accompany media. Each accepted MMS consumes **1.5 credits**
        from your wallet; SMS is billed at 1 credit per segment.

        Merge tokens in `body` such as `{first_name}`, `{name}`, `{company}`,
        `{brand_name}`, and `{sender_name}` are resolved server-side from the
        recipient contact (when one exists for that phone) and brand/sender
        context before compliance and delivery. Unknown tokens are removed.

        Compliance checks (DNC, opt-out, quiet hours, approval-required,
        illegal words, and charset/lookalike security evasion) are enforced
        server-side and may return 422. Charset evasion returns
        `charset_security_evasion` with `normalized_text` — resubmit that
        Latin-normalized body after review (no silent rewrite-and-send).
      operationId: sendMessage
      security: [{ bearerAuth: [messages:send] }]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendMessageRequest' }
            examples:
              sms:
                summary: SMS only
                value:
                  to: "+14155550100"
                  from_phone_number_id: "00000000-0000-4000-8000-000000000001"
                  body: "Hello from Statxt"
              sms_from_e164:
                summary: SMS using sender E.164
                value:
                  to: "+14155550100"
                  from: "+14155550199"
                  body: "Hello from Statxt"
              personalized:
                summary: Merge fields from contact
                value:
                  to: "+14155550100"
                  from_phone_number_id: "00000000-0000-4000-8000-000000000001"
                  body: "Hi {first_name}, this is {sender_first_name} from {brand_name}."
              mms:
                summary: Picture message (MMS)
                value:
                  to: "+14155550100"
                  from: "+14155550199"
                  body: "Check out this photo"
                  media_urls:
                    - "https://cdn.example.com/promo.jpg"
      responses:
        '202':
          description: Message accepted for delivery
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SendMessageAccepted' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ComplianceBlocked' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /media:
    post:
      tags: [Messages]
      summary: Upload media for MMS
      description: |
        Stores an image (or other allowed media) in the organization's MMS blob
        and returns a public HTTPS `url` suitable for `media_urls` on
        `POST /messages` and campaign create.

        Provide **exactly one** of:
        - `content_base64` — raw base64 or a `data:<type>;base64,...` data URL
          (preferred for AI/MCP clients that have image bytes in chat)
        - `source_url` — a public HTTPS URL that Statxt will fetch and copy
          (SSRF-guarded; private/link-local hosts are rejected)

        Limits: 10MB max; allowed types jpeg, png, gif, webp, mp4, quicktime,
        mpeg audio, wav, pdf. Content is sniffed from magic bytes (declared
        `content_type` must match when provided). Requires `messages:send` and
        at least one media feature flag (`mms_enabled`, `image_uploads`, or
        `media_attachments`).
      operationId: uploadMedia
      security: [{ bearerAuth: [messages:send] }]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UploadMediaRequest' }
            examples:
              base64:
                summary: Upload from base64
                value:
                  content_base64: "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBD..."
                  content_type: "image/jpeg"
                  filename: "promo.jpg"
              source_url:
                summary: Copy from public URL
                value:
                  source_url: "https://cdn.example.com/promo.jpg"
                  filename: "promo.jpg"
      responses:
        '201':
          description: Media stored
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Media' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /messages/preview:
    post:
      tags: [Messages]
      summary: Preview a message without sending
      description: |
        Dry-run the same outbound prep pipeline as `POST /messages` (sender
        resolution, merge tokens, compliance, media gates, credit estimate)
        without inserting a message, calling a carrier, or charging credits.
        Returns `allowed: true|false` with blockers and the normalized body
        that would be sent.
      operationId: previewMessage
      security: [{ bearerAuth: [messages:send] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendMessageRequest' }
            examples:
              preview:
                summary: Preview personalized SMS
                value:
                  to: "+14155550100"
                  from: "+14155550199"
                  body: "Hi {first_name}, this is {brand_name}."
      responses:
        '200':
          description: Preview result (always 200; check `allowed`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MessagePreview' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /messages/{id}:
    get:
      tags: [Messages]
      summary: Get a message
      operationId: getMessage
      security: [{ bearerAuth: [messages:read] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
      responses:
        '200':
          description: A single message
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────── Phone Numbers ───────────────────────────────
  /phone-numbers:
    get:
      tags: [Phone Numbers]
      summary: List phone numbers
      description: |
        All sender phone numbers visible to the calling key. Live keys see
        their organization's phone numbers; test keys see the shared sandbox
        pool. Filter by `client_id` or `ten_dlc_id` for partner private-pool
        purchases. The list is currently unpaginated but always emits the standard
        list envelope (`object: list`, `has_more: false`, `next_cursor: null`).
      operationId: listPhoneNumbers
      security: [{ bearerAuth: [messages:read] }]
      parameters:
        - in: query
          name: client_id
          schema: { type: string, format: uuid }
          description: Filter to numbers purchased for this partner client
        - in: query
          name: ten_dlc_id
          schema: { type: string, format: uuid }
          description: Filter to numbers purchased for this 10DLC application
      responses:
        '200':
          description: All sender phone numbers visible to the key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PhoneNumberListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /phone-numbers/default:
    get:
      tags: [Phone Numbers]
      summary: Get recommended default sender
      description: |
        Rank visible senders and return one recommendation. Ranking considers
        conversation continuity, assignment to the acting user, destination
        area-code match, and remaining sender capacity. Returns
        `404 no_default_sender` when no valid sender exists — never invents one.
      operationId: getDefaultSender
      security: [{ bearerAuth: [messages:read] }]
      parameters:
        - in: query
          name: to
          schema: { type: string }
          description: Destination phone used for area-code matching
        - in: query
          name: conversation_phone_number_id
          schema: { type: string, format: uuid }
          description: Prefer continuity with this existing conversation sender
        - in: query
          name: require_mms
          schema: { type: boolean, default: false }
          description: Only consider MMS-capable senders
      responses:
        '200':
          description: Recommended sender
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DefaultSender' }
        '404':
          description: No valid default sender
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  # ───────────────────────────── Clients (API partners) ──────────────────────
  /clients:
    get:
      tags: [Clients]
      summary: List partner clients
      description: Lists active clients for the API partner organization.
      operationId: listClients
      security: [{ bearerAuth: [messages:read] }]
      responses:
        '200':
          description: Partner clients
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClientListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /clients/{id}:
    get:
      tags: [Clients]
      summary: Retrieve a partner client
      operationId: getClient
      security: [{ bearerAuth: [messages:read] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: A partner client
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Client' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────── Ten DLC applications ────────────────────────
  /ten-dlc:
    get:
      tags: [Ten DLC]
      summary: List 10DLC applications
      description: Lists non-archived 10DLC applications for the partner organization.
      operationId: listTenDlcApplications
      security: [{ bearerAuth: [messages:read] }]
      parameters:
        - in: query
          name: client_id
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: 10DLC applications
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenDlcListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /ten-dlc/{id}:
    get:
      tags: [Ten DLC]
      summary: Retrieve a 10DLC application
      operationId: getTenDlcApplication
      security: [{ bearerAuth: [messages:read] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: A 10DLC application
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TenDlcApplication' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────── Premium Numbers ─────────────────────────────
  /premium-number-offers:
    get:
      tags: [Premium Numbers]
      summary: Search premium number offers
      description: |
        Search provider inventory for an approved client and return short-lived opaque
        `premium_number_offer` objects. Requires an active client, approved 10DLC
        application, and configured campaign binding. Test keys return synthetic offers
        and never call live inventory.
      operationId: listPremiumNumberOffers
      security: [{ bearerAuth: [premium_numbers:read] }]
      parameters:
        - name: client_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: area_code
          in: query
          required: false
          schema: { type: string, pattern: '^\d{3}$' }
        - name: state
          in: query
          required: false
          schema: { type: string, pattern: '^[A-Za-z]{2}$' }
        - name: number
          in: query
          required: false
          schema: { type: string, pattern: '^\+1\d{10}$' }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
      responses:
        '200':
          description: A list of premium number offers
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PremiumNumberOfferListResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /premium-numbers:
    get:
      tags: [Premium Numbers]
      summary: List premium numbers
      description: |
        Paginated list of premium managed numbers visible to the calling key.
        Personal keys see only numbers assigned to the acting user; organization
        keys see the full org inventory. Test keys receive synthetic sandbox numbers.
      operationId: listPremiumNumbers
      security: [{ bearerAuth: [premium_numbers:read] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/After'
        - name: client_id
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Filter to numbers attributed to a partner client
      responses:
        '200':
          description: A page of premium numbers
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PremiumNumberListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Premium Numbers]
      summary: Buy a premium number
      description: |
        Queues purchase and provisioning of a premium number. Returns `202 Accepted`
        with a `premium_number_operation` — poll
        `GET /premium-number-operations/{id}` until `status` is terminal
        (`completed`, `failed`, or `needs_action`).

        Preferred body: `{ offer_id, client_id? }`. Legacy `{ area_code | number, client_id }`
        is resolved through the offer flow when commerce is enabled.

        Personal keys automatically assign the purchased number to the acting user.
        Organization keys may pass `assign_to_user_id` or leave the number in the org pool.
      operationId: buyPremiumNumber
      security: [{ bearerAuth: [premium_numbers:write] }]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BuyPremiumNumberRequest' }
            examples:
              by_offer:
                summary: Purchase from an offer
                value:
                  offer_id: "00000000-0000-4000-8000-000000000099"
                  client_id: "00000000-0000-4000-8000-000000000001"
              by_area_code:
                summary: Legacy purchase by area code
                value:
                  client_id: "00000000-0000-4000-8000-000000000001"
                  area_code: "213"
              explicit_number:
                summary: Legacy purchase a specific number
                value:
                  client_id: "00000000-0000-4000-8000-000000000001"
                  number: "+12135551234"
      responses:
        '202':
          description: Operation queued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PremiumNumberOperation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/BadRequest' }
        '422': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /premium-numbers/{id}:
    get:
      tags: [Premium Numbers]
      summary: Retrieve a premium number
      operationId: getPremiumNumber
      security: [{ bearerAuth: [premium_numbers:read] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: Premium number details
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PremiumNumber' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    delete:
      tags: [Premium Numbers]
      summary: Delete a premium number
      description: |
        Releases the number from carrier inventory and marks it released in Statxt.
        This operation is synchronous.
      operationId: deletePremiumNumber
      security: [{ bearerAuth: [premium_numbers:write] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: Number deleted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PremiumNumberDeletedResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Delete failed (carrier or policy error)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /premium-numbers/{id}/health:
    get:
      tags: [Premium Numbers]
      summary: Get premium number health
      description: Returns 7-day and 30-day delivery health scores for the number.
      operationId: getPremiumNumberHealth
      security: [{ bearerAuth: [premium_numbers:read] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: Health scores
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PremiumNumberHealth' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Health lookup failed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /premium-numbers/{id}/rotate:
    post:
      tags: [Premium Numbers]
      summary: Rotate a premium number
      description: |
        Queues replacement of an underperforming number with a new one in the same
        campaign context. Returns `202 Accepted` with a `premium_number_operation`.
        Poll `GET /premium-number-operations/{id}` until terminal.

        If the operation ends with `needs_action` and `action_required: choose_area_code`,
        retry with an explicit `area_code` or `replacement_number`.
      operationId: rotatePremiumNumber
      security: [{ bearerAuth: [premium_numbers:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RotatePremiumNumberRequest' }
      responses:
        '202':
          description: Operation queued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PremiumNumberOperation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Number is locked or rotation is not allowed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /premium-number-operations/{id}:
    get:
      tags: [Premium Numbers]
      summary: Poll a premium number operation
      description: |
        Returns the current status of an async buy or rotate operation. Poll until
        `status` ∈ `completed | failed | needs_action`.
      operationId: getPremiumNumberOperation
      security: [{ bearerAuth: [premium_numbers:read] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: Operation status
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PremiumNumberOperation' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────────── Contacts ────────────────────────────────────
  /contacts:
    get:
      tags: [Contacts]
      summary: List contacts
      operationId: listContacts
      security: [{ bearerAuth: [contacts:read] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/After'
        - in: query
          name: search
          schema: { type: string }
        - in: query
          name: group_id
          schema: { type: string, format: uuid }
          description: |
            Restrict results to a single contact group.
            Static groups require an actual `contact_group_members` membership
            (`!inner` constraint). Dynamic groups resolve via `filter_rules`
            instead of membership rows. Unknown or out-of-scope group ids
            return `404 not_found`.
        - in: query
          name: tag
          schema: { type: string }
        - in: query
          name: dnc
          schema: { type: string, enum: ["true", "false"] }
      responses:
        '200':
          description: A page of contacts
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactListResponse' }
    post:
      tags: [Contacts]
      summary: Create a contact
      operationId: createContact
      security: [{ bearerAuth: [contacts:write] }]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateContactRequest' }
      responses:
        '201':
          description: Contact created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contact' }
        '409':
          description: Phone already in use by another contact in this organization
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contacts/by-phone:
    get:
      tags: [Contacts]
      summary: Exact contact lookup by phone
      description: |
        Normalize `phone` to E.164 and return all exact `phone_normalized`
        matches in scope. Never silently picks among duplicates —
        `resolution` is `exact`, `ambiguous`, or `none`.
      operationId: getContactByPhone
      security: [{ bearerAuth: [contacts:read] }]
      parameters:
        - in: query
          name: phone
          required: true
          schema: { type: string }
          description: Phone number in E.164 or a normalizable national form
      responses:
        '200':
          description: Lookup result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactPhoneLookup' }
        '400':
          description: Missing or invalid phone
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contacts/bulk:
    post:
      tags: [Contacts]
      summary: Bulk upsert contacts
      description: Upserts up to 500 contacts by phone number. Optionally adds them to groups.
      operationId: bulkUpsertContacts
      security: [{ bearerAuth: [contacts:write] }]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BulkUpsertContactRequest' }
      responses:
        '200':
          description: Bulk upsert result with per-row outcomes
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactBulkResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /contacts/{id}:
    get:
      tags: [Contacts]
      summary: Get a contact
      operationId: getContact
      security: [{ bearerAuth: [contacts:read] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: A single contact
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contact' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Contacts]
      summary: Update a contact
      operationId: updateContact
      security: [{ bearerAuth: [contacts:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateContactRequest' }
      responses:
        '200':
          description: Contact updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contact' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Contacts]
      summary: Delete a contact
      operationId: deleteContact
      security: [{ bearerAuth: [contacts:write] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: Contact deleted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedResponse' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────── Contact Groups ──────────────────────────────
  /contact-groups:
    get:
      tags: [Contact Groups]
      summary: List contact groups
      operationId: listContactGroups
      security: [{ bearerAuth: [contacts:read] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/After'
      responses:
        '200':
          description: A page of contact groups (with member_count)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactGroupListResponse' }
    post:
      tags: [Contact Groups]
      summary: Create a contact group
      operationId: createContactGroup
      security: [{ bearerAuth: [contacts:write] }]
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateContactGroupRequest' }
      responses:
        '201':
          description: Group created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactGroup' }
        '409':
          description: A group with that name already exists in this organization
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contact-groups/{id}:
    get:
      tags: [Contact Groups]
      summary: Get a contact group
      operationId: getContactGroup
      security: [{ bearerAuth: [contacts:read] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: A single contact group
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactGroup' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Contact Groups]
      summary: Update a contact group
      operationId: updateContactGroup
      security: [{ bearerAuth: [contacts:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateContactGroupRequest' }
      responses:
        '200':
          description: Group updated
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContactGroup' }
    delete:
      tags: [Contact Groups]
      summary: Delete a contact group
      description: Returns 409 if the group is referenced by an active campaign.
      operationId: deleteContactGroup
      security: [{ bearerAuth: [contacts:write] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: Group deleted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedResponse' }
        '409':
          description: Group is in use by an active campaign
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /contact-groups/{id}/members:
    post:
      tags: [Contact Groups]
      summary: Add members to a group
      operationId: addContactGroupMembers
      security: [{ bearerAuth: [contacts:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MembersRequest' }
      responses:
        '200':
          description: Membership change result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MembersAddResult' }
    delete:
      tags: [Contact Groups]
      summary: Remove members from a group
      operationId: removeContactGroupMembers
      security: [{ bearerAuth: [contacts:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MembersRequest' }
      responses:
        '200':
          description: Membership change result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MembersRemoveResult' }

  # ───────────────────────────── Campaigns ───────────────────────────────────
  /campaigns:
    get:
      tags: [Campaigns]
      summary: List campaigns
      operationId: listCampaigns
      security: [{ bearerAuth: [campaigns:read] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/After'
      responses:
        '200':
          description: A page of campaigns
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignListResponse' }
    post:
      tags: [Campaigns]
      summary: Create a campaign
      description: |
        Creates a text campaign. Include `media_urls` to send picture messaging
        (MMS) to all recipients. Each accepted MMS per recipient consumes
        **1.5 credits**; SMS is billed at 1 credit per segment.
      operationId: createCampaign
      security: [{ bearerAuth: [campaigns:write] }]
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateCampaignRequest' }
      responses:
        '201':
          description: Campaign created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignCreatedResponse' }
        '422':
          description: Phone number is not active or no eligible recipients
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /campaigns/preflight:
    post:
      tags: [Campaigns]
      summary: Preflight a campaign without launching
      description: |
        Side-effect-free campaign readiness check. Resolve an existing campaign
        by id or exact name, or provide draft fields. Supports sender E.164,
        sender id, or deterministic default selection plus group ids or exact
        group names. Returns audience, credit and completion estimates with
        provider-neutral blockers and warnings.
      operationId: preflightCampaign
      security: [{ bearerAuth: [campaigns:write] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CampaignPreflightRequest' }
      responses:
        '200':
          description: Campaign readiness result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignPreflight' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /campaigns/{id}:
    get:
      tags: [Campaigns]
      summary: Get a campaign
      operationId: getCampaign
      security: [{ bearerAuth: [campaigns:read] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: A single campaign with live stats
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignWithStats' }
        '404': { $ref: '#/components/responses/NotFound' }

  /campaigns/{id}/launch:
    post:
      tags: [Campaigns]
      summary: Launch a campaign
      description: Transitions a draft / scheduled / paused campaign into the sending pipeline.
      operationId: launchCampaign
      security: [{ bearerAuth: [campaigns:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Launch acknowledged
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignLaunchedResponse' }
        '409':
          description: Campaign is in a state that cannot be launched
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /campaigns/{id}/pause:
    post:
      tags: [Campaigns]
      summary: Pause a sending campaign
      description: "Idempotent — already-paused returns 200 with `idempotent: true`."
      operationId: pauseCampaign
      security: [{ bearerAuth: [campaigns:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Paused
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignPausedResponse' }

  /campaigns/{id}/cancel:
    post:
      tags: [Campaigns]
      summary: Cancel a campaign
      description: "Idempotent — already-cancelled returns 200 with `idempotent: true`."
      operationId: cancelCampaign
      security: [{ bearerAuth: [campaigns:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Cancelled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CampaignCancelledResponse' }

  # ───────────────────────────── Templates ───────────────────────────────────
  /templates:
    get:
      tags: [Templates]
      summary: List templates
      operationId: listTemplates
      security: [{ bearerAuth: [templates:read] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/After'
        - in: query
          name: category
          schema: { type: string }
        - in: query
          name: search
          schema: { type: string }
      responses:
        '200':
          description: A page of templates
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TemplateListResponse' }
    post:
      tags: [Templates]
      summary: Create a template
      operationId: createTemplate
      security: [{ bearerAuth: [templates:write] }]
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateTemplateRequest' }
      responses:
        '201':
          description: Template created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Template' }
        '409':
          description: A template with that name already exists in this organization
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /templates/{id}:
    get:
      tags: [Templates]
      summary: Get a template
      operationId: getTemplate
      security: [{ bearerAuth: [templates:read] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: A single template
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Template' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Templates]
      summary: Update a template
      operationId: updateTemplate
      security: [{ bearerAuth: [templates:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateTemplateRequest' }
      responses:
        '200':
          description: Template updated (merge_fields recomputed if body changed)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Template' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Templates]
      summary: Delete a template
      operationId: deleteTemplate
      security: [{ bearerAuth: [templates:write] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: Template deleted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeletedResponse' }

  # ───────────────────────────── Webhooks ────────────────────────────────────
  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions
      operationId: listWebhookSubscriptions
      security: [{ bearerAuth: [webhooks:read] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/After'
      responses:
        '200':
          description: A page of webhook subscriptions for the organization
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookSubscriptionListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      description: |
        Returns the signing secret **once** in the response — store it securely.
        SSRF validation runs on create and again before every delivery attempt.
      operationId: createWebhookSubscription
      security: [{ bearerAuth: [webhooks:write] }]
      parameters: [ { $ref: '#/components/parameters/IdempotencyKey' } ]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateWebhookSubscriptionRequest' }
      responses:
        '201':
          description: Subscription created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/WebhookSubscription'
                  - type: object
                    required: [signing_secret]
                    properties:
                      signing_secret:
                        type: string
                        description: Shown only once at creation. Use this to verify HMAC-SHA256 signatures on incoming webhook bodies.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: An active subscription for this `(organization, url)` already exists. PATCH the existing subscription or DELETE it first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /webhooks/{id}:
    get:
      tags: [Webhooks]
      summary: Get a webhook subscription
      operationId: getWebhookSubscription
      security: [{ bearerAuth: [webhooks:read] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: A single webhook subscription. The signing secret is never returned here.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookSubscription' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Webhooks]
      summary: Update a webhook subscription
      description: |
        Partial update. Any field omitted is left unchanged. When `url` changes
        the SSRF guard runs again. Setting `status: "disabled"` halts dispatch
        without deleting the subscription. Setting `rotate_secret: true`
        replaces the signing secret immediately and returns the new value
        once in `signing_secret` — the old secret stops verifying as soon as
        the response is committed.
      operationId: updateWebhookSubscription
      security: [{ bearerAuth: [webhooks:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpdateWebhookSubscriptionRequest' }
      responses:
        '200':
          description: >-
            Updated subscription. signing_secret appears in the body only when the client sent rotate_secret true.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/WebhookSubscription'
                  - type: object
                    properties:
                      signing_secret:
                        type: string
                        description: >-
                          New signing secret. Present only when the request included rotate_secret true.
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook subscription
      operationId: deleteWebhookSubscription
      security: [{ bearerAuth: [webhooks:write] }]
      parameters: [ { $ref: '#/components/parameters/PathId' } ]
      responses:
        '200':
          description: Subscription deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
        '404': { $ref: '#/components/responses/NotFound' }

  /webhooks/{id}/deliveries/{delivery_id}/replay:
    post:
      tags: [Webhooks]
      summary: Replay a webhook delivery
      operationId: replayWebhookDelivery
      security: [{ bearerAuth: [webhooks:write] }]
      parameters:
        - $ref: '#/components/parameters/PathId'
        - in: path
          name: delivery_id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Delivery re-enqueued
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  replayed_delivery_id: { type: string, format: uuid }
        '404': { $ref: '#/components/responses/NotFound' }

  # ───────────────────────────── Credit allocation ────────────────────────────
  /credits/allocate:
    post:
      tags: [Credits]
      summary: Allocate organization credits to a user
      description: Organization-scoped owner/admin operation that transfers credits to a user in the same organization.
      operationId: allocateCreditsToUser
      security: [{ bearerAuth: [credits:allocate] }]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AllocateCreditsRequest' }
      responses:
        '200':
          description: Credits allocated successfully
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditAllocation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422':
          description: The organization or user has insufficient credits
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /credits/allocations:
    get:
      tags: [Credits]
      summary: List organization credit allocations
      operationId: listCreditAllocations
      security: [{ bearerAuth: [credits:allocate] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/After'
      responses:
        '200':
          description: Cursor-paginated credit allocations
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CreditAllocationListResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────────── Notifications (AI inbox) ────────────────────
  /notifications/subscriptions:
    get:
      tags: [Notifications]
      summary: List AI notification subscriptions
      description: |
        Lists this principal's AI/MCP event subscriptions. These do **not** use a
        public callback URL — events land in `GET /notifications` for polling
        (ChatGPT/Cursor/Claude cannot receive live webhook POSTs into chat).
      operationId: listMcpNotificationSubscriptions
      security: [{ bearerAuth: [webhooks:read] }]
      responses:
        '200':
          description: Active and disabled subscriptions for the acting user
          content:
            application/json:
              schema: { $ref: '#/components/schemas/McpEventSubscriptionListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
    post:
      tags: [Notifications]
      summary: Upsert AI notification subscription
      description: |
        Creates (or replaces) the caller's active subscription for the listed
        event types. Classic HTTPS webhooks remain under `/webhooks`.
      operationId: upsertMcpNotificationSubscription
      security: [{ bearerAuth: [webhooks:write] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UpsertMcpNotificationSubscriptionRequest' }
      responses:
        '200':
          description: Subscription saved
          content:
            application/json:
              schema: { $ref: '#/components/schemas/McpEventSubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /notifications:
    get:
      tags: [Notifications]
      summary: List AI notification inbox
      description: |
        Poll events fan-out from `emitWebhookEvent` for the acting user.
        Defaults to unread (`acknowledged_at` is null) only.
      operationId: listMcpNotifications
      security: [{ bearerAuth: [webhooks:read] }]
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/After'
        - in: query
          name: unread_only
          schema: { type: boolean, default: true }
          description: When true (default), only return unacknowledged rows.
      responses:
        '200':
          description: Notification inbox page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/McpNotificationListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /notifications/acknowledge:
    post:
      tags: [Notifications]
      summary: Acknowledge AI notifications
      description: Marks inbox rows as read/acknowledged for the acting user.
      operationId: acknowledgeMcpNotifications
      security: [{ bearerAuth: [webhooks:write] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AcknowledgeMcpNotificationsRequest' }
      responses:
        '200':
          description: Acknowledged rows
          content:
            application/json:
              schema: { $ref: '#/components/schemas/McpNotificationAckResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────────── Usage ───────────────────────────────────────
  /usage:
    get:
      tags: [Usage]
      summary: Get current billing-period usage
      operationId: getUsage
      security: [{ bearerAuth: [usage:read] }]
      responses:
        '200':
          description: Usage snapshot
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UsageResponse' }

  # ───────────────────────────── Compliance ──────────────────────────────────
  /compliance/dnc:
    get:
      tags: [Compliance]
      summary: List DNC entries
      description: |
        List Do Not Contact entries for the organization. Pass `client_id` to list
        a specific API partner client's brand list. Without `client_id`, returns all
        org rows (org-wide and per-client).
      operationId: listDnc
      security: [{ bearerAuth: [compliance:read] }]
      parameters:
        - name: client_id
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: Filter to a partner client brand list
        - name: search
          in: query
          required: false
          schema: { type: string }
        - name: page
          in: query
          required: false
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: DNC entry list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DncListResponse' }
    post:
      tags: [Compliance]
      summary: Add a DNC entry
      description: |
        Add a phone to the DNC list. Omit `client_id` for an org-wide block (all clients).
        Pass `client_id` for a brand-specific block. Requires `compliance:write`.
      operationId: addDnc
      security: [{ bearerAuth: [compliance:write] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, description: E.164 phone number }
                client_id: { type: string, format: uuid, nullable: true }
                reason:
                  type: string
                  enum: [opt_out, complaint, manual, bounced, national_dnc]
                  default: manual
                notes: { type: string, nullable: true, maxLength: 2000 }
                source: { type: string, nullable: true, maxLength: 120 }
      responses:
        '200':
          description: DNC entry added
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DncMutationResponse' }
    delete:
      tags: [Compliance]
      summary: Remove a DNC entry
      description: |
        Remove by phone. With `client_id`, removes that client's row only.
        Without `client_id`, removes all matching org rows for the phone.
        Requires `compliance:write`.
      operationId: removeDnc
      security: [{ bearerAuth: [compliance:write] }]
      parameters:
        - name: phone
          in: query
          required: true
          schema: { type: string }
        - name: client_id
          in: query
          required: false
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: DNC entry removed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DncMutationResponse' }

  /compliance/dnc/check:
    post:
      tags: [Compliance]
      summary: Pre-flight DNC check
      description: |
        Read-only DNC lookup. Does not mutate state. Result is Redis-cached.
        With `client_id`, blocks on org-wide rows or that client's rows.
        Without `client_id`, any org row for the phone blocks.
      operationId: checkDnc
      security: [{ bearerAuth: [compliance:read] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, description: E.164 phone number }
                client_id:
                  type: string
                  format: uuid
                  nullable: true
                  description: Optional partner client for brand-scoped check
      responses:
        '200':
          description: DNC lookup result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DncCheckResponse' }

  /compliance/illegal-words:
    get:
      tags: [Compliance]
      summary: List illegal words
      description: |
        Returns the effective prohibited-word list for the organization
        (platform baseline ∪ org custom terms). Read-only. Organization-scoped keys only.
      operationId: listIllegalWords
      security: [{ bearerAuth: [compliance:read] }]
      responses:
        '200':
          description: Effective illegal-word list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IllegalWordsResponse' }

  /compliance/illegal-words/check:
    post:
      tags: [Compliance]
      summary: Pre-flight illegal-words and charset check
      description: |
        Scan message text for prohibited terms (including spelling-evasion variants)
        and mixed-script / lookalike Unicode that carriers treat as security evasion.
        When charset evasion is detected, `is_blocked` is true and `normalized_text`
        contains a Latin-only rewrite you can review and resubmit on send/template paths.
        Read-only — does not send a message or consume credits. Organization-scoped keys only.
      operationId: checkIllegalWords
      security: [{ bearerAuth: [compliance:read] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [body]
              properties:
                body:
                  type: string
                  maxLength: 10000
                  description: Message text to scan
                brand_display_name:
                  type: string
                  maxLength: 200
                  description: Optional line/brand name allowed as a legal phrase during the scan
            examples:
              illegal_words:
                summary: Prohibited phrase
                value:
                  body: "Get a quick loan today"
                  brand_display_name: "Acme Lending"
              charset_lookalike:
                summary: Mixed-script / lookalike characters
                value:
                  body: "Неу Anatoly, НОТ busіnеss рrоduсt just drоppеd. Reply STOP to opt out."
      responses:
        '200':
          description: Illegal-words and charset scan result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IllegalWordsCheckResponse' }

  /compliance/quiet-hours:
    get:
      tags: [Compliance]
      summary: Get quiet hours
      description: Returns the organization's quiet-hours policy. Organization-scoped keys only.
      operationId: getQuietHours
      security: [{ bearerAuth: [compliance:read] }]
      responses:
        '200':
          description: Quiet-hours configuration
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuietHoursResponse' }
    patch:
      tags: [Compliance]
      summary: Update quiet hours
      description: |
        Partially update quiet-hours policy (enable/disable, schedule, timezone, 1:1 exemption).
        Requires `compliance:write`. Organization-scoped keys only.
      operationId: updateQuietHours
      security: [{ bearerAuth: [compliance:write] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                enabled: { type: boolean }
                start_hour: { type: integer, minimum: 0, maximum: 23 }
                start_minute: { type: integer, minimum: 0, maximum: 59 }
                end_hour: { type: integer, minimum: 0, maximum: 23 }
                end_minute: { type: integer, minimum: 0, maximum: 59 }
                timezone: { type: string, description: IANA timezone, e.g. America/New_York }
                days_of_week:
                  type: array
                  maxItems: 7
                  items: { type: integer, minimum: 0, maximum: 6 }
                  description: Days when quiet hours apply (0=Sunday). Empty = every day.
                exempt_one_to_one:
                  type: boolean
                  description: When true, 1:1 conversational sends may go out during quiet hours; blasts stay blocked.
            example:
              enabled: true
              start_hour: 21
              start_minute: 0
              end_hour: 8
              end_minute: 0
              timezone: America/New_York
              exempt_one_to_one: false
      responses:
        '200':
          description: Updated quiet-hours configuration
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuietHoursResponse' }

  /compliance/quiet-hours/check:
    post:
      tags: [Compliance]
      summary: Pre-flight quiet-hours check
      description: |
        Check whether a send is allowed right now under quiet-hours policy.
        Optionally pass a recipient IANA timezone. Read-only. Organization-scoped keys only.
      operationId: checkQuietHours
      security: [{ bearerAuth: [compliance:read] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                timezone:
                  type: string
                  description: Optional recipient IANA timezone; falls back to org default when omitted or invalid
            example:
              timezone: America/Los_Angeles
      responses:
        '200':
          description: Quiet-hours availability result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuietHoursCheckResponse' }

# ── Components ────────────────────────────────────────────────────────────────
components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key (intx_public_live_… or intx_public_test_…)

  parameters:
    Limit:
      in: query
      name: limit
      description: Page size (1–100, default 20)
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    After:
      in: query
      name: after
      description: Opaque cursor returned as `next_cursor` from the previous page
      schema: { type: string }
    PathId:
      in: path
      name: id
      required: true
      schema: { type: string, format: uuid }
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      description: |
        Caller-supplied key (max 255 chars). The server caches the response for 24h.
        Re-issuing the same key with the same body returns the cached response;
        re-issuing with a different body returns 409 `idempotency_key_reused`.
      required: false
      schema: { type: string, maxLength: 255 }

  responses:
    BadRequest:
      description: Invalid request body or query parameters
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: API key is missing the required scope
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: The requested resource does not exist or is not visible to this key
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Per-IP or per-key rate limit exceeded
      headers:
        Retry-After:
          schema: { type: integer }
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer, description: Unix timestamp in ms }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ComplianceBlocked:
      description: |
        Send was blocked by compliance (DNC, opt-out, quiet hours, approval-required,
        illegal words, or charset/lookalike security evasion). For
        `charset_security_evasion`, the error body includes `normalized_text`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:

    # ── Common ──────────────────────────────────────────────────────────────
    Error:
      type: object
      required: [object, code, message, request_id, docs_url]
      properties:
        object: { type: string, const: error }
        code:
          type: string
          enum:
            - validation_failed
            - missing_api_key
            - invalid_api_key
            - insufficient_scope
            - scope_invalid
            - not_found
            - rate_limited
            - idempotency_in_progress
            - idempotency_key_reused
            - dnc_blocked
            - opted_out
            - approval_required
            - insufficient_credits
            - charset_security_evasion
            - internal_error
            - operation_in_progress
            - inventory_unavailable
            - premium_number_locked
            - budget_exceeded
            - action_required
            - production_not_ready
            - feature_not_enabled
            - invalid_sender
            - sender_not_assigned
            - sender_inactive
            - capability_not_supported
            - no_default_sender
            - provider_unavailable
            - media_fetch_failed
            - quiet_hours_blocked
            - compliance_blocked
            - ambiguous_contact
            - offer_expired
            - campaign_not_configured
            - billing_required
            - billing_failed
            - cleanup_required
        message: { type: string }
        request_id:
          type: string
          description: Echoed in the X-Request-Id response header
        param: { type: string }
        docs_url: { type: string, format: uri }
        retryable:
          type: boolean
          description: Whether a retry with the same Idempotency-Key (for writes) is appropriate
        recommended_action:
          type: string
          description: Short agent-oriented next step for resolving this error
        retry_after_seconds:
          type: integer
          description: Suggested wait before retry when retryable is true; also sent as Retry-After
        normalized_text:
          type: string
          description: Latin-normalized body when code is charset_security_evasion; resubmit this text
        details:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              message: { type: string }

    DeletedResponse:
      type: object
      required: [id, deleted]
      properties:
        id: { type: string, format: uuid }
        object: { type: string }
        deleted: { type: boolean, const: true }

    Pagination:
      type: object
      required: [object, data, has_more, next_cursor]
      properties:
        object: { type: string, const: list }
        has_more: { type: boolean }
        next_cursor: { type: string, nullable: true }

    # ── Webhook event payload ──────────────────────────────────────────────
    Event:
      type: object
      required: [id, object, type, livemode, occurred_at, data]
      description: Canonical webhook event envelope received by every subscription.
      properties:
        id:
          type: string
          description: Stable event id; safe to use for de-duplication.
        object: { type: string, const: event }
        type:
          type: string
          description: Event type, drawn from the live event catalog.
        livemode:
          type: boolean
          description: False when the event was produced by a test-mode key.
        occurred_at:
          type: string
          format: date-time
        data:
          type: object
          required: [object]
          properties:
            object:
              type: string
              description: Public object name of the inner resource.
          additionalProperties: true

    # ── Messages ────────────────────────────────────────────────────────────
    MessageStatus:
      type: string
      enum: [queued, sending, sent, delivered, failed, undelivered]

    SendMessageRequest:
      type: object
      required: [to]
      properties:
        to:                    { type: string, minLength: 1, maxLength: 50 }
        from_phone_number_id:
          type: string
          format: uuid
          description: Sender phone number id. Provide exactly one of this or `from`.
        from:
          type: string
          minLength: 1
          maxLength: 50
          description: Sender E.164 phone number. Provide exactly one of this or `from_phone_number_id`.
        body:                  { type: string, maxLength: 1600, default: "" }
        media_urls:
          type: array
          description: |
            HTTPS URLs of images to attach. When present, the send is treated as
            MMS (picture messaging) and bills at 1.5 credits per accepted message.
            Up to 10 URLs; body text is optional.
          items: { type: string, format: uri }
          maxItems: 10
        metadata:
          type: object
          additionalProperties: true
      description: Provide exactly one of `from_phone_number_id` or `from`.

    UploadMediaRequest:
      type: object
      properties:
        content_base64:
          type: string
          description: Raw base64 or a data URL (data:image/jpeg;base64,...)
        source_url:
          type: string
          format: uri
          description: Public HTTPS URL to fetch and copy into Statxt storage
        content_type:
          type: string
          description: Optional declared MIME type; must match sniffed file contents
        filename:
          type: string
          maxLength: 180
        kind:
          type: string
          enum: [mms, conversation, campaign]
          default: mms
        conversation_id:
          type: string
          format: uuid
      description: Provide exactly one of content_base64 or source_url.

    Media:
      type: object
      required: [object, id, url, content_type, bytes, filename, bucket, path, created_at]
      properties:
        object:       { type: string, const: media }
        id:           { type: string, format: uuid }
        url:          { type: string, format: uri }
        content_type: { type: string }
        bytes:        { type: integer, minimum: 1 }
        filename:     { type: string }
        bucket:       { type: string }
        path:         { type: string }
        created_at:   { type: string, format: date-time }

    SendMessageAccepted:
      type: object
      required: [object, id, status, to, from, from_phone_number_id, body, segments, media_urls, metadata, created_at]
      properties:
        object:     { type: string, const: message }
        id:         { type: string, format: uuid }
        status:     { $ref: '#/components/schemas/MessageStatus' }
        to:         { type: string }
        from:       { type: string }
        from_phone_number_id:
          type: string
          format: uuid
          description: Resolved sender phone number id
        body:       { type: string }
        segments:   { type: integer, minimum: 0 }
        media_urls: { type: array, items: { type: string, format: uri } }
        metadata:   { type: object, additionalProperties: true }
        created_at: { type: string, format: date-time }

    Message:
      type: object
      required: [object, id, status, to, from, direction, body, created_at]
      properties:
        object:         { type: string, const: message }
        id:             { type: string, format: uuid }
        status:         { $ref: '#/components/schemas/MessageStatus' }
        to:             { type: string }
        from:           { type: string }
        direction:      { type: string, enum: [inbound, outbound] }
        body:           { type: string }
        segments:       { type: integer, nullable: true }
        media_urls:     { type: array, items: { type: string, format: uri }, nullable: true }
        metadata:       { type: object, additionalProperties: true }
        error_code:     { type: string, nullable: true }
        error_message:  { type: string, nullable: true }
        sent_at:        { type: string, format: date-time, nullable: true }
        delivered_at:   { type: string, format: date-time, nullable: true }
        created_at:     { type: string, format: date-time }

    MessagePreview:
      type: object
      required: [object, allowed, from, body_template, media_urls, environment]
      properties:
        object: { type: string, const: message_preview }
        allowed: { type: boolean }
        blocker:
          type: object
          nullable: true
          properties:
            code: { type: string }
            message: { type: string }
            param: { type: string }
            normalized_text: { type: string }
        from:
          type: object
          required: [phone_number_id]
          properties:
            phone_number_id: { type: string, format: uuid }
            number: { type: string, nullable: true }
            capabilities:
              type: array
              items: { type: string, enum: [sms, mms, voice] }
        to: { type: string, nullable: true }
        body_template: { type: string }
        body_resolved: { type: string, nullable: true }
        body_normalized:
          type: string
          nullable: true
          description: Final body that would be sent (merge + opt-out footer)
        contact_id: { type: string, format: uuid, nullable: true }
        segments: { type: integer, nullable: true }
        billable_credits: { type: number, nullable: true }
        credits:
          type: object
          properties:
            allowed: { type: boolean, nullable: true }
            wallet: { type: string, nullable: true, description: "user or org when known" }
        compliance:
          type: object
          nullable: true
          additionalProperties: true
        media_urls:
          type: array
          items: { type: string, format: uri }
        environment: { type: string, enum: [live, test] }

    MessageListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Message' }

    # ── Phone Numbers ───────────────────────────────────────────────────────
    PhoneNumber:
      type: object
      required: [object, id, number]
      properties:
        object: { type: string, const: phone_number }
        id:     { type: string, format: uuid }
        number: { type: string, description: E.164 }
        type:
          type: string
          enum: [org, sandbox]
          description: |
            `org` for live keys' organization-owned numbers; `sandbox` for the
            shared sandbox pool returned to test-mode keys.
        capabilities:
          type: array
          items:
            type: string
            enum: [sms, mms, voice]
          description: |
            Capabilities the send pipeline will honor for this number
            (`sms`, `mms`, `voice`). Same serializer as premium-number listings.
        client_id:
          type: [string, "null"]
          format: uuid
          description: Partner client that purchased this number (API portal private pool)
        ten_dlc_id:
          type: [string, "null"]
          format: uuid
          description: 10DLC application this number is attributed to
        assigned_user_id:
          type: [string, "null"]
          format: uuid
          description: |
            Statxt user this sender number is assigned to. Null when unassigned
            or for sandbox pool numbers.
        assigned_to_me:
          type: boolean
          description: |
            True when `assigned_user_id` matches the authenticated acting user.
            Prefer these numbers when sending as yourself. Always false for
            sandbox pool numbers.
        assigned_user_email:
          type: [string, "null"]
          format: email
          description: |
            Email of the assigned teammate when known. Null when unassigned,
            sandbox, or lookup failed.

    PhoneNumberListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/PhoneNumber' }

    DefaultSender:
      type: object
      required:
        [object, phone_number_id, number, reason, capabilities, assigned_to_me, remaining_segments]
      properties:
        object: { type: string, const: default_sender }
        phone_number_id: { type: string, format: uuid }
        number: { type: string, description: E.164 }
        reason:
          type: string
          description: Why this sender was selected
        capabilities:
          type: array
          items:
            type: string
            enum: [sms, mms, voice]
        assigned_to_me: { type: boolean }
        remaining_segments:
          type: integer
          nullable: true

    Client:
      type: object
      required: [object, id, display_name, status]
      properties:
        object: { type: string, const: client }
        id: { type: string, format: uuid }
        display_name: { type: string }
        legal_name: { type: [string, "null"] }
        external_client_id: { type: [string, "null"] }
        status: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ClientListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Client' }

    TenDlcApplication:
      type: object
      required: [object, id, status]
      properties:
        object: { type: string, const: ten_dlc_application }
        id: { type: string, format: uuid }
        client_id: { type: [string, "null"], format: uuid }
        status: { type: string }
        business_legal_name: { type: [string, "null"] }
        submitted_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    TenDlcListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/TenDlcApplication' }

    # ── Premium Numbers ─────────────────────────────────────────────────────
    PremiumNumber:
      type: object
      required: [object, id, number, status, capabilities, tier, created_at]
      properties:
        object:       { type: string, const: premium_number }
        id:           { type: string, format: uuid }
        number:       { type: string, description: E.164 }
        status:       { type: string, description: e.g. active, pending, inactive }
        capabilities:
          type: array
          items:
            type: string
            enum: [sms, mms, voice]
          description: |
            Same capability serializer as `/phone-numbers`. Reflects what the
            send pipeline will permit (not a hard-coded SMS-only list).
        tier:           { type: string, const: premium }
        client_id:
          type: string
          format: uuid
          nullable: true
          description: Partner client attribution when purchased via commerce
        monthly_price_cents:
          type: integer
          nullable: true
          description: Recurring monthly price snapshot when available
        rotation:
          type: object
          nullable: true
          properties:
            locked_until: { type: string, format: date-time, nullable: true }
            status:       { type: string, nullable: true }
        assigned_user_id:
          type: string
          format: uuid
          nullable: true
          description: Present only for organization-scoped keys
        created_at:   { type: string, format: date-time }

    PremiumNumberListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/PremiumNumber' }

    PremiumNumberOffer:
      type: object
      required:
        - object
        - id
        - client_id
        - number
        - capabilities
        - monthly_price_cents
        - currency
        - expires_at
        - created_at
      properties:
        object: { type: string, const: premium_number_offer }
        id: { type: string, format: uuid }
        client_id: { type: string, format: uuid }
        number: { type: string, description: E.164 }
        area_code: { type: string, nullable: true }
        state: { type: string, nullable: true }
        locality: { type: string, nullable: true }
        capabilities:
          type: array
          items: { type: string, enum: [sms, mms, voice] }
        monthly_price_cents: { type: integer, minimum: 0 }
        currency: { type: string, example: USD }
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    PremiumNumberOfferListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/PremiumNumberOffer' }

    PremiumNumberHealthWindow:
      type: object
      required: [score, has_data, delivery_rate, opt_out_rate]
      properties:
        score:          { type: number, nullable: true }
        has_data:       { type: boolean }
        delivery_rate:  { type: number, nullable: true }
        opt_out_rate:   { type: number, nullable: true }

    PremiumNumberHealth:
      type: object
      required: [object, phone_number_id, seven_day, thirty_day]
      properties:
        object:          { type: string, const: premium_number_health }
        phone_number_id: { type: string, format: uuid }
        seven_day:       { $ref: '#/components/schemas/PremiumNumberHealthWindow' }
        thirty_day:      { $ref: '#/components/schemas/PremiumNumberHealthWindow' }

    PremiumNumberOperationStatus:
      type: string
      enum: [queued, in_progress, completed, failed, needs_action]

    PremiumNumberOperationType:
      type: string
      enum: [buy, rotate, delete]

    PremiumNumberOperation:
      type: object
      required: [object, id, operation, status, phone_number_id, action_required, result, error_message, created_at, updated_at]
      properties:
        object:                    { type: string, const: premium_number_operation }
        id:                        { type: string, format: uuid }
        operation:                 { $ref: '#/components/schemas/PremiumNumberOperationType' }
        status:                    { $ref: '#/components/schemas/PremiumNumberOperationStatus' }
        phase:
          type: string
          nullable: true
          description: |
            Customer-facing saga phase such as acquiring_inventory, assigning_campaign,
            billing, activating, compensating, or completed.
        phone_number_id:           { type: string, format: uuid, nullable: true }
        replacement_phone_number_id:
          type: string
          format: uuid
          nullable: true
          description: Set on completed rotate operations
        client_id:
          type: string
          format: uuid
          nullable: true
        offer_id:
          type: string
          format: uuid
          nullable: true
        action_required:
          type: string
          nullable: true
          description: e.g. choose_area_code, search_again, cleanup_required, billing_required
        result:
          type: object
          nullable: true
          additionalProperties: true
        error_message:             { type: string, nullable: true }
        created_at:                { type: string, format: date-time }
        updated_at:                { type: string, format: date-time }

    BuyPremiumNumberRequest:
      type: object
      description: Prefer offer_id. Legacy area_code / number still accepted and resolved via offers when commerce is enabled.
      properties:
        offer_id:
          type: string
          format: uuid
          description: Opaque offer from GET /premium-number-offers
        client_id:
          type: string
          format: uuid
          description: Required for legacy area_code / number purchases when commerce is enabled
        area_code:
          type: string
          pattern: '^\d{3}$'
          description: 3-digit US area code to search inventory (legacy)
        number:
          type: string
          pattern: '^\+1\d{10}$'
          description: Explicit E.164 number to purchase (+1 followed by 10 digits)
        assign_to_user_id:
          type: string
          format: uuid
          nullable: true
          description: Organization keys only; defaults to acting user for personal keys

    RotatePremiumNumberRequest:
      type: object
      properties:
        area_code:
          type: string
          pattern: '^\d{3}$'
          description: Fallback area code when automatic selection fails
        replacement_number:
          type: string
          pattern: '^\+1\d{10}$'
          description: Explicit replacement number when retrying after needs_action

    PremiumNumberDeletedResponse:
      type: object
      required: [deleted, id]
      properties:
        deleted: { type: boolean, const: true }
        id:      { type: string, format: uuid }

    # ── Contacts ────────────────────────────────────────────────────────────
    CreateContactRequest:
      type: object
      required: [phone]
      properties:
        phone:         { type: string, minLength: 1, maxLength: 50 }
        email:         { type: string, format: email, nullable: true }
        first_name:    { type: string, maxLength: 255, nullable: true }
        last_name:     { type: string, maxLength: 255, nullable: true }
        company:       { type: string, maxLength: 255, nullable: true }
        tags:          { type: array, items: { type: string, maxLength: 200 }, maxItems: 200 }
        custom_fields: { type: object, additionalProperties: true }
        group_ids:     { type: array, items: { type: string, format: uuid }, maxItems: 200 }
        dnc:           { type: boolean, default: false }

    UpdateContactRequest:
      type: object
      properties:
        phone:         { type: string, minLength: 1, maxLength: 50 }
        email:         { type: string, format: email, nullable: true }
        first_name:    { type: string, maxLength: 255, nullable: true }
        last_name:     { type: string, maxLength: 255, nullable: true }
        company:       { type: string, maxLength: 255, nullable: true }
        tags:          { type: array, items: { type: string, maxLength: 200 }, maxItems: 200 }
        custom_fields: { type: object, additionalProperties: true, nullable: true }
        group_ids:     { type: array, items: { type: string, format: uuid }, maxItems: 200, nullable: true }
        dnc:           { type: boolean }

    BulkUpsertContactRequest:
      type: object
      required: [contacts]
      properties:
        contacts:
          type: array
          minItems: 1
          maxItems: 500
          items:
            type: object
            required: [phone]
            properties:
              phone:         { type: string, minLength: 1, maxLength: 50 }
              email:         { type: string, format: email, nullable: true }
              first_name:    { type: string, maxLength: 255, nullable: true }
              last_name:     { type: string, maxLength: 255, nullable: true }
              company:       { type: string, maxLength: 255, nullable: true }
              tags:          { type: array, items: { type: string, maxLength: 200 }, maxItems: 200 }
              custom_fields: { type: object, additionalProperties: true }
              dnc:           { type: boolean }
        group_ids:
          type: array
          items: { type: string, format: uuid }
          maxItems: 50

    ContactBulkResult:
      type: object
      required: [object, upserted, created, updated, results, summary]
      properties:
        object: { type: string, const: contact_bulk_result }
        upserted:
          type: integer
          minimum: 0
          description: Total unique phones persisted (created + updated)
        created:
          type: integer
          minimum: 0
          description: Rows newly inserted; one contact.created webhook per row
        updated:
          type: integer
          minimum: 0
          description: Existing contacts updated in place
        invalid_phones: { type: integer, minimum: 0 }
        invalid_phone_values:
          type: array
          items: { type: string }
        group_ids_applied:
          type: array
          items: { type: string, format: uuid }
        results:
          type: array
          description: One entry per input contact, original index order
          items:
            type: object
            required: [index, status]
            properties:
              index: { type: integer, minimum: 0 }
              status: { type: string, enum: [created, updated, failed, skipped] }
              contact_id: { type: string, format: uuid }
              phone: { type: string }
              phone_normalized: { type: string }
              error:
                type: object
                properties:
                  code: { type: string }
                  message: { type: string }
        summary:
          type: object
          required: [created, updated, failed, skipped]
          properties:
            created: { type: integer, minimum: 0 }
            updated: { type: integer, minimum: 0 }
            failed: { type: integer, minimum: 0 }
            skipped: { type: integer, minimum: 0 }
        meta:
          type: object
          additionalProperties: true

    Contact:
      type: object
      required: [id, object, phone, created_at]
      properties:
        id:                 { type: string, format: uuid }
        object:             { type: string, const: contact }
        phone:              { type: string }
        email:              { type: string, format: email, nullable: true }
        first_name:         { type: string, nullable: true }
        last_name:          { type: string, nullable: true }
        company:            { type: string, nullable: true }
        status:             { type: string }
        dnc:                { type: boolean }
        bounced:            { type: boolean }
        tags:               { type: array, items: { type: string } }
        custom_fields:      { type: object, additionalProperties: true }
        group_ids:          { type: array, items: { type: string, format: uuid } }
        last_contacted_at:  { type: string, format: date-time, nullable: true }
        created_at:         { type: string, format: date-time }
        updated_at:         { type: string, format: date-time, nullable: true }

    ContactListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Contact' }

    ContactPhoneLookup:
      type: object
      required:
        [object, phone, phone_normalized, match_count, resolution, contact, contacts]
      properties:
        object: { type: string, const: contact_phone_lookup }
        phone: { type: string }
        phone_normalized: { type: string }
        match_count: { type: integer, minimum: 0 }
        resolution:
          type: string
          enum: [exact, ambiguous, none]
        contact:
          oneOf:
            - $ref: '#/components/schemas/Contact'
            - type: "null"
          description: Populated only when resolution is `exact`
        contacts:
          type: array
          items: { $ref: '#/components/schemas/Contact' }
        code:
          type: string
          const: ambiguous_contact
          description: Present when resolution is `ambiguous`
        message: { type: string }

    # ── Contact Groups ──────────────────────────────────────────────────────
    CreateContactGroupRequest:
      type: object
      required: [name]
      properties:
        name:        { type: string, minLength: 1, maxLength: 255 }
        description: { type: string, maxLength: 1000, nullable: true }
        color:       { type: string, maxLength: 50, nullable: true }

    UpdateContactGroupRequest:
      type: object
      properties:
        name:        { type: string, minLength: 1, maxLength: 255 }
        description: { type: string, maxLength: 1000, nullable: true }
        color:       { type: string, maxLength: 50, nullable: true }

    ContactGroup:
      type: object
      required: [id, object, name, member_count, created_at]
      properties:
        id:           { type: string, format: uuid }
        object:       { type: string, const: contact_group }
        name:         { type: string }
        description:  { type: string, nullable: true }
        color:        { type: string, nullable: true }
        member_count: { type: integer, minimum: 0 }
        created_at:   { type: string, format: date-time }
        updated_at:   { type: string, format: date-time, nullable: true }

    ContactGroupListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/ContactGroup' }

    MembersRequest:
      type: object
      required: [contact_ids]
      properties:
        contact_ids:
          type: array
          minItems: 1
          maxItems: 1000
          items: { type: string, format: uuid }

    MembersAddResult:
      type: object
      required: [added, already_member, invalid]
      properties:
        added:          { type: integer, minimum: 0 }
        already_member: { type: integer, minimum: 0 }
        invalid:        { type: integer, minimum: 0 }

    MembersRemoveResult:
      type: object
      required: [removed]
      properties:
        removed: { type: integer, minimum: 0 }
        invalid: { type: integer, minimum: 0 }

    # ── Campaigns ───────────────────────────────────────────────────────────
    CreateCampaignRequest:
      type: object
      required: [name, message_body]
      properties:
        name:             { type: string, minLength: 1, maxLength: 255 }
        message_body:     { type: string, minLength: 1, maxLength: 1600 }
        phone_number_id:  { type: string, format: uuid }
        from:             { type: string, description: Sender in E.164 format }
        use_default_sender: { type: boolean, default: false }
        group_ids:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string, format: uuid }
        group_names:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string }
        media_urls:
          type: array
          description: |
            HTTPS image URLs attached to every recipient message. When present,
            the campaign sends MMS and bills at 1.5 credits per accepted message
            per recipient.
          maxItems: 10
          items: { type: string, format: uri }
        category:     { type: string, enum: [marketing, transactional], default: marketing }
        scheduled_at: { type: string, format: date-time, nullable: true }
        auto_launch:  { type: boolean, default: false }
      description: |
        Provide exactly one sender selector: `phone_number_id`, `from`, or
        `use_default_sender: true`. Provide exactly one audience selector:
        `group_ids` or `group_names`.

    CampaignPreflightRequest:
      type: object
      properties:
        campaign_id: { type: string, format: uuid }
        campaign_name: { type: string }
        name: { type: string }
        message_body: { type: string, maxLength: 1600 }
        phone_number_id: { type: string, format: uuid }
        from: { type: string }
        use_default_sender: { type: boolean, default: false }
        group_ids:
          type: array
          items: { type: string, format: uuid }
          maxItems: 50
        group_names:
          type: array
          items: { type: string }
          maxItems: 50
        media_urls:
          type: array
          items: { type: string, format: uri }
          maxItems: 10
        category: { type: string, enum: [marketing, transactional] }
        scheduled_at: { type: string, format: date-time, nullable: true }
      description: |
        Existing campaign: provide exactly one of campaign_id or campaign_name.
        Proposed campaign: omit both and provide message_body, one sender
        selector, and one audience selector.

    CampaignPreflightIssue:
      type: object
      required: [category, code, message]
      properties:
        category:
          type: string
          enum: [resolution, campaign_state, audience, sender, compliance, credits, limits, concurrency, schedule, system]
        code: { type: string }
        message: { type: string }
        details: { type: object, additionalProperties: true }

    CampaignPreflight:
      type: object
      required: [object, ready, resolved, audience, estimate, blockers, warnings]
      properties:
        object: { type: string, const: campaign_preflight }
        ready: { type: boolean }
        resolved:
          type: object
          properties:
            campaign_id: { type: string, format: uuid, nullable: true }
            campaign_name: { type: string }
            phone_number_id: { type: string, format: uuid, nullable: true }
            from: { type: string, nullable: true }
            sender_reason: { type: string, nullable: true }
            groups:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
        audience:
          type: object
          properties:
            total: { type: integer, minimum: 0 }
            unique: { type: integer, minimum: 0 }
            eligible: { type: integer, minimum: 0 }
            dnc_skipped: { type: integer, minimum: 0 }
            stopped_skipped: { type: integer, minimum: 0 }
            pending: { type: integer, minimum: 0 }
        estimate:
          type: object
          properties:
            credits: { type: number, minimum: 0 }
            credits_per_message: { type: number, minimum: 0 }
            completion_seconds: { type: integer, minimum: 0 }
            completion_minutes: { type: integer, minimum: 0 }
            confidence: { type: string, enum: [high, medium, low] }
            queue_tier: { type: string }
        blockers:
          type: array
          items: { $ref: '#/components/schemas/CampaignPreflightIssue' }
        warnings:
          type: array
          items: { $ref: '#/components/schemas/CampaignPreflightIssue' }

    Campaign:
      type: object
      required: [id, object, name, status, created_at]
      properties:
        id:               { type: string, format: uuid }
        object:           { type: string, const: campaign }
        name:             { type: string }
        status:           { type: string, description: "One of draft, scheduled, starting, sending, paused, completed, failed, cancelled" }
        message_body:     { type: string }
        phone_number_id:  { type: string, format: uuid, nullable: true }
        group_ids:        { type: array, items: { type: string, format: uuid } }
        media_urls:       { type: array, items: { type: string, format: uri } }
        category:         { type: string }
        scheduled_at:     { type: string, format: date-time, nullable: true }
        started_at:       { type: string, format: date-time, nullable: true }
        completed_at:     { type: string, format: date-time, nullable: true }
        recipient_count:  { type: integer, nullable: true }
        sent_count:       { type: integer, nullable: true }
        failed_count:     { type: integer, nullable: true }
        created_at:       { type: string, format: date-time }

    CampaignWithStats:
      allOf:
        - $ref: '#/components/schemas/Campaign'
        - type: object
          properties:
            stats:
              type: object
              nullable: true
              properties:
                sent:          { type: integer, minimum: 0 }
                failed:        { type: integer, minimum: 0 }
                total:         { type: integer, minimum: 0 }
                delivery_rate: { type: number, nullable: true, description: "Percentage, e.g. 87.5" }

    CampaignCreatedResponse:
      allOf:
        - $ref: '#/components/schemas/Campaign'
        - type: object
          properties:
            meta:
              type: object
              properties:
                total_campaigns:  { type: integer, minimum: 1 }
                total_recipients: { type: integer, minimum: 0 }
                dnc_skipped:      { type: integer, minimum: 0 }
                stopped_skipped:  { type: integer, minimum: 0 }
                batch_campaign_ids:
                  type: array
                  items: { type: string, format: uuid }
                launch_results:
                  type: array
                  items:
                    type: object
                    properties:
                      campaign_id: { type: string, format: uuid }
                      launched:    { type: boolean }
                      error:       { type: string }
                launch_failed_count: { type: integer, minimum: 0 }

    CampaignListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Campaign' }

    CampaignLaunchedResponse:
      type: object
      required: [campaign_id, launched, launched_at]
      properties:
        campaign_id: { type: string, format: uuid }
        launched:    { type: boolean }
        launched_at: { type: string, format: date-time }

    CampaignPausedResponse:
      type: object
      required: [campaign_id, paused]
      properties:
        campaign_id: { type: string, format: uuid }
        paused:      { type: boolean }
        paused_at:   { type: string, format: date-time }
        idempotent:  { type: boolean, description: Present and true when the campaign was already paused }

    CampaignCancelledResponse:
      type: object
      required: [campaign_id, cancelled]
      properties:
        campaign_id:  { type: string, format: uuid }
        cancelled:    { type: boolean }
        cancelled_at: { type: string, format: date-time }
        idempotent:   { type: boolean, description: Present and true when the campaign was already cancelled }

    # ── Templates ───────────────────────────────────────────────────────────
    CreateTemplateRequest:
      type: object
      required: [name, body]
      properties:
        name:     { type: string, minLength: 1, maxLength: 255 }
        body:     { type: string, minLength: 1, maxLength: 1600 }
        category: { type: string, minLength: 1, maxLength: 64, nullable: true }

    UpdateTemplateRequest:
      type: object
      description: At least one field must be provided
      properties:
        name:     { type: string, minLength: 1, maxLength: 255 }
        body:     { type: string, minLength: 1, maxLength: 1600 }
        category: { type: string, minLength: 1, maxLength: 64, nullable: true }

    Template:
      type: object
      required: [id, object, name, body, merge_fields, usage_count, created_at]
      properties:
        id:           { type: string, format: uuid }
        object:       { type: string, const: template }
        name:         { type: string }
        body:         { type: string }
        category:     { type: string, nullable: true }
        merge_fields: { type: array, items: { type: string }, description: Server-derived from body — read-only }
        usage_count:  { type: integer, minimum: 0, description: Read-only over the public API }
        created_at:   { type: string, format: date-time }
        updated_at:   { type: string, format: date-time, nullable: true }

    TemplateListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/Template' }

    # ── Webhooks ────────────────────────────────────────────────────────────
    WebhookEvent:
      type: string
      enum:
        - message.queued
        - message.sent
        - message.delivered
        - message.failed
        - message.received
        - campaign.created
        - campaign.launched
        - campaign.paused
        - campaign.cancelled
        - campaign.completed
        - campaign.failed
        - contact.created
        - contact.opted_out
        - premium_number.purchased
        - premium_number.rotated
        - premium_number.deleted
        - premium_number.operation.failed
        - premium_number.operation.needs_action

    CreateWebhookSubscriptionRequest:
      type: object
      required: [url, events]
      properties:
        url:         { type: string, format: uri, description: Must be HTTPS in production }
        events:
          type: array
          minItems: 1
          maxItems: 20
          items: { $ref: '#/components/schemas/WebhookEvent' }
        description: { type: string, maxLength: 255, nullable: true }

    UpdateWebhookSubscriptionRequest:
      type: object
      description: |
        Partial update. At least one field must be provided (or
        `rotate_secret: true`). Fields not present are left unchanged.
      properties:
        url:         { type: string, format: uri }
        events:
          type: array
          minItems: 1
          maxItems: 20
          items: { $ref: '#/components/schemas/WebhookEvent' }
        description: { type: string, maxLength: 255, nullable: true }
        status:
          type: string
          enum: [active, disabled]
          description: |
            `active` resumes dispatch; `disabled` halts dispatch without
            deleting the subscription.
        rotate_secret:
          type: boolean
          default: false
          description: When true, generate a new signing secret and return it once in the response body.

    WebhookSubscription:
      type: object
      required: [object, id, url, events, status, failure_count, created_at]
      properties:
        object:             { type: string, const: webhook }
        id:                 { type: string, format: uuid }
        url:                { type: string, format: uri }
        description:        { type: string, nullable: true }
        events:
          type: array
          items: { $ref: '#/components/schemas/WebhookEvent' }
        status:             { type: string, enum: [active, paused, disabled, deleted] }
        failure_count:      { type: integer, minimum: 0 }
        last_delivered_at:  { type: string, format: date-time, nullable: true }
        last_failed_at:     { type: string, format: date-time, nullable: true }
        created_at:         { type: string, format: date-time }
        updated_at:         { type: string, format: date-time, nullable: true }

    WebhookSubscriptionListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/WebhookSubscription' }

    UpsertMcpNotificationSubscriptionRequest:
      type: object
      required: [events]
      properties:
        events:
          type: array
          minItems: 1
          maxItems: 20
          items: { $ref: '#/components/schemas/WebhookEvent' }
        description: { type: string, maxLength: 255, nullable: true }

    McpEventSubscription:
      type: object
      required: [object, id, events, status, created_at, updated_at]
      properties:
        object:      { type: string, const: mcp_event_subscription }
        id:          { type: string, format: uuid }
        events:
          type: array
          items: { $ref: '#/components/schemas/WebhookEvent' }
        description: { type: string, nullable: true }
        status:      { type: string, enum: [active, disabled, deleted] }
        created_at:  { type: string, format: date-time }
        updated_at:  { type: string, format: date-time }

    McpEventSubscriptionListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/McpEventSubscription' }

    McpNotification:
      type: object
      required: [object, id, event_id, event_type, payload, created_at, is_unread]
      properties:
        object:           { type: string, const: mcp_notification }
        id:               { type: string, format: uuid }
        event_id:         { type: string }
        event_type:       { $ref: '#/components/schemas/WebhookEvent' }
        payload:
          type: object
          additionalProperties: true
          description: Normalized webhook event envelope
        created_at:       { type: string, format: date-time }
        read_at:          { type: string, format: date-time, nullable: true }
        acknowledged_at:  { type: string, format: date-time, nullable: true }
        is_unread:        { type: boolean }

    McpNotificationListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            unread_count: { type: integer, minimum: 0 }
            data:
              type: array
              items: { $ref: '#/components/schemas/McpNotification' }

    AcknowledgeMcpNotificationsRequest:
      type: object
      required: [ids]
      properties:
        ids:
          type: array
          minItems: 1
          maxItems: 100
          items: { type: string, format: uuid }

    McpNotificationAckResponse:
      type: object
      required: [object, acknowledged, data]
      properties:
        object:       { type: string, const: mcp_notification_ack }
        acknowledged: { type: integer, minimum: 0 }
        data:
          type: array
          items: { $ref: '#/components/schemas/McpNotification' }

    # ── Credits ─────────────────────────────────────────────────────────────
    AllocateCreditsRequest:
      type: object
      additionalProperties: false
      required: [to_user_id, credits]
      properties:
        to_user_id: { type: string, format: uuid }
        credits: { type: integer, minimum: 1, maximum: 1000000 }
        notes: { type: string, maxLength: 500 }

    CreditAllocation:
      type: object
      required: [object, id, to_user_id, credits, notes, performed_by, created_at]
      properties:
        object: { type: string, const: credit_allocation }
        id: { type: string, format: uuid, nullable: true }
        to_user_id: { type: string, format: uuid, nullable: true }
        credits: { type: integer, minimum: 0 }
        notes: { type: string, nullable: true }
        performed_by: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }

    CreditAllocationListResponse:
      allOf:
        - $ref: '#/components/schemas/Pagination'
        - type: object
          properties:
            data:
              type: array
              items: { $ref: '#/components/schemas/CreditAllocation' }

    # ── Usage / Compliance ──────────────────────────────────────────────────
    UsageResponse:
      type: object
      required: [object, period, credits, messages, campaigns]
      properties:
        object: { type: string, const: usage }
        period:
          type: object
          required: [start, end]
          properties:
            start: { type: string, format: date-time }
            end:   { type: string, format: date-time }
        credits:
          type: object
          required: [balance, currency]
          properties:
            balance:  { type: number, nullable: true }
            currency: { type: string }
        messages:
          type: object
          required: [sent_this_period]
          properties:
            sent_this_period: { type: integer, minimum: 0 }
        campaigns:
          type: object
          required: [created_this_period]
          properties:
            created_this_period: { type: integer, minimum: 0 }

    DncCheckResponse:
      type: object
      required: [object, phone, phone_normalized, is_blocked]
      properties:
        object:           { type: string, const: dnc_check }
        phone:            { type: string, description: Caller-supplied input }
        phone_normalized: { type: string, description: Normalized E.164 used for the lookup }
        client_id:        { type: string, format: uuid, nullable: true }
        is_blocked:       { type: boolean }
        reason:           { type: string, nullable: true }
        source:           { type: string, nullable: true, description: 'e.g. "internal_list", "ftc_dnc", "carrier_block"' }
        blocked_at:       { type: string, format: date-time, nullable: true }

    DncEntry:
      type: object
      required: [object, id, phone, phone_normalized, created_at]
      properties:
        object: { type: string, const: dnc_entry }
        id: { type: string, format: uuid }
        phone: { type: string }
        phone_normalized: { type: string }
        reason: { type: string, nullable: true }
        source: { type: string, nullable: true }
        notes: { type: string, nullable: true }
        client_id: { type: string, format: uuid, nullable: true }
        created_at: { type: string, format: date-time }

    DncListResponse:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: '#/components/schemas/DncEntry' }
        has_more: { type: boolean }
        total: { type: integer, minimum: 0 }
        page: { type: integer, minimum: 1 }
        limit: { type: integer, minimum: 1 }

    DncMutationResponse:
      type: object
      required: [object]
      properties:
        object: { type: string, const: dnc_entry }
        phone: { type: string }
        phone_normalized: { type: string }
        reason: { type: string, nullable: true }
        client_id: { type: string, format: uuid, nullable: true }
        added: { type: boolean }
        removed: { type: boolean }

    IllegalWordsResponse:
      type: object
      required: [object, words, count]
      properties:
        object: { type: string, const: illegal_words }
        words:
          type: array
          items: { type: string }
          description: Effective prohibited terms (platform baseline ∪ org custom)
        count: { type: integer, minimum: 0 }

    IllegalWordsCheckResponse:
      type: object
      required:
        - object
        - is_blocked
        - matches
        - spelling_evasion_detected
        - charset_evasion_detected
        - normalized_text
        - charset_spans
        - detector_version
      properties:
        object: { type: string, const: illegal_words_check }
        is_blocked:
          type: boolean
          description: True when illegal-word matches or charset evasion is detected
        matches:
          type: array
          items:
            type: object
            required: [word, start, end, used_spelling_evasion]
            properties:
              word: { type: string }
              start: { type: integer, minimum: 0 }
              end: { type: integer, minimum: 0 }
              used_spelling_evasion: { type: boolean }
        spelling_evasion_detected: { type: boolean }
        charset_evasion_detected:
          type: boolean
          description: True when mixed-script or lookalike Unicode was detected
        normalized_text:
          type: string
          nullable: true
          description: |
            Latin-normalized body when charset evasion is detected; otherwise null.
            Review and resubmit this text on send/template endpoints — the platform
            does not silently rewrite and send.
        charset_spans:
          type: array
          description: Offending ranges in the original body (empty when clean)
          items:
            type: object
            required: [start, end, excerpt, kind]
            properties:
              start: { type: integer, minimum: 0 }
              end: { type: integer, minimum: 0 }
              excerpt: { type: string }
              kind:
                type: string
                enum: [lookalike, mixed_script, fullwidth]
        detector_version:
          type: string
          description: Version string for the charset/lookalike detector (audit / RCA)

    QuietHoursResponse:
      type: object
      required:
        - object
        - enabled
        - start_hour
        - start_minute
        - end_hour
        - end_minute
        - timezone
        - days_of_week
        - exempt_one_to_one
      properties:
        object: { type: string, const: quiet_hours }
        enabled: { type: boolean }
        start_hour: { type: integer, minimum: 0, maximum: 23 }
        start_minute: { type: integer, minimum: 0, maximum: 59 }
        end_hour: { type: integer, minimum: 0, maximum: 23 }
        end_minute: { type: integer, minimum: 0, maximum: 59 }
        timezone: { type: string }
        days_of_week:
          type: array
          items: { type: integer, minimum: 0, maximum: 6 }
        exempt_one_to_one: { type: boolean }

    QuietHoursCheckResponse:
      type: object
      required: [object, allowed, reason, next_send_at, effective_timezone, timezone_source]
      properties:
        object: { type: string, const: quiet_hours_check }
        allowed: { type: boolean }
        reason: { type: string, nullable: true, description: 'e.g. "quiet_hours" when blocked' }
        next_send_at: { type: string, format: date-time, nullable: true }
        effective_timezone: { type: string, nullable: true }
        timezone_source:
          type: string
          nullable: true
          description: 'recipient when a valid timezone was supplied; otherwise org_default'
          enum: [recipient, org_default]

