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://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.0.0
  contact:
    name: Statxt Developer Support
    url: https://docs.statxt.com
servers:
  - url: https://app.statxt.com/api/v1
    description: Production
  - 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: Webhooks
    description: Receive real-time event notifications via HTTP callbacks
  - name: Usage
    description: Read current billing-period usage
  - name: Compliance
    description: Pre-flight DNC / opt-out lookups

# ── 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 for delivery. Compliance checks (DNC, opt-out, quiet hours,
        approval-required) are enforced server-side and may return 422.
      operationId: sendMessage
      security: [{ bearerAuth: [messages:send] }]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendMessageRequest' }
      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' }

  /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. 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] }]
      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' }

  # ───────────────────────────── 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 }
        - 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/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
          content:
            application/json:
              schema:
                type: object
                properties:
                  upserted: { type: integer, minimum: 0, description: Total rows persisted (created + updated). }
                  created: { type: integer, minimum: 0, description: Subset of `upserted` whose phone did not exist in the org before this call. One `contact.created` webhook fires per row in this count. }
                  invalid:  { type: integer, minimum: 0 }
                  added_to_groups: { type: integer, minimum: 0 }

  /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
      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/{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' }

  # ───────────────────────────── 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/check:
    post:
      tags: [Compliance]
      summary: Pre-flight DNC check
      description: Read-only DNC lookup. Does not mutate state. Result is Redis-cached.
      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 }
      responses:
        '200':
          description: DNC lookup result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DncCheckResponse' }

# ── 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)
      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
            - internal_error
        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 }
        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, from_phone_number_id]
      properties:
        to:                    { type: string, minLength: 1, maxLength: 50 }
        from_phone_number_id:  { type: string, format: uuid }
        body:                  { type: string, maxLength: 1600, default: "" }
        media_urls:
          type: array
          items: { type: string, format: uri }
          maxItems: 10
        metadata:
          type: object
          additionalProperties: true

    SendMessageAccepted:
      type: object
      required: [object, id, status, to, from, 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 }
        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 }

    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: object
          additionalProperties: true

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

    # ── 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

    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' }

    # ── 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, phone_number_id, group_ids]
      properties:
        name:             { type: string, minLength: 1, maxLength: 255 }
        message_body:     { type: string, minLength: 1, maxLength: 1600 }
        phone_number_id:  { type: string, format: uuid }
        group_ids:
          type: array
          minItems: 1
          maxItems: 50
          items: { type: string, format: uuid }
        media_urls:
          type: array
          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 }

    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

    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' }

    # ── 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 }
        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 }
