MOAA TECH API Reference
v1.0
Open Console →

Introduction

REST API for transactional SMS, OTP, and bulk campaign delivery across Nigerian telecom networks.

Base URL https://api.moaatech.com

Transactional Route

OTPs, transaction alerts, and critical notifications. Highest priority delivery, DND-exempt, sub-second dispatch via dedicated telecom routes.

Promotional Route

Bulk campaigns, personalized marketing messages, and scheduled delivery. DND-compliant with automatic failover across MTN, Airtel, Glo, and 9mobile.

Intelligent Routing

AI picks the best delivery route at the exact moment each message is sent. Provider selection and switching are transparent to your application.

Request Format

All request bodies are JSON. Set Content-Type: application/json. Responses are always JSON unless exporting CSV.

All timestamps are ISO 8601 UTC. Nigerian phone numbers are accepted in local format (0801...) or E.164 (+2348...) — the API normalises them automatically.

Authentication

Bearer token authentication. Include your access token in every API request.

Obtain an access token by signing in to the Sharp Console. Include the token in every API request using the Authorization header:

HTTP Header
Authorization: Bearer <your_access_token>
API keys (prefixed mt_) can also be used in place of session tokens for server-to-server integrations. Manage keys in Console → Settings → Developer. See the API Keys section for details.
Tokens expire after 15 minutes. If you receive a 401 UNAUTHORIZED response, your token has expired. Sign in again through the Console or use your integration's stored API key.

SMS

Send direct messages and retrieve message history. All sending endpoints require a valid sender ID registered to your organization.

Sender ID: Use only approved and pre-registered sender IDs. You can view your approved sender IDs via GET /sender-ids.
Message Type: Use plain for normal messages and unicode for messages containing special characters (e.g. accented letters, emojis).
POST /api/sms/transactional

Send a transactional SMS — OTP delivery confirmations, transaction alerts, and critical notifications. These messages are routed with highest priority, are DND-exempt, and are dispatched within milliseconds.

Wallet balance is checked before queuing. An HTTP 402 is returned if funds are insufficient.

Request Body
FieldTypeDescription
tostringRecipient phone number required
senderstringApproved sender ID (e.g. MTECH) required
messagestringMessage body (160 chars = 1 unit) required
typestringplain (default) or unicode optional
curl -X POST https://api.moaatech.com/api/sms/transactional \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348031234567",
    "sender": "MTECH",
    "message": "Your transaction of ₦50,000 was successful. Balance: ₦245,000."
  }'
const res = await fetch('https://api.moaatech.com/api/sms/transactional', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    to: '+2348031234567',
    sender: 'MTECH',
    message: 'Your transaction of ₦50,000 was successful.'
  })
});
const { messageId, status } = await res.json();
Response
200 OK
{
  "success": true,
  "messageId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "queued",
  "units": 1,
}
POST /api/sms/enterprise

Send an enterprise SMS — bulk campaigns, personalized marketing, and promotional messages. DND-compliant with scheduled delivery support. Pass scheduledAt to defer delivery to a specific time.

Wallet balance is checked before queuing. An HTTP 402 is returned if funds are insufficient.

Request Body
FieldTypeDescription
tostringRecipient phone number required
senderstringApproved sender ID (e.g. MTECH) required
messagestringMessage body (160 chars = 1 unit) required
typestringplain (default) or unicode optional
scheduledAtstringISO 8601 future timestamp to schedule delivery optional
curl -X POST https://api.moaatech.com/api/sms/enterprise \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+2348031234567",
    "sender": "MTECH",
    "message": "Hi! Check out our new savings plan with 15% returns.",
    "scheduledAt": "2026-04-01T09:00:00.000Z"
  }'
