Skip to content

Developer docs

Webhook integration

Receive every page BlazeHive generates as a signed HTTP POST to any endpoint you own — your own server, Zapier, Make, or n8n. This page documents contract version 2: events, the full payload, signature verification, and delivery semantics.

Overview

The webhook integration is the escape hatch when your stack isn't one of our native CMS integrations. On every publish, BlazeHive POSTs a JSON payload to your URL with everything needed to render the page: the markdown source, pre-rendered HTML, SEO metadata, and JSON-LD structured data. Landing pages additionally include a structured block layout and a self-contained rich_html document.

  • Signed — HMAC-SHA256 with a timestamp, Stripe-style. No shared-secret-in-plaintext.
  • Reliable — automatic retries on transient failures, with a stable delivery id for deduplication.
  • Versioned — every payload carries version: 2; breaking changes bump it.

Setup

  1. In app.blazehive.io, go to Integrations → Webhook.
  2. Paste your endpoint URL. It must be HTTPS, publicly reachable (private and internal addresses are blocked), and must not redirect.
  3. Optionally set a secret — any long random string. When set, every request is signed (verification below). Strongly recommended for production.
  4. Click Test connection — BlazeHive sends a test event and expects a 2xx response.

Events

The event field tells you what happened. There are four:

EventWhen
publish.live A page went live. Create it — or update it if you've seen its id before.
publish.draft Same payload as publish.live, but the page should be stored unpublished.
unpublish A page was taken down in BlazeHive. Remove or unpublish it on your side.
test Sent by the "Test connection" button. Minimal payload — just acknowledge with a 2xx.

Sample publish.live

{
  "event": "publish.live",
  "version": 2,
  "id": "b3d7a2f1-4c8e-4a1d-9f2b-7e6d5c4b3a2f",
  "title": "Best Project Management Tools in 2026",
  "keyword": "best project management tools",
  "slug": "best-project-management-tools",
  "type": "listicle",
  "url_path": "/listicles/best-project-management-tools",
  "content": "# Best Project Management Tools in 2026\n\nChoosing the right tool...",
  "meta_title": "Best Project Management Tools in 2026",
  "meta_description": "Compare the 10 best project management tools for small teams.",
  "schema_json": { "@context": "https://schema.org", "@type": "Article" },
  "completed_at": "2026-07-24T14:32:00.000Z"
}

Sample unpublish

{
  "event": "unpublish",
  "version": 2,
  "id": "b3d7a2f1-4c8e-4a1d-9f2b-7e6d5c4b3a2f",
  "slug": "best-project-management-tools",
  "type": "listicle",
  "url_path": "/listicles/best-project-management-tools",
  "external_id": "rec_abc123xyz"
}

Payload reference

Fields sent on publish.live and publish.draft:

FieldTypeDescription
event string "publish.live", "publish.draft", "unpublish", or "test".
version number Contract version. Currently 2 — bumped only on breaking changes.
id string (uuid) Stable BlazeHive page id. Use it as your idempotency / upsert key.
title string The page title as plain text, for your own convenience — store it, list it, use it in <title>. Do NOT render it as a heading; content already contains the H1.
keyword string The exact search keyword the page targets.
slug string URL-safe slug, unique per page.
type string Page format: "landing", "faq", "alternatives", "comparison", or "listicle". ("vs" may also arrive — head-to-head pages are served at the comparison path, so no extra route is needed.)
url_path string Recommended path on your site, e.g. "/faq/{slug}" or "/solutions/{slug}".
content string The page. Markdown, complete and self-contained — its H1 is the page heading. Render this.
meta_title string | null SEO <title> for the page.
meta_description string | null SEO meta description.
schema_json object | null JSON-LD structured data, ready to embed in a <script type="application/ld+json"> tag.
completed_at string (ISO) When BlazeHive finished generating the page.

Page types and their URLs

url_path is built for you — serve the page there rather than inventing your own scheme. New types may be added over time, so route on url_path generically instead of hard-coding these six prefixes.

typeurl_pathArchiveWhat it is
landing /solutions/{slug} /solutions/ Designed, conversion-focused landing page. The only type carrying rich_html + blocks.
faq /faq/{slug} /faq/ One question answered per page.
alternatives /alternatives/{slug} /alternatives/ "Alternatives to X" page.
comparison /comparisons/{slug} /comparisons/ Multi-product comparison, and head-to-head "X vs Y" pages.
listicle /listicles/{slug} /listicles/ Ranked list article.
content is markdown — render it with any markdown library. marked, markdown-it, remark, python-markdown: all fine. It is plain CommonMark plus tables — headings, paragraphs, lists, links, images, tables, blockquotes and code fences. No front-matter, no shortcodes. Wrap the rendered output in your own prose container and style it like the rest of your content, and give images max-width: 100%; height: auto — they are around 1700px wide. (rich_html, on landing pages, is the opposite: it ships its own scoped stylesheet and needs no styling from you.)
Title vs. content. content is the whole page and already contains its own H1 — render it and nothing else. title is metadata for YOU: save it alongside the page so you can show it in an index, a card, a breadcrumb or the <title> tag. Printing title as a heading above content gives the page two H1s. Note the H1 is not the first line — pages open with a short ## TL;DR summary above it on purpose, because that block is what AI Overviews and assistants quote.

