Webhooks
Receive an HTTP POST at your own endpoint when a message event occurs.
Events
Subscribing to an event outside this list returns 400.
| Event | Fires when |
|---|---|
sms.sent | An SMS was accepted by the carrier |
sms.failed | An SMS could not be sent |
email.sent | An email was handed to the mail provider |
email.failed | An email could not be sent |
push.sent | A push notification reached every target device |
push.partial | A push reached some but not all target devices |
push.failed | A push reached no devices |
whatsapp.delivered | A WhatsApp message was delivered |
whatsapp.read | A WhatsApp message was read by the recipient |
whatsapp.failed | A WhatsApp message could not be delivered |
webhook.test | A test event you triggered yourself |
sms.sent and email.sent mark acceptance by the carrier or mail provider, not receipt by the recipient. whatsapp.delivered and whatsapp.read are recipient-side, reported by Meta's status callbacks.
Fetch the list at runtime:
curl https://api.msgine.net/api/v1/developers/webhooks/events \
-H "x-api-key: $MSGINE_API_KEY"Registering a webhook
Webhooks are registered per account and apply to every message on the subscribed channels.
curl -X POST https://api.msgine.net/api/v1/developers/webhooks \
-H "x-api-key: $MSGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/webhooks/msgine",
"events": ["sms.sent", "sms.failed"],
"description": "Production handler",
"maxRetries": 3
}'{
"webhook": {
"id": "9c2d5040-a8c2-4569-a55b-03b7d99d69f5",
"url": "https://your-app.com/webhooks/msgine",
"events": ["sms.sent", "sms.failed"],
"isActive": true,
"maxRetries": 3,
"createdAt": "2026-02-17T10:00:00.000Z"
},
"secret": "whsec_3f9a...only shown once"
}Save the secret now
secret is returned once, at creation. It is encrypted at rest and is never returned by any other endpoint. If you lose it, rotate it with POST /developers/webhooks/:id/rotate-secret — which issues a new secret and immediately invalidates the old one.
Requires an API key with the MANAGE_WEBHOOKS (or FULL_ACCESS) scope.
URL requirements
- HTTPS only in production.
- The hostname must resolve to a public address. URLs pointing at loopback, private ranges, link-local (including
169.254.169.254) or unique-local IPv6 are rejected, and resolution is rechecked before every delivery attempt. - Redirects are not followed. Your endpoint must respond directly.
Payload
Every delivery has the same envelope:
{
"event": "sms.sent",
"channel": "sms",
"messageId": "9c2d5040-a8c2-4569-a55b-03b7d99d69f5",
"timestamp": "2026-02-17T10:30:15.663Z",
"data": {
"to": "+256700000000",
"from": "MyApp",
"message": "Your verification code is 123456",
"sid": "SM1234567890abcdef"
}
}| Field | Type | Description |
|---|---|---|
event | string | One of the event types above |
channel | string | sms, email, push, whatsapp or system |
messageId | string | The message this event concerns; absent for webhook.test |
timestamp | string | ISO 8601, when the event was recorded |
data | object | Channel-specific detail; treat its shape as additive |
Alongside the body:
| Header | Description |
|---|---|
X-MsGine-Signature | t=<unix>,v1=<hmac> — see below |
X-MsGine-Event | The event type, for routing before parsing |
X-MsGine-Delivery | Unique delivery id; use it to deduplicate |
User-Agent | MsGine-Webhooks/1.0 |
Verifying the signature
X-MsGine-Signature: t=1771324215,v1=6b1f...c4The HMAC covers the timestamp and the raw body joined by a dot:
signed_payload = "<t>" + "." + "<raw request body>"
v1 = HMAC_SHA256(webhook_secret, signed_payload)Parse the header and compare only its v1 part; it is not a bare digest.
Sign the raw body bytes as received. Re-serialising the parsed JSON (JSON.stringify(req.body), json.dumps(payload)) can reorder keys or change spacing, which produces a different signature.
Reject deliveries whose t is older than your tolerance — 300 seconds is a reasonable default — to limit replay of a captured request.
Node.js / Express
import express from 'express'
import crypto from 'crypto'
const app = express()
// express.raw, not express.json — verification needs the original bytes.
app.post('/webhooks/msgine',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!verify(req.body, req.get('X-MsGine-Signature'), process.env.MSGINE_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'invalid signature' })
}
const event = JSON.parse(req.body.toString())
// Acknowledge first, process afterwards: a slow handler is retried.
res.status(200).json({ received: true })
handle(event, req.get('X-MsGine-Delivery')).catch(console.error)
})
function verify(rawBody, header, secret, toleranceSeconds = 300) {
if (!header) return false
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=', 2)),
)
const { t, v1 } = parts
if (!t || !v1) return false
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody.toString()}`)
.digest('hex')
const a = Buffer.from(v1)
const b = Buffer.from(expected)
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Python / Flask
import hashlib
import hmac
import os
import time
from flask import Flask, request, jsonify
app = Flask(__name__)
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
timestamp, provided = parts.get("t"), parts.get("v1")
if not timestamp or not provided:
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(),
"{}.{}".format(timestamp, raw_body.decode()).encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(provided, expected)
@app.route("/webhooks/msgine", methods=["POST"])
def webhook():
# request.get_data() is the raw body; request.json would already be parsed.
if not verify(request.get_data(),
request.headers.get("X-MsGine-Signature", ""),
os.environ["MSGINE_WEBHOOK_SECRET"]):
return jsonify({"error": "invalid signature"}), 401
event = request.get_json()
delivery_id = request.headers.get("X-MsGine-Delivery")
handle(event, delivery_id)
return jsonify({"received": True}), 200PHP
<?php
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_MSGINE_SIGNATURE'] ?? '';
if (!verify($raw, $header, getenv('MSGINE_WEBHOOK_SECRET'))) {
http_response_code(401);
echo json_encode(['error' => 'invalid signature']);
exit;
}
$event = json_decode($raw, true);
handle($event, $_SERVER['HTTP_X_MSGINE_DELIVERY'] ?? null);
http_response_code(200);
echo json_encode(['received' => true]);
function verify(string $raw, string $header, string $secret, int $tolerance = 300): bool {
if ($header === '') return false;
$parts = [];
foreach (explode(',', $header) as $piece) {
[$k, $v] = array_pad(explode('=', $piece, 2), 2, null);
$parts[$k] = $v;
}
if (empty($parts['t']) || empty($parts['v1'])) return false;
if (abs(time() - (int) $parts['t']) > $tolerance) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $raw, $secret);
return hash_equals($expected, $parts['v1']);
}Retries
A delivery counts as successful only on a 2xx. Anything else — including a timeout after 10 seconds or an unreachable host — is retried.
maxRetries is set per webhook (default 3, maximum 5), giving up to maxRetries + 1 attempts in total. Retries use exponential backoff:
| Retry | Delay after the previous attempt |
|---|---|
| 1st | 5 seconds |
| 2nd | 15 seconds |
| 3rd | 35 seconds |
| 4th | 75 seconds |
| 5th | 155 seconds |
Each attempt is signed at the moment it is sent, so a retry always carries a fresh timestamp and passes the tolerance check.
Inspect what happened:
curl "https://api.msgine.net/api/v1/developers/webhooks/$WEBHOOK_ID/deliveries?limit=50" \
-H "x-api-key: $MSGINE_API_KEY"Each row carries status, httpStatus, attempts, responseBody, errorMessage and nextRetryAt.
Deduplicating
A retry fires whenever an endpoint is slow or briefly unavailable, so the same event can arrive more than once. X-MsGine-Delivery is stable across retries of the same delivery:
async function handle(event, deliveryId) {
if (await seen(deliveryId)) return
await markSeen(deliveryId)
// ...process
}Testing
Send a webhook.test event to your endpoint at any time:
curl -X POST "https://api.msgine.net/api/v1/developers/webhooks/$WEBHOOK_ID/test" \
-H "x-api-key: $MSGINE_API_KEY"For local development, expose your server with ngrok and register the HTTPS URL it prints:
ngrok http 3000A sandbox key records sends without dispatching them, so message events do not fire for sandbox traffic. Use webhook.test to exercise an endpoint, or a production key for real message events.
Troubleshooting
Signature never matches. Almost always the raw body: a JSON body-parser replaces the bytes before you see them. Capture the raw buffer (express.raw, request.get_data(), php://input) and sign that. Then check you are comparing against the v1 part of the header, not the whole header.
Nothing arrives. Confirm the URL is HTTPS and publicly resolvable, then read the delivery history above — httpStatus and errorMessage record what MsGine saw from your endpoint. A 4xx there means your handler rejected it.
Events fire for some messages only. Check the webhook's events list, and remember sandbox keys emit no message events.
Next steps
- Authentication — API keys, scopes and sandbox mode
- REST API — full endpoint reference
- Rate limits — quotas and
429handling