const res = await fetch('https://api.moaatech.com/api/sms/enterprise', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    to: '+2348031234567',
    sender: 'MTECH',
    message: 'Hi! Check out our new savings plan with 15% returns.',
    scheduledAt: '2026-04-01T09:00:00.000Z'
  })
});
const { messageId, status } = await res.json();
Response
200 OK
{
  "success": true,
  "messageId": "660e8400-e29b-41d4-a716-446655440000",
  "status": "scheduled",
  "units": 1,
}
GET /api/messages
List messages for your organization, with filtering and pagination.
Query Parameters
ParameterTypeDescription
pageintegerPage number, default 1 optional
limitintegerResults per page, default 25, max 100 optional
statusstringFilter: queued, sent, delivered, failed optional
telcostringFilter: mtn, airtel, glo, 9mobile optional
dateFromstringISO 8601 start date optional
dateTostringISO 8601 end date optional
senderIdstringFilter by sender ID optional
channelstringFilter: sms, otp optional
200 OK
{
  "data": [
    {
      "id": "uuid",
      "phone": "+2348031234567",
      "sender": "MTECH",
      "message": "Your transaction of ₦50,000 was successful.",
      "status": "delivered",
      "telco": "mtn",
      "units": 1,

      "sentAt": "2026-03-26T08:14:03.000Z",
      "deliveredAt": "2026-03-26T08:14:04.312Z",
      "latencyMs": 1312
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 25,
    "total": 18432,
    "pages": 738
  }
}
GET /api/messages/:id
Fetch full details for a single message.
Path Parameters
ParameterTypeDescription
idstringMessage UUID required
200 OK
{
  "id": "uuid",
  "phone": "+2348031234567",
  "sender": "MTECH",
  "message": "Your transaction of ₦50,000 was successful.",
  "status": "delivered",
  "telco": "mtn",
  "units": 1,
  "latencyMs": 1312,
  "sentAt": "2026-03-26T08:14:03.000Z",
  "deliveredAt": "2026-03-26T08:14:04.312Z"
}
GET /api/messages/scheduled
List all pending scheduled messages and campaigns for your organization.
200 OK
{
  "data": [
    {
      "id": "uuid",
      "type": "campaign",
      "sender": "MTECH-MKT",
      "recipientCount": 5000,
      "scheduledAt": "2026-03-28T09:00:00.000Z",
      "status": "scheduled"
    }
  ]
}
GET /api/messages/export/csv
Export messages as a CSV file. Accepts the same query parameters as the list endpoint. Returns a file download with Content-Disposition: attachment.
Response content-type is text/csv. Headers: id, phone, sender, message, status, telco, units, cost, sentAt, deliveredAt, latencyMs.

Notifications

Unified multi-channel notification delivery. Send to any channel your account has enabled — SMS, email, and more — with optional automatic fallback if delivery fails on the primary channel.

Channel availability: Each account has a set of enabled channels. Attempting to send on a channel not enabled for your account returns HTTP 403. Contact support to enable additional channels.
POST /api/notify/send

Send a notification on any enabled channel. Provide the channel, the message body, and the appropriate recipient field for that channel. Wallet balance is checked before queuing — HTTP 402 is returned if funds are insufficient.

To send on SMS include phone and sender. For email include email and subject. Use fallbackChain to automatically retry on a second channel if the first fails, providing all required recipient fields for every channel in the chain.

Request Body
FieldTypeDescription
channelstringDelivery channel: sms or email required
messagestringMessage body required
phonestringRecipient phone number — required for sms channel
emailstringRecipient email address — required for email channel
senderstringApproved sender ID — required for sms channel optional
subjectstringEmail subject line — required for email channel optional
routeTypestringtransactional (default) or promotional optional
fallbackChainarrayOrdered list of channels to try if primary fails, e.g. ["email"]. Include recipient fields for all channels in the chain. optional
scheduledAtstring (ISO 8601)Defer delivery to a future time, e.g. 2025-01-15T09:00:00Z optional
curl -X POST https://api.moaatech.com/api/notify/send \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "sms",
    "phone": "+2348031234567",
    "sender": "MTECH",
    "message": "Your order #12345 has been confirmed. Delivery expected within 2 hours.",
    "routeType": "transactional"
  }'
