Correctly implement HMAC verification to authenticate that each notification comes from Whalemate.
Handle retries, duplicates, and timeouts without production errors.
Get ready-to-copy snippets in the most common languages.
Prerequisites
Publicly accessible server over HTTPS with a valid TLS certificate (not self-signed).
Ability to respond in under 10 seconds — if processing takes longer, queue the work and respond 200 OK immediately.
URL with https:// scheme — Whalemate rejects http:// and private domains (localhost, 192.168.x.x, etc.).
To activate your webhook, reach out to our support or CX team.
All events share the same base structure. The body is sent as JSON in UTF-8.
{
"event": "campaign.clicked",
"campaign_id": 142,
"campaign_type": "phishing",
"employee_email": "[email protected]",
"employee_id": 1204,
"occurred_at": "2026-04-24T08:14:55Z"
}
Field | Type | Nullable | Description |
|---|---|---|---|
| string | No | Event name |
| integer | No | Internal campaign ID |
| string | No | Campaign type. Current value: |
| string | Yes | Employee email. See note below. |
| integer | Yes | Internal employee ID. See note below. |
| string | No | ISO 8601 UTC timestamp |
employee_emailandemployee_idcan benullonly in thecampaign.openedevent when the tracking pixel is triggered by a security scanner or proxy. In all other events, they are always present.Events are delivered in the order they occur, but Whalemate does not guarantee they arrive at your server in the same order. Use the
occurred_atfield to sort them chronologically in your system.
Header | Example | Description |
|---|---|---|
|
| Always |
|
| HTTP client identifier |
|
| Unique ID for this delivery |
|
| Unix timestamp UTC of the send time |
|
| HMAC-SHA256 signature |
You can use
User-Agent: WhalemateWebhooks/1.0as a quick filter, but don't use it as a security mechanism. Always verify the HMAC signature.
Each webhook includes an HMAC-SHA256 signature in the X-Whalemate-Signature header. You must verify it before processing any payload.
Algorithm:
signature = "sha256=" + HMAC_SHA256(key=secret, message=timestamp + "." + raw_body)
secret: the secret generated when the webhook was activated (available only once).
timestamp: value of the X-Whalemate-Timestamp header.
raw_body: the JSON body exactly as received, unparsed, as a UTF-8 string or bytes.
Steps:
Read the body as a raw string before any JSON parsing.
Build the message: timestamp + "." + raw_body.
Compute HMAC-SHA256 of the message using the secret as the key.
Compare the result (with sha256= prefix) against X-Whalemate-Signature using constant-time comparison.
Verify that |now() - timestamp| < 300 seconds (5 minutes) to prevent replay attacks.
If you parse the JSON and re-serialize it, key order or whitespace may change and the signature won't match. Always work with the original raw bytes of the body.
Scenario | Recommended response |
|---|---|
Event received and valid | Immediate |
Invalid signature |
|
Expired timestamp |
|
Internal error |
|
Timeout (> 10s) | Whalemate marks as failed and retries |
The response body doesn't matter. Whalemate logs it but doesn't process it.
Whalemate may deliver the same event more than once in exceptional network conditions. Your system must be idempotent.
Recommended strategy:
Use X-Whalemate-Delivery-Id as the idempotency key.
Before processing, check if you've already processed that Delivery ID.
If it already exists, respond 200 OK without reprocessing.
delivery_id = request.headers.get("X-Whalemate-Delivery-Id")
if already_processed(delivery_id):
return Response(status=200)
mark_as_processed(delivery_id)
process_event(request.json)
function verifyWebhookSignature(string $secret, string $timestamp, string $body, string $signature): bool
{
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
return hash_equals($expected, $signature);
}
$timestamp = $_SERVER['HTTP_X_WHALEMATE_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WHALEMATE_SIGNATURE'] ?? '';
$body = file_get_contents('php://input');
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(400); exit('Timestamp too old');
}
if (!verifyWebhookSignature(getenv('WHALEMATE_WEBHOOK_SECRET'), $timestamp, $body, $signature)) {
http_response_code(401); exit('Invalid signature');
}
http_response_code(200);
$event = json_decode($body, true);
// procesar $event...
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
def verify_signature(secret, timestamp, raw_body, signature):
message = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.post("/webhook")
def webhook():
secret = os.environ["WHALEMATE_WEBHOOK_SECRET"]
timestamp = request.headers.get("X-Whalemate-Timestamp", "")
signature = request.headers.get("X-Whalemate-Signature", "")
raw_body = request.get_data()
if abs(time.time() - int(timestamp)) > 300:
abort(400, "Timestamp too old")
if not verify_signature(secret, timestamp, raw_body, signature):
abort(401, "Invalid signature")
event = request.get_json()
# procesar event...
return "", 200import hashlib, hmac, json, os, time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/webhook")
async def webhook(request: Request):
secret = os.environ["WHALEMATE_WEBHOOK_SECRET"]
timestamp = request.headers.get("x-whalemate-timestamp", "")
signature = request.headers.get("x-whalemate-signature", "")
raw_body = await request.body()
if abs(time.time() - int(timestamp)) > 300:
raise HTTPException(status_code=400, detail="Timestamp too old")
message = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(status_code=401, detail="Invalid signature")
event = json.loads(raw_body)
# procesar event...
return {"status": "ok"}
const crypto = require('crypto');
function verifySignature(secret, timestamp, rawBody, signature) {
const message = `${timestamp}.${rawBody}`;
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(message).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.headers['x-whalemate-timestamp'] ?? '';
const signature = req.headers['x-whalemate-signature'] ?? '';
const rawBody = req.body.toString('utf8');
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
return res.status(400).send('Timestamp too old');
if (!verifySignature(process.env.WHALEMATE_WEBHOOK_SECRET, timestamp, rawBody, signature))
return res.status(401).send('Invalid signature');
const event = JSON.parse(rawBody);
// procesar event...
res.status(200).end();
});
SECRET="tu_secreto_aqui"
TIMESTAMP=$(date +%s)
BODY='{"event":"campaign.sent","campaign_id":1,"campaign_type":"phishing","employee_email":"[email protected]","employee_id":1,"occurred_at":"2026-01-01T00:00:00Z"}'
SIGNATURE="sha256=$(printf '%s' "${TIMESTAMP}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST https://tu-servidor.com/webhook \
-H "Content-Type: application/json" \
-H "X-Whalemate-Delivery-Id: 9999" \
-H "X-Whalemate-Timestamp: $TIMESTAMP" \
-H "X-Whalemate-Signature: $SIGNATURE" \
-d "$BODY"
Always verify the HMAC signature before processing. Don't rely solely on source IP or User-Agent.
Store the secret in environment variables or a secrets manager. Never hardcode it.
Use HTTPS with TLS 1.2 or higher.
Validate payload content even when the signature is valid.
Apply rate limiting on your receiver endpoint.
Signature doesn't match
Cause | Solution |
|---|---|
You parse the body before computing the signature | Always read the body as raw string/bytes before parsing |
Middleware re-serializes the JSON | Disable automatic body parsing or reconstruct from original bytes |
Secret has trailing spaces or newlines | Verify the secret is trimmed when storing it |
You compare with | Use |
Timestamp always expired
Server clock not synchronized with NTP. Check with date or timedatectl status.
Frequent timeouts (> 10s)
Queue the processing and respond 200 OK immediately.
@app.post("/webhook")
def webhook():
# ... verificar firma ...
enqueue_job(process_webhook, payload=request.get_json())
return "", 200Why do I need to read the body as a raw string instead of parsing it as JSON directly?
The HMAC signature is computed over the exact bytes of the body as received. If you parse and re-serialize the JSON, key order or whitespace may change and the signature won't match.
Why use constant-time comparison for the signature?
Normal comparisons (==) are vulnerable to timing attacks. hmac.compare_digest, hash_equals, and timingSafeEqual eliminate that vulnerability.
What if I need more than 10 seconds to process each event?
Respond 200 OK immediately after verifying the signature and queue the processing in an async job/worker.
How do I prevent processing the same event twice?
Use X-Whalemate-Delivery-Id as an idempotency key. Store processed IDs and check before executing.
Have feedback or want to request improvements? Let us know at roadmap.whalemate.com/roadmap