Webhook Signatures
Anyone who knows your Postback URL can send it a request. To make sure a request really comes from Mumara ONE, every request a webhook sends is signed with a secret that only you and Mumara ONE know. Checking the signature tells you that:
- the request came from Mumara ONE,
- the body wasn't changed on the way, and
- it isn't an old request being sent again.
This page covers webhooks on the Web channel. Pushover notifications go to Pushover, not to your server, so there's nothing to verify.
The headers
Each request carries these headers:
| Header | Example | Meaning |
|---|---|---|
X-Mumara-Signature | t=1790072105,v1=0c98ac3d… | The timestamp and the signature. See below. |
X-Mumara-Event | d | The event: r, d, b, t, c, campaign_open, campaign_click, transactional_open or transactional_click. See Events. |
X-Mumara-Delivery | 42-3f786850e387550fdab836ed7e6dc881de23001b | A unique ID for this event and this webhook. It's the same on every retry. See Process each event once. |
X-Mumara-Attempt | 1 | 1 for the first attempt, 2 for the first retry, and so on. |
Only the body and the timestamp are signed. The other headers aren't, so take the event type from type in the verified body rather than from X-Mumara-Event.
Your signing secret
Each webhook has its own signing secret, a string that starts with whsec_. Mumara ONE creates it when you create the webhook.
To get the secret for a webhook, contact Mumara support and tell them the webhook's name. Keep the secret like a password: in an environment variable or your platform's secrets store, never in your code or in version control.
Replacing a secret
If a secret may have leaked, ask Mumara support to replace it. The new secret takes effect straight away, and requests signed with the old one stop arriving. To avoid rejecting events during the change, let your endpoint accept a signature made with either the old or the new secret, then remove the old one once the new secret is in place.
How the signature is made
For every attempt, Mumara ONE:
- takes the current Unix time in seconds as the timestamp
t, - joins the timestamp, a full stop and the exact request body:
<t>.<body>, - computes an HMAC-SHA256 of that string, with the webhook's secret as the key, and writes it as lowercase hex: that's
v1, - sends the header
X-Mumara-Signature: t=<t>,v1=<v1>.
Worked example
Use these values to test your code:
| Value | |
|---|---|
| Secret | whsec_EXAMPLEdoNOTuseTHISsecretINprod |
| Timestamp | 1790072105 |
| Body | {"type":"d","to":"jane@example.net","msg_id":"<1234-a1b2c3d4@example.com>"} |
| String that is signed | 1790072105.{"type":"d","to":"jane@example.net","msg_id":"<1234-a1b2c3d4@example.com>"} |
| Header | t=1790072105,v1=0c98ac3d4f781e4f1748ca8ee9300ab9199945efe15e7ba891282413f89f02b3 |
The timestamp in this example is long past, so switch off your timestamp check, or pass the timestamp in as "now", when you test with it.
Verify a request
- Read the raw body, exactly as received, before any JSON parsing. Parsing and re-encoding the JSON changes the bytes, and the signature no longer matches.
- Read the header. Split
X-Mumara-Signatureon commas, then each part on its first=. Taketand everyv1value. - Check the timestamp. Reject the request if
tis more than five minutes away from your server's clock. - Compute the expected signature: the HMAC-SHA256 of
t, a full stop and the raw body, keyed with your secret, as lowercase hex. - Compare in constant time. Accept the request if any
v1value matches. Use your language's constant-time comparison, never==, so the comparison doesn't leak timing information. - Then parse the JSON, check
X-Mumara-Deliveryfor a duplicate, and answer2xxstraight away. Do the slow work afterwards.
If a check fails, answer 401 and don't process the request.
Mumara ONE treats any answer other than 2xx as a failed delivery and tries again, up to 8 attempts in all. If every request fails for two days, the webhook is switched off. If all your signatures suddenly fail, check your secret rather than letting the webhook be switched off.
Node.js
This example uses Express. express.raw() keeps the body as the exact bytes received, which the signature needs.
const crypto = require('crypto');
const express = require('express');
const app = express();
const secret = process.env.MUMARA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifySignature(rawBody, header, secret, toleranceSeconds = TOLERANCE_SECONDS) {
if (!header) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const i = part.indexOf('=');
if (i === -1) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === 't') timestamp = Number(value);
else if (key === 'v1') signatures.push(value);
}
if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
app.post('/webhooks/mumara', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySignature(req.body, req.get('X-Mumara-Signature'), secret)) {
return res.status(401).send('Invalid signature');
}
const deliveryId = req.get('X-Mumara-Delivery');
const event = JSON.parse(req.body.toString('utf8'));
// Save the event with deliveryId as a unique key, skip it if it's already there,
// and process it after answering.
res.sendStatus(200);
});
app.listen(3000);
PHP
<?php
function mumara_verify_signature(string $rawBody, ?string $header, string $secret, int $tolerance = 300): bool
{
if (!$header) {
return false;
}
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
if ($pair[0] === 't' && ctype_digit($pair[1])) {
$timestamp = (int) $pair[1];
} elseif ($pair[0] === 'v1') {
$signatures[] = $pair[1];
}
}
if ($timestamp === null || !$signatures) {
return false;
}
if (abs(time() - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_MUMARA_SIGNATURE'] ?? null;
$secret = getenv('MUMARA_WEBHOOK_SECRET');
if (!mumara_verify_signature($rawBody, $header, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
$deliveryId = $_SERVER['HTTP_X_MUMARA_DELIVERY'] ?? '';
$event = json_decode($rawBody, true);
// Save the event with $deliveryId as a unique key, skip it if it's already there,
// and process it after answering.
http_response_code(200);
In Laravel, use $request->getContent() for the raw body and $request->header('X-Mumara-Signature') for the header.
Python
This example uses Flask. request.get_data() returns the exact bytes received.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
secret = os.environ["MUMARA_WEBHOOK_SECRET"]
TOLERANCE_SECONDS = 300
def verify_signature(raw_body, header, secret, tolerance=TOLERANCE_SECONDS):
if not header:
return False
timestamp = None
signatures = []
for part in header.split(","):
key, sep, value = part.strip().partition("=")
if not sep:
continue
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > tolerance:
return False
signed = str(timestamp).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), sig.encode()) for sig in signatures)
@app.post("/webhooks/mumara")
def mumara_webhook():
raw_body = request.get_data()
if not verify_signature(raw_body, request.headers.get("X-Mumara-Signature"), secret):
abort(401)
delivery_id = request.headers.get("X-Mumara-Delivery")
event = json.loads(raw_body)
# Save the event with delivery_id as a unique key, skip it if it's already there,
# and process it after answering.
return "", 200
In Django, use request.body for the raw body and request.headers["X-Mumara-Signature"] for the header.
Reject replayed requests
A valid signature proves the body came from Mumara ONE, but someone who captured a request could send it again later. The timestamp stops that: reject any request whose t is more than five minutes away from your server's time.
- Retries still pass. Every attempt, including every retry, is signed again with the time it was sent.
- Keep your clock right. Sync your server's clock with NTP. A clock that drifts by minutes rejects real requests.
- Check before queueing. Verify the signature when the request arrives, not when a background job gets round to it, or a busy queue can push events past the five minutes.
Process each event once
The same event can reach you more than once: a network error can hide a successful delivery from Mumara ONE, which then retries. Use X-Mumara-Delivery to handle each event once:
- It's unique for each event and webhook, and the same on every retry of that event.
- Two webhooks that receive the same event get different delivery IDs.
- Store the IDs you've processed, for example in a table with a unique index, and when an ID is already there, answer
200without processing it again. - Keep the IDs for at least a week. Retries end about two hours after the first attempt, but events that Mumara support re-sends after an outage carry their original IDs.
X-Mumara-Attempt tells you which attempt a request is. A value above 1 means earlier attempts failed or went unanswered, which is worth logging.
Retries
Your endpoint must answer with a 2xx status within 10 seconds. Anything else, a timeout or a connection error is retried: up to 8 attempts in all, with the first retry about a minute later and each wait twice as long as the one before, so the last attempt comes about two hours after the first. A webhook whose deliveries keep failing for two days is switched off. See Delivery and retries.
Troubleshooting
| Problem | What to check |
|---|---|
| Every signature fails | You're using the secret for this webhook, with nothing added or missing. You're signing the raw body: framework JSON middleware, or anything else that parses and re-encodes the body, changes it. You're comparing lowercase hex. |
| Some signatures fail with an old timestamp | Your server's clock is off, or requests wait in a queue before you verify them. |
| Signatures failed after a secret was replaced | Your endpoint still has the old secret. Update it, and accept both secrets during the change. |
A request has no X-Mumara-Signature header | Treat it as not coming from Mumara ONE. If all requests from one of your webhooks arrive unsigned, contact Mumara support. |
| The same event is processed twice | Deduplicate on X-Mumara-Delivery, as described in Process each event once. |
Next steps
- Webhooks: events, rules and payloads
- API Overview: the REST API for managing your account