openapi: 3.1.0

# ==============================================================================
# This file is the contract.
#
# A /v1 handler that disagrees with what is written here is a bug in the handler,
# not documentation that fell behind. The contract test in docs/scripts/ runs
# every example below against a live stack and fails CI when they diverge.
#
# Naming rules, decided once (API_DOCS_PRODUCTION_PLAN.md section 2):
#   - Request and response bodies are camelCase. /v1 is the public surface;
#     the services behind it keep snake_case and the edge translates.
#   - error.code is stable forever. error.message is not - never branch on it.
#   - Breaking changes go to /v2. /v1 only ever gains optional fields.
# ==============================================================================

info:
  title: Selanim Bulk SMS API
  version: "1.0.0"
  summary: Send SMS to Tanzanian networks from your own application.
  description: |
    The Selanim Bulk SMS API sends messages, reports what happened to them, and
    tells you what it cost.

    Every request is authenticated with an API key scoped to one account. Keys
    are created in the portal under **Developer API** and come in two flavours:

    | Prefix       | Behaviour                                                       |
    |--------------|-----------------------------------------------------------------|
    | `sbs_live_`  | Sends real messages and debits your wallet.                     |
    | `sbs_test_`  | Validates the request and simulates delivery. Nothing is billed.|

    Start with a test key. It exercises the whole path - validation, sender ID
    checks, delivery webhooks - without spending anything.
  contact:
    name: Selanim Support
    url: https://selanimsms.co.tz/support
  license:
    name: Proprietary

servers:
  - url: https://api.selanimsms.co.tz/v1
    description: Production
  - url: http://localhost:8081/v1
    description: Local stack (docker compose)

security:
  - bearerAuth: []

tags:
  - name: Messages
    description: Sending, and finding out what happened afterwards.
  - name: WhatsApp
    description: >-
      Two-way WhatsApp: sending, and pulling the messages your customers sent
      you. Meta itself has no pull API — it pushes to a webhook and keeps
      nothing — so GET /whatsapp/messages serves our stored copy, which is what
      makes it pollable, re-readable and resumable after an outage.
  - name: Wallet
    description: Balance and the ledger behind it.
  - name: Sender IDs
    description: The names your recipients see.
  - name: Contacts
    description: The address book you send to.
  - name: Groups
    description: Audiences built from contacts.
  - name: Webhooks
    description: Delivery events pushed to your endpoint.
  - name: Keys
    description: Managing the key you are calling with.

