openapi: "3.0.3"

info:
  title: SureConnect SMS API
  version: "1.0.0"
  description: |
    REST API for sending SMS messages through SureConnect.

    ## Authentication
    Every request must include the `X-API-Key` header set to your API key.

    API keys have the format `sc_` followed by 50 lowercase hex characters (53 chars
    total). Keys are generated and revoked from your account dashboard.

    ## Rate limiting
    Each API key is limited to **60 requests per 60-second rolling window**.
    Exceeding the limit returns HTTP `429` with a `retry_after` field.

    ## Credits
    One credit sends one SMS dispatch. Credits are reserved atomically before each
    send and are refunded immediately on gateway failure.

    ## Phone numbers
    All Kenyan formats are accepted and normalised to E.164 (`254XXXXXXXXX`):
    `07XX`, `01XX`, `+254XX`, `254XX`.

servers:
  - url: https://api.sms.ultimatesolutions.co.ke/v1
    description: Production

security:
  - ApiKeyHeader: []

tags:
  - name: Account
    description: Balance and quota information
  - name: SMS
    description: Send individual and bulk SMS messages

# ─────────────────────────────────────────────────────────────────────────────
# Paths
# ─────────────────────────────────────────────────────────────────────────────

paths:

  /sms/balance:
    get:
      operationId: getBalance
      summary: Get credit balance
      description: >
        Returns the authenticated user's SMS credit balance, total quota, and
        credits consumed. No credits are spent on this call.
      tags: [Account]
      security:
        - ApiKeyHeader: []
      responses:
        "200":
          description: Balance retrieved successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BalanceResponse"
              example:
                success: true
                balance: 850
                quota: 1000
                used: 150
        "401":
          $ref: "#/components/responses/Unauthorized"
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          $ref: "#/components/responses/RateLimited"

  /sms/send:
    post:
      operationId: sendSingle
      summary: Send a single SMS
      description: |
        Sends an SMS to one recipient. The call is **synchronous** — it waits for
        the gateway response before returning. One credit is reserved before the
        gateway call and is refunded automatically if the gateway rejects the message.

        ### Message length
        | Characters | SMS parts |
        |---|---|
        | 1 – 160 | 1 |
        | 161 – 306 | 2 |
        | 307 – 459 | 3 |
        | 460 – 612 | 4 |
        | 613 – 800 | 5 |
      tags: [SMS]
      security:
        - ApiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendRequest"
            examples:
              basic:
                summary: Plain text message
                value:
                  to: "0712345678"
                  message: "Hello from SureConnect!"
              otp:
                summary: One-time password
                value:
                  to: "0712345678"
                  message: "Your OTP is 482910. Valid for 5 minutes. Do not share."
              international:
                summary: E.164 number
                value:
                  to: "+254712345678"
                  message: "Your invoice is ready to view."
      responses:
        "200":
          description: Message dispatched successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendResponse"
              example:
                success: true
                message_id: "TXT-a1b2c3d4"
                recipient: "254712345678"
                credits_used: 1
                balance: 849
        "400":
          description: Missing or invalid request field
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                missingTo:
                  summary: "to field absent"
                  value:
                    success: false
                    error: "Missing required field: to"
                missingMessage:
                  summary: "message field absent"
                  value:
                    success: false
                    error: "Missing required field: message"
                messageTooLong:
                  summary: Message exceeds 800 characters
                  value:
                    success: false
                    error: "Message too long — maximum 800 characters (5 SMS parts)."
                invalidPhone:
                  summary: Unparseable phone number
                  value:
                    success: false
                    error: "Invalid phone number: 12345. Use format 07XXXXXXXX or 2547XXXXXXXX."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Insufficient SMS credits
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InsufficientCreditsError"
              example:
                success: false
                error: "Insufficient SMS credits. Top up your balance to continue sending."
                balance: 0
        "403":
          description: Account not permitted to send SMS
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                success: false
                error: "Super admins do not have personal SMS credits. Use a regular user account."
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "422":
          description: Gateway rejected the message. Credit is refunded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                success: false
                error: "Gateway rejected the message: Invalid mobile number [1003]"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  /sms/bulk:
    post:
      operationId: sendBulk
      summary: Send bulk SMS
      description: |
        Sends the same message to multiple recipients. The request is processed
        **asynchronously** — credits are reserved and the job is enqueued, then
        the endpoint returns immediately with a `job_id`. Your server is never
        blocked waiting for gateway responses.

        HTTP `202 Accepted` confirms the job is queued. Messages are dispatched
        in the background by the queue worker.

        ### Mode A — explicit recipient list
        Provide a `to` array of up to **10,000** phone numbers.
        Duplicates are deduplicated automatically.

        ### Mode B — contact group
        Provide a `group_id`. The group must be owned by or assigned to your account.
        Messages are sent to all contacts in the group.

        If both `to` and `group_id` are present, Mode A takes precedence.
      tags: [SMS]
      security:
        - ApiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/BulkRequestExplicit"
                - $ref: "#/components/schemas/BulkRequestGroup"
            examples:
              modeA:
                summary: "Mode A — phone list"
                value:
                  to:
                    - "0712345678"
                    - "0723456789"
                    - "254733000111"
                  message: "Flash sale — 30% off today only!"
                  campaign: "Flash Sale 23 Jun"
              modeB:
                summary: "Mode B — contact group"
                value:
                  group_id: 5
                  message: "Your appointment is confirmed for Friday at 10am."
                  campaign: "Appointment Confirmations"
              modeANoCampaign:
                summary: "Mode A — no campaign label"
                value:
                  to:
                    - "0712345678"
                    - "0723456789"
                  message: "Your monthly invoice is ready."
      responses:
        "202":
          description: Job enqueued successfully
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkResponse"
              example:
                success: true
                job_id: 42
                queued: 1500
                campaign: "Flash Sale 23 Jun"
                credits_reserved: 1500
                balance: 8500
        "400":
          description: Missing or invalid request field
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                tooManyRecipients:
                  summary: Exceeds 10,000 limit
                  value:
                    success: false
                    error: "Too many recipients — maximum 10 000 per request. Split into batches."
                noInput:
                  summary: Neither to nor group_id provided
                  value:
                    success: false
                    error: "Provide either \"to\" (array of phone numbers) or \"group_id\"."
                noValidPhones:
                  summary: All numbers failed to parse
                  value:
                    success: false
                    error: "No valid phone numbers found after formatting."
                emptyGroup:
                  summary: Group has no contacts
                  value:
                    success: false
                    error: "Group 5 has no contacts."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Insufficient credits for all recipients
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BulkInsufficientCreditsError"
              example:
                success: false
                error: "Insufficient credits — you need 1500 but have 200. Top up your balance."
                balance: 200
                needed: 1500
        "403":
          description: Access denied
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              examples:
                superAdmin:
                  summary: Super admin account
                  value:
                    success: false
                    error: "Super admins do not have personal SMS credits. Use a regular user account."
                groupDenied:
                  summary: Group not accessible
                  value:
                    success: false
                    error: "You do not have access to group 5."
        "405":
          $ref: "#/components/responses/MethodNotAllowed"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: Queue insert failed. Credits are refunded automatically.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
              example:
                success: false
                error: "Failed to queue the job. Please try again."

