Skip to main content

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:

HeaderExampleMeaning
X-Mumara-Signaturet=1790072105,v1=0c98ac3d…The timestamp and the signature. See below.
X-Mumara-EventdThe event: r, d, b, t, c, campaign_open, campaign_click, transactional_open or transactional_click. See Events.
X-Mumara-Delivery42-3f786850e387550fdab836ed7e6dc881de23001bA unique ID for this event and this webhook. It's the same on every retry. See Process each event once.
X-Mumara-Attempt11 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:

  1. takes the current Unix time in seconds as the timestamp t,
  2. joins the timestamp, a full stop and the exact request body: <t>.<body>,
  3. computes an HMAC-SHA256 of that string, with the webhook's secret as the key, and writes it as lowercase hex: that's v1,
  4. sends the header X-Mumara-Signature: t=<t>,v1=<v1>.

Worked example​

Use these values to test your code:

Value
Secretwhsec_EXAMPLEdoNOTuseTHISsecretINprod
Timestamp1790072105
Body{"type":"d","to":"jane@example.net","msg_id":"<1234-a1b2c3d4@example.com>"}
String that is signed1790072105.{"type":"d","to":"jane@example.net","msg_id":"<1234-a1b2c3d4@example.com>"}
Headert=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​

  1. 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.
  2. Read the header. Split X-Mumara-Signature on commas, then each part on its first =. Take t and every v1 value.
  3. Check the timestamp. Reject the request if t is more than five minutes away from your server's clock.
  4. Compute the expected signature: the HMAC-SHA256 of t, a full stop and the raw body, keyed with your secret, as lowercase hex.
  5. Compare in constant time. Accept the request if any v1 value matches. Use your language's constant-time comparison, never ==, so the comparison doesn't leak timing information.
  6. Then parse the JSON, check X-Mumara-Delivery for a duplicate, and answer 2xx straight away. Do the slow work afterwards.

If a check fails, answer 401 and don't process the request.

A rejected request is retried

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 200 without 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​

ProblemWhat to check
Every signature failsYou'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 timestampYour server's clock is off, or requests wait in a queue before you verify them.
Signatures failed after a secret was replacedYour endpoint still has the old secret. Update it, and accept both secrets during the change.
A request has no X-Mumara-Signature headerTreat 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 twiceDeduplicate on X-Mumara-Delivery, as described in Process each event once.

Next steps​