SMS API Documentation
v1.0

Introduction

SMS delivery across Nigerian telecom networks via the MOAA TECH platform.

The MOAA TECH SMS API enables programmatic SMS delivery across all major Nigerian telecom networks: MTN, Airtel, Glo, and 9mobile. Messages are routed through one of two route types depending on your use case.

Transactional Route

For OTPs, transaction alerts, and time sensitive notifications. Highest delivery priority. Exempt from Do Not Disturb (DND) filtering. Sub second dispatch.

Promotional Route

For bulk campaigns and marketing messages. DND compliant. Supports scheduled delivery. Cost effective for high volume sends.

Base URL https://api.moaatech.com
All phone numbers must be in international format with country code. Nigerian numbers use the +234 prefix (e.g. +2348031234567). A single SMS unit covers up to 160 characters for plain text or 70 characters for unicode.

Authentication

All API requests require a valid Bearer token or API key in the Authorization header.

Include your token in every request using the Authorization header. Bearer tokens are obtained through the login flow and expire after 15 minutes. For server to server integration, use API keys instead. API keys are prefixed with mt_ and do not expire.

# Using a Bearer token
curl https://api.moaatech.com/api/sms/transactional \
  -H "Authorization: Bearer eyJhbGciOi..."

# Using an API key
curl https://api.moaatech.com/api/sms/transactional \
  -H "Authorization: Bearer mt_your_api_key_here"
const headers = {
  'Authorization': `Bearer ${apiKey}`,
  'Content-Type': 'application/json'
};
Manage your API keys in the Console → Settings → Developer tab. You can create, rotate, and revoke keys from there. Never expose API keys in client side code.

Send Transactional SMS

Send a high priority, DND exempt SMS for OTPs, alerts, and transaction notifications.

POST /api/sms/transactional

Delivers a single SMS via the transactional route. Messages sent through this endpoint bypass DND filters and are dispatched with highest priority. Ideal for OTP codes, payment confirmations, and time critical alerts.

Request Body
FieldTypeDescription
to requiredstringRecipient phone number in international format (e.g. +2348031234567)
sender requiredstringApproved sender ID (e.g. MTECH). Max 11 characters.
message requiredstringSMS body content. 160 chars per unit (plain) or 70 chars (unicode).
type optionalstringplain or unicode. Defaults to plain.
curl -X POST https://api.moaatech.com/api/sms/transactional \
  -H "Authorization: Bearer mt_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348031234567",
    "sender": "MTECH",
    "message": "Your verification code is 482910. Expires in 5 minutes."
  }'
const response = await fetch('https://api.moaatech.com/api/sms/transactional', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer mt_your_api_key',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    to: '+2348031234567',
    sender: 'MTECH',
    message: 'Your verification code is 482910. Expires in 5 minutes.'
  })
});

const data = await response.json();
200 Success
{
  "success": true,
  "messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "queued",
  "units": 1,
}
400 Validation Error
{
  "success": false,
  "error": "VALIDATION_ERROR",
  "message": "\"to\" must be a valid phone number"
}

Send Enterprise SMS

Send SMS via the enterprise route with optional scheduling for future delivery.

POST /api/sms/enterprise

Delivers a single SMS via the enterprise route. Supports all the same features as transactional with the addition of scheduled delivery. Suitable for business notifications, appointment reminders, and bulk communications.

Request Body
FieldTypeDescription
to requiredstringRecipient phone number in international format (e.g. +2348031234567)
sender requiredstringApproved sender ID (e.g. MTECH). Max 11 characters.
message requiredstringSMS body content. 160 chars per unit (plain) or 70 chars (unicode).
type optionalstringplain or unicode. Defaults to plain.
scheduledAt optionalstringISO 8601 datetime for future delivery (e.g. 2026-04-10T09:00:00Z). Omit to send immediately.
curl -X POST https://api.moaatech.com/api/sms/enterprise \
  -H "Authorization: Bearer mt_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348031234567",
    "sender": "MTECH",
    "message": "Your appointment is confirmed for April 10 at 9:00 AM.",
    "scheduledAt": "2026-04-10T09:00:00Z"
  }'
const response = await fetch('https://api.moaatech.com/api/sms/enterprise', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer mt_your_api_key',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    to: '+2348031234567',
    sender: 'MTECH',
    message: 'Your appointment is confirmed for April 10 at 9:00 AM.',
    scheduledAt: '2026-04-10T09:00:00Z'
  })
});

const data = await response.json();
200 Success (scheduled)
{
  "success": true,
  "messageId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
  "status": "scheduled",
  "units": 1,
}
401 Unauthorized
{
  "success": false,
  "error": "UNAUTHORIZED",
  "message": "Invalid or expired token"
}

Send OTP

Generate and deliver a one time password via SMS for identity verification.

POST /api/otp/send

