openapi: "3.0.3"
info:
  title: Robase SMS OTP API
  description: |
    Multi-provider SMS OTP delivery and transactional SMS API with automatic
    failover and per-country routing. Send and verify one-time passwords across
    Africa and globally.

    ## Authentication

    Every endpoint under `/v1` requires an API key issued in the dashboard,
    passed as a bearer token:

    ```
    Authorization: Bearer robe_your_api_key_here
    ```

    ## Idempotency

    `POST` endpoints accept an `Idempotency-Key` header. Replaying the same key
    within 24 hours returns the original response instead of sending a second
    message — so a request that times out mid-flight can be retried safely
    without double-charging credits. The official SDKs generate a key for every
    `POST` automatically.

    ## Rate limits

    Requests are limited per client IP (100/minute) and per destination phone
    number (3 OTP sends / 10 minutes, 10 OTP verifications / 10 minutes,
    10 transactional SMS / minute). Exceeding a limit returns `429` with a
    `Retry-After` header.

    ## Errors

    Failures return a consistent envelope:

    ```json
    {"error": {"type": "insufficient_credits", "message": "insufficient credit balance"}}
    ```

    Match on `error.type` — it is stable — rather than on `error.message`, which
    is prose and may change.

    ## Languages

    `error.message` is written in your workspace's default language. Send an
    `Accept-Language` header (`en` or `fr`) to override that for one request —
    it changes only the prose, never `error.type`.

    `Accept-Language` does not affect the text of an SMS. The OTP message body
    follows the workspace's language setting, or the `language` field on
    `POST /v1/otp/send`. A header describing which language *you* read should
    not decide what your end user receives.

    ## Webhooks

    Dashboard-configured HTTPS callbacks. The signed body is compact JSON
    (no pretty-print, no HTML escaping of `<>&`). HMAC-SHA256 is over those
    exact bytes. Envelope and `data` objects are `WebhookEnvelope`,
    `OTPWebhookData`, `SMSWebhookData`, and `CreditWebhookData`.
  version: "1.0.0"
  contact:
    name: Robase Support
    url: https://robase.dev

servers:
  - url: https://api.robase.dev
    description: Production
  - url: http://localhost:8080
    description: Local development

tags:
  - name: OTP
    description: Generate, deliver and verify one-time passwords.
  - name: SMS
    description: Send arbitrary transactional messages.
  - name: System
    description: Unauthenticated operational endpoints.