paths:
  # ---------------------------------------------------------------- messages --
  /sms/send:
    post:
      operationId: sendSms
      tags: [Messages]
      summary: Send a message
      description: |
        Queues one message per recipient and returns immediately. Delivery is
        asynchronous - subscribe to `message.delivered` and `message.failed`
        webhooks, or poll `GET /sms/{id}`.

        Billing is per **segment per recipient**, not per request. A 200
        character message to two people costs four segments. If your balance
        cannot cover the whole batch, nothing is sent and the call returns
        `insufficient_funds` - there is no partial send.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendSmsRequest'
            examples:
              simple:
                summary: One message, two recipients
                value:
                  senderId: SELANIM
                  to: ["255712345678", "0754112233"]
                  message: "Dear parent, Term 3 fees are due on 25th Aug."
                  reference: term3-fees
              personalised:
                summary: A different body per recipient
                value:
                  senderId: SELANIM
                  message: "Hello {{name}}, your balance is {{amount}}."
                  to:
                    - phone: "255712345678"
                      variables: { name: Rehema, amount: "TZS 45,000" }
                    - phone: "255754112233"
                      variables: { name: Baraka, amount: "TZS 12,500" }
              scheduled:
                summary: Send later
                value:
                  senderId: SELANIM
                  to: ["255712345678"]
                  message: "Your appointment is tomorrow at 10:00."
                  scheduleAt: "2026-09-01T07:00:00Z"
      responses:
        '202':
          description: Accepted and queued.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendSmsResponse'
              example:
                requestId: REQ-90022
                parts: 1
                cost: 30
                currency: TZS
                reference: term3-fees
                messages:
                  - id: SM-4a91c0
                    to: "+255712345678"
                    network: Vodacom
                    status: queued
                  - id: SM-4a91c1
                    to: "+255754112233"
                    network: Vodacom
                    status: queued
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /sms:
    get:
      operationId: listSms
      tags: [Messages]
      summary: List messages
      description: Your message log, newest first.
      parameters:
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/MessageStatus' }
        - name: reference
          in: query
          description: Filter to the messages you tagged with this reference.
          schema: { type: string }
        - name: from
          in: query
          description: ISO 8601 timestamp. Messages created at or after this.
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: ISO 8601 timestamp. Messages created at or before this.
          schema: { type: string, format: date-time }
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of messages.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Message' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /sms/{messageId}:
    get:
      operationId: getSms
      tags: [Messages]
      summary: Fetch one message
      parameters:
        - name: messageId
          in: path
          required: true
          schema: { type: string }
          example: SM-4a91c0
      responses:
        '200':
          description: The message.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------- whatsapp --
  #
  # Two rules of this channel shape everything below, and both cause failures
  # that are otherwise hard to diagnose:
  #
  #   The 24-hour window. A customer writing to you opens it. Inside it you may
  #   send anything; outside it WhatsApp carries only a template Meta has
  #   approved. Sending `text` outside the window returns 409 — the API refuses
  #   rather than letting Meta reject it, because a rejected send still counts
  #   against your number's quality rating.
  #
  #   Templates are approved by Meta, not by us. One sits pending for minutes to
  #   days, and nothing on this platform can hurry it.
  /whatsapp/messages:
    post:
      operationId: sendWhatsApp
      tags: [WhatsApp]
      summary: Send a WhatsApp message
      description: >-
        Sends one message. Give `text` to reply inside an open 24-hour window, or
        `template` to reach somebody outside one. Answers 202 — accepted and
        queued, not yet delivered.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WhatsAppSendRequest' }
      responses:
        '202':
          description: Accepted and queued.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WhatsAppSendResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }

    get:
      operationId: pullWhatsApp
      tags: [WhatsApp]
      summary: Pull messages
      description: >-
        The inbound feed. Call it with no cursor, process `data`, then keep
        calling with `nextCursor` while `hasMore` is true. Storing that cursor is
        your entire replication state.


        Paging is by cursor rather than page number on purpose. An inbox gains
        rows at the top constantly, so with an offset your page 2 shifts between
        polls — you would see some messages twice and silently miss others. A
        cursor names a position in the data: repeating a request returns the same
        rows, and resuming after a two-day outage picks up exactly where you
        stopped.
      parameters:
        - name: cursor
          in: query
          description: >-
            The `nextCursor` from your previous call. Opaque: send it back
            verbatim and do not parse it. Omit to start from the newest message.
          schema: { type: string }
        - name: direction
          in: query
          description: >-
            `inbound` (the default) is what customers sent you, `outbound` is what
            you sent, `all` returns both in one stream.
          schema:
            type: string
            enum: [inbound, outbound, all]
            default: inbound
        - name: since
          in: query
          description: RFC3339 timestamp. Only messages at or after this.
          schema: { type: string, format: date-time }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: A page of messages.
          content:
            application/json:
              schema:
                type: object
                required: [data, hasMore]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WhatsAppMessage' }
                  nextCursor:
                    type: string
                    description: Pass this back to fetch the next page.
                  hasMore:
                    type: boolean
                    description: >-
                      Whether more messages remain. False means you are caught up,
                      so there is no need for one more empty call to find out.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /whatsapp/messages/{messageId}:
    get:
      operationId: getWhatsAppMessage
      tags: [WhatsApp]
      summary: Fetch one WhatsApp message
      parameters:
        - name: messageId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The message.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WhatsAppMessage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /whatsapp/conversations:
    get:
      operationId: listWhatsAppConversations
      tags: [WhatsApp]
      summary: List conversations
      description: >-
        One thread per customer. `windowOpen` and `windowRemainingSeconds` are the
        fields to build on — they tell you whether you may reply freely or must
        use an approved template, which is the rule most likely to make a first
        integration fail in a way that is hard to explain.
      parameters:
        - name: state
          in: query
          schema:
            type: string
            enum: [open, closed, archived]
        - name: unread
          in: query
          description: Only threads with unread messages.
          schema: { type: boolean }
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of conversations.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WhatsAppConversation' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  # ------------------------------------------------------------------ wallet --
  /wallet/balance:
    get:
      operationId: getBalance
      tags: [Wallet]
      summary: Current balance
      description: |
        Balance is held in SMS credits, not currency. One credit pays for one
        segment to one recipient.
      responses:
        '200':
          description: Your balance.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Balance' }
              example:
                balance: 12480
                currency: TZS
                lowBalanceThreshold: 500
        '401': { $ref: '#/components/responses/Unauthorized' }

  /wallet/transactions:
    get:
      operationId: listTransactions
      tags: [Wallet]
      summary: Ledger entries
      description: |
        Append-only. Every credit and every debit, in the order it happened.
      parameters:
        - name: type
          in: query
          schema:
            type: string
            enum: [topup, sms_charge, refund, adjustment]
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of ledger entries.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Transaction' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  # -------------------------------------------------------------- sender ids --
  /sender-ids:
    get:
      operationId: listSenderIds
      tags: [Sender IDs]
      summary: List sender IDs
      description: |
        Your own sender IDs plus the shared platform names every account may
        send under. Only `approved` ones can be used on `POST /sms/send`.
      responses:
        '200':
          description: Your sender IDs.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/SenderId' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: requestSenderId
      tags: [Sender IDs]
      summary: Request a sender ID
      description: |
        Creates a request in `pending`. Approval is manual and involves the
        operator, so allow a few working days. Keep sending under an approved
        name in the meantime.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, purpose]
              properties:
                name:
                  type: string
                  maxLength: 11
                  pattern: '^[A-Za-z0-9 ]{3,11}$'
                  description: What recipients will see. Up to 11 characters.
                  example: SHULEYETU
                purpose:
                  type: string
                  description: What you will send under it. The operator reads this.
                  example: Fee reminders and results notifications for parents.
                sampleMessage:
                  type: string
                  example: "Dear parent, Term 3 results are out."
      responses:
        '201':
          description: Request created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SenderId' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  # ---------------------------------------------------------------- contacts --
  /contacts:
    get:
      operationId: listContacts
      tags: [Contacts]
      summary: List contacts
      parameters:
        - name: search
          in: query
          description: Matches name, phone or email.
          schema: { type: string }
        - name: groupId
          in: query
          schema: { type: string, format: uuid }
        - name: subscribed
          in: query
          schema: { type: boolean }
        - $ref: '#/components/parameters/Page'
        - $ref: '#/components/parameters/PageSize'
      responses:
        '200':
          description: A page of contacts.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Contact' }
                  meta: { $ref: '#/components/schemas/PageMeta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createContact
      tags: [Contacts]
      summary: Create a contact
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContactWrite' }
            example:
              phone: "0713111321"
              firstName: Rehema
              lastName: Mwakalinga
              customFields: { class: "Form 4", balance: "45000" }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contact' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  /contacts/{contactId}:
    parameters:
      - name: contactId
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: getContact
      tags: [Contacts]
      summary: Fetch one contact
      responses:
        '200':
          description: The contact.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contact' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: updateContact
      tags: [Contacts]
      summary: Update a contact
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContactWrite' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Contact' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
    delete:
      operationId: deleteContact
      tags: [Contacts]
      summary: Delete a contact
      responses:
        '204': { description: Deleted. }
        '404': { $ref: '#/components/responses/NotFound' }

  /contacts/import:
    post:
      operationId: importContacts
      tags: [Contacts]
      summary: Import contacts in bulk
      description: |
        Up to 50,000 contacts per call. Numbers are normalised before the
        duplicate check, so `0713111321` and `+255 713 111 321` collide.

        The response reports every rejected row with a reason rather than
        failing the whole batch - a spreadsheet with four bad numbers in five
        thousand should still import.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contacts]
              properties:
                contacts:
                  type: array
                  maxItems: 50000
                  items: { $ref: '#/components/schemas/ContactWrite' }
                groupId:
                  type: string
                  format: uuid
                  description: Add every imported contact to this group.
                skipDuplicates:
                  type: boolean
                  default: true
                  description: When false, an existing number is updated instead of skipped.
      responses:
        '200':
          description: Import report.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportReport' }
              example:
                imported: 4821
                updated: 0
                skipped: 175
                rejected:
                  - row: 12
                    phone: "07131"
                    reason: invalid_phone_number
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ------------------------------------------------------------------ groups --
  /groups:
    get:
      operationId: listGroups
      tags: [Groups]
      summary: List groups
      responses:
        '200':
          description: Your groups.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Group' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createGroup
      tags: [Groups]
      summary: Create a group
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, example: Form 4 parents }
                description: { type: string }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Group' }
        '409': { $ref: '#/components/responses/Conflict' }

  /groups/{groupId}:
    parameters:
      - name: groupId
        in: path
        required: true
        schema: { type: string, format: uuid }
    delete:
      operationId: deleteGroup
      tags: [Groups]
      summary: Delete a group
      description: Deletes the group. The contacts in it are not deleted.
      responses:
        '204': { description: Deleted. }
        '404': { $ref: '#/components/responses/NotFound' }

  # ---------------------------------------------------------------- webhooks --
  /webhooks:
    get:
      operationId: getWebhook
      tags: [Webhooks]
      summary: Read your webhook endpoint
      responses:
        '200':
          description: The configured endpoint.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Webhook' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: setWebhook
      tags: [Webhooks]
      summary: Set your webhook endpoint
      description: |
        Creates the endpoint on first call and replaces it afterwards. The
        signing secret is returned **once**, on the call that creates it.

        Verify every request: recompute
        `HMAC-SHA256(secret, "{timestamp}.{rawBody}")` where the timestamp is the
        `X-SBS-Timestamp` header, and compare with `X-SBS-Signature` in constant
        time. Reject anything whose timestamp is more than five minutes old.

        Lost the secret? Send `rotateSecret: true` — there is no endpoint that
        can read it back.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  description: Must be https. Must answer within 10 seconds.
                  example: https://brightschool.co.tz/hooks/sbs-delivery
                events:
                  type: array
                  items: { $ref: '#/components/schemas/WebhookEvent' }
                  example: ["message.delivered", "message.failed"]
                enabled:
                  type: boolean
                  default: true
                rotateSecret:
                  type: boolean
                  default: false
                  description: |
                    Reissue the signing secret and return it. Off by default: a
                    call that only changes the URL must not change the secret
                    you are already verifying with. This is the only way to
                    recover from a lost secret.
      responses:
        '200':
          description: Saved.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Webhook' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  # -------------------------------------------------------------------- keys --
  /keys/rotate:
    post:
      operationId: rotateKey
      tags: [Keys]
      summary: Rotate the key you are calling with
      description: |
        Issues a new key and revokes the current one **immediately**. There is
        no overlap window, so deploy the new value everywhere it is used before
        you rely on it.

        The new key is returned once. It is not stored anywhere you can read it
        back.
      responses:
        '200':
          description: Rotated.
          content:
            application/json:
              schema:
                type: object
                required: [apiKey, rotatedAt]
                properties:
                  apiKey:
                    type: string
                    example: sbs_live_9d1e4c8b2a7f6e3d0c9b8a7f6e5d
                  rotatedAt:
                    type: string
                    format: date-time
        '401': { $ref: '#/components/responses/Unauthorized' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer sbs_live_...`

        `X-API-Key: sbs_live_...` is accepted as well, for callers behind a
        proxy that strips `Authorization`.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Replay protection. Send the same key with the same body within 24 hours
        and you get the original response instead of a second send - the safe
        way to retry after a timeout. The same key with a *different* body is a
        `409`.
      schema: { type: string, maxLength: 255 }
      example: order-4471-confirmation
    Page:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    PageSize:
      name: pageSize
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 25 }

  headers:
    RateLimitLimit:
      description: Requests allowed in the current window.
      schema: { type: integer }
    RateLimitRemaining:
      description: Requests left in the current window.
      schema: { type: integer }
    RateLimitReset:
      description: Unix timestamp when the window resets.
      schema: { type: integer }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer }

  schemas:
    MessageStatus:
      type: string
      enum: [queued, sent, delivered, failed, expired, rejected]
      description: |
        | Status | Meaning |
        |---|---|
        | `queued` | Accepted, waiting for the operator route. |
        | `sent` | Handed to the operator, awaiting a delivery receipt. |
        | `delivered` | Confirmed delivered to the handset. |
        | `failed` | Operator rejected it - unreachable, barred or invalid. |
        | `expired` | Validity window passed before the handset came online. |
        | `rejected` | Blocked before dispatch - sender ID or content policy. |

    Recipient:
      oneOf:
        - type: string
          description: A phone number, local or E.164.
          example: "255712345678"
        - type: object
          required: [phone]
          properties:
            phone: { type: string, example: "255712345678" }
            variables:
              type: object
              additionalProperties: { type: string }
              description: Substituted into `{{placeholders}}` in the message body.

    SendSmsRequest:
      type: object
      required: [senderId, to, message]
      properties:
        senderId:
          type: string
          description: An approved sender ID on your account.
          example: SELANIM
        to:
          type: array
          minItems: 1
          maxItems: 1000
          items: { $ref: '#/components/schemas/Recipient' }
          description: |
            Up to 1,000 recipients per request. For larger audiences, import
            them into a group and send from the portal.
        message:
          type: string
          description: |
            GSM-7 and UCS-2 are both supported; the segment count is worked out
            for you. A message longer than 10 segments is rejected.
          example: "Dear parent, Term 3 fees are due on 25th Aug."
        scheduleAt:
          type: string
          format: date-time
          description: ISO 8601. Omit to send immediately.
        reference:
          type: string
          description: Your own identifier, echoed on every delivery webhook.
          maxLength: 120
        callbackUrl:
          type: string
          format: uri
          description: Overrides the account webhook for this request only.

    SendSmsResponse:
      type: object
      required: [requestId, parts, cost, currency, messages]
      properties:
        requestId: { type: string, example: REQ-90022 }
        parts:
          type: integer
          description: Segments per recipient for this body.
        cost:
          type: integer
          description: Credits debited for the whole request.
        currency: { type: string, example: TZS }
        reference: { type: string }
        messages:
          type: array
          items: { $ref: '#/components/schemas/Message' }

    Message:
      type: object
      required: [id, to, status]
      properties:
        id: { type: string, example: SM-4a91c0 }
        to: { type: string, example: "+255712345678" }
        senderId: { type: string, example: SELANIM }
        message: { type: string }
        network: { type: string, example: Vodacom }
        status: { $ref: '#/components/schemas/MessageStatus' }
        parts: { type: integer }
        cost: { type: integer }
        reference: { type: string }
        failureReason:
          type: string
          description: Present on `failed` and `rejected`.
        createdAt: { type: string, format: date-time }
        deliveredAt: { type: string, format: date-time }

    Balance:
      type: object
      required: [balance, currency]
      properties:
        balance:
          type: integer
          description: Credits remaining. One credit is one segment to one recipient.
        currency: { type: string, example: TZS }
        lowBalanceThreshold:
          type: integer
          description: Below this, a `wallet.low_balance` event fires.

    Transaction:
      type: object
      required: [id, type, amount, balanceAfter, createdAt]
      properties:
        id: { type: string, format: uuid }
        type:
          type: string
          enum: [topup, sms_charge, refund, adjustment]
        amount:
          type: integer
          description: Positive for credits in, negative for credits out.
        balanceAfter: { type: integer }
        description: { type: string }
        reference: { type: string }
        createdAt: { type: string, format: date-time }

    SenderId:
      type: object
      required: [id, name, status]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, example: SELANIM }
        status:
          type: string
          enum: [pending, approved, rejected]
        shared:
          type: boolean
          description: A platform name available to every account.
        purpose: { type: string }
        rejectionReason: { type: string }
        createdAt: { type: string, format: date-time }

    ContactWrite:
      type: object
      required: [phone]
      properties:
        phone: { type: string, example: "0713111321" }
        firstName: { type: string }
        lastName: { type: string }
        email: { type: string, format: email }
        notes: { type: string }
        customFields:
          type: object
          additionalProperties: { type: string }
          description: Read by `{{placeholders}}` when you personalise a send.
        groupIds:
          type: array
          items: { type: string, format: uuid }

    Contact:
      allOf:
        - $ref: '#/components/schemas/ContactWrite'
        - type: object
          required: [id, phone, isSubscribed, createdAt]
          properties:
            id: { type: string, format: uuid }
            network: { type: string, example: Vodacom }
            isSubscribed:
              type: boolean
              description: False once they have opted out. You may not message them.
            source:
              type: string
              enum: [manual, import, api, campaign]
            createdAt: { type: string, format: date-time }

    Group:
      type: object
      required: [id, name, contactCount]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: string }
        contactCount: { type: integer }
        createdAt: { type: string, format: date-time }

    ImportReport:
      type: object
      required: [imported, updated, skipped, rejected]
      properties:
        imported: { type: integer }
        updated: { type: integer }
        skipped:
          type: integer
          description: Numbers already in your address book.
        rejected:
          type: array
          items:
            type: object
            properties:
              row: { type: integer }
              phone: { type: string }
              reason: { type: string, example: invalid_phone_number }

    WebhookEvent:
      type: string
      description: >-
        The set a client may subscribe to. It matches `AllWebhookEvents` in
        notification_service, which is what a `PUT /webhooks` is validated
        against — an event missing from here was a subscription the docs said
        would be rejected and the API accepted.
      enum:
        - message.sent
        - message.delivered
        - message.failed
        - message.expired
        - campaign.completed
        - wallet.low_balance
        # WhatsApp events are separate from message.* rather than folded into it:
        # message.* is outbound only, and whatsapp.received is the first event
        # this API pushes that the client did not initiate.
        - whatsapp.received
        - whatsapp.status

    Webhook:
      type: object
      required: [url, events, enabled]
      properties:
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: '#/components/schemas/WebhookEvent' }
        enabled: { type: boolean }
        secret:
          type: string
          description: |
            Returned **only** on the call that creates the endpoint. Store it -
            it cannot be read back.
        createdAt: { type: string, format: date-time }

    WhatsAppSendRequest:
      type: object
      required: [to]
      description: >-
        Exactly one of `text`, `template` or `mediaUrl`. There is no "message
        type" field: whether a message goes free-form or as a template is decided
        by whether the customer's 24-hour window is open, not by the caller, and
        asserting otherwise would only produce a rejection from Meta a second
        later.
      properties:
        to:
          type: string
          description: E.164, with or without the leading plus.
          example: '+255712345678'
        text:
          type: string
          description: >-
            A free-form message. Only deliverable inside an open 24-hour window;
            outside one this returns 409.
        template:
          type: string
          description: >-
            The name of a template Meta has approved. The only thing WhatsApp
            carries outside an open window.
          example: order_ready
        templateLanguage:
          type: string
          example: sw
        templateParams:
          type: array
          items: { type: string }
          description: >-
            Values for the template's {{1}}, {{2}} … placeholders, in order. The
            count must match the approved template EXACTLY — Meta rejects the
            whole message otherwise rather than ignoring extras.
        mediaUrl:
          type: string
          format: uri
          description: >-
            A publicly reachable URL. Meta fetches it directly, so a link only
            your own network can resolve will fail.
        mediaType:
          type: string
          enum: [image, video, audio, document]
        mediaCaption: { type: string }
        reference:
          type: string
          description: Your own identifier, returned on every event about this message.

    WhatsAppSendResult:
      type: object
      required: [id, to, status]
      properties:
        id: { type: string }
        to: { type: string }
        status:
          type: string
          description: '`queued` on acceptance. Delivery is reported separately.'
        cost:
          type: integer
          format: int64
          description: Credits charged for this message.
        reference: { type: string }

    WhatsAppMessage:
      type: object
      required: [id, direction, type, status]
      properties:
        id: { type: string }
        conversationId: { type: string }
        whatsappId:
          type: string
          description: Meta's own message id (wamid), once it has one.
        direction:
          type: string
          enum: [inbound, outbound]
        from: { type: string }
        to: { type: string }
        type:
          type: string
          description: >-
            text, template, image, video, audio, document, location, interactive,
            button, reaction — or `unsupported` for a type this API does not model
            yet. An unrecognised type still appears here rather than being
            dropped, so a customer's turn is never lost.
        body: { type: string }
        mediaId:
          type: string
          description: >-
            Our stored copy of an attachment. Meta keeps inbound media about 30
            days; this does not expire.
        mediaType: { type: string }
        template: { type: string }
        status:
          type: string
          enum: [queued, sent, failed, received]
        deliveryStatus:
          type: string
          enum: [sent, delivered, read, failed]
          description: >-
            What the handset told Meta. Note `read`, which SMS has no equivalent
            of.
        failureReason: { type: string }
        cost: { type: integer, format: int64 }
        reference: { type: string }
        createdAt: { type: string, format: date-time }
        sentAt: { type: string, format: date-time }
        receivedAt: { type: string, format: date-time }
        deliveredAt: { type: string, format: date-time }
        readAt: { type: string, format: date-time }

    WhatsAppConversation:
      type: object
      required: [id, contact, windowOpen]
      properties:
        id: { type: string }
        contact: { type: string, example: '+255712345678' }
        contactName:
          type: string
          description: The customer's WhatsApp profile name, when they have one set.
        lastMessage: { type: string }
        lastMessageAt: { type: string, format: date-time }
        unreadCount: { type: integer }
        state:
          type: string
          enum: [open, closed, archived]
        windowOpen:
          type: boolean
          description: >-
            Whether you may still reply freely. False means only an approved
            template will be delivered.
        windowRemainingSeconds:
          type: integer
          description: >-
            How long is left to reply freely, zero once it has closed. Computed
            against the server clock on every read — the window shuts by the
            passage of time, so a value cached client-side goes stale.

    Error:
      type: object
      required: [error, requestId]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: |
                Stable machine-readable identifier. Branch on this, never on
                `message`.
            message:
              type: string
              description: Human-readable. Wording may change at any time.
            field:
              type: string
              description: Which request field caused it, when that is knowable.
        requestId:
          type: string
          description: Quote this to support. It is how a call is found in the logs.

    PageMeta:
      type: object
      required: [page, pageSize, total]
      properties:
        page: { type: integer }
        pageSize: { type: integer }
        total: { type: integer }
        totalPages: { type: integer }

  responses:
    BadRequest:
      description: Malformed JSON, or a failed validation rule.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: recipients_exceeded
              message: "1,240 recipients in one call; the limit is 1,000."
              field: to
            requestId: req_01J8XK2M4Q
    Unauthorized:
      description: Missing, malformed or revoked API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: invalid_api_key
              message: That API key is not valid.
            requestId: req_01J8XK2M4Q
    PaymentRequired:
      description: Wallet balance is below the cost of the send.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: insufficient_funds
              message: "This send needs 1197 credits; your balance is 982."
            requestId: req_01J8XK2M4Q
    Forbidden:
      description: |
        Valid key, but not permitted - missing scope, IP outside your allowlist,
        or a suspended account.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: ip_not_allowed
              message: Requests from 41.59.x.x are not on your allowlist.
            requestId: req_01J8XK2M4Q
    NotFound:
      description: No such resource on your account.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: |
        A duplicate `Idempotency-Key` replayed with a different body, or a
        resource that already exists.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: idempotency_key_reused
              message: That Idempotency-Key was used with a different request body.
            requestId: req_01J8XK2M4Q
    Unprocessable:
      description: Well-formed but semantically invalid.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: sender_id_not_approved
              message: "Sender ID 'SHULEYETU' is pending review."
              field: senderId
            requestId: req_01J8XK2M4Q
    RateLimited:
      description: Too many requests in the window.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        X-RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: rate_limited
              message: 60 requests per minute exceeded. Retry in 12 seconds.
            requestId: req_01J8XK2M4Q
