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>
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.
Sender ID (SMS): Use only approved, pre-registered sender IDs — view yours via GET /sender-ids. The delivery route class (transactional / enterprise) comes from that sender ID's registered classification, not from anything in the request body.
Message history: full message logs, per-message delivery status, scheduled sends and CSV exports are in the Sharp Console under Messages.
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, email, whatsapp or push 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"]
}
Every send you have scheduled that has not gone out yet, on any channel, soonest first. A message leaves this list the moment it is sent or cancelled.
{
"scheduled": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"message_id": "MTECH-2afabb2febb5",
"channel": "email",
"recipient": "customer@example.com",
"sender_id": null,
"subject": "Your order has been confirmed",
"message": "Hello, your order #12345 has been confirmed.",
"route_type": "transactional",
"scheduled_at": "2025-01-15T09:00:00.000Z",
"created_at": "2025-01-14T16:22:08.913Z"
}
]
}
Call off a scheduled send before it goes. :id is the message_id returned by POST /api/notify/send. Cancelling is final: to send it after all, schedule it again.
A message can only be cancelled while it is still waiting. Once the send time arrives the message is on its way and the call returns 409, with status saying what it is now. The call is safe to repeat: the second one also returns 409 and changes nothing.
curl -X POST https://api.moaatech.com/api/notify/scheduled/550e8400-e29b-41d4-a716-446655440000/cancel \ -H "Authorization: Bearer <token>"
{
"cancelled": true,
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"job_removed": true
}
{
"error": "This message is no longer scheduled and cannot be cancelled",
"status": "sent"
}
{
"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 limit each phone to 3 OTPs in 5 minutes and 10 an hour, and each account to 300 a minute. Over a limit the call returns 429 with a Retry-After header. 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 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. |
| 409 | "This message is no longer scheduled..." | The resource exists but is past the state the action needs. Returned when cancelling a scheduled send that has already gone out or was already cancelled; status says what it is now. |
| 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. |
Delivery errors
The errors above mean the API call failed. A call can succeed and the message still fail to reach the handset later — those carry an error_code and error_message on the message itself, available in the Sharp Console message log and CSV export. Branch on error_code.
| Code | Retry | What it means | What to do |
|---|---|---|---|
| CARRIER_REJECTED | auto-retried | The recipient network refused the message without a specific reason. | Re-sent automatically on another route. Persistent failures on one network usually mean the sender ID is not registered there. |
| PROVIDER_ERROR | auto-retried | The network did not accept the message and gave no usable reason. | Re-sent automatically on another route. |
| NETWORK_TIMEOUT | auto-retried | The carrier did not respond in time. | Re-sent automatically on another route. |
| NETWORK_CONGESTION | auto-retried | The recipient network is congested. | Re-sent automatically on another route. |
| INVALID_PHONE_FORMAT | no retry | The recipient number is malformed. | Correct the number. No route can deliver to it, so no retry is attempted. |
| UNKNOWN_NUMBER | no retry | Correctly formatted but not assigned by any network. | Verify the number with the recipient. |
| RECIPIENT_UNREACHABLE | no retry | Handset off, out of coverage, or absent. | Resend later. Another route reaches the same handset, so retrying immediately does not help. |
| MESSAGE_EXPIRED | no retry | The network could not deliver it within its validity window. | Resend if the content is still relevant. These reports can arrive up to three days later. |
| RECIPIENT_SUPPRESSED | no retry | Sending to this number was stopped after repeated hard rejections. | Contact support to review the suppression. |
| RECIPIENT_BLOCKED | no retry | The recipient has blocked messages from this sender. | Do not resend on this sender ID. |
| DND_ACTIVE | no retry | The recipient has Do Not Disturb on and the route is promotional. | Use a transactional (DND-registered) sender ID if the content qualifies. |
| SENDER_NOT_APPROVED | no retry | The sender ID is not registered on the recipient's network. | Request it under Sender IDs → Request New and submit the registration documents. |
| NO_ACTIVE_PROVIDER | no retry | No route can carry this sender ID to this network. | Contact support. For a custom sender ID, request registration first. |
| CONTENT_REJECTED | no retry | The network flagged the body as spam or prohibited. | Reword the message. |
| MESSAGE_TOO_LONG | no retry | Longer than the network permits. | Shorten the body. |
| INVALID_ENCODING | no retry | The body contains characters the network will not accept. | Remove unsupported characters. |
| INVALID_REQUEST | no retry | The network rejected the request as invalid. | Check the recipient number and the sender ID. |
| RATE_LIMITED | no retry | You exceeded your sending rate. | Slow down and resend. |
| BILLING_ISSUE | no retry | An upstream billing problem blocked delivery. | Contact support. |
| CONFIG_ERROR | no retry | An account configuration problem prevented delivery. | Contact support. |
| UNSPECIFIED | no retry | The failure did not match any known cause. | Support is notified automatically. Quote the message_id if you contact us. |
| Code | Retry | What it means | What to do |
|---|---|---|---|
| INVALID_EMAIL | no retry | The address is malformed. | Correct the address. |
| MAILBOX_UNAVAILABLE | no retry | The mailbox is full, disabled, or does not exist. | Verify the address with the recipient. |
| EMAIL_BOUNCED | no retry | The receiving mail server rejected the message. | Check the address and your sending domain's DNS records. |
| EMAIL_NOT_CONFIGURED | no retry | Email is not enabled on your account. | Contact support to enable the channel. |
| Code | Retry | What it means | What to do |
|---|---|---|---|
| WHATSAPP_OPT_IN | no retry | No approved template was used and the 24-hour service window is closed. | Send an approved template, or wait for the recipient to message you first. |
| WHATSAPP_TEMPLATE_REJECTED | no retry | The template is not approved for this send. | Check its status under WhatsApp Templates. |
| WHATSAPP_NOT_CONFIGURED | no retry | WhatsApp is not enabled on your account. | Contact support to enable the channel. |
| Code | Retry | What it means | What to do |
|---|---|---|---|
| PUSH_TOKEN_INVALID | no retry | The device token is no longer registered. | Re-register the device and resend. |
| PUSH_NOT_CONFIGURED | no retry | Push is not enabled on your account. | Contact support to enable the channel. |
{
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "failed",
"error_code": "INVALID_PHONE_FORMAT",
"error_message": "Invalid phone number. Check the recipient number and try again."
}
Rate Limits
Most 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.