curl -X POST https://api.moaatech.com/api/notify/send \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "email",
    "email": "customer@example.com",
    "subject": "Your order has been confirmed",
    "message": "Hello,\n\nYour order #12345 has been confirmed. Expected delivery within 2 hours.\n\nThank you.",
    "routeType": "transactional"
  }'
// Send via SMS first; fall back to email if SMS fails
const res = await fetch('https://api.moaatech.com/api/notify/send', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    channel: 'sms',
    phone: '+2348031234567',
    sender: 'MTECH',
    email: 'customer@example.com',
    message: 'Your transaction of ₦50,000 was successful.',
    subject: 'Transaction Confirmation',
    routeType: 'transactional',
    fallbackChain: ['email']
  })
});
const data = await res.json();
// data.accepted, data.message_id, data.channel, data.fallback_channels
Response
200 OK
{
  "accepted": true,
  "message_id": "550e8400-e29b-41d4-a716-446655440000",
  "channel": "sms",
  "scheduled": false,
  "fallback_channels": ["email"]
}

OTP

Bank-grade one-time password delivery. OTPs are generated server-side using cryptographically secure random integers and are never transmitted in logs.

POST /api/otp/send

Generate and deliver an OTP to the given phone number. Returns a session_id which must be used in the subsequent verify call.

Fraud guards enforce per-phone and per-IP limits before dispatch. The OTP expires after the configured TTL; the session is invalidated after three failed attempts.

Request Body
FieldTypeDescription
phonestringRecipient phone (080... or +234...) required
lengthintegerOTP length: 4 or 6, default 6 optional
ttlintegerExpiry in seconds, default 300 optional
curl -X POST https://api.moaatech.com/api/otp/send \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+2348031234567",
    "length": 6,
    "ttl": 300
  }'
const res = await fetch('https://api.moaatech.com/api/otp/send', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ phone: '+2348031234567', length: 6 })
});
const { session_id, expires_in } = await res.json();
Response
200 OK
{
  "success": true,
  "session_id": "otp_550e8400e29b41d4a716446655440000",
  "expires_in": 300,
  "messageId": "uuid"
}
POST /api/otp/verify

Verify a submitted OTP against a session. Maximum 3 attempts per session; exceeding this invalidates the session and requires a new send.

Request Body
FieldTypeDescription
session_idstringSession ID from the send response required
otpstringOTP entered by the user required
curl -X POST https://api.moaatech.com/api/otp/verify \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "otp_550e8400e29b41d4a716446655440000",
    "otp": "847261"
  }'
const res = await fetch('https://api.moaatech.com/api/otp/verify', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    session_id: 'otp_550e8400e29b41d4a716446655440000',
    otp: '847261'
  })
});
const { verified } = await res.json();
Response
200 OK
{
  "verified": true,
  "attemptsUsed": 1,
  "attemptsRemaining": 2
}

Sender IDs

Inspect the approved sender IDs registered to your organization.

GET /api/sender-ids
List all sender IDs for your organization and their current status.
200 OK
{
  "data": [
    {
      "id": "uuid",
      "senderId": "MTECH",
      "status": "active",
      "purpose": "transactional",
      "approvedAt": "2025-11-01T00:00:00.000Z",
      "expiresAt": "2026-11-01T00:00:00.000Z"
    },
    {
      "id": "uuid",
      "senderId": "MTECH-MKT",
      "status": "active",
      "purpose": "promotional",
      "approvedAt": "2025-11-01T00:00:00.000Z",
      "expiresAt": "2026-11-01T00:00:00.000Z"
    }
  ]
}

API Keys

Programmatic API keys for server-to-server integration without user sessions.

API keys are managed entirely from the Console. Go to Settings → Developer to create a key. The full key value (mt_...) is shown only once at creation — store it securely immediately. If a key is compromised, rotate it from the same settings page. The old key is invalidated instantly and a new key is shown once.
GET /api/keys
List all API keys for your organization. The key value itself is never returned — only metadata.
200 OK
{
  "data": [
    {
      "id": "uuid",
      "name": "Core Banking Integration",
      "prefix": "mt_live_xK9...",
      "lastUsedAt": "2026-03-26T07:55:00.000Z",
      "createdAt": "2025-12-01T00:00:00.000Z"
    }
  ]
}

