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...",
  "content_html": "<p>Choosing the right tool...</p>",
  "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 H1. Render your own heading from this.
keyword string The exact search keyword the page targets.
slug string URL-safe slug, unique per page.
type string Page format: "landing", "faq", "alternatives", "vs", "comparison", or "listicle".
url_path string Recommended path on your site, e.g. "/faq/{slug}" or "/solutions/{slug}".
content string Raw markdown source, including the leading H1. Untouched.
content_html string The markdown rendered to HTML, without the leading H1 (see title).
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.
vs /vs/{slug} /vs/ Head-to-head "X vs Y" page.
comparison /comparisons/{slug} /comparisons/ Multi-product comparison.
listicle /listicles/{slug} /listicles/ Ranked list article.
content_html is unstyled. It is semantic HTML only — <p>, <h2>, <ul>, <a>, <img>, <strong> — with no class attributes, no inline styles and no CSS of its own. Wrap it in your own prose container and style it like the rest of your content. (rich_html, on landing pages, is the opposite: it ships its own scoped stylesheet.)
Title vs. content. content_html deliberately omits the leading H1 — render your own heading from title, the way a CMS separates title and body. The raw markdown in content keeps its H1 so it round-trips losslessly.

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 the fallback (content_html) 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_html 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…",
  "content_html": "<p>Rufflands does mobile dog grooming in Austin, parked right outside your door.</p>…",
  "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.
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; everything else falls back to content_html.
  // 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"><h1>${esc(page.title)}</h1>${page.content_html}</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 content_html or rich_html. They are already HTML, escaped at the source. In JSX use dangerouslySetInnerHTML; in Vue v-html; in Liquid/Jinja the raw filter.
  • Render your own <h1> from title when using content_html — its leading H1 was removed on purpose. rich_html already contains one, so do not add a second.
  • 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 — /vs/, /comparisons/, /solutions/ 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-vs.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url><loc>https://yoursite.com/vs/</loc></url>
  <url>
    <loc>https://yoursite.com/vs/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.