Home / Docs / Webhooks
Developer Guide · Webhooks

Webhooks — real-time events

MsgHub pushes events to your server the moment they happen — a message gets delivered, a contact unsubscribes, a campaign finishes. HMAC-signed (SHA-256, Stripe-style), single-attempt with auto-disable after 10 consecutive failures. This guide covers the event catalog (6 types), signature verification in three languages, failure handling, and a production security checklist.

Push-based · HTTPS only HMAC-SHA256 signed Auto-disable after 10 fails Last updated: 2026-05-14

Overview

Webhooks are the inverse of REST. Instead of you polling MsgHub for state changes, MsgHub HTTP-POSTs to your endpoint when something happens. Every payload is signed with HMAC-SHA256 so you can verify it really came from MsgHub.

Webhooks are how you build:

Scope note: webhooks currently fire on outbound message lifecycle, contact creation / unsubscribe, and campaign completion. Inbound messages are not delivered to webhooks — listen via the dashboard inbox or Zapier triggers. Six event types in total, listed below.

Register an endpoint

In MsgHub: sidebar → Webhooks → "+ Add Webhook" (it's a top-level sidebar item, not nested under Settings).

  1. Name — internal label, e.g. "CRM sync" or "Slack alerts".
  2. URL — your endpoint. Must be a public HTTPS URL — private / internal addresses are rejected by the SSRF guard.
  3. Events — tick at least one event type. Subscribe only to the events you handle.
  4. Secret — optional. If you leave it blank, MsgHub auto-generates a 64-char hex secret. Either way, the secret is returned once in the create response — copy it immediately.
  5. Click Save.

Treat the signing secret like a password. Anyone with it can forge events that look like they came from MsgHub. Store as an environment variable (e.g. MSGHUB_WEBHOOK_SECRET) — never in source code.

Event payload format

Every event is a JSON POST with a consistent envelope:

{
  "event":     "message.delivered",
  "tenantId":  "",
  "timestamp": "2026-05-14T10:32:18.000Z",
  "data": {
    // event-specific payload
  }
}

Request headers

HeaderDescription
Content-TypeAlways application/json
User-AgentMsgHub-Webhooks/1.0
X-MsgHub-EventThe event type (e.g. message.delivered)
X-MsgHub-DeliveryUUID per delivery attempt — use as the dedup key
X-MsgHub-TimestampUnix seconds at signing time — used in the signed string
X-MsgHub-Signaturet={timestamp},v1={hmac_sha256} — see verification below

Event catalog

Six event types ship today. Subscribe only to what you actually consume — fewer subscriptions = less load on your handler.

Message events

message.queued — outbound message accepted and queued for dispatch.

message.delivered — DLR received from the underlying channel (Meta / SMS aggregator) confirming delivery.

message.failed — delivery failed. data carries the failure details from the provider.

// Example payload — message.delivered
{
  "event":     "message.delivered",
  "tenantId":  "",
  "timestamp": "2026-05-14T10:32:18.000Z",
  "data": {
    "messageId":  "",
    "campaignId": "",
    "channel":    "whatsapp",
    "to":         "+919876543210"
  }
}

Contact events

contact.created — new contact added (via API, inbound message, widget, or import).

contact.unsubscribed — contact marked as DND (via POST /api/v1/contacts/unsubscribe, opt-out reply, or unsubscribe link).

Campaign events

campaign.completed — campaign finished sending. data includes delivery stats.

HMAC signature verification

Every webhook includes an X-MsgHub-Signature header with format:

X-MsgHub-Signature: t=1715680800,v1=a3b8c9...

t is the Unix timestamp at signing. v1 is the HMAC-SHA256 of {timestamp}.{raw_body} using your signing secret as the key, hex-encoded.

Verify in three steps:

  1. Extract t and v1 from the header.
  2. Compute HMAC_SHA256(secret, "{t}." + raw_body) and hex-encode.
  3. Compare against v1 using constant-time comparison (resist timing attacks).
  4. Reject if t is older than 5 minutes (replay protection).

Node.js

const crypto = require('crypto');

function verifyMsgHubSignature(rawBody, signatureHeader, secret) {
  const parts = signatureHeader.split(',');
  const t = parts.find(p => p.startsWith('t=')).slice(2);
  const v1 = parts.find(p => p.startsWith('v1=')).slice(3);

  // Reject if older than 5 min — replay protection
  if (Math.abs(Date.now() / 1000 - parseInt(t)) > 300) {
    throw new Error('Signature timestamp too old');
  }

  const expected = crypto
    .createHmac('sha256', secret)
    .update(t + '.' + rawBody)
    .digest('hex');

  const ok = crypto.timingSafeEqual(
    Buffer.from(v1),
    Buffer.from(expected)
  );
  if (!ok) throw new Error('Invalid signature');
}

// Express usage — note: raw body needed, not parsed JSON
app.post('/webhooks/msghub',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    try {
      verifyMsgHubSignature(
        req.body.toString('utf8'),
        req.header('X-MsgHub-Signature'),
        process.env.MSGHUB_WEBHOOK_SECRET
      );
    } catch (e) {
      return res.status(401).send('invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));
    // process event...
    res.status(200).send('ok');
  }
);

Python (Flask)

import hmac, hashlib, time, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["MSGHUB_WEBHOOK_SECRET"].encode()

def verify(raw_body, signature_header):
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    t, v1 = parts["t"], parts["v1"]

    if abs(time.time() - int(t)) > 300:
        raise ValueError("Signature too old")

    expected = hmac.new(
        SECRET, f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(v1, expected):
        raise ValueError("Invalid signature")

@app.post("/webhooks/msghub")
def handle():
    try:
        verify(request.get_data(), request.headers["X-MsgHub-Signature"])
    except ValueError:
        abort(401)

    event = request.get_json()
    # process event...
    return "", 200

PHP

function verifyMsgHubSignature($rawBody, $signatureHeader, $secret) {
    $parts = [];
    foreach (explode(',', $signatureHeader) as $p) {
        [$k, $v] = explode('=', $p, 2);
        $parts[$k] = $v;
    }
    $t  = $parts['t'];
    $v1 = $parts['v1'];

    if (abs(time() - (int)$t) > 300) {
        throw new Exception('Signature too old');
    }

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);

    if (!hash_equals($expected, $v1)) {
        throw new Exception('Invalid signature');
    }
}

// In your endpoint handler:
$rawBody = file_get_contents('php://input');
try {
    verifyMsgHubSignature(
        $rawBody,
        $_SERVER['HTTP_X_MSGHUB_SIGNATURE'],
        getenv('MSGHUB_WEBHOOK_SECRET')
    );
} catch (Exception $e) {
    http_response_code(401);
    exit;
}
$event = json_decode($rawBody, true);
// process...
http_response_code(200);

Use the RAW request body for HMAC, not the parsed JSON. Parsing changes whitespace and key ordering — the HMAC won't match. In Express, use express.raw() middleware. In Flask, use request.get_data(). In PHP, read php://input before json_decode.

Failure handling

MsgHub uses a single-attempt-with-failure-counter model — fire once, count failures, auto-disable. There is no exponential-backoff retry schedule. Each event is delivered exactly once unless your endpoint is healthy enough to accept it on the first try.

A delivery is considered failed when:

What happens on failure

To re-enable a disabled webhook: sidebar → Webhooks → click the failing webhook → toggle "Active" on. The failure counter resets to 0 on re-enable.

This is "at-most-once-when-healthy", not at-least-once. If your endpoint was down at the moment an event fired, that event is lost — there's no replay queue. Design accordingly: pull missed data from MsgHub via the REST API on reconnect, or accept the small data-loss window.

Return 2xx fast, process async. The 10-second timeout is forgiving but not infinite. Acknowledge with 200, then push the event to a queue (Redis, SQS, RabbitMQ) and process there. This keeps you well under the timeout even when downstream systems are slow.

Deduplication

Even without retries, the same event can occasionally hit your endpoint twice — typically when your handler returns 2xx but the connection drops before MsgHub records the success, and a future code path resends. Defensive code is cheap; just dedupe.

Use the X-MsgHub-Delivery header value (a UUID per delivery attempt) as your dedup key:

// Pseudocode
const deliveryId = req.header('X-MsgHub-Delivery');
if (storage.exists('webhook:' + deliveryId)) {
  return 200;  // Already processed — silent success
}
storage.set('webhook:' + deliveryId, true, ttl=86400);  // 24h is plenty
processEvent(event);

Production security checklist

  1. HTTPS only. Don't accept HTTP webhooks even for "dev". TLS is free now.
  2. Verify HMAC on every request. Reject with 401 if signature is missing, mismatched, or older than 5 minutes.
  3. Constant-time comparison. Use crypto.timingSafeEqual / hmac.compare_digest / hash_equals — not ==. Timing attacks are real.
  4. Validate the raw body matches the signature before parsing JSON. A subtly malformed body could parse OK but mean different things.
  5. Store the secret as an environment variable. Never in source control. Rotate every 90 days or after any incident.
  6. Reject events older than 5 minutes (replay protection). MsgHub never legitimately delays more than a few seconds for fresh events.
  7. Deduplicate by X-MsgHub-Delivery UUID. See above.
  8. Acknowledge within 10 seconds. Queue-and-process if needed.
  9. Don't trust the payload contents for security decisions (e.g. don't decide "this customer has admin rights" purely from a webhook attribute). Re-fetch via API if a decision is sensitive.
  10. Log every event ID + outcome. Makes incident debugging 10× easier.

Local testing with ngrok

You can't point a webhook at localhost from the public internet. Use ngrok to expose your dev server:

# Terminal 1: your dev server
node app.js   # listening on :3000

# Terminal 2: ngrok tunnel
ngrok http 3000

# Output:
Forwarding   https://abc-123.ngrok-free.app -> http://localhost:3000

Copy the ngrok-free.app URL, paste as your webhook URL in the Webhooks page (sidebar). Now any real event in MsgHub will reach your local server. Trigger events naturally — send a campaign, unsubscribe a contact, etc.

Troubleshooting

"Invalid signature" on every request

Webhook deliveries timing out

Receiving duplicate events

Endpoint auto-disabled

Inbound messages don't fire a webhook

FAQ

Can I subscribe one endpoint to multiple event types?

Yes. Each endpoint can subscribe to as many or as few of the 6 event types as you want. Use the event field in the payload (or X-MsgHub-Event header) to route inside your handler.

Can I have multiple endpoints?

Yes. Useful for routing different event types to different services — e.g. message events to your delivery-tracking service, contact events to your CRM sync service.

What events fire when an inbound message arrives?

None — currently. The webhook catalog covers outbound message lifecycle (queued / delivered / failed), contact create/unsubscribe, and campaign completion. Inbound messages are handled by the dashboard inbox and Zapier triggers. Inbound webhook events are on the roadmap.

Are webhooks ordered?

Per-message, yes — message.queued always fires before message.delivered for the same message ID. Across different messages or contacts, ordering is not guaranteed since deliveries happen in parallel.

What's the maximum payload size?

Typically under 2 KB — payloads carry IDs and identifiers, not message bodies or media. Use the REST API to fetch full content when an event arrives if you need it.

Can I replay events I missed?

Not currently — there's no event log replay feature. The single-attempt model means missed events are lost. To backfill, query the REST API (e.g. GET /api/v1/contacts) on reconnect.

Why are private/internal URLs rejected when I create a webhook?

SSRF guard. The platform refuses webhook URLs pointing at private IP ranges, localhost, or internal hostnames — preventing tenants from using webhooks as a side channel to probe internal services. Use a public HTTPS URL.

What's next

REST API Reference
If you need to fetch additional data when an event arrives, the REST API is right next door.
SMPP for high-volume SMS
SMS DLRs over SMPP are faster than over webhook for high-volume traffic. See SMPP guide.
Need help debugging?
Share your endpoint logs — we'll help trace where the HMAC verification is going wrong.