Webhooks
Instead of asking CloudMetis whether a message was delivered, let CloudMetis tell you. Each time a message's status changes, we send a POST request to your webhook URL.
Where reports are sent
| Setting | Applies to |
|---|---|
| Key webhook URL — set in Developer API (on create, or with the pencil icon) | Every message sent with that key |
callback_url field in a send request | That request only — overrides the key's URL |
If neither is set, no webhooks are sent; you can still poll GET /v1/sms/status/{message_id}.
URL rules
- Must be reachable on the public internet — a domain or a public IP address. Private and internal addresses (
localhost,10.x,192.168.x,169.254.x…) are rejected. - Live keys require
https. Delivery reports contain phone numbers. Test keys may also usehttp. httpsto a bare IP address usually fails, because certificates are issued for domain names. Use a domain with a valid certificate (for example a free Let's Encrypt one).- Redirects are not followed, and URLs can't contain a username or password.
The request we send
POST /sms/delivery HTTP/1.1
Content-Type: application/json
User-Agent: CloudMetis-Webhooks/1.0
X-CloudMetis-Timestamp: 1793534531
X-CloudMetis-Signature: sha256=24b81b80d866e5a09c349a9854f63a0ae3065d8cae54b0ed4f8d0b069a88f25a
{"event":"sms.status","message_id":"msg_183","to":"+254712345678","status":"delivered","network":"Safaricom","sent_at":"2026-11-01T14:02:11+03:00","delivered_at":"2026-11-01T14:02:15+03:00","failure_reason":null,"sandbox":true}
| Field | Description |
|---|---|
event | sms.status (or test from the Send test webhook button) |
message_id | The ID returned when you sent the message |
to | Recipient in E.164 format |
status | queued, submitted, sent, delivered, failed, expired, rejected or optedout |
network | Safaricom, Airtel, Telkom or null |
sent_at, delivered_at | ISO 8601 times (East Africa Time), or null |
failure_reason | Why the message failed, or null |
sandbox | true for test-key messages |
You may receive more than one event per message (for example sent, then delivered). Use the latest status.
Responding
- Reply with any 2xx status within 5 seconds. Do slow work (database updates, emails) after replying, or in a background job.
- Anything else — a timeout, a connection error, a
4xxor5xx— is treated as a failure and retried.
Retries
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | 30 seconds later |
| 3 | 2 minutes later |
| 4 | 10 minutes later |
| 5 | 1 hour later |
After the fifth failed attempt the event is dropped. You can still read the final status with GET /v1/sms/status/{message_id}.
Because a retry can arrive after a later event, treat webhooks as "latest known status", and make your handler safe to run twice for the same event.
Verifying the signature
Anyone who knows your URL could post fake reports. Every webhook is signed with your key's webhook secret (whsec_…, shown when you create the key, or with Get secret / New secret in Developer API).
To verify:
- Read the raw request body exactly as received — before any JSON parsing.
- Compute
HMAC-SHA256(secret, timestamp + "." + raw_body)as lowercase hex, and prefix it withsha256=. - Compare it with
X-CloudMetis-Signatureusing a constant-time comparison. - Reject requests whose
X-CloudMetis-Timestampis more than 5 minutes from your clock, to stop old requests being replayed.
- PHP
- Python
- Node.js
function cloudmetisWebhookIsValid(string $rawBody, string $timestamp, string $signature, string $secret, int $toleranceSeconds = 300): bool
{
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $toleranceSeconds) {
return false; // too old or malformed: possible replay
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
// Plain PHP:
$rawBody = file_get_contents('php://input');
$valid = cloudmetisWebhookIsValid(
$rawBody,
$_SERVER['HTTP_X_CLOUDMETIS_TIMESTAMP'] ?? '',
$_SERVER['HTTP_X_CLOUDMETIS_SIGNATURE'] ?? '',
getenv('CLOUDMETIS_WEBHOOK_SECRET'),
);
if (!$valid) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(200);
import hashlib, hmac, time
def cloudmetis_webhook_is_valid(raw_body: bytes, timestamp: str, signature: str, secret: str, tolerance_seconds: int = 300) -> bool:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
return False # too old or malformed: possible replay
expected = "sha256=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
# Flask:
# @app.post("/sms/delivery")
# def delivery():
# if not cloudmetis_webhook_is_valid(request.get_data(), request.headers.get("X-CloudMetis-Timestamp", ""),
# request.headers.get("X-CloudMetis-Signature", ""), os.environ["CLOUDMETIS_WEBHOOK_SECRET"]):
# abort(401)
# event = request.get_json()
# return "", 200
const crypto = require('node:crypto');
function cloudmetisWebhookIsValid(rawBody, timestamp, signature, secret, toleranceSeconds = 300) {
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
return false; // too old or malformed: possible replay
}
const expected = 'sha256=' + crypto.createHmac('sha256', secret)
.update(`${timestamp}.`).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express — use express.raw so you get the exact bytes:
app.post('/sms/delivery', express.raw({ type: 'application/json' }), (req, res) => {
const valid = cloudmetisWebhookIsValid(
req.body,
req.get('X-CloudMetis-Timestamp') ?? '',
req.get('X-CloudMetis-Signature') ?? '',
process.env.CLOUDMETIS_WEBHOOK_SECRET,
);
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body);
res.sendStatus(200);
});
:::tip Test your receiver
In Developer API, the send icon next to a key's webhook URL posts a signed "event": "test" request immediately and shows whether your server accepted it — no SMS needed. The sandbox also sends real sms.status webhooks instantly.
:::
Changing the secret
New secret in Developer API replaces the secret immediately — the old one stops working at once. Update your server first, or briefly accept both secrets while you switch. Rolling an API key keeps the same webhook secret.