Skip to main content

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​

SettingApplies 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 requestThat 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 use http.
  • https to 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}
FieldDescription
eventsms.status (or test from the Send test webhook button)
message_idThe ID returned when you sent the message
toRecipient in E.164 format
statusqueued, submitted, sent, delivered, failed, expired, rejected or optedout
networkSafaricom, Airtel, Telkom or null
sent_at, delivered_atISO 8601 times (East Africa Time), or null
failure_reasonWhy the message failed, or null
sandboxtrue 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 4xx or 5xx — is treated as a failure and retried.

Retries​

AttemptWhen
1Immediately
230 seconds later
32 minutes later
410 minutes later
51 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:

  1. Read the raw request body exactly as received — before any JSON parsing.
  2. Compute HMAC-SHA256(secret, timestamp + "." + raw_body) as lowercase hex, and prefix it with sha256=.
  3. Compare it with X-CloudMetis-Signature using a constant-time comparison.
  4. Reject requests whose X-CloudMetis-Timestamp is more than 5 minutes from your clock, to stop old requests being replayed.
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);

:::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.