# 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.

Source: https://seofix.ai/help/webhooks · Category: REST API · Updated: 2026-10-08

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

```bash
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

```http
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):

```js
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):

```python
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:

```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.

## Related

- [REST API quickstart](https://seofix.ai/help/api-quickstart.md)
- [API reference](https://seofix.ai/help/api-reference.md)
- [Errors and rate limits](https://seofix.ai/help/errors-and-rate-limits.md)