Webhooks

MOAA TECH can push real-time delivery status events to your server as messages are delivered or fail.

Configure your callback URL in Console → Settings → Developer. MOAA TECH will POST a signed payload to your URL whenever a message status changes.
POST {your_callback_url}

MOAA TECH sends this request to your registered callback URL when a message is delivered or fails. Your endpoint must respond with HTTP 200 within 10 seconds.

Payload
Outbound Webhook Payload
{
  "event": "message.delivered",
  "message_id": "550e8400-e29b-41d4-a716-446655440000",
  "phone": "+2348031234567",
  "sender_id": "MTECH",
  "status": "delivered",
  "delivered_at": "2026-03-26T08:14:04.000Z"
}
Event Types
EventTypeDescription
message.deliveredstringMessage confirmed delivered by the network
message.failedstringMessage delivery failed after all retry attempts
Signature Verification

Every outbound webhook request includes an X-Moaa-Signature header. Verify this signature before processing the payload to confirm the request originated from MOAA TECH.

Signature Header
X-Moaa-Signature: sha256=a3f8c2d1e4b7...
const crypto = require('crypto');

function verifyWebhook(rawBody, signature, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// Express example
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-moaa-signature'];
  if (!verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  // handle event.event === 'message.delivered' etc.
  res.sendStatus(200);
});
import hmac, hashlib

def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = 'sha256=' + hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

# Flask example
@app.route('/webhook', methods=['POST'])
def webhook():
    sig = request.headers.get('X-Moaa-Signature', '')
    if not verify_webhook(request.data, sig, WEBHOOK_SECRET):
        return '', 401
    event = request.get_json()
    # handle event['event'] == 'message.delivered' etc.
    return '', 200
Always use constant-time comparison (timingSafeEqual / hmac.compare_digest) when verifying signatures. Standard string equality is vulnerable to timing attacks.

Error Reference

All error responses follow a consistent shape with a machine-readable error code and a human-readable message.

Error Response Shape
{
  "success": false,
  "error": "INSUFFICIENT_BALANCE",
  "message": "Wallet balance too low to send this message",
  "details": {
    "balance": 2.50,
    "required": 5.50,
    "shortfall": 3.00
  }
}
Status Error Code Description
400 VALIDATION_ERROR Request body is missing required fields or contains invalid values. The details array lists each failing field.
401 UNAUTHORIZED Missing or expired Authorization token. Sign in again to obtain a new token.
402 INSUFFICIENT_BALANCE Wallet balance is below the cost of the requested operation. The details object includes the current balance, required amount, and shortfall.
403 FORBIDDEN Authenticated user lacks the required permission, or the resource belongs to a different organization.
404 NOT_FOUND The requested resource does not exist or is not visible to your organization.
429 RATE_LIMITED Too many requests. See the Retry-After response header for the number of seconds to wait before retrying.
500 INTERNAL_ERROR An unexpected server error occurred. A unique requestId is included for support escalation.

Rate Limits

All API endpoints are rate-limited. If you exceed the limit, the API returns HTTP 429.

Rate Limit
100 requests / minute
This limit can be increased per request. Contact info@moaatech.com with your use case and desired throughput.

When rate-limited, the response includes a Retry-After header indicating how many seconds to wait before retrying.

Rate Limit Response Headers
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 12
Retry-After: 34

Sandbox Mode

Test the full API without sending real SMS messages or deducting wallet balance.

When an organization has sandbox mode enabled, all message send operations are simulated. Jobs move through the queue normally, message rows are created with status sandbox, but no SMS is dispatched and the wallet is not charged.

Enable sandbox mode from the Settings section in the Sharp Console, or ask your account manager to enable it for your organization.

Sandbox responses are identical in structure to live responses. The only difference is that the message status field returns "sandbox" instead of "queued".

Sandbox Send Response
{
  "success": true,
  "messageId": "uuid",
  "status": "sandbox",
  "units": 1,
  "sandbox": true
}