Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Robase OTP SMS 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: SMS
description: Send arbitrary transactional messages.
paths:
/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'
'422':
$ref: '#/components/responses/SenderIDRequired'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/InternalError'
'503':
$ref: '#/components/responses/ScreeningUnavailable'
/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.'
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'
'403':
$ref: '#/components/responses/InsufficientScope'
'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, how many credits an
SMS to each destination is charged, and live WhatsApp OTP credits.
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.
`whatsapp` lists WhatsApp OTP credit costs computed at request time
from `whatsapp_otp_costs` plus FX: `ceil(cost_ngn * 1.20 / credit_price)`,
minimum 1, the same formula as send. Empty when no cost rows are
loaded. WhatsApp is OTP-only (Meta Authentication + copy-code).
A long SMS is split into parts and charged per part; see
Segments and 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'
components:
schemas:
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
- 'null'
additionalProperties: 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`, `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.
'
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
PricingWhatsApp:
type: object
required:
- country_code
- credits
- price
properties:
country_code:
type: string
description: 'ISO 3166-1 alpha-2 country code, or `*` for the platform default
row used when a destination has no country-specific cost.
'
example: NG
country_name:
type: string
description: Display name when known; `Default` for `*`.
example: Nigeria
credits:
type: integer
description: 'Credits charged for one WhatsApp OTP to this destination.
`ceil(cost_ngn * 1.20 / credit_price)`, minimum 1.
'
example: 5
price:
type: integer
description: '`credits` times `credit_price`.'
example: 30
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
Pricing:
type: object
required:
- currency
- credit_price
- sender_id_requires_purchase
- countries
- whatsapp
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
sender_id_requires_purchase:
type: boolean
description: 'A workspace must complete at least one credit purchase before it
may request a custom Sender ID.
'
example: true
countries:
type: array
description: Priced SMS destinations, cheapest first.
items:
$ref: '#/components/schemas/PricingCountry'
whatsapp:
type: array
description: 'WhatsApp OTP credit costs by destination, computed live from
`whatsapp_otp_costs` (+ FX) with the same formula as send.
Always present; empty when no rows are loaded.
'
items:
$ref: '#/components/schemas/PricingWhatsApp'
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
maxProperties: 50
description: 'Arbitrary key-value data attached to the message. 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.
'
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
responses:
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
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
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
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
SenderIDRequired:
description: 'The workspace needs its own approved sender ID for this destination
country. Workspaces created after 8 October 2026 send transactional SMS
only under their own name where custom sender IDs are offered. Nothing
is charged. OTP is not affected.
'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
error:
type: sender_id_required
message: Request a sender ID to send SMS.
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
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
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
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
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_...'