blogsforseo

Webhooks

Every time you publish, update or unpublish a post, we tell your site, so it can refresh straight away instead of waiting for its cache to expire.

Set these up on your dashboard's "Connect your website" page. Use one or both:

  • Webhook: a signed POST to your endpoint. Best for sites with a server.
  • Deploy hook: an empty POST to your host's rebuild URL (Netlify, Vercel, Cloudflare Pages and others). Best for static sites.

Events

Event When
post.published A post goes live
post.updated A live post changes, possibly including its slug
post.unpublished A post is taken down
test You pressed Send test in the dashboard

Payload

{
  "event": "post.updated",
  "occurredAt": "2026-10-12T09:30:00.000Z",
  "siteKey": "967ac21239c54a8e9f2d094657c0ae28",
  "post": { "slug": "what-is-llms-txt", "title": "…" },
  "previousSlug": "llms-txt-explained"
}
  • post: the full post, the same as GET /posts/{slug} returns, for post.published and post.updated. For post.unpublished it's just { slug }.
  • previousSlug: appears only when the slug changed. Clear the old URL too.

Verify the signature

Every webhook carries an x-blogsforseo-signature header in the form t=<unix seconds>,v1=<hex>. Before trusting a request:

  1. Compute the HMAC-SHA256 of t + "." + rawBody, using your signing secret as the key. Use the raw body, before any JSON parsing.
  2. Compare the result with v1 in constant time.
  3. Reject with 401 if they don't match, or if t is more than 300 seconds from now. The time check blocks replayed requests.

Keep the signing secret in an environment variable, e.g. BLOGSFORSEO_WEBHOOK_SECRET. Rotate it from the dashboard if it ever leaks.

Node.js

import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header) {
  const parts = Object.fromEntries((header ?? "").split(",").map((part) => part.split("=")));
  const expected = createHmac("sha256", process.env.BLOGSFORSEO_WEBHOOK_SECRET).update(`${parts.t}.${rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
  return fresh && typeof parts.v1 === "string" && parts.v1.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}

// In your POST handler:
// const body = await request.text();
// if (!verify(body, request.headers.get("x-blogsforseo-signature"))) return new Response("Invalid signature", { status: 401 });
// const { event, post, previousSlug } = JSON.parse(body);
// Clear the cache for the blog index, post.slug and previousSlug, then return 200.

PHP

$body = file_get_contents("php://input");
parse_str(str_replace(",", "&", $_SERVER["HTTP_X_BLOGSFORSEO_SIGNATURE"] ?? ""), $sig);
$expected = hash_hmac("sha256", ($sig["t"] ?? "") . "." . $body, getenv("BLOGSFORSEO_WEBHOOK_SECRET"));
if (!isset($sig["v1"]) || !hash_equals($expected, $sig["v1"]) || abs(time() - (int) $sig["t"]) > 300) {
  http_response_code(401);
  exit;
}
$event = json_decode($body, true);
// Clear the cache for the blog index, $event["post"]["slug"] and $event["previousSlug"].

Python

import hashlib, hmac, os, time

def verify(raw_body: bytes, header: str) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    expected = hmac.new(os.environ["BLOGSFORSEO_WEBHOOK_SECRET"].encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return t.isdigit() and abs(time.time() - int(t)) <= 300 and hmac.compare_digest(expected, v1)

Delivery

  • Timing: we send within seconds of a publish, wait up to 10 seconds for a reply, and treat any 2xx as success.
  • Retries: a failed delivery is retried up to 3 times.
  • Log: every attempt is listed under Recent deliveries on your dashboard.
  • Respond fast: return 200 straight away, then do any slow work afterwards.
  • Our CDN: we clear our cached copy of your API before sending, so if your handler fetches the API right away, it gets the new version.

IndexNow

Once your site serves /{INDEXNOW_KEY}.txt (the body is just the key), we notify IndexNow on every publish. IndexNow passes the news to Bing, Yandex and others, and ChatGPT search uses Bing. Until the file is in place, we skip this step and log it.