openapi: 3.0.3
info:
  title: SMSWave API
  version: 1.5.1
  description: |
    Multi-tenant SMS platform API. Dashboard uses Bearer JWT.
    Programmatic access uses MAC authentication (HMAC).
    Platform admin routes under `/v1/admin` are intentionally omitted from this public contract.
servers:
  - url: http://localhost:3001
paths:
  /health:
    get:
      summary: Health check (DB, Redis, carrier mode)
      responses:
        '200':
          description: Platform dependency probe (public, no auth)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'

  /r/{code}:
    get:
      summary: Redirect short link (public)
      parameters:
        - in: path
          name: code
          required: true
          schema: { type: string }
      responses:
        '302':
          description: Redirect to original URL
        '410':
          description: Expired or unknown short link
  /openapi.yaml:
    get:
      summary: This OpenAPI document
      responses:
        '200':
          description: YAML OpenAPI 3.0 spec

  /v1/auth/signup:
    post:
      summary: Self-serve SaaS registration (free activates immediately; paid + Stripe returns checkoutUrl)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Signup'
      responses:
        '201':
          description: JWT + account; may include checkoutUrl for paid plans
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthSession'
  /v1/auth/login:
    post:
      summary: Login (may return requires2fa challenge instead of accessToken)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string }
                password: { type: string }
      responses:
        '200':
          description: JWT + account, or { requires2fa, challengeToken, methods }
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AuthSession'
                  - $ref: '#/components/schemas/Login2faChallenge'
  /v1/auth/2fa/verify:
    post:
      summary: Complete login after 2FA challenge
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Verify2fa'
      responses:
        '200':
          description: accessToken + role + account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthSession'
  /v1/auth/2fa/sms/resend:
    post:
      summary: Resend SMS 2FA code during login challenge (no JWT)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResendSms2fa'
      responses:
        '200':
          description: Code resent (channel + masked phone)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Sms2faCodeResult'
        '401':
          description: Invalid or expired challenge
  /v1/auth/profile:
    get:
      summary: Current user profile
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Profile
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserProfile'
    patch:
      summary: Update display name / email / account name (owners)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateProfile'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserProfile'
  /v1/auth/security:
    get:
      summary: 2FA status (TOTP / SMS)
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Security settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SecuritySettings'
  /v1/auth/2fa/totp/setup:
    post:
      summary: Begin TOTP authenticator enrollment
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Pending TOTP setup (secret + otpauth URL)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TotpSetup'
  /v1/auth/2fa/totp/enable:
    post:
      summary: Confirm TOTP with first code
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwoFaCode'
      responses:
        '200':
          description: Enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TotpToggleResult'
  /v1/auth/2fa/totp/disable:
    post:
      summary: Disable TOTP
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwoFaCode'
      responses:
        '200':
          description: Disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TotpToggleResult'
  /v1/auth/2fa/sms/phone:
    post:
      summary: Set phone for SMS 2FA
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetSms2faPhone'
      responses:
        '200':
          description: Phone saved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Sms2faPhoneResult'
  /v1/auth/2fa/sms/send-code:
    post:
      summary: Send SMS 2FA verification code
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Code sent
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Sms2faCodeResult'
  /v1/auth/2fa/sms/enable:
    post:
      summary: Enable SMS 2FA with code
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwoFaCode'
      responses:
        '200':
          description: Enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Sms2faToggleResult'
  /v1/auth/2fa/sms/disable:
    post:
      summary: Disable SMS 2FA
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwoFaCode'
      responses:
        '200':
          description: Disabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Sms2faToggleResult'
  /v1/auth/forgot-password:
    post:
      summary: Request password reset (emails link when mail is configured; token returned in development)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ForgotPassword'
      responses:
        '200':
          description: Accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ForgotPasswordResult'
  /v1/auth/reset-password:
    post:
      summary: Set a new password with a reset token
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPassword'
      responses:
        '200':
          description: Password updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResult'
  /v1/auth/change-password:
    post:
      summary: Change password while authenticated
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangePassword'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResult'

  /v1/account:
    get:
      summary: Current account (balance, plan, phoneNumbers, smsPerMinute, smsSettings, billing alerts)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Account including smsSettings (timezone, defaultCountry, URL shortener, opt-out flags, etc.)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
  /v1/account/billing-alerts:
    patch:
      summary: Set low-balance threshold and optional auto top-up package
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateBillingAlerts'
      responses:
        '200':
          description: Updated account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
  /v1/account/sms-settings:
    patch:
      summary: Update SMS account settings (timezone, country, shortener, opt-outs, unicode, etc.)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SmsSettingsUpdate'
      responses:
        '200':
          description: Updated account with smsSettings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'

  /v1/api-keys:
    get:
      summary: List API keys (includes lastUsedAt)
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Keys
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ApiKey'
    post:
      summary: Create API key (returns key and secret once; multiple keys per account)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateApiKey'
      responses:
        '201':
          description: Created with key and secret fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateApiKeyResult'
        '400':
          description: Limit reached (max active keys per account)
  /v1/api-keys/{id}:
    delete:
      summary: Revoke an API key
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResult'

  /v1/team/members:
    get:
      summary: List team members
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Members
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TeamMember'
    post:
      summary: Create a team member with a temporary password
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTeamMember'
      responses:
        '201':
          description: Member created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TeamMember'
  /v1/team/members/{id}:
    delete:
      summary: Remove a team member
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResult'
  /v1/team/members/{id}/role:
    patch:
      summary: Update a team member role
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateTeamMemberRole'
      responses:
        '200':
          description: Role updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TeamMember'
  /v1/team/invites:
    get:
      summary: List pending invites (with acceptUrl)
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Pending invites
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TeamInvite'
    post:
      summary: Invite a team member (emails accept link when mail is configured)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTeamInvite'
      responses:
        '201':
          description: Invite created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateTeamInviteResult'
  /v1/team/invites/{id}:
    delete:
      summary: Revoke a pending invite
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResult'
  /v1/team/accept:
    post:
      summary: Accept a team invite (public; creates user)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AcceptTeamInvite'
      responses:
        '201':
          description: Member created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AcceptTeamInviteResult'

  /v1/sms:
    post:
      summary: Queue outbound SMS (async 202). Subject to per-account smsPerMinute rate limit (429 when exceeded).
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string, maxLength: 128 }
          description: Safe retries — same key returns the original message
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendSms'
      responses:
        '202':
          description: Queued or scheduled (or prior result when idempotent); body may include scheduledFor and expiresAt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendSmsResponse'
        '429':
          description: Account SMS rate limit exceeded
    get:
      summary: List messages (cursor pagination)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: direction
          schema: { type: string, enum: [inbound, outbound] }
        - in: query
          name: status
          schema:
            type: string
            enum: [queued, sending, sent, failed, delivered, undelivered, cancelled]
        - in: query
          name: contact
          schema: { type: string }
        - in: query
          name: cursor
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer }
        - in: query
          name: days
          description: Only include messages from the last N days (1–365)
          schema: { type: integer }
        - in: query
          name: q
          description: >
            Free-text search across contact, numbers, body, and MMS subject
          schema: { type: string }
        - in: query
          name: batchId
          schema: { type: string }
        - in: query
          name: scheduledOnly
          schema: { type: string, enum: ['1', 'true'] }
        - in: query
          name: scope
          schema: { type: string, enum: [overall, own, user, api] }
        - in: query
          name: userId
          schema: { type: string }
        - in: query
          name: channel
          description: Filter by message channel
          schema: { type: string, enum: [sms, mms, whatsapp] }
      responses:
        '200':
          description: >
            Message list with nextCursor. Each message may include
            `carrierMessageId` once accepted by the network.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessagesPage'
  /v1/sms/export:
    get:
      summary: Export messages as CSV
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: direction
          schema: { type: string, enum: [inbound, outbound] }
        - in: query
          name: limit
          schema: { type: integer }
        - in: query
          name: days
          description: Only include messages from the last N days (1–365)
          schema: { type: integer }
        - in: query
          name: contact
          description: Filter by contact MSISDN (normalized with account default country)
          schema: { type: string }
        - in: query
          name: status
          schema:
            type: string
            enum: [queued, sending, sent, failed, delivered, undelivered, cancelled]
        - in: query
          name: q
          description: >
            Free-text search across contact, numbers, body, and MMS subject
          schema: { type: string }
        - in: query
          name: batchId
          schema: { type: string }
        - in: query
          name: scheduledOnly
          schema: { type: string, enum: ['1', 'true'] }
        - in: query
          name: scope
          schema: { type: string, enum: [overall, own, user, api] }
        - in: query
          name: userId
          schema: { type: string }
        - in: query
          name: channel
          description: Filter by message channel
          schema: { type: string, enum: [sms, mms, whatsapp] }
      responses:
        '200':
          description: >
            text/csv columns: id, createdAt, direction, channel, subject,
            attachmentCount, batchId, origin, destination, status, errorCode,
            errorMessage, segments, cost, carrierMessageId,
            scheduledFor, expiresAt, createdByUserId, createdByApiKeyId, message
          content:
            text/csv:
              schema: { type: string }
  /v1/sms/batch:
    post:
      summary: Queue bulk SMS to many destinations
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendSmsBatch'
      responses:
        '202':
          description: Batch accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchSendResult'
  /v1/sms/batches:
    get:
      summary: List recent SMS campaigns (batches)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: limit
          schema: { type: integer, default: 30, maximum: 100 }
      responses:
        '200':
          description: Batch rollups with status counts and delivery rate
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchesPage'
  /v1/sms/batches/{batchId}/summary:
    get:
      summary: Campaign summary (status counts, segments, delivery rate)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: batchId
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Batch summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchSummary'
        '404':
          description: Batch not found
  /v1/sms/batches/{batchId}/cancel:
    post:
      summary: Cancel remaining queued messages in a campaign
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: batchId
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Cancellation result + updated summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelBatchResult'
  /v1/sms/threads:
    get:
      summary: Conversation threads by MSISDN
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: limit
          schema: { type: integer, default: 40, maximum: 100 }
        - in: query
          name: q
          description: >
            Filter threads by contact label, number, message body, or MMS subject
          schema: { type: string }
      responses:
        '200':
          description: Threads (includes unreadCount for JWT sessions)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Thread'
  /v1/sms/threads/read:
    post:
      summary: Mark an inbox thread as read for the current user
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MarkThreadRead'
      responses:
        '200':
          description: Read cursor updated
          content:
            application/json:
              schema:
                type: object
                required: [ok, contactKey, lastReadAt]
                properties:
                  ok: { type: boolean }
                  contactKey: { type: string }
                  lastReadAt: { type: string, format: date-time }
  /v1/sms/threads/read-all:
    post:
      summary: Mark all inbox threads as read for the current user
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Read cursors upserted for every thread with inbound history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarkAllThreadsReadResult'
  /v1/sms/inbox/unread:
    get:
      summary: Unread inbox thread count for the current user
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Badge summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboxUnread'
  /v1/sms/scheduled/summary:
    get:
      summary: Count of queued messages with a scheduled send time
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Scheduled queue summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScheduledMessagesSummary'
  /v1/sms/{id}:
    get:
      summary: Get message detail (body, error, carrier message id, MMS attachments)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: query
          name: scope
          schema: { type: string, enum: [overall, own, user, api] }
        - in: query
          name: userId
          schema: { type: string }
      responses:
        '200':
          description: >
            Message detail including optional `carrierMessageId`. For MMS, `attachments` include `dataBase64`.
            List endpoints return attachment metadata without payload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Message'
  /v1/sms/{id}/cancel:
    post:
      summary: Cancel a queued or scheduled outbound SMS (refunds charges)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Cancelled message with refund details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelMessageResult'
  /v1/mms:
    post:
      summary: Queue outbound MMS
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendMms'
      responses:
        '202':
          description: Queued or scheduled; body may include scheduledFor and expiresAt
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendSmsResponse'
  /v1/whatsapp:
    post:
      summary: Queue outbound WhatsApp (not implemented)
      description: >
        Reserved endpoint for WhatsApp Business messaging. Authenticated callers
        receive HTTP 501 until a WhatsApp carrier adapter is configured.
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendWhatsApp'
      responses:
        '501':
          description: WhatsApp sending is not configured yet
  /v1/mms/batch:
    post:
      summary: Queue MMS to many destinations (shared attachments, one batchId)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: header
          name: Idempotency-Key
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendMmsBatch'
      responses:
        '202':
          description: Batch accepted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchSendResult'

  /v1/account/number-requests:
    get:
      summary: List this account's virtual number and sender ID requests
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Request list
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/NumberRequest'
  /v1/account/virtual-number-request:
    post:
      summary: Request a dedicated virtual number (ops fulfillment)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '201':
          description: Request created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NumberRequestSubmitResult'
        '200':
          description: Existing pending request returned
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NumberRequestSubmitResult'
  /v1/account/verified-sender-request:
    post:
      summary: Request verified alpha sender ID registration
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RequestVerifiedSender'
      responses:
        '201':
          description: Request created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NumberRequestSubmitResult'
  /v1/sender-ids:
    get:
      summary: List active sender IDs for this account only
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Account-scoped sender IDs
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SenderId'
  /v1/lookup:
    post:
      summary: AU MNP number lookup (debits prepaid credit)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [msisdn]
              properties:
                msisdn: { type: string, example: '+61400000000' }
      responses:
        '200':
          description: Lookup result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NumberLookup'
    get:
      summary: AU MNP number lookup — API-key (MAC) clients only; sessions must use POST
      deprecated: true
      security: [{ macAuth: [] }]
      parameters:
        - in: query
          name: msisdn
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Lookup result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NumberLookup'

  /v1/contact-lists:
    get:
      summary: List contact lists
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Lists
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ContactList'
    post:
      summary: Create contact list
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateContactList'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
  /v1/contact-lists/search:
    get:
      summary: Search contacts across visible lists
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: q
          required: true
          description: At least 2 characters; matches phone, name, email
          schema: { type: string, minLength: 2, maxLength: 80 }
        - in: query
          name: listId
          schema: { type: string }
          description: Limit search to one list
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 200 }
      responses:
        '200':
          description: Matching contacts with listId / listName
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactSearchResponse'
        '400':
          description: Query too short or invalid
  /v1/contact-lists/{id}:
    get:
      summary: Get contact list
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: List
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
    patch:
      summary: Update contact list name or visibility
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateContactList'
      responses:
        '200':
          description: Updated list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactList'
    delete:
      summary: Delete contact list
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResult'
  /v1/contact-lists/{id}/contacts:
    get:
      summary: List contacts on a list (cursor pagination)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, default: 200, maximum: 500 }
        - in: query
          name: cursor
          description: Opaque ISO timestamp from previous nextCursor
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: '{ data, nextCursor? }'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactsPage'
    post:
      summary: Add a contact
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateContact'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
  /v1/contact-lists/{id}/contacts/{contactId}:
    patch:
      summary: Update a contact (phone, name, email)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: path
          name: contactId
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateContact'
      responses:
        '200':
          description: Updated contact
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Contact'
        '400':
          description: Validation error or duplicate MSISDN on list
        '404':
          description: Contact not found
    delete:
      summary: Remove a contact
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: path
          name: contactId
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResult'
  /v1/contact-lists/{id}/import:
    post:
      summary: Import contacts from CSV text
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ImportCsv'
      responses:
        '200':
          description: Import result
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactImportResult'
  /v1/contact-lists/{id}/export:
    get:
      summary: Export contacts as CSV
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: CSV attachment (msisdn,name,email,givenName,familyName,createdAt)
          content:
            text/csv:
              schema: { type: string }
  /v1/contact-lists/{id}/send:
    post:
      summary: Send SMS or MMS to all contacts on a list (chunked batches)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: header
          name: Idempotency-Key
          schema: { type: string, maxLength: 128 }
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendContactList'
      responses:
        '202':
          description: Queued (batches + totals)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ContactListSendResult'

  /v1/suppressions:
    get:
      summary: List opt-out suppressions
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: from
          description: Inclusive start date (YYYY-MM-DD)
          schema: { type: string }
        - in: query
          name: to
          description: Inclusive end date (YYYY-MM-DD)
          schema: { type: string }
        - in: query
          name: q
          description: Filter destination or reason (contains)
          schema: { type: string }
        - in: query
          name: sort
          schema: { type: string, enum: [date, number] }
        - in: query
          name: dir
          schema: { type: string, enum: [asc, desc] }
        - in: query
          name: limit
          schema: { type: integer, default: 5000, maximum: 10000 }
      responses:
        '200':
          description: Suppressions
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Suppression'
    post:
      summary: Manually add a suppression
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSuppression'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Suppression'
  /v1/suppressions/import:
    post:
      summary: Bulk import opt-outs from CSV/TSV/newlines
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ImportCsv'
      responses:
        '200':
          description: Import totals (added, skipped, errors)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CsvImportResult'
  /v1/suppressions/{id}:
    delete:
      summary: Remove a suppression (re-opt-in)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResult'

  /v1/templates:
    get:
      summary: List message templates
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: >
            Templates (SMS and MMS). List omits attachment `dataBase64`;
            use GET /v1/templates/{id} for full media.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MessageTemplate'
    post:
      summary: Create message template
      description: |
        SMS templates require `body`. MMS templates require at least one
        attachment (`name`, `contentType`, `dataBase64`); caption/`subject` optional.
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateMessageTemplate'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageTemplate'
  /v1/templates/{id}:
    get:
      summary: Get template
      description: >
        Full template including MMS attachment `dataBase64` when present.
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Template
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageTemplate'
    patch:
      summary: Update template
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateMessageTemplate'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageTemplate'
    delete:
      summary: Delete template
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OkResult'

  /v1/webhooks:
    description: |
      Configure the HTTPS URL that receives SMSWave event deliveries.

      **Delivery signing (to your endpoint):** each POST includes
      `Content-Type: application/json`, `X-SMSWave-Timestamp` (unix seconds),
      and `X-SMSWave-Signature: sha256=<hex>` where `<hex>` is HMAC-SHA256 of
      the canonical string `timestamp + "." + rawBody` using your webhook
      secret. Verify against the exact request body bytes (do not re-serialize
      JSON). Reject timestamps outside a ~5 minute skew window.
      SDK helper: `verifyWebhookSignature` from `@smswave/sdk` (or
      `@smswave/sdk/webhook`).

      Envelope: `{ "event", "id", "createdAt", "data" }`.

      **message.status `data`:** `{ id, status, destination, channel,
      carrierMessageId, errorCode, errorMessage }`. Fired on carrier DLR,
      cancel, and permanent send failure.

      **message.inbound `data`:** `{ id, origin, destination, message, channel,
      carrierMessageId, receivedAt, subject?, attachments?, optOutAction? }`.
      For MMS, `attachments` is metadata only (`name`, `contentType`); full
      `dataBase64` is on `GET /v1/sms/{id}`.
    get:
      summary: Get customer webhook endpoint (secret masked)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Endpoint or null
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/WebhookEndpoint'
                nullable: true
    post:
      summary: Create/update webhook URL (rotateSecret optional on update)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpsertWebhook'
      responses:
        '201':
          description: Saved (full secret only when rotated)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UpsertWebhookResult'
    delete:
      summary: Remove webhook endpoint (stops outbound deliveries)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Deleted (or already absent)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeleteWebhookResult'
  /v1/webhooks/test:
    post:
      summary: Queue a sample webhook event (message.status by default)
      description: >
        Sample `message.status` payloads include `channel`, `carrierMessageId`,
        `errorCode`, and `errorMessage`. Live deliveries also fire on cancel and permanent
        send failure (not only carrier DLRs). Pass `sample: mms` with
        `message.status` or `message.inbound` for MMS-shaped samples
        (`channel: mms`; inbound also includes `subject` + attachment metadata,
        no base64). Default samples are SMS.
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendWebhookTest'
      responses:
        '200':
          description: Queued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookTestResult'
  /v1/webhooks/deliveries:
    get:
      summary: List recent customer webhook deliveries
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: status
          schema: { type: string, enum: [pending, delivered, failed] }
        - in: query
          name: eventType
          schema:
            type: string
            enum:
              [message.status, message.inbound, account.low_balance, link.click]
        - in: query
          name: limit
          schema: { type: integer, default: 50, maximum: 100 }
      responses:
        '200':
          description: >
            Deliveries (status stays pending while retries remain; includes
            httpStatus/responseBody)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/WebhookDelivery'
  /v1/webhooks/deliveries/summary:
    get:
      summary: Failed and pending delivery counts (nav badge)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Counts for Shell badge
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveriesSummary'
  /v1/webhooks/deliveries/{id}:
    get:
      summary: Get one delivery including JSON payload
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Delivery with payload, httpStatus, and responseBody
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveryDetail'
  /v1/webhooks/deliveries/{id}/retry:
    post:
      summary: Requeue a failed webhook delivery
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Queued
          content:
            application/json:
              schema:
                type: object
                required: [id, status, queued]
                properties:
                  id: { type: string }
                  status: { type: string }
                  queued: { type: boolean }

  /v1/support/tickets:
    get:
      summary: List support tickets for this account
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Tickets
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SupportTicket'
    post:
      summary: Create a support ticket (emails ops + confirmation)
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSupportTicket'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SupportTicket'
  /v1/support/awaiting-reply:
    get:
      summary: Count of tickets awaiting customer reply (staff last spoke)
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Badge summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SupportAwaitingReply'
  /v1/support/tickets/{id}:
    get:
      summary: Get one support ticket (includes message thread)
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Ticket with messages
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SupportTicket'
  /v1/support/tickets/{id}/replies:
    post:
      summary: Reply on a support ticket thread
      security: [{ bearerAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSupportTicketReply'
      responses:
        '200':
          description: Updated ticket with messages
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SupportTicket'
        '400':
          description: Ticket closed or empty reply

  /v1/billing/plans:
    get:
      summary: List active plans
      responses:
        '200':
          description: Plans
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Plan'
  /v1/billing/info:
    get:
      summary: Customer billing summary (plan, balance, Stripe customer presence)
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Billing info
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingInfo'
  /v1/billing/portal:
    post:
      summary: Create Stripe Customer Portal session (manage payment method)
      security: [{ bearerAuth: [] }]
      responses:
        '200':
          description: Portal URL
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingPortalSession'
  /v1/billing/top-up-packages:
    get:
      summary: List prepaid top-up packages
      responses:
        '200':
          description: Packages
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TopUpPackage'
  /v1/billing/subscription:
    get:
      summary: Current subscription (includes stripeRecurring when applicable)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Subscription or null
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Subscription'
                nullable: true
  /v1/billing/payments:
    get:
      summary: Recent payments
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Payments
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Payment'
  /v1/billing/top-ups:
    post:
      summary: Purchase top-up (demo auto-complete or Stripe Checkout URL)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PurchaseTopUp'
      responses:
        '201':
          description: Payment completed or checkout redirect
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingCheckoutResult'
  /v1/billing/plan:
    post:
      summary: Change plan (free activate or Stripe subscription Checkout)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangePlan'
      responses:
        '200':
          description: Activated or checkout redirect
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingCheckoutResult'
  /v1/billing/renew:
    post:
      summary: Renew past_due / expired subscription (Checkout or demo complete)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Payment completed or checkout redirect
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingCheckoutResult'
  /v1/billing/cancel-recurring:
    post:
      summary: Cancel Stripe auto-renew at period end
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Auto-renew cancelled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CancelRecurringResult'
  /v1/billing/payments/confirm-session:
    post:
      summary: Confirm Stripe Checkout session after return
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConfirmCheckoutSession'
      responses:
        '200':
          description: Payment completed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSettlementResult'
  /v1/billing/payments/{id}/complete:
    post:
      summary: Complete a pending payment (demo / non-Stripe only)
      description: Rejected when STRIPE_SECRET_KEY is configured — use confirm-session or webhooks instead.
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Payment completed and balance credited
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentSettlementResult'
        '400':
          description: Stripe is configured or payment not completable

  /v1/short-links:
    get:
      summary: List short links for the account
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: limit
          schema: { type: integer, default: 50 }
        - in: query
          name: messageId
          description: Only links attached to this outbound message
          schema: { type: string }
      responses:
        '200':
          description: Short links with click counts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShortLinksPage'
    post:
      summary: Create a short link
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateShortLink'
      responses:
        '201':
          description: Created short link (code + shortUrl)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShortLink'
  /v1/short-links/clicks:
    get:
      summary: Recent short-link clicks across the account
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: limit
          schema: { type: integer, default: 50 }
        - in: query
          name: shortLinkId
          schema: { type: string }
        - in: query
          name: messageId
          description: Clicks for links attached to this message
          schema: { type: string }
      responses:
        '200':
          description: Click history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShortLinkClicksPage'
  /v1/short-links/{id}:
    patch:
      summary: Expire or extend a short link
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateShortLink'
      responses:
        '200':
          description: Updated short link
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShortLink'
  /v1/short-links/{id}/clicks:
    get:
      summary: Click history for one short link
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string }
        - in: query
          name: limit
          schema: { type: integer, default: 50 }
      responses:
        '200':
          description: Click history
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShortLinkClicksPage'

  /v1/analytics/usage:
    get:
      summary: Usage analytics (series, totals, byStatus)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: days
          schema: { type: integer, default: 30 }
        - in: query
          name: scope
          schema: { type: string, enum: [overall, own, user, api] }
        - in: query
          name: userId
          schema: { type: string }
      responses:
        '200':
          description: Usage series
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageAnalytics'
  /v1/analytics/usage/rebuild:
    post:
      summary: Rebuild daily usage aggregates from messages (owners/admins)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      responses:
        '200':
          description: Rebuild summary (days, outboundSegments, inboundCount)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RebuildUsageResult'
        '403':
          description: Forbidden for members
  /v1/analytics/usage/export:
    get:
      summary: Export daily usage as CSV
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: days
          schema: { type: integer, default: 30 }
        - in: query
          name: scope
          schema: { type: string, enum: [overall, own, user, api] }
        - in: query
          name: userId
          schema: { type: string }
      responses:
        '200':
          description: text/csv
          content:
            text/csv:
              schema: { type: string }
  /v1/analytics/by-user:
    get:
      summary: Usage breakdown by team member (owners/admins)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: days
          schema: { type: integer, default: 30 }
      responses:
        '200':
          description: Per-user outbound counts, segments, spend
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageByUser'
        '403':
          description: Forbidden for members
  /v1/analytics/by-channel:
    get:
      summary: Usage breakdown by channel (owners/admins)
      description: >
        Carrier-accepted outbound only (`carrierMessageId` set), grouped by
        `channel` (sms, mms, whatsapp).
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: days
          schema: { type: integer, default: 30 }
      responses:
        '200':
          description: Per-channel outbound counts, segments, spend
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageByChannel'
        '403':
          description: Forbidden for members
  /v1/analytics/by-channel/export:
    get:
      summary: Export usage by channel as CSV (owners/admins)
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: days
          schema: { type: integer, default: 30 }
      responses:
        '200':
          description: text/csv (channel,outboundCount,outboundSegments,spend)
          content:
            text/csv:
              schema: { type: string }
        '403':
          description: Forbidden for members
  /v1/analytics/clicks:
    get:
      summary: Short-link click rollups by day
      description: >
        Account-wide redirect hits from shortened URLs, grouped by UTC day.
        Includes top links by clicks in the selected window.
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: days
          schema: { type: integer, default: 30 }
      responses:
        '200':
          description: Daily click series, totals, and top links
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShortLinkClicksAnalytics'
  /v1/analytics/clicks/export:
    get:
      summary: Export short-link click rollups as CSV
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: days
          schema: { type: integer, default: 30 }
      responses:
        '200':
          description: text/csv (day,clicks + top links)
          content:
            text/csv:
              schema: { type: string }
  /v1/analytics/ledger:
    get:
      summary: Credit/debit ledger entries
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: limit
          schema: { type: integer }
      responses:
        '200':
          description: Ledger rows
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/LedgerEntry'
  /v1/analytics/ledger/export:
    get:
      summary: Export ledger as CSV
      security: [{ bearerAuth: [] }, { macAuth: [] }]
      parameters:
        - in: query
          name: limit
          schema: { type: integer, default: 1000 }
      responses:
        '200':
          description: text/csv
          content:
            text/csv:
              schema: { type: string }

  /webhooks/stripe:
    post:
      summary: Stripe webhook (checkout, payment_intent, invoice, subscription)
      responses:
        '200':
          description: Received

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    macAuth:
      type: apiKey
      in: header
      name: Authorization
      description: >-
        MAC id="<key>", ts="…", nonce="…", mac="…".
        Credentials are key and secret from POST /v1/api-keys.
  schemas:
    Health:
      type: object
      required: [status, database, redis, carrier]
      properties:
        status:
          type: string
          enum: [ok, degraded]
        service:
          type: string
          example: SMSWave-api
        database:
          type: string
          enum: [ok, error]
        redis:
          type: string
          enum: [ok, error]
        carrier:
          type: string
          enum: [ok, mock]
          description: ok when a live carrier route is configured; mock in local/dev mode
    UsageDay:
      type: object
      required: [day, outboundSegments, inboundCount, spend]
      properties:
        day: { type: string }
        outboundSegments: { type: integer }
        inboundCount: { type: integer }
        spend: { type: string }
    UsageTotals:
      type: object
      required: [outboundSegments, inboundCount, spend]
      properties:
        outboundSegments: { type: integer }
        inboundCount: { type: integer }
        spend: { type: string }
    UsageAnalytics:
      type: object
      required: [days, series, totals, byStatus]
      properties:
        days: { type: integer }
        scope: { type: string }
        series:
          type: array
          items: { $ref: '#/components/schemas/UsageDay' }
        totals: { $ref: '#/components/schemas/UsageTotals' }
        byStatus:
          type: object
          additionalProperties: { type: integer }
    RebuildUsageResult:
      type: object
      required: [days, outboundMessages, outboundSegments, inboundCount]
      properties:
        days: { type: integer }
        outboundMessages: { type: integer }
        outboundSegments: { type: integer }
        inboundCount: { type: integer }
    UsageByUserRow:
      type: object
      required:
        [userId, email, name, label, outboundCount, outboundSegments, spend]
      properties:
        userId: { type: string, nullable: true }
        email: { type: string, nullable: true }
        name: { type: string, nullable: true }
        label: { type: string }
        outboundCount: { type: integer }
        outboundSegments: { type: integer }
        spend: { type: string }
    UsageByUser:
      type: object
      required: [days, users]
      properties:
        days: { type: integer }
        users:
          type: array
          items: { $ref: '#/components/schemas/UsageByUserRow' }
    UsageByChannelRow:
      type: object
      required: [channel, label, outboundCount, outboundSegments, spend]
      properties:
        channel: { type: string }
        label: { type: string }
        outboundCount: { type: integer }
        outboundSegments: { type: integer }
        spend: { type: string }
    UsageByChannel:
      type: object
      required: [days, channels]
      properties:
        days: { type: integer }
        channels:
          type: array
          items: { $ref: '#/components/schemas/UsageByChannelRow' }
    ShortLinkClicksDay:
      type: object
      required: [day, clicks]
      properties:
        day: { type: string, format: date }
        clicks: { type: integer }
    ShortLinkClicksTopLink:
      type: object
      required: [id, code, targetUrl, clicks, hitCount]
      properties:
        id: { type: string }
        code: { type: string }
        targetUrl: { type: string }
        clicks: { type: integer }
        hitCount: { type: integer }
    ShortLinkClicksAnalytics:
      type: object
      required: [days, series, totals, topLinks]
      properties:
        days: { type: integer }
        series:
          type: array
          items: { $ref: '#/components/schemas/ShortLinkClicksDay' }
        totals:
          type: object
          required: [clicks, linksClicked]
          properties:
            clicks: { type: integer }
            linksClicked: { type: integer }
        topLinks:
          type: array
          items: { $ref: '#/components/schemas/ShortLinkClicksTopLink' }
    LedgerEntry:
      type: object
      required: [id, type, amount, balanceAfter, description, createdAt]
      properties:
        id: { type: string }
        type: { type: string }
        amount: { type: string }
        balanceAfter: { type: string }
        description: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
    Plan:
      type: object
      required:
        [
          id,
          name,
          slug,
          monthlyPrice,
          includedSegments,
          overagePerSegment,
          currency,
        ]
      properties:
        id: { type: string }
        name: { type: string }
        slug: { type: string }
        monthlyPrice: { type: string }
        includedSegments: { type: integer }
        overagePerSegment: { type: string }
        currency: { type: string }
    TopUpPackage:
      type: object
      required: [id, name, slug, creditAmount, price, currency]
      properties:
        id: { type: string }
        name: { type: string }
        slug: { type: string }
        creditAmount: { type: string }
        price: { type: string }
        currency: { type: string }
    BillingCard:
      type: object
      required: [brand, last4, expMonth, expYear]
      properties:
        brand: { type: string }
        last4: { type: string }
        expMonth: { type: integer }
        expYear: { type: integer }
    BillingPlanOption:
      allOf:
        - $ref: '#/components/schemas/Plan'
        - type: object
          required: [direction]
          properties:
            direction:
              type: string
              enum: [current, upgrade, downgrade]
    BillingInfo:
      type: object
      required:
        [
          subscription,
          plans,
          stripeConfigured,
          hasStripeCustomer,
          card,
          ownerEmail,
        ]
      properties:
        subscription:
          allOf:
            - $ref: '#/components/schemas/Subscription'
          nullable: true
        plans:
          type: array
          items:
            $ref: '#/components/schemas/BillingPlanOption'
        stripeConfigured: { type: boolean }
        hasStripeCustomer: { type: boolean }
        card:
          allOf:
            - $ref: '#/components/schemas/BillingCard'
          nullable: true
        ownerEmail: { type: string, nullable: true }
    Payment:
      type: object
      required: [id, kind, status, amount, currency, description, createdAt]
      properties:
        id: { type: string }
        kind: { type: string }
        status: { type: string }
        amount: { type: string }
        currency: { type: string }
        description: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
    BillingPortalSession:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri }
    PurchaseTopUp:
      type: object
      required: [packageId]
      properties:
        packageId: { type: string }
    ChangePlan:
      type: object
      required: [planSlug]
      properties:
        planSlug: { type: string }
    ConfirmCheckoutSession:
      type: object
      required: [sessionId]
      properties:
        sessionId: { type: string }
    BillingCheckoutResult:
      type: object
      required: [id, status]
      properties:
        id: { type: string }
        status: { type: string }
        amount: { type: string }
        currency: { type: string }
        balance: { type: string }
        creditAmount: { type: string }
        checkoutMode: { type: string, enum: [stripe, external] }
        checkoutUrl: { type: string, format: uri }
        sessionId: { type: string }
    CancelRecurringResult:
      type: object
      required: [ok, message, periodEnd]
      properties:
        ok: { type: boolean }
        message: { type: string }
        periodEnd: { type: string, format: date-time }
    PaymentSettlementResult:
      type: object
      required: [id, status]
      properties:
        id: { type: string }
        status: { type: string }
        balance: { type: string }
        creditAmount: { type: string }
    UserProfile:
      type: object
      required: [id, email, name, role, account]
      properties:
        id: { type: string }
        email: { type: string, format: email }
        name: { type: string, nullable: true }
        role: { type: string }
        account:
          type: object
          required: [id, name, isPlatform]
          properties:
            id: { type: string }
            name: { type: string }
            isPlatform: { type: boolean }
    UpdateProfile:
      type: object
      properties:
        name: { type: string, nullable: true, maxLength: 120 }
        email: { type: string, format: email }
        accountName: { type: string, minLength: 2, maxLength: 120 }
    NumberLookup:
      type: object
      required: [msisdn, valid, cost, currency]
      properties:
        msisdn: { type: string }
        valid: { type: boolean }
        carrierName: { type: string }
        mccMnc: { type: string }
        ported: { type: boolean }
        cost: { type: string }
        currency: { type: string }
    SenderId:
      type: object
      required: [id, value]
      properties:
        id: { type: string }
        value: { type: string }
        status: { type: string }
        countries:
          type: array
          items: { type: string }
        acmaStatus:
          type: string
          enum: [not_applicable, not_submitted, submitted, registered, rejected]
          description: >-
            ACMA SMS Sender ID Register state. Until `registered`, messages sent
            from this alphanumeric ID are delivered from one of your virtual
            numbers instead (Australian carriers stamp unregistered IDs
            "Likely scam").
        acmaSubmittedAt: { type: string, format: date-time, nullable: true }
        acmaRegisteredAt: { type: string, format: date-time, nullable: true }
        acmaReference: { type: string, nullable: true }
    NumberRequest:
      type: object
      required:
        [id, type, status, payload, adminNote, createdAt, resolvedAt]
      properties:
        id: { type: string }
        type: { type: string }
        status: { type: string }
        payload: {}
        adminNote: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        resolvedAt: { type: string, format: date-time, nullable: true }
    NumberRequestSubmitResult:
      type: object
      required: [ok, message]
      properties:
        ok: { type: boolean }
        message: { type: string }
        id: { type: string }
        status: { type: string }
        senderId: { type: string }
        countries:
          type: array
          items: { type: string }
    RequestVerifiedSender:
      type: object
      required:
        [senderId, countries, description, businessProfile, acceptTerms]
      properties:
        senderId: { type: string, minLength: 2, maxLength: 11 }
        countries:
          type: array
          items: { type: string }
        description: { type: string, minLength: 10, maxLength: 4000 }
        businessProfile: { type: string, maxLength: 200 }
        partnerProfile: { type: string, maxLength: 200 }
        usePartnerProfile: { type: boolean }
        acceptTerms: { type: boolean }
        note: { type: string }
    Subscription:
      type: object
      required:
        [
          id,
          status,
          periodStart,
          periodEnd,
          segmentsUsed,
          segmentsRemaining,
          plan,
        ]
      properties:
        id: { type: string }
        status: { type: string }
        periodStart: { type: string, format: date-time }
        periodEnd: { type: string, format: date-time }
        segmentsUsed: { type: integer }
        segmentsRemaining: { type: integer }
        plan: { $ref: '#/components/schemas/Plan' }
        stripeRecurring: { type: boolean }
        cancelAtPeriodEnd: { type: boolean }
    SmsSettings:
      type: object
      required:
        [
          timezone,
          displayContactsAs,
          defaultCountry,
          defaultSendFrom,
          postSmsRepliesTo,
          smsRepliesEmail,
          emailFailedDeliveries,
          allowUnicodeMessages,
          filterDuplicatesOnSend,
          autoDeleteOptedOutFromLists,
          enforceGlobalOptouts,
          stopConfirmationMessage,
          startConfirmationMessage,
          urlShortenerEnabled,
          urlShortenerTtlDays,
        ]
      properties:
        timezone: { type: string }
        displayContactsAs:
          type: string
          enum: [given_family, family_given, display_name]
        defaultCountry: { type: string }
        defaultSendFrom: { type: string }
        postSmsRepliesTo: { type: string, enum: [email, none] }
        smsRepliesEmail: { type: string, nullable: true }
        emailFailedDeliveries:
          type: boolean
          description: >
            When true, email the replies address (or owner) when a single
            outbound SMS/MMS becomes failed or undelivered. Batch/list sends
            are skipped.
        allowUnicodeMessages: { type: boolean }
        filterDuplicatesOnSend:
          type: boolean
          description: >
            When true, reject outbound SMS with the same destination and body
            within 5 minutes, or outbound MMS with the same destination,
            body/caption, and subject (excludes cancelled).
        autoDeleteOptedOutFromLists: { type: boolean }
        enforceGlobalOptouts: { type: boolean }
        stopConfirmationMessage: { type: string, nullable: true }
        startConfirmationMessage: { type: string, nullable: true }
        urlShortenerEnabled: { type: boolean }
        urlShortenerTtlDays: { type: integer, minimum: 1, maximum: 365 }
    SmsSettingsUpdate:
      type: object
      description: Partial SMS settings patch body
      properties:
        timezone: { type: string }
        displayContactsAs:
          type: string
          enum: [given_family, family_given, display_name]
        defaultCountry: { type: string }
        defaultSendFrom: { type: string }
        postSmsRepliesTo: { type: string, enum: [email, none] }
        smsRepliesEmail: { type: string, nullable: true }
        emailFailedDeliveries: { type: boolean }
        allowUnicodeMessages: { type: boolean }
        filterDuplicatesOnSend: { type: boolean }
        autoDeleteOptedOutFromLists: { type: boolean }
        enforceGlobalOptouts: { type: boolean }
        stopConfirmationMessage: { type: string, nullable: true }
        startConfirmationMessage: { type: string, nullable: true }
        urlShortenerEnabled: { type: boolean }
        urlShortenerTtlDays: { type: integer, minimum: 1, maximum: 365 }
    UpdateBillingAlerts:
      type: object
      properties:
        lowBalanceThreshold:
          type: string
          nullable: true
          description: Decimal string, or null/empty to disable
        autoTopUpPackageId:
          type: string
          nullable: true
          description: Top-up package id, or null/empty to disable
    Account:
      type: object
      required: [id, name, balance, currency]
      properties:
        id: { type: string }
        name: { type: string }
        balance: { type: string }
        currency: { type: string }
        subscription:
          allOf:
            - $ref: '#/components/schemas/Subscription'
          nullable: true
        phoneNumbers:
          type: array
          items: { type: string }
        lowBalanceThreshold: { type: string, nullable: true }
        autoTopUpPackageId: { type: string, nullable: true }
        lowBalanceAlertActive: { type: boolean }
        smsPerMinute: { type: integer }
        smsSettings: { $ref: '#/components/schemas/SmsSettings' }
        isPlatform: { type: boolean }
    ApiKey:
      type: object
      required: [id, name, key, keyId, createdAt, lastUsedAt]
      properties:
        id: { type: string }
        name: { type: string }
        key:
          type: string
          description: Same as keyId (public key id for MAC auth)
        keyId: { type: string }
        createdAt: { type: string, format: date-time }
        lastUsedAt: { type: string, format: date-time, nullable: true }
    CreateApiKeyResult:
      type: object
      required: [id, name, key, keyId, secret, createdAt]
      properties:
        id: { type: string }
        name: { type: string }
        key: { type: string }
        keyId: { type: string }
        secret:
          type: string
          description: Shown once on create; store securely
        createdAt: { type: string, format: date-time }
    CreateApiKey:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 80 }
    SupportTicket:
      type: object
      required:
        [
          id,
          category,
          categoryLabel,
          subject,
          body,
          status,
          createdByEmail,
          createdAt,
          updatedAt,
        ]
      properties:
        id: { type: string }
        category:
          type: string
          enum: [technical, bug, integration, feature]
        categoryLabel: { type: string }
        subject: { type: string }
        body: { type: string }
        status:
          type: string
          enum: [open, in_progress, resolved, closed]
        adminNote:
          type: string
          nullable: true
          description: Always null for customers (internal ops only)
        createdByEmail: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        needsReply:
          type: boolean
          description: >
            True when status is open or in_progress and the latest thread
            message is from staff.
        messages:
          type: array
          items:
            $ref: '#/components/schemas/SupportTicketMessage'
    SupportTicketMessage:
      type: object
      required: [id, authorKind, authorEmail, body, createdAt]
      properties:
        id: { type: string }
        authorKind: { type: string, enum: [customer, staff] }
        authorEmail: { type: string }
        authorName: { type: string, nullable: true }
        body: { type: string }
        createdAt: { type: string, format: date-time }
    SupportAwaitingReply:
      type: object
      required: [awaitingReply]
      properties:
        awaitingReply:
          type: integer
          description: Tickets awaiting a customer reply
    CreateSupportTicket:
      type: object
      required: [category, subject, body]
      properties:
        category:
          type: string
          enum: [technical, bug, integration, feature]
        subject: { type: string, minLength: 3, maxLength: 200 }
        body: { type: string, minLength: 10, maxLength: 10000 }
    CreateSupportTicketReply:
      type: object
      required: [body]
      properties:
        body: { type: string, minLength: 1, maxLength: 10000 }
    TeamMember:
      type: object
      required: [id, email, name, role]
      properties:
        id: { type: string }
        email: { type: string, format: email }
        name: { type: string, nullable: true }
        role: { type: string, enum: [owner, admin, member] }
        createdAt: { type: string, format: date-time }
    TeamInvite:
      type: object
      required:
        [
          id,
          email,
          role,
          expiresAt,
          createdAt,
          expired,
          acceptPath,
          acceptUrl,
        ]
      properties:
        id: { type: string }
        email: { type: string, format: email }
        role: { type: string, enum: [owner, admin, member] }
        expiresAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        expired: { type: boolean }
        acceptPath: { type: string }
        acceptUrl: { type: string, format: uri }
    CreateTeamInviteResult:
      type: object
      required:
        [id, email, role, token, expiresAt, acceptPath, acceptUrl]
      properties:
        id: { type: string }
        email: { type: string, format: email }
        role: { type: string, enum: [owner, admin, member] }
        token: { type: string }
        expiresAt: { type: string, format: date-time }
        acceptPath: { type: string }
        acceptUrl: { type: string, format: uri }
        emailSent: { type: boolean }
        emailChannel: { type: string }
    CreateTeamMember:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string, minLength: 8 }
        name: { type: string }
        role: { type: string, enum: [owner, admin, member] }
    CreateTeamInvite:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        role: { type: string, enum: [owner, admin, member] }
    UpdateTeamMemberRole:
      type: object
      required: [role]
      properties:
        role: { type: string, enum: [owner, admin, member] }
    AcceptTeamInvite:
      type: object
      required: [token, password]
      properties:
        token: { type: string }
        password: { type: string, minLength: 8 }
        name: { type: string }
    AcceptTeamInviteResult:
      type: object
      required: [userId, accountId, email]
      properties:
        userId: { type: string }
        accountId: { type: string }
        email: { type: string, format: email }
    WebhookDeliveriesSummary:
      type: object
      required: [failed, pending]
      properties:
        failed: { type: integer }
        pending: { type: integer }
    WebhookDelivery:
      type: object
      required:
        [
          id,
          eventType,
          status,
          attempts,
          lastError,
          httpStatus,
          responseBody,
          createdAt,
        ]
      properties:
        id: { type: string }
        eventType: { type: string }
        status: { type: string, enum: [pending, delivered, failed] }
        attempts: { type: integer }
        lastError: { type: string, nullable: true }
        httpStatus: { type: integer, nullable: true }
        responseBody: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    WebhookEndpoint:
      type: object
      required: [id, url, secret, eventTypes]
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        secret:
          type: string
          description: Masked on GET; full value only on create/rotate
        eventTypes:
          type: array
          items:
            type: string
            enum:
              - message.status
              - message.inbound
              - account.low_balance
              - link.click
          description: >
            Subscribed events. Legacy empty storage is returned as all four types.
    UpsertWebhook:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri }
        rotateSecret: { type: boolean }
        eventTypes:
          type: array
          minItems: 1
          items:
            type: string
            enum:
              - message.status
              - message.inbound
              - account.low_balance
              - link.click
          description: Omit to subscribe to all events; otherwise at least one required
    UpsertWebhookResult:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpoint'
        - type: object
          properties:
            secretRotated: { type: boolean }
    DeleteWebhookResult:
      type: object
      required: [ok]
      properties:
        ok: { type: boolean }
        deleted: { type: boolean }
    SendWebhookTest:
      type: object
      properties:
        eventType:
          type: string
          enum:
            - message.status
            - message.inbound
            - account.low_balance
            - link.click
        sample:
          type: string
          enum: [sms, mms]
          description: >
            For message.status or message.inbound — `mms` queues channel=mms
            (inbound also includes subject + attachment metadata; status
            payloads have no attachments).
    WebhookTestResult:
      type: object
      required: [ok, eventType, message]
      properties:
        ok: { type: boolean }
        eventType: { type: string }
        message: { type: string }
    WebhookDeliveryDetail:
      allOf:
        - $ref: '#/components/schemas/WebhookDelivery'
        - type: object
          required: [payload, updatedAt]
          properties:
            payload: {}
            updatedAt: { type: string, format: date-time }
    BatchSummary:
      type: object
      required:
        [
          batchId,
          total,
          segments,
          cost,
          byStatus,
          cancellable,
          deliveryRate,
          createdAt,
          lastCreatedAt,
        ]
      properties:
        batchId: { type: string }
        total: { type: integer }
        segments: { type: integer }
        cost: { type: string }
        byStatus:
          type: object
          additionalProperties: { type: integer }
        cancellable: { type: integer }
        deliveryRate: { type: number, nullable: true }
        createdAt: { type: string, format: date-time, nullable: true }
        lastCreatedAt: { type: string, format: date-time, nullable: true }
    BatchesPage:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/BatchSummary'
    ShortLink:
      type: object
      required:
        [
          id,
          code,
          shortUrl,
          targetUrl,
          messageId,
          hitCount,
          expiresAt,
          createdAt,
          expired,
        ]
      properties:
        id: { type: string }
        code: { type: string }
        shortUrl: { type: string, format: uri }
        targetUrl: { type: string, format: uri }
        messageId: { type: string, nullable: true }
        hitCount: { type: integer }
        expiresAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        expired: { type: boolean }
    ShortLinksPage:
      type: object
      required: [totals, data]
      properties:
        totals:
          type: object
          required: [links, active, hits]
          properties:
            links: { type: integer }
            active: { type: integer }
            hits: { type: integer }
        data:
          type: array
          items:
            $ref: '#/components/schemas/ShortLink'
    ShortLinkClicksPage:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ShortLinkClick'
    CreateShortLink:
      type: object
      required: [targetUrl]
      properties:
        targetUrl: { type: string, format: uri }
        ttlDays: { type: integer, minimum: 1, maximum: 365 }
    UpdateShortLink:
      type: object
      properties:
        expireNow: { type: boolean }
        expiresAt: { type: string, format: date-time }
        ttlDays: { type: integer, minimum: 1, maximum: 365 }
    ShortLinkClick:
      type: object
      required:
        [
          id,
          shortLinkId,
          code,
          shortUrl,
          targetUrl,
          messageId,
          userAgent,
          createdAt,
        ]
      properties:
        id: { type: string }
        shortLinkId: { type: string }
        code: { type: string }
        shortUrl: { type: string, format: uri }
        targetUrl: { type: string, format: uri }
        messageId: { type: string, nullable: true }
        userAgent: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
    ContactList:
      type: object
      required: [id, name, contactCount, createdAt, updatedAt]
      properties:
        id: { type: string }
        name: { type: string }
        visibility: { type: string, enum: [private, team] }
        ownerUserId: { type: string, nullable: true }
        ownerEmail: { type: string, nullable: true }
        ownerName: { type: string, nullable: true }
        contactCount: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CreateContactList:
      type: object
      required: [name]
      properties:
        name: { type: string }
        visibility: { type: string, enum: [private, team] }
    UpdateContactList:
      type: object
      properties:
        name: { type: string }
        visibility: { type: string, enum: [private, team] }
    ImportCsv:
      type: object
      required: [csv]
      properties:
        csv: { type: string }
    CsvImportResult:
      type: object
      required: [added, skipped, errors]
      properties:
        added: { type: integer }
        skipped: { type: integer }
        errors:
          type: array
          items:
            type: object
            required: [line, error]
            properties:
              line: { type: integer }
              error: { type: string }
    ContactImportResult:
      allOf:
        - $ref: '#/components/schemas/CsvImportResult'
        - type: object
          required: [contactCount]
          properties:
            contactCount: { type: integer }
    CreateSuppression:
      type: object
      required: [destination]
      properties:
        destination: { type: string }
        reason: { type: string }
    SendContactList:
      type: object
      properties:
        channel: { type: string, enum: [sms, mms, whatsapp], default: sms }
        message: { type: string, description: Required for SMS; optional MMS caption }
        subject: { type: string }
        origin: { type: string }
        sendAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        attachments:
          type: array
          maxItems: 15
          description: Required when channel=mms; each file ≤300KB
          items:
            type: object
            required: [name, contentType, dataBase64]
            properties:
              name: { type: string }
              contentType: { type: string }
              dataBase64: { type: string }
    ContactListSendResult:
      type: object
      required: [listId, batches, totals, failed]
      properties:
        listId: { type: string }
        channel: { type: string }
        batches:
          type: array
          items:
            type: object
            required: [batchId, queued, failed]
            properties:
              batchId: { type: string }
              queued: { type: integer }
              failed: { type: integer }
        totals:
          type: object
          required: [contacts, queued, failed]
          properties:
            contacts: { type: integer }
            queued: { type: integer }
            failed: { type: integer }
        failed:
          type: array
          items:
            type: object
            required: [destination, error]
            properties:
              destination: { type: string }
              error: { type: string }
    OkResult:
      type: object
      required: [ok]
      properties:
        ok: { type: boolean }
    ForgotPassword:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
    ForgotPasswordResult:
      type: object
      required: [ok, message]
      properties:
        ok: { type: boolean }
        message: { type: string }
        resetToken: { type: string }
        resetPath: { type: string }
    ResetPassword:
      type: object
      required: [token, newPassword]
      properties:
        token: { type: string }
        newPassword: { type: string, minLength: 8 }
    ChangePassword:
      type: object
      required: [currentPassword, newPassword]
      properties:
        currentPassword: { type: string }
        newPassword: { type: string, minLength: 8 }
    Contact:
      type: object
      required: [id, msisdn, name, createdAt]
      properties:
        id: { type: string }
        msisdn: { type: string }
        name: { type: string, nullable: true }
        email: { type: string, nullable: true }
        givenName: { type: string, nullable: true }
        familyName: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
    ContactsPage:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Contact'
        nextCursor: { type: string, format: date-time }
    CreateContact:
      type: object
      required: [msisdn]
      properties:
        msisdn: { type: string }
        name: { type: string }
        email: { type: string }
        givenName: { type: string }
        familyName: { type: string }
    UpdateContact:
      type: object
      properties:
        msisdn: { type: string }
        name: { type: string, nullable: true }
        email: { type: string, nullable: true }
        givenName: { type: string, nullable: true }
        familyName: { type: string, nullable: true }
    ContactSearchHit:
      allOf:
        - $ref: '#/components/schemas/Contact'
        - type: object
          required: [listId, listName]
          properties:
            listId: { type: string }
            listName: { type: string }
    ContactSearchResponse:
      type: object
      required: [data, query, limit]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ContactSearchHit'
        query: { type: string }
        limit: { type: integer }
    Suppression:
      type: object
      required: [id, destination, reason, createdAt]
      properties:
        id: { type: string }
        destination: { type: string }
        reason: { type: string }
        createdAt: { type: string, format: date-time }
    MessageTemplate:
      type: object
      required: [id, name, body, channel, attachments, createdAt, updatedAt]
      properties:
        id: { type: string }
        name: { type: string }
        body: { type: string }
        channel: { type: string, enum: [sms, mms] }
        subject: { type: string, nullable: true }
        attachments:
          type: array
          description: >
            Present for MMS. List endpoints omit `dataBase64`;
            GET /v1/templates/{id} (and create/update responses) include it.
          items:
            type: object
            required: [name, contentType]
            properties:
              name: { type: string }
              contentType: { type: string }
              dataBase64: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CreateMessageTemplate:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 80 }
        body: { type: string, maxLength: 1600 }
        channel: { type: string, enum: [sms, mms], default: sms }
        subject: { type: string, maxLength: 200, nullable: true }
        attachments:
          type: array
          maxItems: 15
          items:
            type: object
            required: [name, contentType, dataBase64]
            properties:
              name: { type: string }
              contentType: { type: string }
              dataBase64: { type: string }
    UpdateMessageTemplate:
      type: object
      properties:
        name: { type: string, maxLength: 80 }
        body: { type: string, maxLength: 1600 }
        channel: { type: string, enum: [sms, mms] }
        subject: { type: string, maxLength: 200, nullable: true }
        attachments:
          type: array
          maxItems: 15
          items:
            type: object
            required: [name, contentType, dataBase64]
            properties:
              name: { type: string }
              contentType: { type: string }
              dataBase64: { type: string }
    Thread:
      type: object
      required:
        [contactKey, lastAt, lastBody, lastDirection, messageCount, unreadCount]
      properties:
        contactKey: { type: string }
        contactLabel: { type: string, nullable: true }
        replyFrom: { type: string, nullable: true }
        lastAt: { type: string, format: date-time }
        lastBody:
          type: string
          description: >
            Preview text. For MMS, prefers subject when body is empty or [MMS],
            else a `[MMS · N attachments]` placeholder.
        lastDirection:
          type: string
          enum: [inbound, outbound]
        lastChannel:
          type: string
          enum: [sms, mms, whatsapp]
        lastSubject: { type: string, nullable: true }
        attachmentCount: { type: integer }
        messageCount: { type: integer }
        unreadCount:
          type: integer
          description: >
            Inbound messages since this user last opened the thread.
            Always 0 for API-key auth.
    InboxUnread:
      type: object
      required: [unreadThreads]
      properties:
        unreadThreads:
          type: integer
          description: Threads with at least one unread inbound message
    ScheduledMessagesSummary:
      type: object
      required: [scheduled]
      properties:
        scheduled:
          type: integer
          description: Queued outbound messages with scheduledFor set
    MarkThreadRead:
      type: object
      required: [contactKey]
      properties:
        contactKey: { type: string, minLength: 5 }
    MarkAllThreadsReadResult:
      type: object
      required: [ok, marked]
      properties:
        ok: { type: boolean }
        marked:
          type: integer
          description: Threads whose read cursor was upserted
    MessagesPage:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Message' }
        nextCursor: { type: string, nullable: true }
    SecuritySettings:
      type: object
      required: [totpEnabled, sms2faEnabled, sms2faPhone, smsPhoneMasked]
      properties:
        totpEnabled: { type: boolean }
        sms2faEnabled: { type: boolean }
        sms2faPhone: { type: string, nullable: true }
        smsPhoneMasked: { type: string, nullable: true }
    AuthSession:
      type: object
      required: [accessToken, role, account]
      properties:
        accessToken: { type: string }
        role: { type: string }
        account: { $ref: '#/components/schemas/Account' }
        requiresPayment: { type: boolean }
        checkoutMode: { type: string, enum: [stripe] }
        checkoutUrl: { type: string, format: uri }
        sessionId: { type: string }
        plan:
          type: object
          properties:
            slug: { type: string }
            name: { type: string }
            monthlyPrice: { type: string }
    Signup:
      type: object
      required: [email, password, accountName, planSlug, acceptTerms]
      properties:
        email: { type: string }
        password: { type: string, minLength: 8 }
        accountName: { type: string }
        planSlug: { type: string }
        name: { type: string }
        acceptTerms: { type: boolean }
    Login2faChallenge:
      type: object
      required: [requires2fa, challengeToken, methods]
      properties:
        requires2fa: { type: boolean, enum: [true] }
        challengeToken: { type: string }
        methods:
          type: object
          required: [totp, sms, smsPhoneMasked]
          properties:
            totp: { type: boolean }
            sms: { type: boolean }
            smsPhoneMasked: { type: string, nullable: true }
    Verify2fa:
      type: object
      required: [challengeToken, code]
      properties:
        challengeToken: { type: string }
        code: { type: string }
        method: { type: string, enum: [totp, sms] }
    Sms2faCodeResult:
      type: object
      required: [ok, channel, smsPhoneMasked]
      properties:
        ok: { type: boolean }
        channel: { type: string }
        smsPhoneMasked: { type: string }
        debugCode: { type: string }
    Sms2faPhoneResult:
      type: object
      required: [ok, sms2faPhone, smsPhoneMasked]
      properties:
        ok: { type: boolean }
        sms2faPhone: { type: string }
        smsPhoneMasked: { type: string }
    Sms2faToggleResult:
      type: object
      required: [ok, sms2faEnabled]
      properties:
        ok: { type: boolean }
        sms2faEnabled: { type: boolean }
        smsPhoneMasked: { type: string }
    SetSms2faPhone:
      type: object
      required: [phone]
      properties:
        phone: { type: string }
    TwoFaCode:
      type: object
      required: [code]
      properties:
        code: { type: string }
    ResendSms2fa:
      type: object
      required: [challengeToken]
      properties:
        challengeToken: { type: string }
    BatchSendResult:
      type: object
      required: [batchId, queued, failed]
      properties:
        batchId: { type: string }
        idempotent: { type: boolean }
        queued:
          type: array
          items:
            type: object
            required: [id, destination, status]
            properties:
              id: { type: string }
              destination: { type: string }
              status: { type: string }
        failed:
          type: array
          items:
            type: object
            required: [destination, error]
            properties:
              destination: { type: string }
              error: { type: string }
        totals:
          type: object
          properties:
            queued: { type: integer }
            failed: { type: integer }
    SendSms:
      type: object
      required: [destination, message]
      properties:
        origin: { type: string }
        destination: { type: string }
        message: { type: string }
        sendAt:
          type: string
          format: date-time
          description: Optional ISO schedule time (delayed BullMQ job)
        expiresAt:
          type: string
          format: date-time
          description: Optional carrier delivery expiry (must be after send time)
    SendMms:
      type: object
      required: [destination, attachments]
      properties:
        origin: { type: string }
        destination: { type: string }
        message: { type: string }
        subject: { type: string }
        sendAt:
          type: string
          format: date-time
          description: Optional ISO schedule time (delayed BullMQ job)
        expiresAt:
          type: string
          format: date-time
          description: Stored with the message; MMS gateway may not honour carrier expiry
        attachments:
          type: array
          maxItems: 15
          description: 1–15 files; each ≤300KB decoded size
          items:
            type: object
            required: [name, contentType, dataBase64]
            properties:
              name: { type: string }
              contentType: { type: string }
              dataBase64: { type: string }
    SendSmsResponse:
      type: object
      required: [id, status]
      properties:
        id: { type: string }
        status:
          type: string
          enum:
            [queued, sending, sent, failed, delivered, undelivered, cancelled]
        channel: { type: string, enum: [sms, mms, whatsapp] }
        scheduledFor: { type: string, format: date-time, nullable: true }
        expiresAt: { type: string, format: date-time, nullable: true }
        idempotent: { type: boolean }
    SendSmsBatch:
      type: object
      required: [destinations, message]
      properties:
        destinations:
          type: array
          items: { type: string }
        message: { type: string }
        origin: { type: string }
        sendAt: { type: string, format: date-time }
        expiresAt:
          type: string
          format: date-time
          description: Optional carrier delivery expiry applied to each message
        messageByDestination:
          type: object
          additionalProperties: { type: string }
          description: Optional per-destination personalized body (merge fields)
    SendMmsBatch:
      type: object
      required: [destinations, attachments]
      properties:
        destinations:
          type: array
          items: { type: string }
        message: { type: string }
        subject: { type: string }
        origin: { type: string }
        sendAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        messageByDestination:
          type: object
          additionalProperties: { type: string }
          description: Optional per-destination caption overrides (e.g. merge fields)
        subjectByDestination:
          type: object
          additionalProperties: { type: string }
          description: Optional per-destination subject overrides (e.g. merge fields)
        attachments:
          type: array
          maxItems: 15
          description: Shared across destinations; each file ≤300KB
          items:
            type: object
            required: [name, contentType, dataBase64]
            properties:
              name: { type: string }
              contentType: { type: string }
              dataBase64: { type: string }
    SendWhatsApp:
      type: object
      required: [destination, message]
      properties:
        origin: { type: string }
        destination: { type: string }
        message: { type: string, maxLength: 4096 }
        templateName: { type: string }
    TotpSetup:
      type: object
      required: [secret, otpauthUrl]
      properties:
        secret: { type: string }
        otpauthUrl: { type: string }
    TotpToggleResult:
      type: object
      required: [ok, totpEnabled]
      properties:
        ok: { type: boolean }
        totpEnabled: { type: boolean }
    Message:
      type: object
      properties:
        id: { type: string }
        direction:
          type: string
          enum: [inbound, outbound]
        channel:
          type: string
          enum: [sms, mms, whatsapp]
        status:
          type: string
          enum:
            [queued, sending, sent, failed, delivered, undelivered, cancelled]
        origin: { type: string, nullable: true }
        destination: { type: string, nullable: true }
        contactKey: { type: string, nullable: true }
        message: { type: string, nullable: true }
        segments: { type: integer }
        cost: { type: string }
        batchId: { type: string, nullable: true }
        carrierMessageId: { type: string, nullable: true }
        scheduledFor:
          type: string
          format: date-time
          nullable: true
        expiresAt:
          type: string
          format: date-time
          nullable: true
        subject: { type: string, nullable: true }
        shortLinkCount:
          type: integer
          description: >
            Number of short links attached to this message (present on list and
            detail). Use to gate lazy short-link UI without an extra request.
        attachments:
          type: array
          description: >
            Present for MMS. List endpoints omit `dataBase64`; detail includes it.
          items:
            type: object
            required: [name, contentType]
            properties:
              name: { type: string }
              contentType: { type: string }
              dataBase64: { type: string }
        billedFromQuota: { type: boolean }
        createdByUserId: { type: string, nullable: true }
        createdByApiKeyId: { type: string, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        deliveredAt:
          type: string
          format: date-time
          nullable: true
        errorCode:
          type: string
          nullable: true
          description: Carrier failure code from DLR when known
        errorMessage: { type: string, nullable: true }
    CancelMessageResult:
      allOf:
        - $ref: '#/components/schemas/Message'
        - type: object
          required: [refundAmount, refundCurrency, quotaSegmentsRestored]
          properties:
            refundAmount:
              type: string
              description: Prepaid credit refunded (0 if none)
            refundCurrency: { type: string }
            quotaSegmentsRestored:
              type: integer
              description: Plan segments restored to the subscription
    CancelBatchResult:
      type: object
      required:
        [
          batchId,
          cancelled,
          failed,
          summary,
          refundAmount,
          refundCurrency,
          quotaSegmentsRestored,
        ]
      properties:
        batchId: { type: string }
        cancelled: { type: integer }
        failed:
          type: array
          items:
            type: object
            required: [id, error]
            properties:
              id: { type: string }
              error: { type: string }
        summary:
          $ref: '#/components/schemas/BatchSummary'
        refundAmount: { type: string }
        refundCurrency: { type: string }
        quotaSegmentsRestored: { type: integer }
