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
- In app.blazehive.io, go to Integrations → Webhook.
- Paste your endpoint URL. It must be HTTPS, publicly reachable (private and internal addresses are blocked), and must not redirect.
- Optionally set a secret — any long random string. When set, every request is signed (verification below). Strongly recommended for production.
- Click Test connection — BlazeHive sends a
testevent and expects a2xxresponse.
Events
The event field tells you what happened. There are four:
| Event | When |
|---|---|
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:
| Field | Type | Description |
|---|---|---|
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.
| type | url_path | Archive | What 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.)
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:
| Field | Type | Description |
|---|---|---|
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.
| t | Fields | Notes |
|---|---|---|
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. |
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 usedangerouslySetInnerHTML; in Vuev-html; in Liquid/Jinja the raw filter. - Never add an
<h1>of your own.contentalready contains the page heading, andrich_htmlhas one too — printingtitleabove either gives the page two H1s. Keeptitlefor indexes, cards, breadcrumbs and the<title>tag. - Use
images[id].wand.has width/height attributes to avoid layout shift. - Add each page to your sitemap and ping it on publish. Nothing gets crawled promptly otherwise.
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.
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 | Header | Meaning |
|---|---|
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
2xxwithin 10 seconds. Do slow work asynchronously after acknowledging. - Network failures, timeouts, and
429/502/503/504responses are retried automatically — up to 3 attempts with a short backoff, all sharing oneX-Blazehive-Deliveryid. - Any other non-
2xxresponse 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
3xxresponse 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.
id (the BlazeHive page id) rather than
creating on every event — republished pages arrive as another publish.live
with the same id.