Generates a random OTP, creates a session, and delivers the code via SMS. Each session tracks verification attempts and expires automatically. Built in fraud guards enforce per phone and per IP rate limits.

Request Body
FieldTypeDescription
phone requiredstringRecipient phone number (e.g. +2348031234567)
length optionalintegerOTP length: 4 or 6. Defaults to 6.
ttl optionalintegerTime to live in seconds. Defaults to 300 (5 minutes).
curl -X POST https://api.moaatech.com/api/otp/send \
  -H "Authorization: Bearer mt_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+2348031234567",
    "length": 6,
    "ttl": 300
  }'
const response = await fetch('https://api.moaatech.com/api/otp/send', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer mt_your_api_key',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    phone: '+2348031234567',
    length: 6,
    ttl: 300
  })
});

const data = await response.json();
200 Success
{
  "success": true,
  "session_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
  "expires_in": 300,
  "messageId": "d4e5f6a7-b8c9-0123-defa-234567890123"
}
Fraud protection: OTP requests are limited to 3 attempts per phone number within the session TTL window. Per IP rate limits also apply. Exceeding these limits returns a 429 RATE_LIMITED error.

Verify OTP

Validate a one time password against an active session.

POST /api/otp/verify

Checks the submitted OTP against the session. Returns the verification result along with remaining attempts. Sessions are invalidated after successful verification or after all attempts are exhausted.

Request Body
FieldTypeDescription
session_id requiredstringSession ID returned from POST /api/otp/send
otp requiredstringThe OTP code entered by the user
curl -X POST https://api.moaatech.com/api/otp/verify \
  -H "Authorization: Bearer mt_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
    "otp": "482910"
  }'
const response = await fetch('https://api.moaatech.com/api/otp/verify', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer mt_your_api_key',
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    session_id: 'c3d4e5f6-a7b8-9012-cdef-123456789012',
    otp: '482910'
  })
});

const data = await response.json();
200 Verified
{
  "verified": true,
  "attemptsUsed": 1,
  "attemptsRemaining": 2
}
200 Incorrect OTP
{
  "verified": false,
  "attemptsUsed": 2,
  "attemptsRemaining": 1
}

Message History

Retrieve paginated SMS delivery records with filtering.

GET /api/messages

Returns a paginated list of messages sent by your organization. Supports filtering by status, telco network, date range, sender ID, and channel.

Query Parameters
FieldTypeDescription
page optionalintegerPage number. Defaults to 1.
limit optionalintegerResults per page. Defaults to 20, max 100.
status optionalstringFilter by status: queued, sent, delivered, failed
telco optionalstringFilter by network: MTN, Airtel, Glo, 9mobile
dateFrom optionalstringStart date (ISO 8601). E.g. 2026-04-01T00:00:00Z
dateTo optionalstringEnd date (ISO 8601). E.g. 2026-04-05T23:59:59Z
senderId optionalstringFilter by sender ID (e.g. MTECH)
channel optionalstringFilter by channel: sms
curl "https://api.moaatech.com/api/messages?page=1&limit=20&status=delivered&telco=MTN" \
  -H "Authorization: Bearer mt_your_api_key"
const params = new URLSearchParams({
  page: '1',
  limit: '20',
  status: 'delivered',
  telco: 'MTN'
});

const response = await fetch(
  `https://api.moaatech.com/api/messages?${params}`,
  { headers: { 'Authorization': 'Bearer mt_your_api_key' } }
);

const data = await response.json();
200 Success
{
  "success": true,
  "messages": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "phone": "+2348031234567",
      "sender_id": "MTECH",
      "channel": "sms",
      "route_type": "transactional",
      "status": "delivered",
      "telco": "MTN",
      "sms_units": 1,

      "currency": "NGN",
      "sent_at": "2026-04-05T10:30:00Z",
      "delivered_at": "2026-04-05T10:30:02Z"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 142
  }
}

Get Message

Retrieve details for a single message by its ID.

GET /api/messages/:id

Returns the full details of a specific message including delivery status, cost, and timing information.

Path Parameters
FieldTypeDescription
id requiredstringThe message UUID returned when the SMS was sent
curl https://api.moaatech.com/api/messages/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
  -H "Authorization: Bearer mt_your_api_key"
const messageId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

const response = await fetch(
  `https://api.moaatech.com/api/messages/${messageId}`,
  { headers: { 'Authorization': 'Bearer mt_your_api_key' } }
);

const data = await response.json();
200 Success
{
  "success": true,
  "message": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+2348031234567",
    "sender_id": "MTECH",
    "channel": "sms",
    "route_type": "transactional",
    "status": "delivered",
    "telco": "MTN",
    "message_body": "Your verification code is 482910. Expires in 5 minutes.",
    "message_length": 55,
    "sms_units": 1,
    "sent_at": "2026-04-05T10:30:00Z",
    "delivered_at": "2026-04-05T10:30:02Z",
    "latency_ms": 2100,
    "created_at": "2026-04-05T10:30:00Z"
  }
}

