robase-dev OTP API

Generate, deliver and verify one-time passwords.

Operations 3

POST /v1/otp/send Generate and send an OTP #
POST /v1/otp/verify Verify an OTP code #
GET /v1/otp/{id} Get OTP status #

Work with this as data

Every API here is available over the APIs.io API and to AI agents over MCP.

MCP server

One button, every client — Claude, Cursor, VS Code and the rest.

https://apis.io/mcp

Tools for apis

7 MCP tools reach this
  • find_apisBrowse and filter every API in the catalog.
  • get_api_artifactsOne API's artifacts, grouped by type.
  • get_openapiThe primary OpenAPI for this API.
  • find_similar_apisAPIs that look like this one.
  • apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.
  • resolveTurn a domain, URL or GitHub org into the provider it belongs to.
  • find_cohortsEvery scored population of providers in the catalog.
All 92 tools →

Call it yourself

curl for this page
This API
curl "https://apis.io/api/v1/apis/robase-dev:robase-dev-otp-api"
All apis
curl "https://apis.io/api/v1/apis?limit=25"

Discovery needs no key. Ratings and market analysis are Pro.

Get an API key

Free tier, no form to fill in. Signing in shares your email address with us — we store it to create your key and to recognise you if you sign in with another provider. See our Privacy Policy and Terms.

A second provider on the same verified email joins the account you already have.

OpenAPI Specification

robase-dev-otp-api-openapi.yml Raw ↑
openapi: 3.2.0
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.
paths:
  /v1/otp/send:
    post:
      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
      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'
        '503':
          $ref: '#/components/responses/ScreeningUnavailable'
  /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'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '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.


        `status` never reads `delivered`. A carrier delivery receipt sets

        `delivered_at`, adds a `delivered` hop to `timeline`, and fires the

        `otp.delivered` webhook, while `status` stays where it was (normally

        `sent`) until the code is verified, expires or fails. Some routes never

        send a receipt, so a code can reach the phone without ever showing

        `delivered_at`.'
      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'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  responses:
    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
    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
    ScreeningUnavailable:
      description: 'A pre-send check could not reach a verdict: the content check on a

        new workspace''s `/v1/sms/send` request, or the pumping controls on

        any `/v1/sms/send` or `/v1/otp/send`. The checks run before anything

        is charged or queued; when one fails, the message is not sent.

        Nothing is charged. Retry after `Retry-After` with the same

        `Idempotency-Key`.

        '
      headers:
        Retry-After:
          description: Whole seconds to wait before retrying.
          schema:
            type: integer
            minimum: 1
          example: 5
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              type: screening_unavailable
              message: content check unavailable; retry shortly, nothing charged
    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)
    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
    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
    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
    InsufficientScope:
      description: 'The API key does not have the scope this endpoint needs. Agent keys

        carry scopes (`otp:send`, `otp:verify`, `sms:send`, `read`,

        `approvals:request`); keys made before scopes existed keep full access.

        '
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              type: insufficient_scope
              message: this API key does not have the read scope
              required_scope: read
  schemas:
    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`, `workspace_suspended`,

            `workspace_restricted`, `kyc_required`. 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.

            '
    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
          description: 'The OTP''s state. There is no `delivered` value: a carrier receipt

            is reported through `delivered_at`, a `delivered` hop in

            `timeline`, and the `otp.delivered` webhook, while `status`

            stays where it was (normally `sent`).

            '
        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
          description: 'When the first delivery receipt arrived, from the carrier for the

            SMS leg or from WhatsApp for a WhatsApp leg. Absent until then, and

            absent for good on routes that send no receipt. This, not

            `status`, is how a polled OTP shows delivery.

            '
        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
        channel:
          type: string
          enum:
          - sms
          - whatsapp
          - both
          description: Channel(s) this OTP was sent on. Codes from before WhatsApp OTP read `sms`.
        whatsapp_status:
          type: string
          enum:
          - pending
          - sent
          - delivered
          - failed
          description: 'The WhatsApp leg''s own state. Present only when `channel` is

            `whatsapp` or `both`. Unlike `status`, this field does take

            `delivered`.

            '
        timeline:
          type: array
          description: Hops the OTP took, oldest first.
          items:
            $ref: '#/components/schemas/TimelineItem'
    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.
    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'
    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
    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.

            '
    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 to 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
          maxProperties: 50
          description: 'Arbitrary key-value data stored with the OTP and returned by `GET /v1/otp/{id}`.

            At most 50 keys and 4 KB of JSON, or the request fails with

            `validation_error`. Reserved keys (`antispam` and any key starting

            with `robase_`) are dropped.

            '
      example:
        phone_number: '+2348012345678'
        code_length: 6
        ttl_seconds: 600
        language: fr
        channel: whatsapp
  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
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: 'Your Robase API key. Starts with robe_. Include as Authorization: Bearer robe_...'