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.
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:
- Real-time CRM sync — every new contact or unsubscribe updates a CRM record.
- Slack / Teams notifications — alert your team when a campaign finishes or a high-value send fails.
- Order-status pipelines — DLR arrives, your system marks the SMS as delivered.
- Custom analytics dashboards — feed every event into BigQuery / Snowflake.
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).
- Name — internal label, e.g. "CRM sync" or "Slack alerts".
- URL — your endpoint. Must be a public HTTPS URL — private / internal addresses are rejected by the SSRF guard.
- Events — tick at least one event type. Subscribe only to the events you handle.
- 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.
- 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
| Header | Description |
|---|---|
Content-Type | Always application/json |
User-Agent | MsgHub-Webhooks/1.0 |
X-MsgHub-Event | The event type (e.g. message.delivered) |
X-MsgHub-Delivery | UUID per delivery attempt — use as the dedup key |
X-MsgHub-Timestamp | Unix seconds at signing time — used in the signed string |
X-MsgHub-Signature | t={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:
- Extract
tandv1from the header. - Compute
HMAC_SHA256(secret, "{t}." + raw_body)and hex-encode. - Compare against
v1using constant-time comparison (resist timing attacks). - Reject if
tis 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:
- The HTTP response is non-2xx.
- The request times out (10-second budget).
- The TCP connection is refused.
- The fetch throws any other network error.
What happens on failure
- Each consecutive failure increments
failureCounton the webhook record. - A successful delivery (2xx) resets
failureCountback to 0. - After 10 consecutive failures, the endpoint is automatically set to
isActive: false— no more events are dispatched until you re-enable it.
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
- HTTPS only. Don't accept HTTP webhooks even for "dev". TLS is free now.
- Verify HMAC on every request. Reject with 401 if signature is missing, mismatched, or older than 5 minutes.
- Constant-time comparison. Use
crypto.timingSafeEqual/hmac.compare_digest/hash_equals— not==. Timing attacks are real. - Validate the raw body matches the signature before parsing JSON. A subtly malformed body could parse OK but mean different things.
- Store the secret as an environment variable. Never in source control. Rotate every 90 days or after any incident.
- Reject events older than 5 minutes (replay protection). MsgHub never legitimately delays more than a few seconds for fresh events.
- Deduplicate by
X-MsgHub-DeliveryUUID. See above. - Acknowledge within 10 seconds. Queue-and-process if needed.
- 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.
- 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
- You're computing HMAC over the parsed JSON instead of the raw body. Re-read the verification section — must be raw bytes.
- The signing secret is wrong. The secret is shown once at creation; if you lost it, edit the webhook and either set a new secret explicitly or save without one (MsgHub auto-generates a fresh one).
- Body is being modified by a middleware (e.g. body-parser before your raw handler). Move webhook route before parsers, or use express.raw().
- You're verifying against the wrong webhook's secret — each endpoint has its own secret.
Webhook deliveries timing out
- Your handler is doing heavy work synchronously. Return 200 immediately, process async.
- Your server has cold-start latency (Lambda, Cloud Functions). Pre-warm or use a long-running container.
- You're past the 10-second budget. Reduce work in the handler.
Receiving duplicate events
- Rare — webhook delivery is single-attempt, not retried. Still possible from rare network races. Deduplicate by
X-MsgHub-DeliveryUUID just in case. - If you're seeing many duplicates, double-check your endpoint isn't returning a non-2xx accidentally.
Endpoint auto-disabled
- 10 consecutive failures triggered the disable. Fix the underlying issue, then sidebar → Webhooks → toggle "Active" back on. The failure counter resets to 0.
- Common causes: endpoint URL changed and you forgot to update MsgHub; TLS cert expired; receiving server returned an unexpected error.
Inbound messages don't fire a webhook
- Expected — there's no
message.receivedevent. The current 6-event catalog covers outbound message lifecycle, contact create/unsubscribe, and campaign completion only. - For inbound messages, listen via Zapier triggers (Integrations → Zapier) or poll the inbox via the dashboard.
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.