MOAA TECH API Reference
v2.1
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. Create, rotate, and revoke keys in the Sharp Console under Settings → Developer. The full key value is shown only once at creation.
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.
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.
POST /api/sms/transactional Legacy

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.

Request Body
FieldTypeDescription
tostringRecipient phone number required
senderstringApproved sender ID (e.g. MTECH) required
messagestringMessage body, max 1600 chars (160 chars = 1 unit) required
scheduledAtstringISO 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();
Response
200 OK
{
  "accepted": true,
  "message_id": "550e8400-e29b-41d4-a716-446655440000",
  "scheduled": false
}
POST /api/sms/enterprise Legacy

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.

Request Body
FieldTypeDescription
tostringRecipient phone number required
senderstringApproved sender ID (e.g. MTECH) required
messagestringMessage body, max 1600 chars (160 chars = 1 unit) required
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 { message_id, scheduled } = await res.json();
Response
200 OK
{
  "accepted": true,
  "message_id": "660e8400-e29b-41d4-a716-446655440000",
  "scheduled": true
}
Message history & delivery status: view your full message log, per-message delivery status, scheduled sends, and CSV exports in the Sharp Console under Messages.

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 the sms channel
emailstringRecipient email address, required for the email channel
senderstringApproved sender ID, required for the sms channel optional
subjectstringEmail subject line, required for the email channel optional
routeTypestringtransactional (default) or enterprise. For SMS, the sender ID's registered route class takes precedence. optional
cc, html, templateId…variousEmail-only extras: cc recipients, rich html body, and templateId / templateName + variables to send a stored email template 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"]
}
GET /api/notify/channels
List the channels enabled for your account: the values you can pass to POST /api/notify/send.
200 OK
{
  "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.

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–8 digits, default 6 optional
ttlintegerExpiry 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();
Response
200 OK
{
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "expires_in": 300
}

expires_in is returned in seconds.

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": "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();
Response
200 OK
{
  "verified": true
}
400 Bad Request (wrong code)
{
  "error": "Invalid OTP",
  "attempts": 1,
  "max_attempts": 3
}

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",
      "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.

GET /api/wallet/balance
Returns your current wallet balance and currency. Requires the wallet:read scope.
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();
Response
200 OK
{
  "balance": 245310.50,
  "currency": "NGN"
}

Webhooks

Push delivery status events to your server as messages progress, across every channel.

Setup: configure your callback URL in the Sharp Console under Settings → Developer → Delivery Webhook. A signing secret is generated when you save it; the secret is shown once, and you can regenerate it at any time. A test event can be sent from the same card to verify your receiver.
Delivery Mechanics
PropertyBehavior
TransportHTTPS POST, JSON body. Your endpoint must respond 2xx within 10 seconds. Response bodies are ignored.
RetriesFailed 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.
IdempotencyEvery delivery carries a unique X-Moaa-Delivery id. Retries reuse the same id, so process each id once.
OrderingEvents are sent in near real time but ordering is not guaranteed. Use the payload timestamps, not arrival order.
SandboxSandbox sends fire webhooks too, flagged "sandbox": true, so you can test your receiver end to end without live traffic.
Headers on every delivery
Content-Type: application/json
X-Moaa-Event: message.delivered
X-Moaa-Delivery: whd_9f2ka7c1e4b86d30
X-Moaa-Signature: sha256=a3f8c2d1e4b7...
POST {your_callback_url}

MOAA TECH sends this request to your registered callback URL as messages progress, on every channel.

Event Types
EventChannelsMeaning
message.deliveredsmsConfirmed delivered by the recipient's network (delivery report). SMS is the only channel with network-level confirmation.
message.acceptedemail, whatsapp, pushAccepted 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.failedallThe 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.fallbackallA 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.
Payload Fields
FieldTypeDescription
eventstringOne of the event types above.
message_idstringThe message this event belongs to. Matches the message_id returned by the send endpoints.
channelstringsms, email, whatsapp, or push.
tostringThe recipient: a phone number for SMS and WhatsApp, an email address for email.
sender_idstringSMS only. The sender ID the message went out under.
statusstringdelivered, accepted, or failed.
errorstringFailure events only. Human-readable failure reason.
fallback_queuedbooleanFailure events only. True when the next channel in the chain is being attempted.
fallbackobjectPresent when this message is itself a fallback attempt: { parent_message_id, from_channel, position }.
sandboxbooleanTrue for simulated sandbox sends.
sent_at, delivered_at, failed_atstringISO 8601 timestamps; only those relevant to the event are present.
Example: SMS delivered (network confirmation)
message.delivered
{
  "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"
}
Example: email failed, fallback chain continuing
message.failed
{
  "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"
}
Example: fallback attempt started (email fell back to SMS)
message.fallback
{
  "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"
}
Following a fallback chain: tie events together with fallback.parent_message_id. In the example above, the email 660e... failed, the platform queued SMS attempt 770e..., and that new id will next receive its own message.delivered or message.failed.
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

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.

Example: 402 Insufficient Balance
{
  "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.

Rate Limit
600 requests / 15 minutes per IP
Applies across all API endpoints. This limit can be increased per request. Contact info@moaatech.com with your use case and desired throughput. Message throughput (TPS) is governed separately by your plan.

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

Rate Limit Response Headers
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.

When an organization has sandbox mode enabled, all message send operations are simulated. Jobs move through the queue normally and message rows are created and marked "sandbox": true, but no SMS is dispatched to the network and the wallet is never charged.

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.