Webhooks & Signatures
Receive real-time HTTPS push notifications whenever check runs settle, rank movements occur, credit thresholds drop, or alert conditions trigger.
Supported Event Types
Deliveries use a standardized event envelope containing the event id, type, created_at, and data:
| Event | Trigger Condition | Key Payload Fields |
|---|
run.completed | A scheduled or manual check run finishes successfully. | project_id, run_id, status, keywords_checked |
run.failed | A check run encounters an unrecoverable failure. | project_id, run_id |
rankings.updated | A check run completes and at least one latest keyword rank changed. | project_id, run_id, changes: [...] (up to 500), changes_total |
credits.low | Account credit balance drops below your low-balance threshold. | balance, allowance |
credits.exhausted | Scheduled scans were paused due to zero remaining credits. | project_id |
alert.triggered | Rank movement matches one of your active rank alert rules. | rule_id, rule_name, project_id, matches: [...] |
Signature Verification Scheme
Every delivery carries an HTTP header:
TrackSERPs-Signature: t=1731215460,v1=1a86c554e0f74643817874d9e40d7e122d6079d5638440c9666eef964ab6cf36
To prevent tampering and replay attacks, receivers must:
- Parse
t (Unix timestamp in seconds) and v1 (hex signature). - Verify that
|current_time - t| ≤ 300 (reject events older than 5 minutes). - Compute the HMAC-SHA256 hash of
"<t>.<raw_body>" using your webhook endpoint signing secret (whsec_...). - Compare the computed hash with
v1 using a constant-time equality check.
Signature Verification Code Examples
Node.js / TypeScript
import crypto from "node:crypto";
import express from "express";
const app = express();
// Ensure you capture raw body bytes for accurate signature verification!
app.use(express.raw({ type: "application/json" }));
app.post("/webhook", (req, res) => {
const signatureHeader = req.headers["trackserps-signature"];
const secret = process.env.TRACKSERPS_WEBHOOK_SECRET; // whsec_...
if (!signatureHeader || !secret) {
return res.status(401).send("Missing signature or secret");
}
// 1. Parse header components: t=<unix>,v1=<signature>
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.trim().split("="))
);
const timestamp = parseInt(parts.t, 10);
const receivedSig = parts.v1;
// 2. Reject timestamps older than 5 minutes (300s) to prevent replay attacks
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
return res.status(401).send("Timestamp expired");
}
// 3. Compute expected HMAC-SHA256 of "timestamp.rawBody"
const rawBody = req.body.toString("utf8");
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSig = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// 4. Constant-time comparison
const sigBuf = Buffer.from(receivedSig, "hex");
const expBuf = Buffer.from(expectedSig, "hex");
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send("Invalid signature");
}
// Signature valid! Process event payload
const event = JSON.parse(rawBody);
console.log("Received verified event:", event.type, event.id);
res.status(200).json({ received: true });
});
Python (Flask)
import hmac
import hashlib
import time
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_signing_secret_here"
@app.route("/webhook", methods=["POST"])
def trackserps_webhook():
sig_header = request.headers.get("TrackSERPs-Signature")
if not sig_header:
return "Missing signature header", 401
# 1. Parse header components
header_parts = dict(part.strip().split("=", 1) for part in sig_header.split(","))
timestamp = int(header_parts.get("t", 0))
signature = header_parts.get("v1", "")
# 2. Reject timestamps older than 5 minutes (300 seconds)
if abs(time.time() - timestamp) > 300:
return "Timestamp expired", 401
# 3. Compute HMAC-SHA256 of "timestamp.raw_body"
raw_body = request.get_data(as_text=True)
signed_payload = f"{timestamp}.{raw_body}".encode("utf-8")
expected_sig = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
signed_payload,
hashlib.sha256
).hexdigest()
# 4. Constant-time comparison
if not hmac.compare_digest(signature, expected_sig):
return "Invalid signature", 401
# Verified! Parse JSON
event = json.loads(raw_body)
print(f"Received verified event: {event['type']} ({event['id']})")
return jsonify({"received": True}), 200
PHP
<?php
$secret = getenv('TRACKSERPS_WEBHOOK_SECRET');
$signatureHeader = $_SERVER['HTTP_TRACKSERPS_SIGNATURE'] ?? '';
if (!$signatureHeader || !$secret) {
http_response_code(401);
exit('Missing signature header or secret');
}
// 1. Parse header components: t=<timestamp>,v1=<signature>
$parts = [];
foreach (explode(',', $signatureHeader) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) === 2) {
$parts[$pair[0]] = $pair[1];
}
}
$timestamp = (int)($parts['t'] ?? 0);
$signature = $parts['v1'] ?? '';
// 2. Reject timestamps older than 5 minutes
if (abs(time() - $timestamp) > 300) {
http_response_code(401);
exit('Timestamp expired');
}
// 3. Compute HMAC-SHA256 of "timestamp.rawBody"
$rawBody = file_get_contents('php://input');
$signedPayload = $timestamp . '.' . $rawBody;
$expectedSig = hash_hmac('sha256', $signedPayload, $secret);
// 4. Constant-time comparison
if (!hash_equals($signature, $expectedSig)) {
http_response_code(401);
exit('Invalid signature');
}
// Signature is valid!
$event = json_decode($rawBody, true);
error_log("Verified TrackSERPs event: " . $event['type']);
http_response_code(200);
echo json_encode(['received' => true]);
Retry Policy & Safety Controls
- Retry Schedule: Deliveries that fail or time out (>10s) are automatically retried with exponential backoff across 8 attempts over 24 hours (1m, 5m, 30m, 2h, 6h, 12h, 24h).
- Auto-Disable: After 20 consecutive delivery failures, the webhook endpoint is automatically disabled to protect your server, and the account owner receives an email notification.
- SSRF Protection: All webhook URLs must use HTTPS and resolve to public routable IP addresses. Loopback (127.0.0.1), private RFC-1918 subnets, and cloud metadata endpoints are strictly blocked.