Every API here is available over the APIs.io API and to AI agents over MCP.
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_...'