Base URL
All requests are JSON over HTTPS to this base URL:
https://api.message-hub.cloud/api/v1Authentication
Every request carries your key in the Authorization header. You find it in the dashboard; it is shown only once when created, so store it safely and never ship it in browser or app code.
Authorization: Bearer mh_live_xxxxxxxxxxxxxxxxxxxxxxxxPhone numbers are accepted in local (07701234567) or international (9647701234567) format.
Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /otp/send | Send an OTP to a WhatsApp number |
| POST | /otp/verify | Verify a code the user entered |
| GET | /otp/{id} | Status of an OTP |
| POST | /messages | Send a template or text message |
| GET | /messages | List your messages (status, to, per_page) |
| GET | /messages/{id} | Status of one message |
| GET | /account | Balance, prices and sender number |
Send an OTP
We generate the code and send it with the approved authentication template. Keep the id for verification.
| Field | Description |
|---|---|
phonerequired | The customer's number. |
lengthoptional | Code length, 4 to 10 (default 6). |
expires_inoptional | Validity in minutes, 1 to 60 (default 5). |
codeoptional | Your own code (4–10 digits) if you prefer to generate it yourself. |
referenceoptional | Your request identifier (up to 100 characters) — makes retries safe. |
curl -X POST https://api.message-hub.cloud/api/v1/otp/send \
-H "Authorization: Bearer $MESSAGEHUB_KEY" \
-H "Content-Type: application/json" \
-d '{"phone": "07701234567", "length": 6, "expires_in": 5, "reference": "login-123"}'const res = await fetch('https://api.message-hub.cloud/api/v1/otp/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MESSAGEHUB_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ phone: '07701234567', length: 6, expires_in: 5, reference: 'login-123' }),
})
const { data } = await res.json()
const otpId = data.id$res = Http::withToken(env('MESSAGEHUB_KEY'))
->post('https://api.message-hub.cloud/api/v1/otp/send', [
'phone' => '07701234567',
'length' => 6,
'expires_in' => 5,
'reference' => 'login-123',
]);
$otpId = $res->json('data.id');201 Created
{
"data": {
"id": "9d3c8f2e-…",
"phone": "9647701234567",
"status": "pending",
"expires_at": "2026-11-05T21:35:00+03:00",
"message": { "id": "…", "status": "sent", "cost": …, "currency": "IQD" }
},
"balance": …
}Verify the code
Send the id (or the phone) with the code the user entered. You get 200 when it is valid and 422 with a reason when it is not.
curl -X POST https://api.message-hub.cloud/api/v1/otp/verify \
-H "Authorization: Bearer $MESSAGEHUB_KEY" \
-H "Content-Type: application/json" \
-d '{"id": "9d3c8f2e-…", "code": "482913"}'const res = await fetch('https://api.message-hub.cloud/api/v1/otp/verify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MESSAGEHUB_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ id: otpId, code: userInput }),
})
const { data } = await res.json()
if (data.valid) signIn()$res = Http::withToken(env('MESSAGEHUB_KEY'))
->post('https://api.message-hub.cloud/api/v1/otp/verify', [
'id' => $otpId,
'code' => $request->input('code'),
]);
if ($res->json('data.valid')) {
Auth::login($user);
}200 OK
{ "data": { "valid": true, "reason": null, "otp": { … } } }
422 Unprocessable Entity
{ "data": { "valid": false, "reason": "invalid_code", "otp": { … } } }Reasons: invalid_code (wrong code), expired, too_many_attempts, already_verified.
curl https://api.message-hub.cloud/api/v1/otp/9d3c8f2e-… \
-H "Authorization: Bearer $MESSAGEHUB_KEY"Template messages
Send a notification with an approved WhatsApp template and its variables. Category utility for notifications, marketing for promotions.
curl -X POST https://api.message-hub.cloud/api/v1/messages \
-H "Authorization: Bearer $MESSAGEHUB_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "07701234567",
"template": "order_update",
"language": "ar",
"variables": ["1045", "غداً"],
"category": "utility",
"reference": "order-1045-shipped"
}'curl https://api.message-hub.cloud/api/v1/messages/{id} -H "Authorization: Bearer $MESSAGEHUB_KEY"
curl "https://api.message-hub.cloud/api/v1/messages?status=delivered&per_page=50" -H "Authorization: Bearer $MESSAGEHUB_KEY"Message statuses: queued, sent, delivered, read, failed.
Balance and prices
Returns your account name, your balance in IQD, the price of each category and the sender number.
curl https://api.message-hub.cloud/api/v1/account -H "Authorization: Bearer $MESSAGEHUB_KEY"
{
"data": {
"name": "…",
"balance": …,
"currency": "IQD",
"prices": { … },
"sender": { "phone_number": "…", "display_name": "…", "shared": true }
}
}Safe retries (reference)
Sending the same reference twice returns the same message — no new send and no new charge. Always use it when retrying after a network error.
Balance and refunds
- Insufficient balance → 402 insufficient_balance and nothing is sent.
- Meta rejects the message → 502 send_failed and the amount returns to your balance immediately. If Meta reports a failure later, it is refunded too.
402 Payment Required
{ "message": "Insufficient balance.", "code": "insufficient_balance" }Status notifications (webhook)
When a message status changes we send a POST request to the webhook URL you set in the dashboard.
POST https://your-app.example/webhooks/messagehub
Content-Type: application/json
X-MessageHub-Signature: sha256=<hex hmac_sha256(raw_body, webhook_secret)>
{
"event": "message.status",
"data": {
"id": "…",
"reference": "login-123",
"to": "9647701234567",
"status": "delivered",
"category": "authentication",
"cost": …,
"refunded": false,
"error": null,
"updated_at": "2026-11-05T21:31:04+03:00"
}
}Each request carries an X-MessageHub-Signature header: sha256= followed by the HMAC-SHA256 of the raw body with your webhook secret. Compute it and compare in constant time before trusting the request.
import { createHmac, timingSafeEqual } from 'node:crypto'
function isValid(rawBody, header, secret) {
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(header ?? '')
return a.length === b.length && timingSafeEqual(a, b)
}$expected = 'sha256='.hash_hmac('sha256', $request->getContent(), $secret);
if (! hash_equals($expected, (string) $request->header('X-MessageHub-Signature'))) {
abort(401);
}Error codes
Every error returns JSON with a message and a code.
| 401 | invalid_api_key | The key is missing or invalid. |
| 403 | account_suspended | The account is suspended. |
| 403 | ability_missing | The key lacks the ability for this endpoint (otp or messages). |
| 403 | ip_not_allowed | This IP address is not allowed for the key. |
| 402 | insufficient_balance | Not enough balance; nothing was sent. |
| 422 | validation | The request data is invalid. |
| 429 | rate_limited | Too many requests; retry shortly. |
| 502 | send_failed | Meta rejected the message and the amount was refunded. |
| 503 | no_sender · no_otp_template | No active sender number or OTP template yet. |