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.
Routing: The delivery route class (transactional / enterprise) is determined by your sender ID's registered classification, not by the endpoint. The /api/sms/transactional and /api/sms/enterprise paths below are legacy and will be phased out soon. New integrations should use POST /api/notify/send with channel: "sms", documented in the Notifications section.
Legacy path. This endpoint will be phased out soon. Use POST /api/notify/send instead, documented in the Notifications section.
Send a single SMS: OTP delivery confirmations, transaction alerts, and critical notifications. Messages sent under a DND-registered (transactional) sender ID are DND-exempt and dispatched with highest priority.
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, max 1600 chars (160 chars = 1 unit) required |
| scheduledAt | string | ISO 8601 future timestamp to schedule delivery 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 { message_id } = await res.json();
{
"accepted": true,
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"scheduled": false
}
Legacy path. This endpoint will be phased out soon. Use POST /api/notify/send instead, documented in the Notifications section.
Send a marketing / promotional SMS under an enterprise-class sender ID. DND-compliant with scheduled delivery support. Pass scheduledAt to defer delivery to a specific time.
Same request and response shape as the transactional endpoint; the delivery route follows your sender ID's registered class.
| Field | Type | Description |
|---|---|---|
| to | string | Recipient phone number required |
| sender | string | Approved sender ID (e.g. MTECH) required |
| message | string | Message body, max 1600 chars (160 chars = 1 unit) required |
| 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 { message_id, scheduled } = await res.json();
{
"accepted": true,
"message_id": "660e8400-e29b-41d4-a716-446655440000",
"scheduled": true
}
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 the sms channel |
| string | Recipient email address, required for the email channel | |
| sender | string | Approved sender ID, required for the sms channel optional |
| subject | string | Email subject line, required for the email channel optional |
| routeType | string | transactional (default) or enterprise. For SMS, the sender ID's registered route class takes precedence. optional |
| cc, html, templateId… | various | Email-only extras: cc recipients, rich html body, and templateId / templateName + variables to send a stored email template 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"]
}
{
"channels": ["sms", "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–8 digits, default 6 optional |
| ttl | integer | Expiry in minutes: 1–10, default 2 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": 5 }'
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();
{
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"expires_in": 300
}
expires_in is returned in seconds.
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": "550e8400-e29b-41d4-a716-446655440000", "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: '550e8400-e29b-41d4-a716-446655440000', otp: '847261' }) }); const { verified } = await res.json();
{
"verified": true
}
{
"error": "Invalid OTP",
"attempts": 1,
"max_attempts": 3
}
Sender IDs
Inspect the approved sender IDs registered to your organization.
{
"data": [
{
"id": "uuid",
"sender_id": "MTECH",
"status": "active"
},
{
"id": "uuid",
"sender_id": "MTECH-MKT",
"status": "active"
}
]
}
Wallet
Check your prepaid wallet balance programmatically. Useful for monitoring and pre-send checks in server integrations.
curl https://api.moaatech.com/api/wallet/balance \ -H "Authorization: Bearer mt_your_api_key"
const res = await fetch('https://api.moaatech.com/api/wallet/balance', { headers: { 'Authorization': `Bearer ${apiKey}` } }); const { balance, currency } = await res.json();
{
"balance": 245310.50,
"currency": "NGN"
}
Webhooks
Push delivery status events to your server as messages progress, across every channel.
| Property | Behavior |
|---|---|
| Transport | HTTPS POST, JSON body. Your endpoint must respond 2xx within 10 seconds. Response bodies are ignored. |
| Retries | Failed or timed-out deliveries are retried up to 5 times with increasing delay (about 1 minute to 6 hours). Delivery status also remains visible in the Console message log regardless of webhook outcome. |
| Idempotency | Every delivery carries a unique X-Moaa-Delivery id. Retries reuse the same id, so process each id once. |
| Ordering | Events are sent in near real time but ordering is not guaranteed. Use the payload timestamps, not arrival order. |
| Sandbox | Sandbox sends fire webhooks too, flagged "sandbox": true, so you can test your receiver end to end without live traffic. |
Content-Type: application/json X-Moaa-Event: message.delivered X-Moaa-Delivery: whd_9f2ka7c1e4b86d30 X-Moaa-Signature: sha256=a3f8c2d1e4b7...
MOAA TECH sends this request to your registered callback URL as messages progress, on every channel.
| Event | Channels | Meaning |
|---|---|---|
| message.delivered | sms | Confirmed delivered by the recipient's network (delivery report). SMS is the only channel with network-level confirmation. |
| message.accepted | email, whatsapp, push | Accepted by the delivery provider. This is the final positive signal for these channels; they have no network delivery report, so no later message.delivered follows. |
| message.failed | all | The attempt failed after provider retries. If the message has remaining fallback channels, fallback_queued is true and a message.fallback event follows. In rare cases this can arrive after an earlier message.delivered for the same message, when a late network report contradicts the delivery confirmation; treat the latest event as authoritative. |
| message.fallback | all | A failed attempt triggered the next channel in the fallback chain. A new message id is created for the fallback attempt; that id then receives its own terminal event. |
| Field | Type | Description |
|---|---|---|
| event | string | One of the event types above. |
| message_id | string | The message this event belongs to. Matches the message_id returned by the send endpoints. |
| channel | string | sms, email, whatsapp, or push. |
| to | string | The recipient: a phone number for SMS and WhatsApp, an email address for email. |
| sender_id | string | SMS only. The sender ID the message went out under. |
| status | string | delivered, accepted, or failed. |
| error | string | Failure events only. Human-readable failure reason. |
| fallback_queued | boolean | Failure events only. True when the next channel in the chain is being attempted. |
| fallback | object | Present when this message is itself a fallback attempt: { parent_message_id, from_channel, position }. |
| sandbox | boolean | True for simulated sandbox sends. |
| sent_at, delivered_at, failed_at | string | ISO 8601 timestamps; only those relevant to the event are present. |
{
"event": "message.delivered",
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "sms",
"to": "+2348031234567",
"sender_id": "MTECH",
"status": "delivered",
"sandbox": false,
"sent_at": "2026-07-22T08:14:03.000Z",
"delivered_at": "2026-07-22T08:14:04.312Z"
}
{
"event": "message.failed",
"message_id": "660e8400-e29b-41d4-a716-446655440000",
"channel": "email",
"to": "customer@example.com",
"status": "failed",
"error": "Provider rejected: mailbox does not exist",
"fallback_queued": true,
"sandbox": false,
"failed_at": "2026-07-22T08:14:05.100Z"
}
{
"event": "message.fallback",
"message_id": "770e8400-e29b-41d4-a716-446655440000",
"channel": "sms",
"to": "+2348031234567",
"sender_id": "MTECH",
"fallback": {
"parent_message_id": "660e8400-e29b-41d4-a716-446655440000",
"from_channel": "email",
"position": 1
},
"sandbox": false,
"sent_at": "2026-07-22T08:14:05.400Z"
}
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
Error responses carry an error string, usually a human-readable message, and endpoint-specific context fields. Match on the HTTP status code first, then on the error string.
{
"error": "Insufficient wallet balance",
"message": "Your wallet balance (₦2.50) is insufficient. Estimated cost: ₦5.50. Please top up at least ₦3.00 to continue.",
"balance": 2.50,
"estimated_cost": 5.50,
"shortfall": 3.00,
"action": "topup"
}
| Status | Error Code | Description |
|---|---|---|
| 400 | "\"sender\" is required" | Request body is missing required fields or contains invalid values. The error string names the failing field. |
| 401 | "No token provided" | Missing, invalid, or expired Authorization token or API key. |
| 402 | "Insufficient wallet balance" | Wallet balance is below the cost of the requested operation. The response includes balance, estimated_cost, and shortfall. |
| 403 | "Forbidden" | Authenticated user or API key lacks the required permission, the feature/channel is not enabled for your plan, or the resource belongs to a different organization. |
| 404 | "Route POST /... not found" | The URL path does not exist, or the requested resource is not visible to your organization. |
| 429 | "Too many requests..." | Rate limit exceeded. See the Retry-After response header for the number of seconds to wait before retrying. |
| 500 | "Internal server error" | An unexpected server error occurred. Retry with backoff, and contact support if it persists. |
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.
RateLimit-Limit: 600 RateLimit-Remaining: 412 RateLimit-Reset: 540 Retry-After: 540
Sandbox Mode
Test the full API without sending real SMS messages or deducting wallet balance.
Enable sandbox mode from Settings → Developer in the Sharp Console, or ask your account manager to enable it for your organization.
Send responses are identical in structure to live responses. Simulated messages progress to delivered in the Console message log so your integration can be tested end to end, and are flagged as sandbox sends there.