openapi: 3.1.0
info:
  title: BitSMS Public API
  version: 1.0.0
  summary: Send SMS, OTP and track delivery across Bangladeshi operators through one API.
  description: |
    BitSMS is an SMS gateway operated by BitCommerz. One API, multiple upstream
    providers; BitSMS chooses the route, retries, fails over and reports delivery.

    ## Authentication
    Every request carries an API key: `Authorization: Bearer bsk_live_…`.
    Keys are created in the dashboard, shown once, and have **scopes**:

    | Scope | Allows |
    |---|---|
    | `messages:send` | `POST /messages`, `POST /messages/bulk` |
    | `messages:read` | `GET /messages`, `GET /messages/{id}` |
    | `otp` | `POST /otp`, `POST /otp/{otp_id}/verify` |
    | `balance:read` | `GET /balance` |
    | `senders:read` | `GET /sender-ids` |
    | `webhooks:write` | `/webhooks*` |

    `bsk_test_…` keys use the sandbox: nothing is delivered or charged, and the
    sandbox behaves deterministically by the last two digits of the number
    (see the developer guide).

    ## Asynchronous sending
    Send endpoints return `202 Accepted` with a message `id` as soon as the
    message is validated and paid for. Delivery happens asynchronously. Track it
    with `GET /messages/{id}` or a webhook.

    ## Idempotency
    Send endpoints **require** an `Idempotency-Key` header (1–255 chars, e.g. a
    UUID). Retrying with the same key and the same body returns the original
    response (header `Idempotent-Replayed: true`) and never sends twice — this is
    enforced by a database unique constraint. Same key with a different body →
    `409 idempotency_key_reused`. Keys are remembered for **7 days**.

    ## Rate limits
    Per API key (default 50 requests/s, plan-dependent). Every response carries
    `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`. Exceeding
    it returns `429 rate_limited` with `Retry-After` (seconds).

    ## Numbers
    Bangladesh mobile numbers only at launch. Accepted forms: `01712345678`,
    `8801712345678`, `+8801712345678`. Responses always use E.164
    (`+8801712345678`). Anything else → `invalid_destination`.

    ## Message parts and price
    Text containing only GSM-7 characters: 160 chars in one SMS, 153 per part
    when longer. Any other character (all Bangla text, emoji, `৳`) makes the
    whole message Unicode: 70 chars in one SMS, 67 per part. Maximum 10 parts.
    Cost = price per part (masking or non-masking) × parts.

    ## Money
    Amounts are integers in **paisa** (1 BDT = 100 paisa).

    ## Errors
    All errors share one shape: `{"error": {"code", "message", "request_id", "details"}}`.
    `code` values are stable and listed in the `ErrorCode` schema. Never parse `message`.

    ## Stability
    `/v1` is stable. Only additive changes (new endpoints, new optional fields,
    new enum values in fields documented as extensible) are made to v1. Clients
    must ignore unknown fields.
  contact:
    name: BitSMS Support
    url: https://[DOMAIN]/contact
    email: support@[DOMAIN]
  termsOfService: https://[DOMAIN]/terms
servers:
  - url: https://api.[DOMAIN]/v1
    description: Production
  - url: http://localhost:8080/v1
    description: Local development (docker-compose)
security:
  - apiKey: []
tags:
  - name: Messages
  - name: OTP
  - name: Account
  - name: Webhooks
  - name: Tools