paths:
  /v1/otp/send:
    post:
      summary: Generate and send an OTP
      description: |
        Generates a random numeric code, charges the workspace, and queues the
        code for SMS delivery.

        The response returns once the OTP is persisted and charged — delivery is
        asynchronous, so `status` is `pending` on success. Subscribe to the
        `otp.sent` / `otp.failed` webhooks, or poll `GET /v1/otp/{id}`, to learn
        the delivery outcome.
      operationId: sendOTP
      tags:
        - OTP
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendOTPRequest'
      responses:
        '200':
          description: OTP created, charged, and queued for delivery
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendOTPResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/SendForbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/otp/verify:
    post:
      summary: Verify an OTP code
      description: |
        Verifies a code against a previously sent OTP using a constant-time
        comparison.

        A wrong code that has not exhausted the attempt budget is **not** an
        error: the call returns `200` with `valid: false` and
        `attempts_remaining`. Terminal states — expired, already verified, or
        attempts exhausted — return `409`.
      operationId: verifyOTP
      tags:
        - OTP
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyOTPRequest'
      responses:
        '200':
          description: |
            The code was checked. `valid` reports whether it matched; a `false`
            result still counts against the attempt budget.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerifyOTPResponse'
              examples:
                accepted:
                  summary: Correct code
                  value:
                    valid: true
                    status: verified
                    attempts_used: 1
                rejected:
                  summary: Wrong code, attempts remaining
                  value:
                    valid: false
                    status: sent
                    attempts_used: 2
                    attempts_remaining: 3
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: |
            The OTP is in a terminal state and can no longer be verified.
            `error.type` is `otp_expired`, `otp_already_verified`, or
            `max_attempts_exceeded`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                expired:
                  value:
                    error:
                      type: otp_expired
                      message: OTP has expired
                alreadyVerified:
                  value:
                    error:
                      type: otp_already_verified
                      message: OTP already verified
                maxAttempts:
                  value:
                    error:
                      type: max_attempts_exceeded
                      message: maximum verification attempts exceeded
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/otp/{id}:
    get:
      summary: Get OTP status
      description: |
        Retrieves the current status and details of an OTP. The code itself is
        never returned — only its length and hash-verified state.

        `timeline` lists each hop the OTP took, oldest first: `queued`, `sent`,
        `delivered`, then `verified`, `expired` or `failed`. A `failed` hop
        carries a `reason` token and whether the credit was `refunded`. See
        [Delivery timeline](/sms/delivery-timeline).
      operationId: getOTP
      tags:
        - OTP
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: OTP ID returned from send
      responses:
        '200':
          description: OTP details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OTPDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/sms/send:
    post:
      summary: Send a transactional SMS
      description: |
        Sends an arbitrary SMS message. Uses the same routing, credit billing,
        and provider failover as OTPs.

        The response returns once the message is persisted and charged —
        delivery is asynchronous, so `status` is always `pending`. Subscribe to
        the `sms.delivered` / `sms.failed` / `sms.blocked` webhooks, or poll
        `GET /v1/sms/{id}`, to learn the outcome.

        Workspaces with anti-spam enabled have message bodies classified after
        the response, in the worker that dispatches the message — no send waits
        on a classifier. Depending on the configured action, a flagged message is
        either tagged in `metadata` and delivered, or stopped before it reaches a
        provider: its status becomes `blocked`, the credit is refunded in full,
        and the `sms.blocked` webhook fires with the classifier's verdict.
      operationId: sendSMS
      tags:
        - SMS
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendSMSRequest'
      responses:
        '200':
          description: SMS created, charged, and queued for delivery
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendSMSResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/SendForbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/sms/{id}:
    get:
      summary: Get SMS status
      description: |
        Retrieves the current status and details of a transactional SMS. Only
        messages belonging to the authenticated workspace are visible.

        `timeline` lists each hop the message took, oldest first: `queued`,
        `sent`, then `delivered`, `failed` or `blocked`. A terminal `failed` or
        `blocked` hop carries a `reason` token and whether the credit was
        `refunded`. `delivered` appears only when the carrier sends a receipt.
        See [Delivery timeline](/sms/delivery-timeline).
      operationId: getSMS
      tags:
        - SMS
      security:
        - BearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: SMS message ID returned from send
      responses:
        '200':
          description: SMS details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SMSDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /v1/pricing:
    get:
      summary: Get the price list
      description: |
        The published price list: what one credit costs, and how many credits a
        message to each destination is charged.

        No authentication. Every customer pays the published price, so the
        response is the same for a signed-in tenant and an anonymous reader,
        and it is safe to cache — the server holds it for a minute.

        `countries` is ordered cheapest first. `credits` is charged when no
        telco rate matches the number; `networks` lists the per-telco rates
        where an operator has set them, and `min_credits` / `max_credits` span
        the two so a caller can show a range without walking the list.
        `listed` marks the destinations the published price table names —
        unlisted ones are still sendable and still billed at the price shown.

        A long message is split into parts and charged per part; see
        [Segments and encoding](/sms/segments-encoding).
      operationId: getPricing
      tags:
        - SMS
      security: []
      responses:
        '200':
          description: The price list
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pricing'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /health:
    get:
      summary: Liveness probe
      description: Returns 200 while the API process is serving traffic. No authentication.
      operationId: health
      tags:
        - System
      security: []
      responses:
        '200':
          description: The service is up
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok]
                example:
                  status: ok

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: 'Your Robase API key. Starts with robe_. Include as Authorization: Bearer robe_...'

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        maxLength: 255
      description: |
        Opaque client-generated key. Replaying the same key within 24 hours
        returns the original response instead of performing the action again.
        Recommended for every send so a network-level retry cannot double-charge.
      example: 550e8400-e29b-41d4-a716-446655440000

  schemas:
    SendOTPRequest:
      type: object
      required:
        - phone_number
      properties:
        phone_number:
          type: string
          description: Phone number in E.164 format
          example: "+2348012345678"
        code_length:
          type: integer
          minimum: 4
          maximum: 8
          default: 6
          description: |
            Number of digits in the generated code. Omit to use the default —
            sending `0` is not the same as omitting the field. Anything outside
            4–8 is refused with `validation_error`.
        ttl_seconds:
          type: integer
          minimum: 1
          maximum: 3600
          default: 600
          description: How long the code stays verifiable, in seconds. Omit to use the default.
        language:
          type: string
          enum: [en, fr]
          description: >
            Language for the OTP message body. Omit to use the workspace's
            default language (Settings → General → Language & region). Supply
            it per request when your own end users are not all in the same
            language.


            `Accept-Language` deliberately does not affect the message body —
            it describes the integrator reading our error responses, not the
            person receiving the SMS.
          example: fr
        metadata:
          type: object
          additionalProperties: true
          description: Arbitrary key-value data stored with the OTP and returned by `GET /v1/otp/{id}`
      example:
        phone_number: "+2348012345678"
        code_length: 6
        ttl_seconds: 600
        language: fr

    WebhookEnvelope:
      type: object
      required: [event, timestamp, data]
      properties:
        event:
          type: string
          example: otp.sent
        timestamp:
          type: string
          format: date-time
          description: UTC RFC3339 with whole seconds
          example: "2026-08-29T10:20:00Z"
        data:
          description: Event-specific object (OTPWebhookData, SMSWebhookData, or CreditWebhookData)
          oneOf:
            - $ref: '#/components/schemas/OTPWebhookData'
            - $ref: '#/components/schemas/SMSWebhookData'
            - $ref: '#/components/schemas/CreditWebhookData'
      example:
        event: otp.sent
        timestamp: "2026-08-29T10:20:00Z"
        data:
          id: "550e8400-e29b-41d4-a716-446655440000"
          phone_number: "+2348012345678"
          country_code: NG
          status: sent
          credit_cost: 1

    OTPWebhookData:
      type: object
      required: [id, phone_number, country_code, status, credit_cost]
      properties:
        id:
          type: string
        phone_number:
          type: string
          example: "+2348012345678"
        country_code:
          type: string
          example: NG
        status:
          type: string
          enum: [pending, sent, delivered, verified, expired, failed]
        credit_cost:
          type: integer
        reason:
          type: string
        refunded:
          type: boolean

    SMSWebhookData:
      type: object
      required: [id, phone_number, country_code, message, status, credit_cost]
      properties:
        id:
          type: string
        phone_number:
          type: string
        country_code:
          type: string
        message:
          type: string
        status:
          type: string
          enum: [pending, sent, delivered, failed, blocked]
        credit_cost:
          type: integer
        antispam:
          type: object
          additionalProperties: true
        reason:
          type: string
        refunded:
          type: boolean
      example:
        id: "550e8400-e29b-41d4-a716-446655440000"
        phone_number: "+2348012345678"
        country_code: NG
        message: "Pay <https://example.com?a=1&b=2>"
        status: failed
        credit_cost: 1
        reason: delivery_failed
        refunded: true

    CreditWebhookData:
      type: object
      required: [workspace_id, balance]
      properties:
        workspace_id:
          type: string
        balance:
          type: integer
        threshold:
          type: integer
        test:
          type: boolean
          description: Present only on dashboard test-fire samples

    SendOTPResponse:
      type: object
      required:
        - id
        - phone_number
        - country_code
        - credit_cost
        - status
        - code_length
        - expires_at
        - created_at
      properties:
        id:
          type: string
          format: uuid
          description: OTP ID to use with the verify and get endpoints
        phone_number:
          type: string
          description: Destination the code was sent to, in E.164 format
          example: "+2348012345678"
        country_code:
          type: string
          description: ISO 3166-1 alpha-2 country code derived from the number
          example: NG
        credit_cost:
          type: integer
          description: Credits consumed by this send
          example: 1
        status:
          type: string
          enum:
            - pending
          description: Always `pending` — delivery happens asynchronously
        code_length:
          type: integer
          description: Number of digits in the generated code
          example: 6
        expires_at:
          type: string
          format: date-time
          description: After this instant the code can no longer be verified
        created_at:
          type: string
          format: date-time

    VerifyOTPRequest:
      type: object
      required:
        - otp_id
        - code
      properties:
        otp_id:
          type: string
          format: uuid
          description: The `id` returned by the send endpoint
        code:
          type: string
          description: The code the user entered
          example: "123456"

    VerifyOTPResponse:
      type: object
      required:
        - valid
        - status
        - attempts_used
      properties:
        valid:
          type: boolean
          description: Whether the submitted code matched
        status:
          type: string
          enum:
            - pending
            - sent
            - verified
            - failed
          description: |
            The OTP's state after this attempt. `verified` on success;
            `failed` once the attempt budget is exhausted.
        attempts_used:
          type: integer
          description: Verification attempts made so far, including this one
        attempts_remaining:
          type: integer
          description: Attempts left before the OTP is burned. Present only when `valid` is false.

    OTPDetails:
      type: object
      required:
        - id
        - workspace_id
        - phone_number
        - country_code
        - credit_cost
        - status
        - code_length
        - ttl_seconds
        - attempt_count
        - max_attempts
        - expired_at
        - created_at
      properties:
        id:
          type: string
          format: uuid
        workspace_id:
          type: string
          format: uuid
        phone_number:
          type: string
        country_code:
          type: string
          description: ISO 3166-1 alpha-2 country code
        credit_cost:
          type: integer
        status:
          type: string
          enum:
            - pending
            - sent
            - verified
            - expired
            - failed
        code_length:
          type: integer
        ttl_seconds:
          type: integer
        attempt_count:
          type: integer
          description: Verification attempts made so far
        max_attempts:
          type: integer
          description: Attempt budget before the OTP is burned
          example: 5
        sent_at:
          type: string
          format: date-time
        delivered_at:
          type: string
          format: date-time
        verified_at:
          type: string
          format: date-time
        failed_at:
          type: string
          format: date-time
          description: When the OTP was marked failed. Absent otherwise.
        failure_reason:
          type: string
          description: Reason token behind a failed status. See TimelineItem.
        expired_at:
          type: string
          format: date-time
          description: When the code stops being verifiable
        metadata:
          type: object
          additionalProperties: true
        created_at:
          type: string
          format: date-time
        timeline:
          type: array
          description: Hops the OTP took, oldest first.
          items:
            $ref: '#/components/schemas/TimelineItem'

    SendSMSRequest:
      type: object
      required:
        - phone_number
        - message
      properties:
        phone_number:
          type: string
          description: Phone number in E.164 format
          example: "+2348012345678"
        message:
          type: string
          description: |
            SMS message body. The limit is six SMS segments, not a character
            count: a carrier splits a long message into parts and bills each
            one, and how many characters fit in a part depends on the alphabet
            the message needs — 160 per part in GSM-7 (153 once it is split),
            but 70 (then 67) as soon as one character forces UCS-2. So the
            practical ceiling is 918 plain-Latin characters, or 402 in a script
            that needs UCS-2.
          example: "Your order #12345 has been shipped."
        metadata:
          type: object
          additionalProperties: true
          description: Arbitrary key-value data attached to the message

    SendSMSResponse:
      type: object
      required:
        - id
        - phone_number
        - country_code
        - credit_cost
        - status
        - created_at
      properties:
        id:
          type: string
          format: uuid
          description: SMS message ID
        phone_number:
          type: string
          description: Phone number the SMS was sent to
        country_code:
          type: string
          description: ISO 3166-1 alpha-2 country code
        credit_cost:
          type: integer
          description: |
            Credits consumed: the country's rate multiplied by `segments`, since
            a carrier bills each part of a split message as its own send.
        segments:
          type: integer
          description: |
            How many SMS parts the message was split into, and therefore the
            multiple of the country's rate it was charged at.
          example: 1
        encoding:
          type: string
          enum:
            - gsm7
            - ucs2
          description: |
            The alphabet the message needed, which is what decided the part
            size: 160 characters per part in `gsm7`, 70 in `ucs2`. A single
            character outside the GSM alphabet moves the whole message to
            `ucs2`.
        sanitized:
          type: boolean
          description: |
            Whether the body was rewritten before it was measured. With the
            workspace's `sanitize_symbols` setting on (the default), characters
            GSM-7 cannot carry are replaced with plain equivalents — `₦`
            becomes `NGN`, curly quotes straighten — and anything with no
            equivalent, such as an emoji, is removed. Letters are never
            removed, and the rewrite is dropped whenever it would not lower
            the part count. Turn the setting off to have bodies delivered
            exactly as sent.
        status:
          type: string
          enum:
            - pending
          description: |
            Initial status — always `pending`; transitions to
            sent/delivered/failed asynchronously, or to `blocked` if anti-spam
            screening stops it before dispatch (the credit is then refunded).
        created_at:
          type: string
          format: date-time

    SMSDetails:
      type: object
      required:
        - id
        - phone_number
        - country_code
        - message
        - credit_cost
        - status
        - created_at
      properties:
        id:
          type: string
          format: uuid
        phone_number:
          type: string
        country_code:
          type: string
        message:
          type: string
        credit_cost:
          type: integer
        status:
          type: string
          enum:
            - pending
            - sent
            - delivered
            - failed
            - blocked
          description: |
            `blocked` means anti-spam screening stopped the message before any
            provider saw it. It is terminal, and the credit was refunded in
            full; `metadata.antispam` carries the classifier's verdict.
        metadata:
          type: object
          additionalProperties: true
          nullable: true
          description: |
            Whatever was sent with the message. Screening adds an `antispam`
            key when the classifier flagged or blocked it, carrying `is_spam`,
            `score`, `category` and `reason`.
        created_at:
          type: string
          format: date-time
        timeline:
          type: array
          description: Hops the message took, oldest first.
          items:
            $ref: '#/components/schemas/TimelineItem'
          example:
            - at: "2026-09-13T09:00:00Z"
              status: queued
            - at: "2026-09-13T09:00:02Z"
              status: sent
            - at: "2026-09-13T09:00:41Z"
              status: failed
              reason: delivery_failed
              refunded: true

    TimelineItem:
      type: object
      required:
        - at
        - status
      properties:
        at:
          type: string
          format: date-time
          description: When the hop happened (RFC 3339).
        status:
          type: string
          description: |
            `queued` (charged and waiting for the worker), then the message's
            own status values: `sent`, `delivered`, `failed`, `blocked`, and for
            OTPs `verified` and `expired`. A hop is listed only when its time
            is known; `status` on the parent object is always authoritative.
        reason:
          type: string
          description: |
            Stable machine token. Present on a terminal hop when known, and on
            `sent` when the anti-spam screen flagged but still sent the message.
            Tokens: `send_failed`, `delivery_failed`, `antispam_blocked`,
            `antispam_flagged`, `max_attempts_exceeded`. Omitted when unknown.
        refunded:
          type: boolean
          description: |
            On a terminal `failed` or `blocked` hop only. Reflects the credit
            ledger: `true` means the charge was returned.

    Pricing:
      type: object
      required:
        - currency
        - credit_price
        - countries
      properties:
        currency:
          type: string
          description: The currency credits are sold in.
          example: NGN
        credit_price:
          type: integer
          description: What one credit costs, in `currency`.
          example: 6
        countries:
          type: array
          description: Priced destinations, cheapest first.
          items:
            $ref: '#/components/schemas/PricingCountry'

    PricingCountry:
      type: object
      required:
        - country_code
        - country_name
        - credits
        - price
        - min_credits
        - max_credits
        - listed
      properties:
        country_code:
          type: string
          description: ISO 3166-1 alpha-2 country code
          example: NG
        country_name:
          type: string
          example: Nigeria
        credits:
          type: integer
          description: Credits charged when no telco rate matches the number.
          example: 1
        price:
          type: integer
          description: '`credits` times `credit_price`.'
          example: 6
        min_credits:
          type: integer
          description: The cheapest rate to this destination, across every telco.
          example: 1
        max_credits:
          type: integer
          description: The dearest rate to this destination, across every telco.
          example: 3
        listed:
          type: boolean
          description: |
            Whether the published price table names this destination. An
            unlisted one is still sendable and still charged at this price.
          example: true
        networks:
          type: array
          description: Per-telco rates, cheapest first. Absent where none are set.
          items:
            $ref: '#/components/schemas/PricingNetwork'

    PricingNetwork:
      type: object
      required:
        - key
        - name
        - credits
        - price
      properties:
        key:
          type: string
          description: Stable identifier for the telco.
          example: glo
        name:
          type: string
          example: Glo Mobile
        credits:
          type: integer
          example: 3
        price:
          type: integer
          description: '`credits` times `credit_price`.'
          example: 18

    ErrorResponse:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - type
            - message
          properties:
            type:
              type: string
              description: |
                Stable machine-readable identifier. Match on this rather than on
                `message`.
              enum:
                - validation_error
                - invalid_phone
                - country_not_supported
                - unauthorized
                - insufficient_credits
                - kyc_required
                - account_temporarily_restricted
                - otp_not_found
                - sms_not_found
                - otp_expired
                - otp_already_verified
                - max_attempts_exceeded
                - rate_limited
                - internal_error
            message:
              type: string
              description: Human-readable description. Not stable — do not parse.
      example:
        error:
          type: insufficient_credits
          message: insufficient credit balance

  responses:
    BadRequest:
      description: |
        Invalid request. `error.type` is `validation_error` (malformed body or a
        missing required field), `invalid_phone` (not valid E.164), or
        `country_not_supported` (no SMS route to that destination).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              type: invalid_phone
              message: phone must be in E.164 format (e.g., +2348012345678)

    Unauthorized:
      description: The API key is missing, malformed, or unknown.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              type: unauthorized
              message: invalid API key

    PaymentRequired:
      description: The workspace credit balance is below the cost of this send.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              type: insufficient_credits
              message: insufficient credit balance

    KYCRequired:
      description: |
        Sending is paused for this workspace pending business verification.

        A workspace whose traffic reads as financial transaction alerts is
        sending on behalf of a financial service, so we verify the business
        before sending resumes. Nothing is charged while the hold is on, and
        retrying will not clear it: an administrator completes the verification
        at `/app/kyc`, and sending resumes when staff approve it.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              type: kyc_required
              message: sending is paused pending business verification

    SendForbidden:
      description: |
        Sending is paused. `error.type` is `kyc_required` (business
        verification hold — KYB / financial-alert path, separate from
        antispam) or `account_temporarily_restricted` (48-hour antispam
        warning ban). Nothing is charged in either case.

        A warning ban auto-lifts when `warning_banned_until` expires.
        Retrying before then will not clear it. Match on `error.type`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            kyc_required:
              value:
                error:
                  type: kyc_required
                  message: sending is paused pending business verification
            account_temporarily_restricted:
              value:
                error:
                  type: account_temporarily_restricted
                  message: sending is paused on this workspace until the temporary restriction ends

    NotFound:
      description: No record with that ID exists in this workspace.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              type: otp_not_found
              message: OTP not found

    RateLimited:
      description: Rate limit exceeded. Retry after the window indicated by `Retry-After`.
      headers:
        Retry-After:
          description: Seconds to wait before retrying
          schema:
            type: integer
          example: 60
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              type: rate_limited
              message: too many send attempts, try again later

    InternalError:
      description: An unexpected server-side failure. Safe to retry with the same `Idempotency-Key`.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              type: internal_error
              message: an unexpected error occurred