unpublish events carry id, slug, type, url_path, and external_id (always null for webhook integrations — reserved for CMS platforms that assign their own ids).

Landing page blocks

Pages with type: "landing" ship a designed, conversion-focused layout on top of the markdown floor. Five extra fields are included:

FieldTypeDescription
blocks array The structured layout: hero, split, centered, benefits, faq, and cta blocks in render order.
images object Image manifest keyed by imageId → { src, alt, w, h }. All URLs are absolute and publicly served.
rich_html string An HTML fragment — a <style> tag followed by <div class="bh-landing">…</div>. Drop it straight into your page body. Not a full document: it has no <html>, <head>, or <body> of its own.
schema_version number Version of the block model. Render content instead when it is newer than what you support.
doc_version number Monotonic per-page revision — cache-bust your rendered output when it changes.

Three ways to consume a landing page, in ascending effort: render rich_html as-is (styled, zero work); render content into your own template (plain article fallback); or map blocks + images onto your own components for a fully native look.

The block model

Every block carries a t discriminator naming its type. Blocks are already in render order — do not re-sort them.

tFieldsNotes
hero h1, intro[], cta{label,url}, imageId Always the first block. One per page.
split h2, body[], bullets[]?, imageId, side Image beside copy. side alternates "right", "left", "right", …
centered h2, body[] Full-width centred prose. No image.
benefits h2, items[{title, body}] A card grid.
faq h2, items[{q, a}] Question/answer pairs. Good source for FAQPage structured data.
cta h2, body?, cta{label,url} Always the last block.
Prose fields carry limited markdown. Any intro, body, or bullets string may contain **bold** and [text](https://…) links — nothing else. If you render blocks yourself, HTML-escape the string first, then convert those two patterns, and accept http(s) link targets only.

A real publish.live for a landing page

Generated by the same renderer that serves production deliveries — long strings elided with for readability.

{
  "event": "publish.live",
  "version": 2,
  "id": "b3d7a2f1-4c8e-4a1d-9f2b-7e6d5c4b3a2f",
  "title": "Mobile dog grooming in Austin, at your curb",
  "keyword": "mobile dog grooming in austin",
  "slug": "mobile-dog-grooming-in-austin",
  "type": "landing",
  "url_path": "/solutions/mobile-dog-grooming-in-austin",
  "content": "# Mobile dog grooming in Austin, at your curb\n\nRufflands does mobile dog grooming…",
  "meta_title": "Mobile Dog Grooming in Austin | Rufflands",
  "meta_description": "Rufflands does mobile dog grooming in Austin — van-based, one dog at a time, no cage drying.",
  "schema_json": null,
  "completed_at": "2026-07-28T09:14:00.000Z",
  "blocks": [
    {
      "t": "hero",
      "h1": "Mobile dog grooming in Austin, at your curb",
      "intro": [
        "Rufflands does mobile dog grooming in Austin, parked right outside your door.",
        "One dog at a time, start to finish, in a van with its own water and power."
      ],
      "cta": {
        "label": "Book a groom",
        "url": "https://example.com/book"
      },
      "imageId": "hero"
    },
    {
      "t": "centered",
      "h2": "Why the van beats the salon",
      "body": [
        "A salon groom means a crate, a car ride, and three hours of barking.",
        "The van takes 90 minutes and your dog never leaves the street."
      ]
    },
    {
      "t": "split",
      "h2": "What a Rufflands groom includes",
      "body": [
        "Every appointment is a full groom, not a bath with add-ons stacked on top."
      ],
      "bullets": [
        "Warm hydrobath and hand dry",
        "Breed-standard clip or scissor finish",
        "Nails, ears, and pad trim"
      ],
      "imageId": "included",
      "side": "right"
    },
    {
      "t": "split",
      "h2": "Booked around your day",
      "body": [
        "Pick a two-hour window and we text when the van is ten minutes out, see [service areas](https://example.com/areas)."
      ],
      "bullets": [
        "Same-week slots",
        "Text-ahead arrival",
        "Card on file, no cash"
      ],
      "imageId": "base-value-props",
      "side": "left"
    },
    {
      "t": "split",
      "h2": "Groomers who stay",
      "body": [
        "Every Rufflands groomer is salary-paid and certified — the same hands each visit."
      ],
      "imageId": "base-credibility",
      "side": "right"
    },
    {
      "t": "benefits",
      "h2": "Built for anxious dogs",
      "items": [
        {
          "title": "No cage drying",
          "body": "Hand drying only, so nothing is left in a hot box."
        },
        {
          "title": "One dog at a time",
          "body": "No pack noise, no waiting, no stacked appointments."
        },
        {
          "title": "Same groomer",
          "body": "Your dog sees a familiar face every visit."
        }
      ]
    },
    {
      "t": "centered",
      "h2": "Serving central and south Austin",
      "body": [
        "Travis Heights, Zilker, Hyde Park, Mueller, and everything inside the loop."
      ]
    },
    {
      "t": "faq",
      "h2": "Frequently asked questions",
      "items": [
        {
          "q": "Do you need my driveway?",
          "a": "Street parking is fine. The van runs on its own power and water."
        },
        {
          "q": "How long does a groom take?",
          "a": "About 90 minutes for most breeds, longer for a heavy double coat."
        }
      ]
    },
    {
      "t": "cta",
      "h2": "Your dog's next groom, without the car ride",
      "body": "Same-week appointments across Austin.",
      "cta": {
        "label": "Book a groom",
        "url": "https://example.com/book"
      }
    }
  ],
  "images": {
    "hero": {
      "src": "https://cdn.blazehive.io/demo/page/hero.webp",
      "alt": "Grooming van parked on an Austin street",
      "w": 1712,
      "h": 1063
    },
    "included": {
      "src": "https://cdn.blazehive.io/demo/page/included.webp",
      "alt": "Groomer hand drying a terrier",
      "w": 1712,
      "h": 1057
    },
    "base-value-props": {
      "src": "https://cdn.blazehive.io/demo/_base/value-props.webp",
      "alt": "Booking window on a phone",
      "w": 1712,
      "h": 1060
    },
    "base-credibility": {
      "src": "https://cdn.blazehive.io/demo/_base/credibility.webp",
      "alt": "Certified groomer at work",
      "w": 1712,
      "h": 1060
    }
  },
  "rich_html": "<style>.bh-landing,.bh-landing *{box-sizing:border-box…</style>\n<div class=\"bh-landing\">…</div>",
  "schema_version": 1,
  "doc_version": 3
}

Rendering the pages

Receiving the payload is half the job — the SEO value only exists once the page is server-rendered at a crawlable URL. The route below handles every page type.

// Express — one route renders every BlazeHive page type.
// npm i marked   (any markdown renderer works — markdown-it, remark, …)
import { marked } from "marked";

app.get("/solutions/:slug", async (req, res) => {
  const page = await db.pages.findOne({ slug: req.params.slug });
  if (!page) return res.status(404).send("Not found");

  // Landing pages carry rich_html; every other type renders content (markdown).
  // rich_html is a FRAGMENT (<style> + <div>) — drop it into the body as-is.
  const body = page.rich_html
    ? page.rich_html
    : `<article class="prose">${marked.parse(page.content)}</article>`;

  res.send(`<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>${esc(page.meta_title || page.title)}</title>
  <meta name="description" content="${esc(page.meta_description || "")}">
  <link rel="canonical" href="https://yoursite.com${page.url_path}">
  <meta property="og:title" content="${esc(page.meta_title || page.title)}">
  <meta property="og:description" content="${esc(page.meta_description || "")}">
  <meta property="og:url" content="https://yoursite.com${page.url_path}">
  ${page.schema_json
    ? `<script type="application/ld+json">${JSON.stringify(page.schema_json)}</script>`
    : ""}
</head>
<body>${body}</body>
</html>`);
});

What has to be right

  • Server-render it. A client-side fetch that injects the content after load leaves the page effectively empty for crawlers, which defeats the point.
  • Never iframe rich_html. It is a fragment meant to sit in your body. Inside an <iframe> the content belongs to a different document and carries no SEO weight for the parent page.
  • Do not escape rendered markdown or rich_html. Both are already safe HTML, escaped at the source. In JSX use dangerouslySetInnerHTML; in Vue v-html; in Liquid/Jinja the raw filter.
  • Never add an <h1> of your own. content already contains the page heading, and rich_html has one too — printing title above either gives the page two H1s. Keep title for indexes, cards, breadcrumbs and the <title> tag.
  • Use images[id].w and .h as width/height attributes to avoid layout shift.
  • Add each page to your sitemap and ping it on publish. Nothing gets crawled promptly otherwise.
The stylesheet will not collide with your site. Every rule in rich_html is scoped under .bh-landing, including its reset, so it cannot leak into your theme — and your global styles cannot bleed in.

Archives & sitemaps

Individual pages are only half the structure. Two things make the set crawlable, and the official WordPress plugin sets both up automatically — mirror them on any custom integration.

An archive page per type

Serve a listing page at each type's prefix — /comparisons/, /solutions/, /alternatives/ and so on (the Archive column above) — linking every published page of that type. This is the internal-link hub: without it, each generated page is an orphan reachable only from the sitemap, which is a much weaker crawl signal and wastes most of the internal-linking value.

Order newest-first, paginate if the list grows past ~50, and link each archive from somewhere permanent — a footer column is enough.

One sitemap per type, behind an index

Split the sitemap by page type rather than shipping one flat file. Each stays small and its lastmod values stay meaningful, so a change to one type does not invalidate the crawl budget for the rest.

<!-- /sitemap-index.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap><loc>https://yoursite.com/sitemap-landing.xml</loc></sitemap>
  <sitemap><loc>https://yoursite.com/sitemap-faq.xml</loc></sitemap>
  <sitemap><loc>https://yoursite.com/sitemap-vs.xml</loc></sitemap>
  <sitemap><loc>https://yoursite.com/sitemap-alternatives.xml</loc></sitemap>
  <sitemap><loc>https://yoursite.com/sitemap-comparison.xml</loc></sitemap>
  <sitemap><loc>https://yoursite.com/sitemap-listicle.xml</loc></sitemap>
</sitemapindex>

<!-- /sitemap-comparisons.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://yoursite.com/comparisons/</loc></url>
  <url>
    <loc>https://yoursite.com/comparisons/notion-vs-asana</loc>
    <lastmod>2026-07-28</lastmod>
  </url>
</urlset>
  • /sitemap-{type}.xml — one per page type, listing that type's archive plus every published page of it.
  • /sitemap-index.xml — a <sitemapindex> pointing at each child sitemap.
  • /sitemap.xml — redirect it to the index; that is the path crawlers try first.
Generate both at request time from your stored pages, rather than writing static files on publish. A page that arrives at 3am then needs no deploy to become discoverable.

Request headers

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: BlazeHive-Webhook/2
X-Blazehive-Event: publish.live
X-Blazehive-Delivery: 7c2e6a90-1f4b-4c3d-9e8a-2b5d6f7a8c9d
X-Blazehive-Timestamp: 1753366321
X-Blazehive-Signature: t=1753366321,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
HeaderMeaning
X-Blazehive-Event Event name — route without parsing the body.
X-Blazehive-Delivery Unique id per delivery, stable across retries — your deduplication key.
X-Blazehive-Timestamp Unix seconds when the delivery was signed.
X-Blazehive-Signature Only when a secret is configured — see Verifying signatures.

Verifying signatures

The signature header is t=<timestamp>,v1=<hex digest>, where v1 is HMAC-SHA256 over the string "<timestamp>.<raw body>" keyed with your secret. Verifying the timestamp inside the signed string blocks replay attacks.

const crypto = require("crypto");

// IMPORTANT: verify against the RAW request body bytes.
// Parsing and re-stringifying the JSON changes the bytes and breaks the HMAC.
function verifyWebhook(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("="))
  );
  const { t, v1 } = parts;
  if (!t || !v1) return false;

  // Replay guard: reject deliveries older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

// Express example — capture the raw body before JSON parsing:
app.post("/webhooks/blazehive",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifyWebhook(
      req.body.toString("utf8"),
      req.get("X-Blazehive-Signature") || "",
      process.env.BLAZEHIVE_WEBHOOK_SECRET
    );
    if (!ok) return res.status(401).end();

    const payload = JSON.parse(req.body);
    // ... upsert by payload.id, then:
    res.status(200).json({ ok: true });
  });

Delivery & retries

  • Your endpoint must respond with a 2xx within 10 seconds. Do slow work asynchronously after acknowledging.
  • Network failures, timeouts, and 429/502/503/504 responses are retried automatically — up to 3 attempts with a short backoff, all sharing one X-Blazehive-Delivery id.
  • Any other non-2xx response fails the delivery immediately, and the page is marked publish-failed in the dashboard, where it can be retried manually.
  • Redirects are never followed — a 3xx response is treated as an error. Point BlazeHive at the final URL.
  • Response bodies are ignored on success; on failure the first 200 characters are shown in the dashboard to help you debug.
Idempotency. Upsert by id (the BlazeHive page id) rather than creating on every event — republished pages arrive as another publish.live with the same id.