Webhooks

Get a signed HTTPS POST when an audit finishes - payload, the X-Audit-Signature HMAC header and how to verify it, retries, and the URL rules.

Updated 8 October 2026View as Markdown

Pass webhook_url when you start an audit with POST /v1/crawls (or MCP start_audit), and SEOFix sends one signed JSON POST to that URL when the audit ends. Verify the X-Audit-Signature header (HMAC-SHA256 of the raw body) before you trust the payload.

Set a webhook

curl -s -X POST https://api.seofix.ai/v1/crawls \
  -H "Authorization: Bearer $SEOFIX_API_KEY" -H "Content-Type: application/json" \
  -d '{"url": "https://example.com", "webhook_url": "https://hooks.example.net/seofix"}'

The webhook is set per audit. There is no account-wide webhook setting, and POST /v1/sites/{id}/crawls (site audits), scheduled audits and rechecks do not send webhooks. To get a webhook for a registered site, start the audit with POST /v1/crawls and site_id plus webhook_url.

Event

There is one event: the audit ended. It fires when the audit reaches done, failed or cancelled, once its credits are settled (usually within a minute of the end).

Payload

POST /seofix HTTP/1.1
Content-Type: application/json
X-Audit-Signature: sha256=5d2c1f0e...

{"crawl_id":4821,"status":"done","pages_crawled":500,"health_score":87}
Field Type Meaning
crawl_id integer the audit
status string done, failed or cancelled
pages_crawled integer pages fetched
health_score integer or null 0-100; null when the audit has no report (for example a failed audit)

The payload is small on purpose. Fetch the details with GET /v1/crawls/{crawl_id}/report.

Verify the signature

X-Audit-Signature is sha256= followed by the hex HMAC-SHA256 of the exact request body, keyed with your account's webhook secret.

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Compute HMAC-SHA256 of those bytes with your webhook secret, as lowercase hex.
  3. Compare sha256=<hex> with the header using a constant-time comparison.
  4. Reject the request (for example with 401) if they differ.

Node.js (Express):

import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.SEOFIX_WEBHOOK_SECRET;

app.post("/seofix", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(req.body).digest("hex");
  const given = req.get("X-Audit-Signature") ?? "";
  const ok = given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString("utf8"));
  // queue your own work here and answer quickly
  res.sendStatus(204);
});

Python (Flask):

import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["SEOFIX_WEBHOOK_SECRET"].encode()

@app.post("/seofix")
def seofix():
    body = request.get_data()
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Audit-Signature", "")):
        abort(401)
    event = request.get_json()
    return "", 204

PHP:

$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, getenv('SEOFIX_WEBHOOK_SECRET'));
if (!hash_equals($expected, $_SERVER['HTTP_X_AUDIT_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}
$event = json_decode($body, true);

The webhook secret

Each SEOFix account has its own webhook secret, generated when the account is created. It signs the webhooks of every audit you start. Keep it on your server only.

  • See it: Settings → Agent & MCP → Webhook signing secret → Show, then Copy.
  • Rotate it: Rotate on the same row. The new secret takes effect at once, also for retries of earlier webhooks, so signatures checked with the old secret fail until you update your server.

The secret belongs to you, not to the team: each person who starts audits has their own. It is shown only in the app, not through the REST API or MCP.

Delivery and retries

  • SEOFix waits up to 10 seconds for your answer and does not read more than 4 KB of it.
  • Any 2xx answer counts as delivered. Redirects are not followed, and a 3xx answer also ends delivery, so answer from the final URL.
  • A 4xx or 5xx answer, a timeout or a connection error is retried. There are at most 5 attempts in total; the retries come about 1, 5, 15 and 30 minutes after the previous failure.
  • After the fifth failed attempt the webhook is dropped. Poll GET /v1/crawls/{crawl_id} to recover.
  • Deliveries can arrive more than once. Use crawl_id to ignore duplicates.

Answer fast with 2xx and do slow work (fetching the report, notifying people) in the background.

URL rules

The URL is checked at each delivery attempt:

  • It must use https. An http URL is accepted when you start the audit, but every delivery attempt fails.
  • Its host must resolve only to public IP addresses. Private, loopback, link-local, carrier-grade NAT, documentation, multicast and other reserved ranges are refused, for IPv4 and IPv6 (for example 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, ::1, fc00::/7, fe80::/10).
  • The connection goes to the IP address that was checked, so DNS changes during delivery cannot redirect it.
  • Any port works; 443 is used when none is given.

A refused URL counts as a failed attempt and is retried like any other failure.

More in REST API

Still stuck? Email [email protected] with your site and what you expected to see.