Skip to content

Webhooks

Receive an HTTP POST at your own endpoint when a message event occurs.

Events

Subscribing to an event outside this list returns 400.

EventFires when
sms.sentAn SMS was accepted by the carrier
sms.failedAn SMS could not be sent
email.sentAn email was handed to the mail provider
email.failedAn email could not be sent
push.sentA push notification reached every target device
push.partialA push reached some but not all target devices
push.failedA push reached no devices
whatsapp.deliveredA WhatsApp message was delivered
whatsapp.readA WhatsApp message was read by the recipient
whatsapp.failedA WhatsApp message could not be delivered
webhook.testA 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:

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

bash
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
  }'
json
{
  "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:

json
{
  "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"
  }
}
FieldTypeDescription
eventstringOne of the event types above
channelstringsms, email, push, whatsapp or system
messageIdstringThe message this event concerns; absent for webhook.test
timestampstringISO 8601, when the event was recorded
dataobjectChannel-specific detail; treat its shape as additive

Alongside the body:

HeaderDescription
X-MsGine-Signaturet=<unix>,v1=<hmac> — see below
X-MsGine-EventThe event type, for routing before parsing
X-MsGine-DeliveryUnique delivery id; use it to deduplicate
User-AgentMsGine-Webhooks/1.0

Verifying the signature

X-MsGine-Signature: t=1771324215,v1=6b1f...c4

The 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

js
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

python
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}), 200

PHP

php
<?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:

RetryDelay after the previous attempt
1st5 seconds
2nd15 seconds
3rd35 seconds
4th75 seconds
5th155 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:

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

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

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

bash
ngrok http 3000

A 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

Released under the MIT License.