Seamtel OTP API design guide
This is the field-by-field contract for outbound verification codes. You send a 6-digit token to your user. They type it. You hold a reference. The follow-along page is the story. This file is the schema.
Authentication and verification flows only (login, signup, a confirmation). This is not a broadcast channel.
Base URL: https://seamtel.com/api/v1
API Reference (Scalar, Try It Out): /docs
Endpoint index
| Method | Path | Purpose |
|---|---|---|
GET |
/me |
Account profile and USD balance |
GET |
/otp/pricing |
OTP rate card by channel |
POST |
/otp/send |
Send a verification code |
POST |
/otp/verify |
Verify a code against a reference |
Typical integration flow
1. GET /me check balance
2. GET /otp/pricing optional, the rate card
3. POST /otp/send save the reference
4. POST /otp/verify pair that reference with the 6-digit token
Part 1: Foundations
Authentication
Every request requires a Bearer API key.
Authorization: Bearer SHS_your_api_key
Content-Type: application/json
Accept: application/json
Generate a key from your dashboard under API Key. The full key is shown once when you create or regenerate it. Copy it immediately.
Missing or invalid keys return 401. Suspended accounts return 403.
Response format
Success responses wrap the payload:
{
"msg": "OTP requested",
"data": { ... }
}
Error responses use the same envelope with an errors field:
{
"msg": "Unprocessable entity",
"errors": {
"channel": ["The selected channel is invalid."]
}
}
Validation failures return 422. Business failures (insufficient funds, verify codes) return 400.
Rate limits
Limits apply per API key:
| Activity | Default limit |
|---|---|
Read endpoints (GET) |
100 requests / minute |
OTP send (POST /otp/send) |
10 / minute per key |
| OTP send to one recipient | 5 / hour, all accounts |
| OTP resend to the same recipient | 60 seconds between sends |
When exceeded, the API returns 429 Too Many Requests.
GET /me
Returns the authenticated user's profile and spendable USD balance.
Request
GET /api/v1/me
Authorization: Bearer SHS_your_api_key
Response 200
{
"msg": "Profile",
"data": {
"id": "usr_abc123",
"name": "Jane Doe",
"username": "janedoe",
"email": "jane@example.com",
"balance": 12.5,
"credits": 250,
"created_at": "2026-08-07T14:20:00+00:00"
}
}
| Field | Type | Description |
|---|---|---|
id |
string | Opaque user identifier |
balance |
number | Spendable USD wallet balance |
credits |
integer | Remaining credits (0 if none) |
created_at |
string | ISO 8601 account creation timestamp |
Call this before a batch of sends to confirm you have enough balance.
Part 2: Outbound OTP
Seamtel owns the code — generating it, delivering it, and knowing whether it is still valid.
Channels
You pick a channel per send. The code, the expiry, and the verify step stay the same. What changes is the inbox and the recipient format.
| Channel | Value | Recipient | Pricing |
|---|---|---|---|
| SMS | sms |
E.164 phone (+15551234567) |
Per country, plus a fallback row |
whatsapp |
E.164 phone | Per country, plus a fallback row | |
email |
Email address | Fallback row only |
What carries the message is an internal routing decision and is not part of the contract. You send sms, whatsapp, or email.
Sender ID
An account needs an approved Sender ID before it can send. That is the name in the message (“Your {Sender ID} verification code is …”). Submit it from the dashboard. Sends return 403 until it is approved.
Names that impersonate banks, brands, or institutions are reserved. Impersonating a bank will not work. We checked.
POST /otp/send
| Field | Type | Notes |
|---|---|---|
channel |
string | sms, whatsapp, or email |
recipient |
string | E.164 for SMS/WhatsApp; an email address for email |
idempotency_key |
string? | shs- + a UUID. Omit it and one is minted and returned |
Request
POST /api/v1/otp/send
Authorization: Bearer SHS_your_api_key
Content-Type: application/json
{
"channel": "sms",
"recipient": "+15551234567"
}
Response 201
{
"msg": "OTP requested",
"data": {
"reference": "550e8400-e29b-41d4-a716-446655440000",
"channel": "sms",
"expires_at": "2026-09-18T14:25:00+00:00",
"idempotency_key": "shs-550e8400-e29b-41d4-a716-446655440000"
}
}
Reusing the same idempotency_key for the same recipient and channel returns 200 with the original reference and an Idempotency-Replayed: true header. The send is not repeated. You are not charged twice. That is the point of the header.
POST /otp/verify
| Field | Type | Notes |
|---|---|---|
reference |
string | the reference from the send response |
token |
string | the 6-digit code the end user read |
Request
POST /api/v1/otp/verify
Authorization: Bearer SHS_your_api_key
Content-Type: application/json
{
"reference": "550e8400-e29b-41d4-a716-446655440000",
"token": "123456"
}
Response 200
{
"msg": "Verified.",
"data": {
"reference": "550e8400-e29b-41d4-a716-446655440000",
"verified": true
}
}
Failure 400 with a stable errors.code. “It didn’t work” is not an error model.
| Code | Meaning |
|---|---|
invalid_token |
wrong code |
expired |
the code's window has passed |
superseded |
a newer code was sent — use the most recent message |
pending |
the code is not ready yet — retry shortly |
already_verified |
the code was already used |
too_many_attempts |
too many wrong attempts — request a new code |
GET /otp/pricing
Returns the retail rate card, grouped by channel.
GET /api/v1/otp/pricing
Authorization: Bearer SHS_your_api_key
{
"msg": "OTP pricing",
"data": {
"sms": [
{ "country": null, "name": "Global", "price": "0.006000" },
{ "country": "NG", "name": "Nigeria", "price": "0.006500" }
]
}
}
The row with country: null is the fallback rate. Email always uses that row.
Lifecycle
- A
referencenames one send, forever. Nothing else identifies a send. - A code is valid until
expires_at(default 300 seconds). - A code can be verified once. After that the reference reports
already_verified. - Requesting a new code for the same recipient retires the previous one (
superseded) only once the new code has actually been sent.
OTP billing
- Prices are per channel, with optional per-country overrides and a fallback row.
- A send is charged once it is accepted, at the rate for that channel and destination.
- Prices are in USD, quoted to 6 decimal places.
- A send that cannot be covered by the account balance returns
400. - Wrong verify attempts are free. We are not that kind of shop.
OTP errors
| Status | Meaning |
|---|---|
401 |
missing or invalid API key |
403 |
account not allowed (suspended, or no approved Sender ID) |
422 |
the request body failed validation |
400 |
a business failure (insufficient balance, or a verify code above) |
429 |
a rate limit or cooldown |
Questions or feedback? Contact us and we will help. We still like you.