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...",
"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:
| 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 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.
| 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. |
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.)
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:
| 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 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.
| 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…",
"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_htmlorrich_html. They are already HTML, escaped at the source. In JSX usedangerouslySetInnerHTML; in Vuev-html; in Liquid/Jinja the raw filter. - Render your own
<h1>fromtitlewhen usingcontent_html— its leading H1 was removed on purpose.rich_htmlalready contains one, so do not add a second. - 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 — /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.
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.