SeoPrimo · SEO per Google e per le AI · presenza garantita, o rimborso della quota di attivazione · Audit gratuito del tuo sito
SeoPrimo · SEO per Google e per le AI · presenza garantita, o rimborso della quota di attivazione · Audit gratuito del tuo sito
seoprimo
— Sviluppatori · Webhook

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.

6
eventi
HMAC
SHA-256 su ogni consegna
6
tentativi per consegna
— Come funziona

Un indirizzo, un segreto.

  1. Nel pannello apri seoprimo.it/app/integrations/webhooks (Integrazioni, Webhook), inserisci l'indirizzo del tuo server e scegli gli eventi.
  2. 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.
  3. A ogni evento SeoPrimo fa un POST con il corpo JSON e le intestazioni qui sotto.
  4. 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à.
— Eventi

Cosa ti arriva.

Scegli gli eventi per ogni indirizzo. I campi elencati stanno nell'oggetto data del messaggio.

Eventi dei webhook
EventoQuandoCampi di data
audit.completedFinisce l'audit completo del sito (non l'audit rapido).site_id, run_id, audit_id, pages_crawled, issues {critical, warning, info, total}
score.changedLo SeoPrimo Score cambia di almeno 3 punti rispetto alla misura precedente.site_id, snapshot_id, score, previous_score, delta, preliminary
rank.changedUn controllo delle posizioni trova almeno una posizione cambiata.site_id, run_id, tracker_id, changes [{keyword, device, position, previous}]
report.readyUn nuovo report è pronto.site_id, report_id, title, source
credits.lowI 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.completedUn 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
testEvento di prova: lo manda il pulsante «Invia prova» del pannello. Non è tra quelli da scegliere.message
— Il messaggio

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

JSON
{
  "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.

HTTP
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
Intestazioni delle consegne
IntestazioneValore
Content-Typeapplication/json
User-AgentSeoPrimo-Webhooks/1.0
X-SeoPrimo-EventIl tipo di evento, per esempio score.changed.
X-SeoPrimo-DeliveryL'id di questa consegna.
X-SeoPrimo-SignatureLa firma: t=<unix>,v1=<hex>.
— Firma

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.

  1. Leggi t e v1 dall'intestazione.
  2. Rifiuta se t è più vecchio di 5 minuti (o nel futuro): è la difesa dai replay.
  3. 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.
  4. Confronta con un confronto a tempo costante (hmac.compare_digest, crypto.timingSafeEqual).

Python

Python verifier
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)
FastAPI
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

Node verifier
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 };
Express
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.

— Tentativi

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.

Calendario dei tentativi
TentativoQuando
1subito
21 minuto dopo il fallimento
35 minuti dopo
430 minuti dopo
52 ore dopo
66 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.

— Prova

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 type uguale a test e data uguale a {"message": …}.
Ricevi, non interrogare

Sapere quando
cambia qualcosa.

Aggiungi il tuo indirizzo, scegli gli eventi e fai la prova: la firma ti dice che il messaggio è nostro.

Cosa trovi nella guida
6
Eventi
più la prova
HMAC
Firma verificabile
Python e Node
6
Tentativi
poi la consegna è persa