Correctly implement HMAC verification to authenticate that each notification comes from Whalemate.
Handle retries, duplicates, and timeouts without errors in production.
Have ready-to-copy snippets in the most common languages.
Publicly accessible server via HTTPS with a valid TLS certificate (not self-signed).
Ability to respond in less than 10 seconds — if processing takes longer, queue the job and respond 200 OK immediately.
URL with https:// scheme — Whalemate rejects http:// and private domains (localhost, 192.168.x.x, etc.).
Configure the webhook from
Settings → Integrations → Webhooks → Configure(see functional doc). The HMAC secret is shown only once when saving — copy it before continuing with signature verification.
All events share the same base structure. The body is sent as UTF-8 JSON.
{
"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
event
string
No
Event name
campaign_id
integer
No
Internal campaign ID
campaign_type
string
No
Campaign type. Current value: "phishing"
employee_email
string
Yes
Employee's email. See note below.
employee_id
integer
Yes
Internal employee ID. See note below.
occurred_at
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 in which they occur, but Whalemate does not guarantee they will arrive at your server in that same order. Use the
occurred_atfield to sort them chronologically in your system.
Header
Example
Description
Content-Type
application/json
Always application/json; charset=utf-8
User-Agent
WhalemateWebhooks/1.0
HTTP client identifier
X-Whalemate-Delivery-Id
"4521"
Unique ID for this delivery
X-Whalemate-Timestamp
"1714986000"
Unix UTC timestamp of when it was sent
X-Whalemate-Signature
sha256=abc123...
HMAC-SHA256 signature
You can use
User-Agent: WhalemateWebhooks/1.0as a quick filter, but do not 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:
firma = "sha256=" + HMAC_SHA256(key=secret, message=timestamp + "." + raw_body)
secret: the secret generated when activating the webhook (available only once).
timestamp: value of the X-Whalemate-Timestamp header.
raw_body: the JSON body exactly as it arrives, 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.
Calculate HMAC-SHA256 of the message using the secret as the key.
Compare the result (with the sha256= prefix) with X-Whalemate-Signature using a constant-time comparison.
Verify that |now() - timestamp| < 300 seconds (5 minutes) to prevent replay attacks.
If you parse the JSON and re-serialize it, the order of the keys or the spacing may change and the signature will not match. Always work with the original bytes of the body.
Scenario
Recommended response
Event received and valid
Immediate 200 OK, process asynchronously
Invalid signature
401 Unauthorized
Expired timestamp
400 Bad Request
Internal error
500 — Whalemate will retry
Timeout (> 10s)
Whalemate marks it as failed and retries
The response body doesn't matter. Whalemate logs it but does not process it.
Whalemate may deliver the same event more than once under exceptional network circumstances. Your system must be idempotent.
Recommended strategy:
Use X-Whalemate-Delivery-Id as the idempotency key.
Before processing, check if you already processed that Delivery ID.
If it already exists, respond 200 OK without processing it again.
delivery_id = request.headers.get("X-Whalemate-Delivery-Id")
if already_processed(delivery_id):
return Response(status=200) # duplicado ignorado
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", "")