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.
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.
- Read the raw request body as bytes, before any JSON parsing.
- Compute HMAC-SHA256 of those bytes with your webhook secret, as lowercase hex.
- Compare
sha256=<hex>with the header using a constant-time comparison. - 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
2xxanswer counts as delivered. Redirects are not followed, and a3xxanswer also ends delivery, so answer from the final URL. - A
4xxor5xxanswer, 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_idto 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. AnhttpURL 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;
443is used when none is given.
A refused URL counts as a failed attempt and is retried like any other failure.
Related
More in REST API
Still stuck? Email [email protected] with your site and what you expected to see.