Webhooks API reference
Payload format, headers, signature verification, retries and the full event list.
Request
Every delivery is a POST with a JSON body and these headers: Serpfull-Signature, Serpfull-Timestamp, Serpfull-Event-Id, Serpfull-Event-Type, Serpfull-Delivery-Attempt (1–8), Serpfull-Api-Version (2026-10-26) and User-Agent Serpfull-Webhooks/2.0.
Payload
Fields: id (event id, the same across retries and redeliveries), type, api_version, created_at (ISO 8601, UTC), created (Unix seconds), org_id, site (id, domain and name of the website, or null for organisation events) and data (event-specific).
{
"id": "6f1c2b8e-3a4d-4e5f-9a1b-2c3d4e5f6a7b",
"type": "uptime.monitor.down",
"api_version": "2026-10-26",
"created_at": "2026-10-26T09:14:03.120Z",
"created": 1793006043,
"org_id": "0d9a…",
"site": { "id": "8b2e…", "domain": "example.co.uk", "name": "Example" },
"data": { "monitor_id": "…", "url": "https://example.co.uk/", "reason": "timeout" }
}Signature
Serpfull-Signature is t=TIMESTAMP,v1=SIGNATURE. SIGNATURE is the hex HMAC-SHA256 of the string TIMESTAMP + "." + raw request body, keyed with your endpoint’s signing secret. During a secret rotation there are two v1 values; accept the request if either matches. Compare in constant time and reject timestamps more than 5 minutes from your clock to block replays.
Verify in Node.js
import crypto from "node:crypto"
// Use the raw request body (a string or Buffer), not re-serialised JSON.
export function verifySerpfull(rawBody, header, secret, toleranceSeconds = 300) {
const parts = header.split(",").map((p) => p.trim().split("="))
const t = Number(parts.find(([k]) => k === "t")?.[1])
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest()
return parts.some(([k, v]) => k === "v1" && v?.length === expected.length * 2 &&
crypto.timingSafeEqual(Buffer.from(v, "hex"), expected))
}
// Express example
app.post("/webhooks/serpfull", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySerpfull(req.body.toString("utf8"), req.get("serpfull-signature") ?? "", process.env.SERPFULL_WEBHOOK_SECRET)) {
return res.status(400).end()
}
const event = JSON.parse(req.body)
// Deduplicate on event.id (also sent as the Serpfull-Event-Id header), then handle event.type.
res.status(200).end()
})Verify in Python
import hashlib, hmac, time
def verify_serpfull(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
try:
t = int(next(v for k, v in parts if k == "t"))
except (StopIteration, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(k == "v1" and hmac.compare_digest(v, expected) for k, v in parts)
# Flask example
@app.post("/webhooks/serpfull")
def serpfull_webhook():
if not verify_serpfull(request.get_data(), request.headers.get("Serpfull-Signature", ""), os.environ["SERPFULL_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json()
# Deduplicate on event["id"], then handle event["type"].
return "", 200Responses and retries
- 2xx within 10 seconds: delivered.
- Anything else, a timeout or a connection error: retried after 1 m, 5 m, 30 m, 2 h, 6 h, 12 h and 24 h (8 attempts in total).
- 410 Gone: we stop retrying that event.
- Deliveries are at least once and may arrive out of order; deduplicate on the event id and use created_at to order.
Events
- serp.run.completed: A search run finished and clicked your listing.
- serp.run.not_found: A search run didn’t find your website in the results.
- serp.run.site_challenge: Your website showed a challenge page during a run.
- serp.run.failed: A search run failed (timeout, blocked or other error).
- serp.term.auto_paused: A term was paused after 3 runs in a row didn’t find your website.
- serp.failure_rate.high: Over half of a website’s runs failed in 24 hours.
- local.run.completed: A Local Business run finished and opened your listing.
- local.run.not_found: A Local Business run didn’t find your listing.
- local.run.site_challenge: Your website showed a challenge page during a Local Business run.
- local.run.failed: A Local Business run failed.
- aio.run.completed: An AI Overview run finished and clicked your citation.
- aio.run.not_found: An AI Overview run didn’t find your website cited.
- aio.run.site_challenge: Your website showed a challenge page during an AI Overview run.
- aio.run.failed: An AI Overview run failed.
- serp.rank.changed: A tracked position moved.
- competitor.rank.changed: A tracked competitor moved 3+ places or entered or left the top 10 or 30.
- rank_ai.summary_ready: A Rank AI report finished, with the health score, summary and top recommendations.
- website.verified: A website’s ownership was verified.
- uptime.monitor.down: An uptime monitor confirmed an outage.
- uptime.monitor.up: An uptime monitor recovered.
- domains.expiry.warning: A domain registration or SSL certificate is close to expiry.
- domains.change.detected: A watched domain changed (DNS, registrar, certificate and more).
- audit.completed: An SEO audit finished.
- audit.score_dropped: A page’s SEO score fell 5 points or more.
- backlinks.domain.new: New referring domains were found.
- backlinks.domain.lost: Referring domains were lost.
- bot.attack.detected: Bad bot traffic spiked well above normal.
- cookies.scan.completed: A cookie scan finished.
- report.generated: A report was generated.
- exchange.installed: Your Partners block was found and the website joined.
- exchange.link_missing: Your Partners block went missing, so your links are paused.
- exchange.partner_added: A partner was added to your block, or you to theirs.
- billing.subscription.updated: A subscription was created, changed or cancelled.
- plan.limit.reached: A website used all of a plan allowance.
Run events
serp.run.*, local.run.* and aio.run.* carry run_id, website_id, search_term_id, module, outcome, position, page, dwell_seconds, pages_visited, device, country, started_at, finished_at and error_code. completed means the listing was found and clicked, not_found that it wasn’t in the results, site_challenge that your website showed a challenge page, and failed covers timeouts, blocks and other errors.
Test events
test.ping is sent only when you press Send test event (or use webhooks_send_test). Its data has a message and your organisation.
Updated