paths:
  /messages:
    post:
      tags: [Messages]
      operationId: sendMessage
      summary: Send a single SMS
      description: |
        Validates, prices and debits the message, then queues it. Requires scope
        `messages:send`. The default `class` is `transactional`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendMessageRequest' }
            examples:
              basic:
                value: { to: "01712345678", text: "Your order #5521 has shipped." }
              bangla_masked:
                value: { to: "+8801812345678", text: "আপনার অর্ডার পাঠানো হয়েছে।", sender_id: "BitCommerz", client_ref: "order-5521" }
      responses:
        '202':
          description: Accepted and queued.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            Idempotent-Replayed: { $ref: '#/components/headers/Idempotent-Replayed' }
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '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/TooManyRequests' }
        '503': { $ref: '#/components/responses/Unavailable' }
    get:
      tags: [Messages]
      operationId: listMessages
      summary: List messages
      description: Newest first, cursor-paginated. Requires `messages:read`. Searches within the last 13 months.
      parameters:
        - { name: status, in: query, schema: { $ref: '#/components/schemas/MessageStatus' } }
        - { name: to, in: query, description: Destination in any accepted form., schema: { type: string } }
        - { name: client_ref, in: query, schema: { type: string, maxLength: 128 } }
        - { name: batch_id, in: query, schema: { type: string, format: uuid } }
        - { name: sender_id, in: query, schema: { type: string } }
        - { name: created_from, in: query, schema: { type: string, format: date-time } }
        - { name: created_to, in: query, schema: { type: string, format: date-time } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of messages.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Message' } }
                  next_cursor: { type: [string, 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /messages/bulk:
    post:
      tags: [Messages]
      operationId: sendBulk
      summary: Send one text to many numbers
      description: |
        Up to 10 000 recipients per request. Valid recipients are accepted and
        debited in one transaction; invalid ones are listed in `rejected` and not
        charged. If the balance cannot cover **all** valid recipients the whole
        request fails with `402 insufficient_credit` (nothing is sent).
        Default class `bulk`. May return `503 backpressure` when the bulk queue
        is saturated; retry after `Retry-After`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendBulkRequest' }
      responses:
        '202':
          description: Batch accepted.
          headers:
            X-Request-Id: { $ref: '#/components/headers/X-Request-Id' }
            Idempotent-Replayed: { $ref: '#/components/headers/Idempotent-Replayed' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkResult' }
        '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/TooManyRequests' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /messages/{id}:
    get:
      tags: [Messages]
      operationId: getMessage
      summary: Get a message and its delivery status
      description: Requires `messages:read`. `?include=events` adds the status history.
      parameters:
        - $ref: '#/components/parameters/MessageId'
        - name: include
          in: query
          schema: { type: string, enum: [events] }
      responses:
        '200':
          description: The message.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Message' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /otp:
    post:
      tags: [OTP]
      operationId: sendOtp
      summary: Generate and send a one-time password
      description: |
        BitSMS generates the code, sends it with class `otp` (highest priority),
        stores only a hash, and returns an `otp_id` to verify against. The code
        is never returned. Requires scope `otp`.

        The `template` must contain `{{code}}` once; default
        `"Your {{app_name}} code is {{code}}. Valid for {{ttl_minutes}} minutes."`.

        Limits: 3 OTPs per number per 10 minutes, 10 per number per day (per tenant).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SendOtpRequest' }
      responses:
        '202':
          description: OTP sent (queued).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Otp' }
        '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/TooManyRequests' }

  /otp/{otp_id}/verify:
    post:
      tags: [OTP]
      operationId: verifyOtp
      summary: Verify a code
      description: |
        Returns `200 {"verified": true}` on success. A wrong code returns `200`
        with `verified: false` and `attempts_remaining`. After 5 wrong codes, or
        after expiry, returns `410` with `otp_exhausted` / `otp_expired`. A
        verified OTP cannot be verified again (`410 otp_already_verified`).
      parameters:
        - name: otp_id
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, pattern: '^[0-9]{4,8}$' }
      responses:
        '200':
          description: Verification result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OtpVerifyResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { $ref: '#/components/responses/Gone' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /balance:
    get:
      tags: [Account]
      operationId: getBalance
      summary: Current credit balance and prices
      description: Requires `balance:read`.
      responses:
        '200':
          description: Balance.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Balance' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /sender-ids:
    get:
      tags: [Account]
      operationId: listSenderIds
      summary: Sender IDs this account may use
      description: Approved masking sender IDs and the default non-masking sender. Requires `senders:read`. Requests for new masking IDs are made in the dashboard (documents required).
      responses:
        '200':
          description: Sender IDs.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/SenderId' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhook endpoints
      responses:
        '200':
          description: Endpoints.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: '#/components/schemas/Webhook' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Register a delivery-report webhook
      description: |
        Max 5 endpoints per account. URL must be `https://` and publicly
        resolvable (private/loopback addresses are refused). The signing secret
        is returned **once** in this response.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookCreate' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookWithSecret' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/Unprocessable' }

  /webhooks/{webhook_id}:
    parameters:
      - $ref: '#/components/parameters/WebhookId'
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: Get a webhook endpoint
      responses:
        '200':
          description: Endpoint.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Webhook' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Update URL, events, or re-enable
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookUpdate' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Webhook' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/Unprocessable' }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete an endpoint
      responses:
        '204': { description: Deleted. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /webhooks/{webhook_id}/rotate-secret:
    post:
      tags: [Webhooks]
      operationId: rotateWebhookSecret
      summary: Rotate the signing secret
      description: The old secret stays valid for 24 hours; during that time deliveries carry two signatures.
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: New secret (shown once).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookWithSecret' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /webhooks/{webhook_id}/test:
    post:
      tags: [Webhooks]
      operationId: testWebhook
      summary: Send a test event now
      description: Sends a signed sample `message.delivered` event (event_id `test_<uuid>`, message_id all zeros) to the endpoint right away and returns the result. Nothing is stored or retried.
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: Result of the test delivery.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookDeliveryAttempt' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /webhooks/{webhook_id}/replay:
    post:
      tags: [Webhooks]
      operationId: replayWebhook
      summary: Re-deliver events from a time range
      description: Queues re-delivery of events created in `[from, to)` (max 72 hours, within the last 30 days). Events keep their original `event_id`.
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from, to]
              properties:
                from: { type: string, format: date-time }
                to: { type: string, format: date-time }
                only_failed: { type: boolean, default: true }
      responses:
        '202':
          description: Replay queued.
          content:
            application/json:
              schema:
                type: object
                required: [queued]
                properties:
                  queued: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /tools/sms-count:
    post:
      tags: [Tools]
      operationId: countSms
      summary: Count characters and parts for a text
      description: |
        Same counting the send endpoints use. No authentication required;
        rate-limited per IP (60/minute). Texts over 10 parts are counted, not
        rejected; the send endpoints reject them with `text_too_long`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, maxLength: 1530 }
      responses:
        '200':
          description: Count.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SmsCount' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

webhooks:
  messageStatus:
    post:
      operationId: onMessageStatus
      summary: Message status changed
      description: |
        Sent to each registered endpoint subscribed to the event type.

        **Signature.** Header `BitSMS-Signature: t=<unix>,v1=<hex>` where
        `v1 = HMAC-SHA256(secret, "<t>.<raw body>")`. Verify with a constant-time
        compare and reject if `t` is more than 5 minutes old. During secret
        rotation two `v1=` values are present; accept either.

        **Delivery.** Respond `2xx` within 10 s. Otherwise retried 8 times
        over about 6 hours (1m, 5m, 15m, 30m, 1h, 1h, 1h, 2h). Events may arrive
        more than once and out of order: dedupe on `event_id` and ignore an event
        whose `status_rank` is lower than one you already processed.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
      responses:
        '200': { description: Acknowledged. Any 2xx works. }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: 'bsk_(live|test)_<prefix>_<secret>'
      description: API key from the dashboard.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: Unique per logical send. Same key + same body within 7 days returns the original result.
      schema: { type: string, minLength: 1, maxLength: 255 }
    MessageId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
    WebhookId:
      name: webhook_id
      in: path
      required: true
      schema: { type: string, format: uuid }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Cursor:
      name: cursor
      in: query
      schema: { type: string }

  headers:
    X-Request-Id:
      description: Unique id for this request; quote it to support.
      schema: { type: string }
    Idempotent-Replayed:
      description: '`true` when this response is a replay of an earlier request with the same Idempotency-Key.'
      schema: { type: string, enum: ['true'] }
    X-RateLimit-Limit:
      description: Requests per second allowed for this key.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests left in the current window.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix time when the window resets.
      schema: { type: integer }
    Retry-After:
      description: Seconds to wait before retrying.
      schema: { type: integer }

  responses:
    BadRequest:
      description: Malformed request (invalid JSON, missing field, bad type).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Missing, invalid, revoked or expired API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PaymentRequired:
      description: Balance does not cover the cost (`insufficient_credit`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Key lacks a scope, IP not allowed, account not verified / suspended / closed, or sender not allowed.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Not found (or belongs to another account).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: Idempotency key reused with a different body, or the same key is in flight.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Gone:
      description: OTP expired, exhausted, or already verified.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unprocessable:
      description: Semantically invalid (bad number, text too long, unknown sender).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooManyRequests:
      description: Rate limit exceeded.
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unavailable:
      description: Temporarily unable to accept (`backpressure`, `internal_unavailable`). Nothing was charged.
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Money:
      type: object
      required: [amount, currency]
      properties:
        amount: { type: integer, format: int64, description: Minor units (paisa)., examples: [2500] }
        currency: { type: string, const: BDT }

    MessageClass:
      type: string
      enum: [otp, transactional, bulk]
      description: Priority class. `otp` > `transactional` > `bulk`. Affects queue priority and retry policy only, not price.

    MessageStatus:
      type: string
      description: |
        Public view of the internal state machine: internal `accepted`/`queued`
        → `queued`; `processing`/`retry_wait` → `sending`.
        `queued` → `sending` → `sent` → `delivered`, or a terminal failure. Terminal:
        `delivered`, `failed`, `failed_unknown`, `expired`, `rejected`.
        Extensible: clients must handle unknown values as non-terminal.
      enum: [queued, sending, sent, delivered, failed, failed_unknown, expired, rejected]

    SendMessageRequest:
      type: object
      required: [to, text]
      additionalProperties: false
      properties:
        to: { type: string, description: 'Bangladesh mobile number: 01XXXXXXXXX, 8801XXXXXXXXX or +8801XXXXXXXXX.', examples: ['01712345678'] }
        text: { type: string, minLength: 1, maxLength: 1530, description: 'Up to 10 parts (1530 GSM-7 chars / 670 Unicode).' }
        sender_id: { type: string, description: 'Approved masking sender name. Omit for the default non-masking sender.', maxLength: 11 }
        class: { $ref: '#/components/schemas/MessageClass' }
        client_ref: { type: string, maxLength: 128, description: Your reference; searchable and echoed in webhooks. }
        validity_seconds: { type: integer, minimum: 60, maximum: 86400, default: 3600, description: Give up if not handed to a provider within this time. }
        metadata: { type: object, additionalProperties: true, description: 'Echoed in webhooks. Max 2 KB serialized.' }

    SendBulkRequest:
      type: object
      required: [to, text]
      additionalProperties: false
      properties:
        to:
          type: array
          minItems: 1
          maxItems: 10000
          items: { type: string }
          description: Duplicates are sent once.
        text: { type: string, minLength: 1, maxLength: 1530 }
        sender_id: { type: string, maxLength: 11 }
        class: { $ref: '#/components/schemas/MessageClass' }
        client_ref: { type: string, maxLength: 128 }
        validity_seconds: { type: integer, minimum: 60, maximum: 86400, default: 3600 }

    Message:
      type: object
      required: [id, to, sender_id, sender_type, class, status, parts, encoding, cost, created_at]
      properties:
        id: { type: string, format: uuid }
        batch_id: { type: [string, 'null'], format: uuid }
        to: { type: string, examples: ['+8801712345678'] }
        sender_id: { type: string }
        sender_type: { type: string, enum: [masking, non_masking] }
        class: { $ref: '#/components/schemas/MessageClass' }
        text: { type: string, description: Present on GET only. }
        status: { $ref: '#/components/schemas/MessageStatus' }
        status_reason: { type: [string, 'null'], description: 'Stable reason code for failures, e.g. `all_providers_failed`, `invalid_number`, `no_dlr`.' }
        encoding: { type: string, enum: [gsm7, ucs2] }
        characters: { type: integer }
        parts: { type: integer, minimum: 1, maximum: 10 }
        cost: { $ref: '#/components/schemas/Money' }
        refunded: { type: boolean }
        client_ref: { type: [string, 'null'] }
        metadata: { type: [object, 'null'] }
        provider: { type: [string, 'null'], description: Display name of the provider that carried the message. }
        created_at: { type: string, format: date-time }
        sent_at: { type: [string, 'null'], format: date-time }
        delivered_at: { type: [string, 'null'], format: date-time }
        expires_at: { type: string, format: date-time }
        events:
          type: array
          description: Only with `include=events`.
          items:
            type: object
            required: [status, at]
            properties:
              status: { $ref: '#/components/schemas/MessageStatus' }
              at: { type: string, format: date-time }
              reason: { type: [string, 'null'] }

    BulkResult:
      type: object
      required: [batch_id, accepted, rejected, total_cost]
      properties:
        batch_id: { type: string, format: uuid }
        parts_per_message: { type: integer }
        encoding: { type: string, enum: [gsm7, ucs2] }
        total_cost: { $ref: '#/components/schemas/Money' }
        accepted:
          type: array
          items:
            type: object
            required: [id, to]
            properties:
              id: { type: string, format: uuid }
              to: { type: string }
        rejected:
          type: array
          items:
            type: object
            required: [to, code]
            properties:
              to: { type: string }
              code: { $ref: '#/components/schemas/ErrorCode' }

    SendOtpRequest:
      type: object
      required: [to]
      additionalProperties: false
      properties:
        to: { type: string }
        length: { type: integer, minimum: 4, maximum: 8, default: 6 }
        ttl_seconds: { type: integer, minimum: 60, maximum: 600, default: 300 }
        template: { type: string, maxLength: 300, description: 'Must contain `{{code}}`. May use `{{app_name}}`, `{{ttl_minutes}}`.' }
        app_name: { type: string, maxLength: 30 }
        sender_id: { type: string, maxLength: 11 }
        client_ref: { type: string, maxLength: 128 }

    Otp:
      type: object
      required: [otp_id, message_id, to, expires_at]
      properties:
        otp_id: { type: string, format: uuid }
        message_id: { type: string, format: uuid }
        to: { type: string }
        expires_at: { type: string, format: date-time }
        cost: { $ref: '#/components/schemas/Money' }

    OtpVerifyResult:
      type: object
      required: [verified]
      properties:
        verified: { type: boolean }
        attempts_remaining: { type: integer }

    Balance:
      type: object
      required: [balance, low_balance_threshold, prices]
      properties:
        balance: { $ref: '#/components/schemas/Money' }
        low_balance_threshold: { $ref: '#/components/schemas/Money' }
        account_status: { type: string, enum: [trial, active, suspended, closed] }
        prices:
          type: object
          required: [masking_per_part, non_masking_per_part]
          properties:
            masking_per_part: { $ref: '#/components/schemas/Money' }
            non_masking_per_part: { $ref: '#/components/schemas/Money' }
        estimated_parts_remaining:
          type: object
          properties:
            masking: { type: integer }
            non_masking: { type: integer }

    SenderId:
      type: object
      required: [name, type, is_default]
      properties:
        name: { type: string }
        type: { type: string, enum: [masking, non_masking] }
        is_default: { type: boolean }
        approved_at: { type: [string, 'null'], format: date-time }

    WebhookEventType:
      type: string
      enum: [message.sent, message.delivered, message.failed, message.expired, message.rejected, otp.verified]

    WebhookCreate:
      type: object
      required: [url, events]
      additionalProperties: false
      properties:
        url: { type: string, format: uri, pattern: '^https://' }
        events: { type: array, minItems: 1, items: { $ref: '#/components/schemas/WebhookEventType' } }
        description: { type: string, maxLength: 100 }

    WebhookUpdate:
      type: object
      additionalProperties: false
      properties:
        url: { type: string, format: uri, pattern: '^https://' }
        events: { type: array, minItems: 1, items: { $ref: '#/components/schemas/WebhookEventType' } }
        description: { type: string, maxLength: 100 }
        enabled: { type: boolean }

    Webhook:
      type: object
      required: [id, url, events, status, secret_prefix, created_at]
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        events: { type: array, items: { $ref: '#/components/schemas/WebhookEventType' } }
        description: { type: [string, 'null'] }
        status: { type: string, enum: [active, disabled_by_user, disabled_failing] }
        secret_prefix: { type: string, examples: ['whsec_ab12'] }
        consecutive_failures: { type: integer }
        created_at: { type: string, format: date-time }

    WebhookWithSecret:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          required: [secret]
          properties:
            secret: { type: string, description: Shown once. }

    WebhookDeliveryAttempt:
      type: object
      required: [delivered]
      properties:
        delivered: { type: boolean }
        http_status: { type: [integer, 'null'] }
        duration_ms: { type: integer }
        error: { type: [string, 'null'] }

    WebhookEvent:
      type: object
      required: [event_id, type, created_at, data]
      properties:
        event_id: { type: string, description: 'Stable: `<message_id>:<status>`. Dedupe on it.' }
        type: { $ref: '#/components/schemas/WebhookEventType' }
        created_at: { type: string, format: date-time }
        data:
          type: object
          required: [message_id, status, status_rank, occurred_at]
          properties:
            message_id: { type: string, format: uuid }
            batch_id: { type: [string, 'null'], format: uuid }
            to: { type: string }
            status: { $ref: '#/components/schemas/MessageStatus' }
            status_rank: { type: integer, description: Monotonic; ignore events with a lower rank than one already processed. }
            status_reason: { type: [string, 'null'] }
            occurred_at: { type: string, format: date-time }
            parts: { type: integer }
            cost: { $ref: '#/components/schemas/Money' }
            refunded: { type: boolean }
            client_ref: { type: [string, 'null'] }
            metadata: { type: [object, 'null'] }
            otp_id: { type: [string, 'null'], format: uuid }

    SmsCount:
      type: object
      required: [encoding, characters, parts, per_part, remaining_in_part]
      properties:
        encoding: { type: string, enum: [gsm7, ucs2] }
        characters: { type: integer, description: 'GSM-7 septets (extension chars count 2) or UTF-16 code units.' }
        parts: { type: integer }
        per_part: { type: integer, enum: [160, 153, 70, 67] }
        remaining_in_part: { type: integer }
        non_gsm_characters: { type: array, items: { type: string }, description: First characters that forced Unicode (max 10). }

    ErrorCode:
      type: string
      description: |
        Stable machine-readable codes. Extensible — new codes may be added.

        | Code | HTTP | Meaning |
        |---|---|---|
        | `invalid_request` | 400 | Malformed JSON / missing or wrong-typed field (`details` lists fields) |
        | `idempotency_key_missing` | 400 | Send endpoint called without `Idempotency-Key` |
        | `unauthorized` | 401 | Missing or invalid API key |
        | `api_key_revoked` | 401 | Key revoked or expired |
        | `insufficient_credit` | 402 | Balance below cost (`details.required`, `details.balance`) |
        | `scope_missing` | 403 | Key lacks the scope (`details.required_scope`) |
        | `ip_not_allowed` | 403 | Caller IP not in key allowlist |
        | `account_not_verified` | 403 | Sign-up not completed |
        | `account_suspended` | 403 | Account suspended |
        | `account_closed` | 403 | Account closed |
        | `sender_not_allowed` | 403 | Sender ID unknown, not approved, or disabled for this account |
        | `not_found` | 404 | Resource not found |
        | `idempotency_key_reused` | 409 | Same key, different body |
        | `idempotency_in_progress` | 409 | Same key still being processed; retry shortly |
        | `otp_expired` | 410 | OTP expired |
        | `otp_exhausted` | 410 | Too many wrong codes |
        | `otp_already_verified` | 410 | Already verified |
        | `invalid_destination` | 422 | Not a valid Bangladesh mobile number |
        | `recipient_opted_out` | 422 | Number opted out of your bulk messages |
        | `text_too_long` | 422 | More than 10 parts |
        | `text_empty` | 422 | Empty text |
        | `invalid_template` | 422 | OTP template missing `{{code}}` |
        | `too_many_recipients` | 422 | Bulk over 10 000 |
        | `webhook_url_invalid` | 422 | Not https or resolves to a private address |
        | `webhook_limit_reached` | 422 | Max 5 endpoints |
        | `rate_limited` | 429 | Key rate limit exceeded |
        | `otp_rate_limited` | 429 | Too many OTPs to this number |
        | `backpressure` | 503 | Bulk queue saturated; retry after `Retry-After` |
        | `internal_unavailable` | 503 | Temporary internal failure; nothing charged |
        | `internal_error` | 500 | Unexpected error; nothing charged unless a message id was returned |
      enum:
        - invalid_request
        - idempotency_key_missing
        - unauthorized
        - api_key_revoked
        - insufficient_credit
        - scope_missing
        - ip_not_allowed
        - account_not_verified
        - account_suspended
        - account_closed
        - sender_not_allowed
        - not_found
        - idempotency_key_reused
        - idempotency_in_progress
        - otp_expired
        - otp_exhausted
        - otp_already_verified
        - invalid_destination
        - recipient_opted_out
        - text_too_long
        - text_empty
        - invalid_template
        - too_many_recipients
        - webhook_url_invalid
        - webhook_limit_reached
        - rate_limited
        - otp_rate_limited
        - backpressure
        - internal_unavailable
        - internal_error

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code: { $ref: '#/components/schemas/ErrorCode' }
            message: { type: string, description: Human-readable; may change. }
            request_id: { type: string }
            details: { type: object, additionalProperties: true }
      examples:
        - error:
            code: insufficient_credit
            message: Balance 0.50 BDT does not cover 3 parts at 0.25 BDT (0.75 BDT).
            request_id: req_01JB…
            details: { required: 75, balance: 50 }
