Signed webhooks.
SeoPrimo writes to you when something happens: audit finished, Score changed, credits low. Every delivery is signed with HMAC-SHA256 and, if it fails, retried.
One address, one secret.
- In the panel open seoprimo.it/app/integrations/webhooks (Integrations, Webhooks), enter your server's address and choose the events.
- You get the signing secret (
whsec_…): it is shown only once. You can replace it whenever you want (rotate the secret): the old one stops working at once. - On every event SeoPrimo makes a POST with the JSON body and the headers below.
- You verify the signature and answer with any 2xx within 10 seconds.
- The address must be https, without credentials, on a standard port and pointing to a public address: private networks, localhost and internal addresses are refused both when you save it and on every delivery. While developing use an https tunnel (for example cloudflared or ngrok).
- Redirects (3xx) are not followed and count as a failure.
- You can have up to 10 webhooks. Account owners and administrators manage them.
What you receive.
Choose the events for each address. The fields listed are in the message's data object.
| Event | When | Fields of data |
|---|---|---|
audit.completed | The full site audit finishes (not the quick audit). | site_id, run_id, audit_id, pages_crawled, issues {critical, warning, info, total} |
score.changed | The SeoPrimo Score changes by at least 3 points from the previous measurement. | site_id, snapshot_id, score, previous_score, delta, preliminary |
rank.changed | A rank check finds at least one changed position. | site_id, run_id, tracker_id, changes [{keyword, device, position, previous}] |
report.ready | A new report is ready. | site_id, report_id, title, source |
credits.low | Available credits fall below 20 or below 10% of the plan's monthly credits. At most once every 24 hours. | available, monthly_credits, threshold |
agent.completed | An AI agent (full analysis or monthly report) has finished, or was stopped, or failed. | agent_run_id, site_id, agent, status, credits_used, report_id |
test | Test event: sent by the "Send test" button in the panel. It is not one you subscribe to. | message |
Body and headers.
The body is always an object with id (starts with evt_), type (the event), created (Unix seconds) and data. It does not contain the account id.
{
"id": "evt_6f1c1c5e6b9c4c589d0e5e0a3b1d2c77",
"type": "score.changed",
"created": 1790000000,
"data": {
"site_id": "7b0b6f0e-4f0e-4f6c-9d57-0c4f3a1f2a10",
"snapshot_id": "3f7f0c1a-6b2e-4d49-8c7a-9d1e0f2b3c44",
"score": 72,
"previous_score": 65,
"delta": 7,
"preliminary": false
}
}
The id is the same on every attempt of the same delivery: use it to avoid processing the same event twice.
POST /webhooks/seoprimo HTTP/1.1
Content-Type: application/json
User-Agent: SeoPrimo-Webhooks/1.0
X-SeoPrimo-Event: score.changed
X-SeoPrimo-Delivery: 5c1e7a3b-8f3d-4f1e-a0c2-6b1c9a3f4d22
X-SeoPrimo-Signature: t=1790000000,v1=3b7c0f4e...e9d2
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SeoPrimo-Webhooks/1.0 |
X-SeoPrimo-Event | The event type, for example score.changed. |
X-SeoPrimo-Delivery | The id of this delivery. |
X-SeoPrimo-Signature | The signature: t=<unix>,v1=<hex>. |
Check that it is ours.
The header is X-SeoPrimo-Signature: t=<unix>,v1=<hex>. The v1 signature is the HMAC-SHA256, in hexadecimal, of the string "<t>.<body>" (the time, a dot and the raw body) with the endpoint's secret.
- Read
tandv1from the header. - Reject if
tis older than 5 minutes (or in the future): that is the defence against replays. - Recompute the HMAC over
"<t>."plus the body exactly as you received it, before parsing it as JSON: re-serializing the JSON changes the bytes and the signature will not match. - Compare with a constant-time comparison (
hmac.compare_digest,crypto.timingSafeEqual).
Python
import hashlib
import hmac
import time
def verify_signature(secret: str, raw_body: bytes, header: str, tolerance: int = 300, now: float | None = None) -> bool:
"""True when `header` is a valid signature of `raw_body` made with `secret`, at most `tolerance` seconds old."""
try:
parts = dict(item.split("=", 1) for item in header.split(","))
timestamp, received = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs((time.time() if now is None else now) - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
import json
import os
from fastapi import FastAPI, HTTPException, Request
# verify_signature is the function above (same file or imported)
app = FastAPI()
SECRET = os.environ["SEOPRIMO_WEBHOOK_SECRET"] # whsec_...
@app.post("/webhooks/seoprimo")
async def seoprimo_webhook(request: Request):
raw_body = await request.body() # the bytes as received, before any JSON parsing
if not verify_signature(SECRET, raw_body, request.headers.get("x-seoprimo-signature", "")):
raise HTTPException(status_code=401, detail="invalid signature")
event = json.loads(raw_body)
# answer fast (2xx within 10 seconds) and process in the background; use event["id"] to deduplicate
return {"received": True}
Node.js
const crypto = require("node:crypto");
// rawBody: a Buffer with the bytes as received (before any JSON parsing)
function verifySignature(secret, rawBody, header, toleranceSeconds = 300, nowSeconds = Date.now() / 1000) {
let timestamp;
let received;
try {
const parts = Object.fromEntries(
header.split(",").map((item) => {
const i = item.indexOf("=");
return [item.slice(0, i), item.slice(i + 1)];
}),
);
timestamp = Number.parseInt(parts.t, 10);
received = parts.v1;
if (!Number.isFinite(timestamp) || typeof received !== "string") return false;
} catch {
return false;
}
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(received);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
module.exports = { verifySignature };
const express = require("express");
const { verifySignature } = require("./verify");
const app = express();
// express.raw keeps the body as a Buffer: do NOT use express.json() on this route
app.post("/webhooks/seoprimo", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.get("X-SeoPrimo-Signature") || "";
if (!verifySignature(process.env.SEOPRIMO_WEBHOOK_SECRET, req.body, signature)) {
return res.status(401).json({ error: "invalid_signature" });
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).json({ received: true }); // answer fast (2xx within 10 seconds)
// ... process event.type / event.data in the background; use event.id to deduplicate
});
app.listen(3000);
If you use n8n, the Code node of the downloadable workflows does the same: see the n8n guide.
If you do not answer, we retry.
A delivery succeeds with a 2xx answer. Anything else (HTTP error, redirect, no answer within 10 seconds) is a failure, and SeoPrimo retries with growing delays, up to six attempts in all. Every attempt carries a fresh signature, with a new t.
| Attempt | When |
|---|---|
| 1 | immediately |
| 2 | 1 minute after the failure |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 6 hours later (the last one) |
After the sixth failure the delivery is given up (status "failed") and is not retried again.
Automatic disabling
- Every failed attempt adds 1 to the address's failure counter; a successful delivery resets it.
- At 20 consecutive failures the address is disabled: pending deliveries are closed as failed and nothing more is sent.
- To start again, re-enable it from the panel: the counter goes back to zero.
Answer first and work later: a 2xx within 10 seconds, then process the event in the background. Keep track of the id values you have already seen, because after an error of yours an event can arrive more than once.
The "Send test" button.
In Integrations, Webhooks, next to every address, Send test immediately sends a signed test event and shows the outcome (the response code or the error). Recent deliveries are in the "Latest deliveries" list (the last 50).
- The test makes a single attempt, without retries.
- It does not count towards the failure counter and works even on a disabled address: it is the way to check your server is fine before re-enabling it.
- The body has
typeequal totestanddataequal to{"message": …}.
Know when
something changes.
Add your address, choose the events and send a test: the signature tells you the message is ours.