Webhook firmati.
SeoPrimo scrive a te quando succede qualcosa: audit finito, Score cambiato, crediti bassi. Ogni consegna è firmata con HMAC-SHA256 e, se fallisce, viene ritentata.
Un indirizzo, un segreto.
- Nel pannello apri seoprimo.it/app/integrations/webhooks (Integrazioni, Webhook), inserisci l'indirizzo del tuo server e scegli gli eventi.
- Ricevi il segreto di firma (
whsec_…): si vede una sola volta. Lo puoi sostituire quando vuoi (ruota il segreto): il vecchio smette subito di valere. - A ogni evento SeoPrimo fa un POST con il corpo JSON e le intestazioni qui sotto.
- Tu verifichi la firma e rispondi con un qualunque 2xx entro 10 secondi.
- L'indirizzo deve essere https, senza credenziali, su una porta standard e verso un indirizzo pubblico: reti private, localhost e indirizzi interni sono rifiutati sia quando lo salvi sia a ogni consegna. In sviluppo usa un tunnel https (per esempio cloudflared o ngrok).
- I reindirizzamenti (3xx) non vengono seguiti e contano come errore.
- Puoi avere fino a 10 webhook. Li gestisce chi è titolare o amministratore dell'attività.
Cosa ti arriva.
Scegli gli eventi per ogni indirizzo. I campi elencati stanno nell'oggetto data del messaggio.
| Evento | Quando | Campi di data |
|---|---|---|
audit.completed | Finisce l'audit completo del sito (non l'audit rapido). | site_id, run_id, audit_id, pages_crawled, issues {critical, warning, info, total} |
score.changed | Lo SeoPrimo Score cambia di almeno 3 punti rispetto alla misura precedente. | site_id, snapshot_id, score, previous_score, delta, preliminary |
rank.changed | Un controllo delle posizioni trova almeno una posizione cambiata. | site_id, run_id, tracker_id, changes [{keyword, device, position, previous}] |
report.ready | Un nuovo report è pronto. | site_id, report_id, title, source |
credits.low | I crediti disponibili scendono sotto 20 o sotto il 10% dei crediti mensili del piano. Al massimo una volta ogni 24 ore. | available, monthly_credits, threshold |
agent.completed | Un agente AI (analisi completa o report mensile) ha finito, o è stato interrotto o è fallito. | agent_run_id, site_id, agent, status, credits_used, report_id |
test | Evento di prova: lo manda il pulsante «Invia prova» del pannello. Non è tra quelli da scegliere. | message |
Corpo e intestazioni.
Il corpo è sempre un oggetto con id (inizia con evt_), type (l'evento), created (secondi Unix) e data. Non contiene l'id dell'attività.
{
"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
}
}
L'id è lo stesso a ogni tentativo della stessa consegna: usalo per non elaborare due volte lo stesso evento.
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
| Intestazione | Valore |
|---|---|
Content-Type | application/json |
User-Agent | SeoPrimo-Webhooks/1.0 |
X-SeoPrimo-Event | Il tipo di evento, per esempio score.changed. |
X-SeoPrimo-Delivery | L'id di questa consegna. |
X-SeoPrimo-Signature | La firma: t=<unix>,v1=<hex>. |
Verifica che sia nostro.
L'intestazione è X-SeoPrimo-Signature: t=<unix>,v1=<hex>. La firma v1 è l'HMAC-SHA256, in esadecimale, della stringa "<t>.<corpo>" (il tempo, un punto e il corpo grezzo) con il segreto dell'endpoint.
- Leggi
tev1dall'intestazione. - Rifiuta se
tè più vecchio di 5 minuti (o nel futuro): è la difesa dai replay. - Ricalcola l'HMAC su
"<t>."più il corpo esattamente come l'hai ricevuto, prima di leggerlo come JSON: ricomporre il JSON cambia i byte e la firma non torna. - Confronta con un confronto a tempo costante (
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);
Se usi n8n, il nodo Code dei workflow scaricabili fa la stessa cosa: vedi la guida a n8n.
Se non rispondi, riproviamo.
Una consegna è riuscita con una risposta 2xx. Qualunque altra cosa (errore HTTP, reindirizzamento, nessuna risposta in 10 secondi) è un fallimento, e SeoPrimo riprova con attese crescenti, fino a sei tentativi in tutto. Ogni tentativo porta una firma nuova, con un t nuovo.
| Tentativo | Quando |
|---|---|
| 1 | subito |
| 2 | 1 minuto dopo il fallimento |
| 3 | 5 minuti dopo |
| 4 | 30 minuti dopo |
| 5 | 2 ore dopo |
| 6 | 6 ore dopo (l'ultimo) |
Dopo il sesto fallimento la consegna è data per persa (stato «fallita») e non viene più ritentata.
Disattivazione automatica
- Ogni tentativo fallito aggiunge 1 al contatore degli errori dell'indirizzo; una consegna riuscita lo azzera.
- A 20 errori consecutivi l'indirizzo viene disattivato: le consegne in attesa sono chiuse come fallite e non parte più niente.
- Per ripartire riattivalo dal pannello: il contatore torna a zero.
Rispondi subito e lavora dopo: un 2xx entro 10 secondi, poi elabora l'evento in background. Tieni traccia degli id già visti, perché dopo un tuo errore un evento può arrivare più di una volta.
Il pulsante «Invia prova».
In Integrazioni, Webhook, accanto a ogni indirizzo, Invia prova spedisce subito un evento test firmato e mostra l'esito (il codice di risposta o l'errore). Le consegne recenti sono nell'elenco «Ultime consegne» (le ultime 50).
- La prova fa un solo tentativo, senza ripetizioni.
- Non conta nel contatore degli errori e funziona anche su un indirizzo disattivato: è il modo per controllare che il tuo server sia a posto prima di riattivarlo.
- Il corpo ha
typeuguale atestedatauguale a{"message": …}.
Sapere quando
cambia qualcosa.
Aggiungi il tuo indirizzo, scegli gli eventi e fai la prova: la firma ti dice che il messaggio è nostro.