> ## Documentation Index
> Fetch the complete documentation index at: https://docs.robase.dev/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> For chatbots and coding agents, start at /guides/ai-agents.
> Hosted MCP: POST https://api.robase.dev/mcp (Streamable HTTP). Auth: Authorization: Bearer robe_… or robe_agent_….
> Phase 1 tools: get_pricing, send_otp, verify_otp, get_otp, send_sms, get_sms. No payment or top-up tools.
> Call get_pricing; treat listed:true as marketed markets (NG, GH, KE, ZA, GB, BJ, CI, US). Do not invent countries or delivery percentages.
> Prefer OTP-shaped tests such as "Your Robase test OTP is 123456". Do not send Hello or Good morning as tests. Match errors on error.type.

# Send an OTP

> POST /v1/otp/send — generate a code, charge credits, queue SMS and/or WhatsApp OTP delivery.

Workspace `otp_delivery_mode` is `sms` | `whatsapp` | `both` | `either` (default `sms`). Optional request `channel` is `sms` | `whatsapp`.

There is **no silent WhatsApp→SMS fallback**. A failed WhatsApp leg is refunded and reported failed. Mode `both` sends both legs and refuses an explicit `channel`. Mode `either` omits to SMS.


## OpenAPI

````yaml openapi.yaml POST /v1/otp/send
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
security: []
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:
      tags:
        - OTP
      summary: Generate and send an OTP
      description: |
        Generates a random numeric code, charges the workspace, and queues
        delivery over SMS and/or WhatsApp according to the workspace
        `otp_delivery_mode` (`sms` | `whatsapp` | `both` | `either`, default
        `sms`) and the optional request `channel`.

        WhatsApp OTP uses a Meta Authentication template with a copy-code
        button. There is no silent WhatsApp→SMS fallback: a failed WhatsApp
        leg is refunded and reported failed. Mode `both` sends both legs and
        refuses an explicit `channel`. Mode `either` omits to SMS.

        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
      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'
      security:
        - BearerAuth: []
components:
  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 (with `Idempotent-Replayed: true`)
        instead of performing the action again. Recommended for every send so
        a network-level retry cannot double-charge. The same key with a
        different endpoint or body returns `422 idempotency_key_reused`; a
        repeat while the first request is still running returns
        `409 request_in_progress`. `5xx` and `429` responses are not kept, so
        a retry with the same key runs again.
      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
        channel:
          type: string
          enum:
            - sms
            - whatsapp
          description: >
            Delivery channel for this send. Honoured only when the workspace

            `otp_delivery_mode` allows it:


            - `sms` mode: only `sms` (or omit); `whatsapp` →
            `channel_not_allowed`

            - `whatsapp` mode: only `whatsapp` (or omit); `sms` →
            `channel_not_allowed`

            - `both` mode: omit only; any explicit value → `channel_not_allowed`

            - `either` mode: `sms` or `whatsapp`; omit means SMS


            WhatsApp is OTP-only (Meta Authentication + copy-code). A failed

            WhatsApp leg is never silently re-sent as SMS.
          example: whatsapp
        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
        channel: whatsapp
    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
        channel:
          type: string
          enum:
            - sms
            - whatsapp
            - both
          description: |
            Channel(s) this OTP was queued on. `both` means SMS and WhatsApp
            legs were charged together.
    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_suspended
                - account_temporarily_restricted
                - otp_not_found
                - sms_not_found
                - insufficient_scope
                - country_not_enabled
                - prefix_blocked
                - content_blocked
                - screening_unavailable
                - idempotency_key_reused
                - request_in_progress
                - otp_expired
                - otp_already_verified
                - max_attempts_exceeded
                - rate_limited
                - internal_error
                - whatsapp_not_available
            message:
              type: string
              description: Human-readable description. Not stable — do not parse.
            required_scope:
              type: string
              description: With `insufficient_scope`, the scope the API key is missing.
      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
    SendForbidden:
      description: |
        Sending is refused. `error.type` is `kyc_required` (business
        verification hold — KYB / financial-alert path, separate from
        antispam), `account_suspended` (the workspace is suspended after
        antispam review; contact support to appeal),
        `account_temporarily_restricted` (48-hour antispam
        warning ban), `insufficient_scope` (the API key lacks the send
        scope), `prefix_blocked` (sending to this number range is blocked
        for the workspace after unusual traffic), `country_not_enabled`
        (the destination country is not turned on in Settings → Countries;
        returned only where geo permissions are enforced) or
        `content_blocked` (`/v1/sms/send` only: a new workspace's message
        read as non-transactional in the synchronous content check; a
        workspace is new until it is old enough, has enough message history
        and has enough SMS already sent, and again after a recent spam block).
        Nothing is charged in any 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_suspended:
              value:
                error:
                  type: account_suspended
                  message: this workspace is suspended and cannot send
            account_temporarily_restricted:
              value:
                error:
                  type: account_temporarily_restricted
                  message: >-
                    sending is paused on this workspace until the temporary
                    restriction ends
            insufficient_scope:
              value:
                error:
                  type: insufficient_scope
                  message: this API key does not have the otp:send scope
                  required_scope: otp:send
            prefix_blocked:
              value:
                error:
                  type: prefix_blocked
                  message: >-
                    sending to this number range is blocked for this workspace
                    after unusual traffic; contact support
            content_blocked:
              value:
                error:
                  type: content_blocked
                  message: >-
                    message blocked: content is not transactional; nothing
                    charged
            country_not_enabled:
              value:
                error:
                  type: country_not_enabled
                  message: >-
                    sending to GH is not enabled for this workspace; turn it on
                    in Settings → Countries
    RateLimited:
      description: |
        A rate limit was exceeded: per API key (600 sends or 1200 reads a
        minute by default), per workspace (3000 a minute by default), per
        destination prefix (300 OTPs an hour by default), per destination
        phone, or per client IP. Limits are counted per workspace, so another
        workspace's traffic never uses up yours. Retry after `Retry-After`.
      headers:
        Retry-After:
          description: >-
            Whole seconds until the counter that refused the request resets (at
            least 1).
          schema:
            type: integer
            minimum: 1
          example: 42
      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
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: >-
        Your Robase API key. Starts with robe_. Include as Authorization: Bearer
        robe_...

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.