Sender IDs

List the sender IDs registered to your organization.

GET /api/sender-ids

Returns all sender IDs associated with your organization, including their approval status and purpose. Only sender IDs with active status can be used to send messages.

curl https://api.moaatech.com/api/sender-ids \
  -H "Authorization: Bearer mt_your_api_key"
const response = await fetch('https://api.moaatech.com/api/sender-ids', {
  headers: { 'Authorization': 'Bearer mt_your_api_key' }
});

const data = await response.json();
200 Success
{
  "success": true,
  "senderIds": [
    {
      "id": "e5f6a7b8-c9d0-1234-efab-567890123456",
      "sender_id": "MTECH",
      "status": "active",
      "purpose": "transactional",
      "approved_at": "2026-01-15T12:00:00Z"
    },
    {
      "id": "f6a7b8c9-d0e1-2345-fabc-678901234567",
      "sender_id": "MTECH-MKT",
      "status": "active",
      "purpose": "promotional",
      "approved_at": "2026-01-15T12:00:00Z"
    }
  ]
}

Webhooks

Receive real time delivery status updates via HTTP callbacks.

MOAA TECH sends outbound HTTP POST requests to your configured callback URL when message delivery events occur. Configure your webhook URL in Console → Settings → Webhooks.

POST your-callback-url

MOAA TECH will POST to your callback URL with the following payload when a message status changes. Events include message.delivered and message.failed.

Webhook Payload
FieldTypeDescription
eventstringmessage.delivered or message.failed
message_idstringUUID of the message
phonestringRecipient phone number
sender_idstringSender ID used
statusstringdelivered or failed
delivered_atstringISO 8601 timestamp (present for delivered events)
Example Webhook Payload
{
  "event": "message.delivered",
  "message_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "phone": "+2348031234567",
  "sender_id": "MTECH",
  "status": "delivered",
  "delivered_at": "2026-04-05T10:30:02Z"
}
Signature Verification

Every webhook request includes an X-Moaa-Signature header containing an HMAC SHA256 signature of the raw request body, signed with your webhook secret. Always verify this signature to confirm the request originated from MOAA TECH.

const crypto = require('crypto');

function verifyWebhook(req, secret) {
  const signature = req.headers['x-moaa-signature'];
  if (!signature) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(req.rawBody)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Express route handler
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyWebhook(req, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);
  // Process the webhook event...
  res.status(200).send('OK');
});
Always use constant time comparison (e.g. crypto.timingSafeEqual) when verifying signatures to prevent timing attacks. Your webhook secret is available in Console → Settings → Webhooks.

Error Codes

Standard error responses returned by the API.

All error responses follow a consistent structure with success: false, an error code, and a human readable message.

StatusCodeDescription
400 VALIDATION_ERROR Request body or query parameters failed validation. Check the message field for specifics.
401 UNAUTHORIZED Missing, invalid, or expired authentication token.
402 INSUFFICIENT_BALANCE Wallet balance is too low to cover the message cost. Top up via Console.
403 FORBIDDEN Authenticated but lacking permission for this action or resource.
404 NOT_FOUND The requested resource does not exist.
429 RATE_LIMITED Too many requests. Back off and retry after the period indicated in the Retry-After header.
500 INTERNAL_ERROR An unexpected server error occurred. Contact support if it persists.
Error Response Format
{
  "success": false,
  "error": "INSUFFICIENT_BALANCE",
  "message": "Insufficient wallet balance for this request."
}

Rate Limits

Request throttling applied to all API endpoints.

All API endpoints are rate limited to ensure fair usage. Rate limit information is returned in response headers on every request.

Limit
100
requests per minute
Header
X-RateLimit-Limit
max requests allowed
Header
X-RateLimit-Remaining
requests left in window
When rate limited, the API returns 429 RATE_LIMITED with a Retry-After header indicating how many seconds to wait before retrying. Need a higher limit? Contact support to request an increase for your account.
429 Rate Limited
{
  "success": false,
  "error": "RATE_LIMITED",
  "message": "Too many requests. Please retry after 45 seconds."
}

Sandbox Mode

Test your integration without dispatching real SMS messages.

Enable sandbox mode in Console → Settings → Sandbox to test API calls without sending real SMS messages. All endpoints behave normally, but no messages are actually dispatched. Message status is simulated as delivered and no wallet balance is deducted.

Sandbox mode is ideal for development and integration testing. All API responses match the production format exactly, so your code will work without changes when you switch to live mode.
Sandbox Send Response
{
  "success": true,
  "messageId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "sandbox",
  "units": 1,
  "sandbox": true
}