SeoPrimo · SEO for Google and for AI · guaranteed presence, or your setup fee refunded · Free audit of your site
SeoPrimo · SEO for Google and for AI · guaranteed presence, or your setup fee refunded · Free audit of your site
seoprimo
— Developers · Webhooks

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.

6
events
HMAC
SHA-256 on every delivery
6
attempts per delivery
— How it works

One address, one secret.

  1. In the panel open seoprimo.it/app/integrations/webhooks (Integrations, Webhooks), enter your server's address and choose the events.
  2. 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.
  3. On every event SeoPrimo makes a POST with the JSON body and the headers below.
  4. 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.
— Events

What you receive.

Choose the events for each address. The fields listed are in the message's data object.

Webhook events
EventWhenFields of data
audit.completedThe full site audit finishes (not the quick audit).site_id, run_id, audit_id, pages_crawled, issues {critical, warning, info, total}
score.changedThe SeoPrimo Score changes by at least 3 points from the previous measurement.site_id, snapshot_id, score, previous_score, delta, preliminary
rank.changedA rank check finds at least one changed position.site_id, run_id, tracker_id, changes [{keyword, device, position, previous}]
report.readyA new report is ready.site_id, report_id, title, source
credits.lowAvailable credits fall below 20 or below 10% of the plan's monthly credits. At most once every 24 hours.available, monthly_credits, threshold
agent.completedAn 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
testTest event: sent by the "Send test" button in the panel. It is not one you subscribe to.message
— The 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.

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
  }
}

The id is the same on every attempt of the same delivery: use it to avoid processing the same event twice.

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
Delivery headers
HeaderValue
Content-Typeapplication/json
User-AgentSeoPrimo-Webhooks/1.0
X-SeoPrimo-EventThe event type, for example score.changed.
X-SeoPrimo-DeliveryThe id of this delivery.
X-SeoPrimo-SignatureThe signature: t=<unix>,v1=<hex>.
— Signature

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.

  1. Read t and v1 from the header.
  2. Reject if t is older than 5 minutes (or in the future): that is the defence against replays.
  3. 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.
  4. Compare with a constant-time comparison (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);

If you use n8n, the Code node of the downloadable workflows does the same: see the n8n guide.

— Retries

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.

Retry schedule
AttemptWhen
1immediately
21 minute after the failure
35 minutes later
430 minutes later
52 hours later
66 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.

— Test

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 type equal to test and data equal to {"message": …}.
Receive, do not poll

Know when
something changes.

Add your address, choose the events and send a test: the signature tells you the message is ours.

What the guide gives you
6
Events
plus the test
HMAC
Verifiable signature
Python and Node
6
Attempts
then the delivery is given up