Introduction
REST API for transactional SMS, OTP, and bulk campaign delivery across Nigerian telecom networks.
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.
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:
Authorization: Bearer <your_access_token>
SMS
Send direct messages and retrieve message history. All sending endpoints require a valid sender ID registered to your organization.
Message Type: Use plain for normal messages and unicode for messages containing special characters (e.g. accented letters, emojis).
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.
| Field | Type | Description |
|---|---|---|
| to | string | Recipient phone number required |
| sender | string | Approved sender ID (e.g. MTECH) required |
| message | string | Message body (160 chars = 1 unit) required |
| type | string | plain (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();
{
"success": true,
"messageId": "550e8400-e29b-41d4-a716-446655440000",
"status": "queued",
"units": 1,
}
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.
| Field | Type | Description |
|---|---|---|
| to | string | Recipient phone number required |
| sender | string | Approved sender ID (e.g. MTECH) required |
| message | string | Message body (160 chars = 1 unit) required |
| type | string | plain (default) or unicode optional |
| scheduledAt | string | ISO 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();
{
"success": true,
"messageId": "660e8400-e29b-41d4-a716-446655440000",
"status": "scheduled",
"units": 1,
}
| Parameter | Type | Description |
|---|---|---|
| page | integer | Page number, default 1 optional |
| limit | integer | Results per page, default 25, max 100 optional |
| status | string | Filter: queued, sent, delivered, failed optional |
| telco | string | Filter: mtn, airtel, glo, 9mobile optional |
| dateFrom | string | ISO 8601 start date optional |
| dateTo | string | ISO 8601 end date optional |
| senderId | string | Filter by sender ID optional |
| channel | string | Filter: sms, otp optional |
{
"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
}
}
| Parameter | Type | Description |
|---|---|---|
| id | string | Message UUID required |
{
"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"
}
{
"data": [
{
"id": "uuid",
"type": "campaign",
"sender": "MTECH-MKT",
"recipientCount": 5000,
"scheduledAt": "2026-03-28T09:00:00.000Z",
"status": "scheduled"
}
]
}
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.
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.
| Field | Type | Description |
|---|---|---|
| channel | string | Delivery channel: sms or email required |
| message | string | Message body required |
| phone | string | Recipient phone number — required for sms channel |
| string | Recipient email address — required for email channel | |
| sender | string | Approved sender ID — required for sms channel optional |
| subject | string | Email subject line — required for email channel optional |
| routeType | string | transactional (default) or promotional optional |
| fallbackChain | array | Ordered list of channels to try if primary fails, e.g. ["email"]. Include recipient fields for all channels in the chain. optional |
| scheduledAt | string (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
{
"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.
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.
| Field | Type | Description |
|---|---|---|
| phone | string | Recipient phone (080... or +234...) required |
| length | integer | OTP length: 4 or 6, default 6 optional |
| ttl | integer | Expiry 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();
{
"success": true,
"session_id": "otp_550e8400e29b41d4a716446655440000",
"expires_in": 300,
"messageId": "uuid"
}
Verify a submitted OTP against a session. Maximum 3 attempts per session; exceeding this invalidates the session and requires a new send.
| Field | Type | Description |
|---|---|---|
| session_id | string | Session ID from the send response required |
| otp | string | OTP 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();
{
"verified": true,
"attemptsUsed": 1,
"attemptsRemaining": 2
}
Sender IDs
Inspect the approved sender IDs registered to your organization.
{
"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.
{
"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.
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.
{
"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 | Type | Description |
|---|---|---|
| message.delivered | string | Message confirmed delivered by the network |
| message.failed | string | Message delivery failed after all retry attempts |
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.
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
Error Reference
All error responses follow a consistent shape with a machine-readable error code and a human-readable message.
{
"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.
When rate-limited, the response includes a Retry-After header indicating how many seconds to wait before retrying.
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.
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".
{
"success": true,
"messageId": "uuid",
"status": "sandbox",
"units": 1,
"sandbox": true
}