TrackSERPs
Log in

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:

EventTrigger ConditionKey Payload Fields
run.completedA scheduled or manual check run finishes successfully.project_id, run_id, status, keywords_checked
run.failedA check run encounters an unrecoverable failure.project_id, run_id
rankings.updatedA check run completes and at least one latest keyword rank changed.project_id, run_id, changes: [...] (up to 500), changes_total
credits.lowAccount credit balance drops below your low-balance threshold.balance, allowance
credits.exhaustedScheduled scans were paused due to zero remaining credits.project_id
alert.triggeredRank 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:

Delivery Header
TrackSERPs-Signature: t=1731215460,v1=1a86c554e0f74643817874d9e40d7e122d6079d5638440c9666eef964ab6cf36

To prevent tampering and replay attacks, receivers must:

  1. Parse t (Unix timestamp in seconds) and v1 (hex signature).
  2. Verify that |current_time - t| ≤ 300 (reject events older than 5 minutes).
  3. Compute the HMAC-SHA256 hash of "<t>.<raw_body>" using your webhook endpoint signing secret (whsec_...).
  4. Compare the computed hash with v1 using a constant-time equality check.

Signature Verification Code Examples

Node.js / TypeScript

express_webhook.ts
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)

flask_webhook.py
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

webhook.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.
Webhooks & Signatures · TrackSERPs API Docs