# ─────────────────────────────────────────────────────────────────────────────
# Components
# ─────────────────────────────────────────────────────────────────────────────

components:

  # ── Security schemes ────────────────────────────────────────────────────────

  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: >
        Set the `X-API-Key` header to your API key (`sc_` + 50 hex characters).

  # ── Reusable responses ──────────────────────────────────────────────────────

  responses:
    Unauthorized:
      description: Authentication failed
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          examples:
            noKey:
              summary: No key provided
              value:
                success: false
                error: "API key required. Send X-API-Key header with your key."
            invalidKey:
              summary: Invalid or revoked key
              value:
                success: false
                error: "Invalid or revoked API key."

    MethodNotAllowed:
      description: Wrong HTTP method for this endpoint
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            success: false
            error: "Method not allowed. Use POST."

    RateLimited:
      description: Rate limit exceeded (60 requests / 60 seconds per key)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/RateLimitError"
          example:
            success: false
            error: "Rate limit exceeded — max 60 requests per 60s."
            retry_after: 47

    InternalError:
      description: Unexpected server-side error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            success: false
            error: "Internal server error"

  # ── Schemas ─────────────────────────────────────────────────────────────────

  schemas:

    # ── Shared ──

    ErrorResponse:
      type: object
      required: [success, error]
      properties:
        success:
          type: boolean
          enum: [false]
          example: false
        error:
          type: string
          description: Human-readable description of the error
          example: "Invalid or revoked API key."

    RateLimitError:
      allOf:
        - $ref: "#/components/schemas/ErrorResponse"
        - type: object
          properties:
            retry_after:
              type: integer
              minimum: 0
              description: Seconds until the rate-limit window resets. Wait this long before retrying.
              example: 47

    InsufficientCreditsError:
      allOf:
        - $ref: "#/components/schemas/ErrorResponse"
        - type: object
          properties:
            balance:
              type: integer
              minimum: 0
              description: Current credit balance
              example: 0

    BulkInsufficientCreditsError:
      allOf:
        - $ref: "#/components/schemas/ErrorResponse"
        - type: object
          properties:
            balance:
              type: integer
              minimum: 0
              description: Current credit balance
              example: 200
            needed:
              type: integer
              minimum: 1
              description: Credits required to send to all recipients
              example: 1500

    # ── Balance ──

    BalanceResponse:
      type: object
      required: [success, balance, quota, used]
      properties:
        success:
          type: boolean
          enum: [true]
          example: true
        balance:
          type: integer
          minimum: 0
          description: "Credits currently available (quota − used)."
          example: 850
        quota:
          type: integer
          minimum: 0
          description: Total credits ever allocated to this account.
          example: 1000
        used:
          type: integer
          minimum: 0
          description: Credits consumed so far.
          example: 150

    # ── Single send ──

    SendRequest:
      type: object
      required: [to, message]
      properties:
        to:
          type: string
          description: >
            Recipient phone number. Accepted formats: `07XXXXXXXX`, `01XXXXXXXX`,
            `+254XXXXXXXXX`, `254XXXXXXXXX`. Normalised to E.164 before dispatch.
          example: "0712345678"
        message:
          type: string
          minLength: 1
          maxLength: 800
          description: >
            Message text. Maximum 800 characters (5 SMS parts). Messages over 160
            characters are sent as linked multi-part SMS.
          example: "Your OTP is 482910. Expires in 5 minutes."

    SendResponse:
      type: object
      required: [success, recipient, credits_used, balance]
      properties:
        success:
          type: boolean
          enum: [true]
          example: true
        message_id:
          type: string
          nullable: true
          description: >
            Gateway-assigned message ID. Use this to query delivery reports.
            `null` if the gateway did not return an ID in its response.
          example: "TXT-a1b2c3d4"
        recipient:
          type: string
          description: Normalised E.164 number the message was dispatched to.
          example: "254712345678"
        credits_used:
          type: integer
          enum: [1]
          description: Always `1` for single sends.
          example: 1
        balance:
          type: integer
          minimum: 0
          description: Remaining credits after this send.
          example: 849

    # ── Bulk send ──

    BulkRequestExplicit:
      type: object
      required: [to, message]
      description: Mode A — send to an explicit list of phone numbers.
      properties:
        to:
          type: array
          minItems: 1
          maxItems: 10000
          items:
            type: string
          description: >
            Array of recipient phone numbers. Accepts any valid Kenyan format.
            Duplicates are removed automatically.
          example:
            - "0712345678"
            - "0723456789"
            - "+254733000111"
        message:
          type: string
          minLength: 1
          maxLength: 800
          description: Message text. Maximum 800 characters.
          example: "Your order has been shipped. Expected delivery: Friday."
        campaign:
          type: string
          description: >
            Optional label for message history and reporting. Pass an empty
            string or omit entirely if not needed.
          example: "Order Dispatch June"

    BulkRequestGroup:
      type: object
      required: [group_id, message]
      description: Mode B — send to all contacts in a saved group.
      properties:
        group_id:
          type: integer
          minimum: 1
          description: >
            ID of a contact group you own or have been assigned. Groups are
            managed in the SureConnect dashboard under Contacts → Groups.
          example: 5
        message:
          type: string
          minLength: 1
          maxLength: 800
          description: Message text. Maximum 800 characters.
          example: "Your monthly statement is ready to view."
        campaign:
          type: string
          description: Optional label for message history.
          example: "June Statements"

    BulkResponse:
      type: object
      required: [success, job_id, queued, credits_reserved, balance]
      properties:
        success:
          type: boolean
          enum: [true]
          example: true
        job_id:
          type: integer
          minimum: 1
          description: >
            Unique job identifier. Retain this for support reference or internal
            tracking. There is no polling endpoint — use the campaign name to
            find messages in the dashboard.
          example: 42
        queued:
          type: integer
          minimum: 0
          description: >
            Number of unique valid phone numbers enqueued. Invalid or duplicate
            numbers are excluded before counting.
          example: 1500
        campaign:
          type: string
          nullable: true
          description: The campaign label you provided, or `null` if none was given.
          example: "Flash Sale 23 Jun"
        credits_reserved:
          type: integer
          minimum: 0
          description: Credits reserved for this job. Equals `queued`.
          example: 1500
        balance:
          type: integer
          minimum: 0
          description: Remaining credits after the reservation.
          example: 8500
