Overview
Use the webhook integration when your stack has no native BlazeHive integration. On every
publish, BlazeHive POSTs a JSON payload to your URL with the Markdown content, SEO metadata
and JSON-LD structured data. Landing pages also include a block layout and a
rich_html fragment that carries its own styles.
- Signed: HMAC-SHA256 over a timestamp and the raw body. The secret itself is never sent.
- Retried: network errors, timeouts and
429,502,503and504responses are retried, with one delivery id for deduplication. - Versioned: every payload carries
version: 2. Breaking changes raise it.
Setup
To have a coding agent (Cursor, Claude Code, Copilot) build the receiver, give it this prompt first. It covers the endpoint, signature verification, storage and server-rendered pages for Laravel, Express, Django, Next.js, Rails or any other stack. It finishes by printing the URL and secret you paste in steps 2 and 3.
Show the full prompt
# 1. Task context
You are a senior full-stack engineer working inside this codebase. You are
adding a BlazeHive integration to it.
BlazeHive is an SEO content engine: it researches and writes pages
automatically, then delivers each finished page to my site as a signed HTTP
POST. Your job is to receive those deliveries, store them, and serve them as
crawlable, server-rendered pages on this site — so that the content actually
ranks.
Judge your own work by that last part. A receiver that accepts webhooks
perfectly but produces pages Google cannot read is a failed integration.
# 2. Background — the BlazeHive webhook contract
Everything in this section is the complete public documentation for the
contract: the events, the full payload, the page types and their URLs, the
landing-page block model with a real example payload, signature verification
with working code, the rendering rules, the archive and sitemap conventions, and
retry semantics. Treat it as the spec. Read it before you write anything.
Two things in it do not apply to you:
- Its **Setup** section describes what I do in the BlazeHive dashboard. That is my job, not yours.
- Where it shows an example endpoint path, ignore it and use the path specified in section 3.
---
# BlazeHive Webhook Integration (contract v2)
Receive every page BlazeHive generates as a signed HTTP POST to any endpoint you own: your own server, Zapier, Make or n8n.
## Overview
Use the webhook integration when your stack has no native BlazeHive integration. On every publish, BlazeHive POSTs a JSON payload to your URL with the Markdown content, SEO metadata and JSON-LD structured data. Landing pages also include a block layout and a `rich_html` fragment that carries its own styles.
- **Signed**: HMAC-SHA256 over a timestamp and the raw body. The secret itself is never sent.
- **Retried**: network errors, timeouts and 429, 502, 503 and 504 responses are retried, with one delivery id for deduplication.
- **Versioned**: every payload carries `version: 2`. Breaking changes raise it.
## Setup
To have a coding agent (Cursor, Claude Code, Copilot) build the receiver, give it the agent prompt from https://www.blazehive.io/docs/webhooks/#setup first. It covers the endpoint, signature verification, storage and server-rendered pages for Laravel, Express, Django, Next.js, Rails or any other stack. It finishes by printing the URL and secret you paste in steps 2 and 3.
1. In app.blazehive.io, go to Integrations → Webhook.
2. Paste your endpoint URL into **Webhook URL**. It must be HTTPS and publicly reachable (private and internal addresses are blocked), and it must not redirect.
3. Paste a long random string into **Signing Secret**. It is required, and every request is signed with it.
4. Click "Save & Test Connection". BlazeHive sends a `test` event and expects a 2xx response.
## Events
| Event | When |
| --- | --- |
| `publish.live` | A page went live. Create it, or update it if you already have a page with this id. |
| `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 when you click "Save & Test Connection", or "Test" on a saved integration. Respond with a 2xx. |
### Sample publish.live
```json
{
"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
```json
{
"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 (publish.live / publish.draft)
| Field | Type | Description |
| --- | --- | --- |
| `event` | string | "publish.live", "publish.draft", "unpublish", or "test". |
| `version` | number | Contract version. Currently 2. It changes 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. Store it and use it in lists or the <title> tag. Don't 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 use the comparison path, so you need no extra route.) |
| `url_path` | string | Recommended path on your site, e.g. "/faq/{slug}" or "/solutions/{slug}". |
| `content` | string | The page, as complete Markdown. 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 at that path. New types may be added later, so route on `url_path` instead of hard-coding these prefixes.
| type | url_path | Archive | What it is |
| --- | --- | --- | --- |
| `landing` | `/solutions/{slug}` | `/solutions/` | Landing page. The only type with rich_html and 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). It is plain CommonMark plus tables: headings, paragraphs, lists, links, images, tables, blockquotes and code fences. No front matter, no shortcodes. Style the output with your own prose container, and give images `max-width: 100%; height: auto`. They are absolute BlazeHive CDN URLs, about 1700px wide. `rich_html` on landing pages is different: it includes its own scoped stylesheet.
> **Title vs. content:** `content` is the whole page and already contains its H1. Render it and nothing else. `title` is metadata: use it in an index, a card, a breadcrumb or the `<title>` tag. Printing `title` above `content` gives the page two H1s. The H1 is not the first line: pages open with a short `## TL;DR` summary on purpose, because that block is what AI Overviews and assistants quote. Render the Markdown in the order it arrives.
`unpublish` events carry `id`, `slug`, `type`, `url_path`, and `external_id` (always null for webhook integrations).
## Landing page blocks (type: "landing" only)
| 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>. Put it in 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 | Per-page revision number that only goes up. Clear your cached output when it changes. |
You can render a landing page three ways, from least to most work: output `rich_html` as-is, render `content` into your own template, or map `blocks` and `images` onto your own components.
### The block model
Every block has a `t` field 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 centered 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, and nothing else. 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
Built by the same code that sends real deliveries. Long strings are shortened with `…`.
```json
{
"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
Search engines only see a page once your server renders it at a crawlable URL. The route below handles every page type.
```js
// 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.
// No <h1> of our own: content already opens with the page heading.
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 content after load leaves the page empty for crawlers.
- **Never put `rich_html` in an iframe.** It is a fragment for your page body. Inside an iframe it carries no SEO weight for the parent page.
- **Do not escape rendered markdown or `rich_html`.** Both are already safe HTML. Use `dangerouslySetInnerHTML` (React), `v-html` (Vue), or the raw filter (Liquid/Jinja).
- **Never add an `<h1>` of your own.** `content` already contains the page heading, and so does `rich_html`. Printing `title` above either gives the page two H1s. Keep `title` for indexes, cards, breadcrumbs and the `<title>` tag.
- **Use `images[id].w` / `.h`** as width/height attributes to avoid layout shift.
- **Add each page to your sitemap** and ping it on publish.
> **The stylesheet does not collide with your site.** Every rule in `rich_html` is scoped under `.bh-landing`, including its reset.
## Archives & sitemaps
Two more things make the pages crawlable. The WordPress plugin sets up both. Add them to any custom integration.
### An archive page per type
Serve a listing page at each type's prefix (the Archive column above), for example `/comparisons/` or `/solutions/`, and link every published page of that type from it. Without it, each page is reachable only from the sitemap.
Order newest first, paginate past about 50 pages, and link each archive from a permanent place, such as a footer column.
### One sitemap per type, behind an index
Split the sitemap by page type instead of shipping one flat file. Each file stays small and its `lastmod` values stay accurate.
```xml
<!-- /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-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-comparison.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. It lists that type's archive and every published page of that type.
- `/sitemap-index.xml`: a `<sitemapindex>` that points at each child sitemap.
- `/sitemap.xml`: redirect it to the index. Crawlers try this path first.
> **Generate both at request time** from your stored pages, not as static files written on publish. New pages then show up without a deploy.
## Request headers
```http
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, so you can route without parsing the body. |
| `X-Blazehive-Delivery` | Unique per delivery and the same across retries. Use it to deduplicate. |
| `X-Blazehive-Timestamp` | Unix seconds when the delivery was signed. |
| `X-Blazehive-Signature` | The HMAC signature. 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.
```js
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
- Respond with a 2xx within 10 seconds. Do slow work asynchronously after acknowledging.
- Network failures, timeouts, and 429, 502, 503 and 504 responses are retried automatically: up to 3 attempts with a short backoff, all with the same `X-Blazehive-Delivery` id.
- Any other non-2xx response fails the delivery immediately (retry manually from the dashboard).
- Redirects are never followed. A 3xx response is treated as an error. Point BlazeHive at the final URL.
- **Idempotency:** upsert by `id`. Republished pages arrive as another `publish.live` with the same `id`.
# 3. What to build
## 3.1 Endpoint
Add a route: `POST /api/blazehive/webhook`. Follow this app's existing routing
conventions, but keep that public path — I paste it into BlazeHive.
If the app already has a webhook receiver for another content tool, leave it
alone. Build this as its own route; the payloads and signatures are different.
Verify the HMAC signature against the raw request body BEFORE any JSON parsing,
and return 401 if it does not match. Working code is in section 2, under
"Verifying signatures" — it is Node, so port it to this app's language, keeping
the timestamp check and a constant-time comparison. How to get the raw body in
common stacks:
- **Laravel:** register the route in `routes/api.php` (no CSRF there — if it has to live in `routes/web.php`, exclude it from CSRF verification). Read the raw body with `$request->getContent()`, compute `hash_hmac('sha256', $t . '.' . $raw, $secret)`, and compare with `hash_equals`.
- **Express:** mount `express.raw({ type: "application/json" })` on this route specifically — if `express.json()` runs first, verification fails.
- **Next.js (App Router):** `await req.text()` in the route handler.
- **Django:** `request.body`, with the view marked `@csrf_exempt`; compare with `hmac.compare_digest`.
- **Rails:** `request.raw_post`, with `verify_authenticity_token` skipped for this action; compare with `ActiveSupport::SecurityUtils.secure_compare`.
- **Plain PHP:** `file_get_contents('php://input')` (`$_POST` is empty for a JSON body); the signature header arrives as `$_SERVER['HTTP_X_BLAZEHIVE_SIGNATURE']`. Compute it with `hash_hmac` and compare with `hash_equals`, as in Laravel.
- **Symfony:** `$request->getContent()`; compare with `hash_equals`.
- **CodeIgniter 4:** `$this->request->getBody()`, with the route excluded from the CSRF filter if it is enabled.
- **Astro (server output):** `await request.text()` in an endpoint with `export const prerender = false`.
Respond 2xx within 10 seconds; do slow work after responding.
Generate the signing secret yourself — at least 32 random bytes, hex or base64,
from a real CSPRNG — and store it in the app's environment config as
`BLAZEHIVE_WEBHOOK_SECRET` (the `.env` file locally, the host's secret store in
production). In Laravel, read it through a config file such as
`config/services.php`, never `env()` in application code — `env()` returns null
once the config is cached. You will print the secret back to me at the end so I
can paste it into BlazeHive.
## 3.2 Store the pages
Persist what you receive. **Use whatever storage this project already uses, and
update the database yourself if it needs new columns, tables, or collections to
hold these fields** — add a database if there isn't one. You know this codebase;
model it however fits. I am not prescribing a schema.
However you store it, these have to hold:
- Upsert on `id`. Republished pages arrive again with the same `id` and must update in place, never create a duplicate.
- You must be able to look a page up by its `url_path` (or `slug`) when serving a request — index accordingly.
- Track published state: `publish.live` → published, `publish.draft` → stored but not published, `unpublish` → unpublished or deleted.
- `schema_json`, `blocks`, and `images` are JSON. Store them as JSON if your database supports it, rather than as stringified text.
- Keep `doc_version` if present, and use it to invalidate any cached render.
## 3.3 Render the pages
Serve each published page at its `url_path`. **The HTML must be complete in the
server response.** This is the entire point: a page assembled client-side after
load is invisible to search crawlers, and the content is worthless for SEO.
Section 2 spells out the head tags, the `rich_html` vs `content` branch,
and the escaping rules. Two things worth being explicit about:
- On a landing page you get `rich_html`, `blocks`, and `content`. These are three representations of the SAME content — pick one and render only that, or you will publish the page twice over. `rich_html` is the recommended default: it carries its own scoped CSS and needs no styling work from you.
- `content` on every other page type is markdown. Render it with any markdown library and wrap the output in your own prose container, styled like the rest of your site.
## 3.4 Archives and sitemaps
Build the archive pages and the per-type sitemaps described under
"Archives & sitemaps" in section 2. Generate both from your stored pages at
request time, so a page that arrives overnight is discoverable without a deploy.
## 3.5 Stack specifics
- Render the pages with this app's existing server-side templating (Blade, ERB, Django templates, server-rendered JSX, and so on), inside the site's normal layout so they carry its header, footer, and styles.
- If the public site is a client-rendered SPA in front of an API, these pages still have to come back from the server as complete HTML — see example 4.1. Serve them from the backend or add server rendering for these routes. If you cannot, STOP and tell me rather than shipping a client-rendered version.
- If one of the `url_path` prefixes collides with an existing route, tell me instead of overriding it.
- If the app caches routes, config, or pages (`php artisan route:cache`, a CDN, full-page caching), make sure a page delivered overnight shows up without a deploy.
- The endpoint must work on the **production** site, not just locally.
## 3.6 Rules — do not break these
1. Verify the signature over the **raw request body bytes**, before any JSON parsing. Parse-then-restringify changes the bytes and fails every time.
2. Reject an invalid signature with **401**. Never process an unverified payload.
3. Always respond **2xx within 10 seconds**. Do slow work after responding.
4. **Upsert by `id`**, never blind-insert. Duplicate pages are worse than no pages.
5. **Never escape rendered markdown or `rich_html`.** Both are already safe HTML; escaping again ships visible tags to users.
6. **Never put `rich_html` in an `<iframe>`.** It is a fragment meant to live in your page; in an iframe it carries no SEO weight.
7. **Never render two representations of the same landing page.** Pick one of `rich_html`, `blocks`, or `content`.
8. **Never add an `<h1>` of your own** — every representation already carries the page heading. `title` is metadata, not a heading.
9. Serve pages at the **`url_path` given to you**. Do not invent a different URL scheme.
10. Do not restructure this app, change its styling, or refactor unrelated code. Add what is needed and nothing else.
11. Do not commit the signing secret to the repo. It lives in the secret store only.
12. If something in this prompt conflicts with what you find in the codebase, stop and tell me rather than guessing.
# 4. Examples
## 4.1 What "server-rendered" means
**GOOD** — `curl https://yoursite.com/comparisons/notion-vs-asana` returns the page:
```html
<!doctype html>
<html lang="en">
<head>
<title>Notion vs Asana: which fits your team? | Rufflands</title>
<meta name="description" content="A side-by-side look at how the two tools handle projects.">
<link rel="canonical" href="https://yoursite.com/comparisons/notion-vs-asana">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"Article"}</script>
</head>
<body>
<article class="prose">
<h1>Notion vs Asana: which fits your team?</h1>
<p>Both tools promise to replace your project tracker...</p>
</article>
</body>
</html>
```
**BAD** — the same `curl` returns a shell, with the content fetched by JS after
load:
```html
<!doctype html>
<html><head><title>Rufflands</title></head>
<body><div id="root"></div><script src="/assets/index.js"></script></body>
</html>
```
The second one is exactly what a crawler sees. It contains no page, so as far as
search is concerned the page does not exist. If your result looks like this, the
integration has failed no matter how well the webhook works.
## 4.2 The report you finish with
**GOOD** — real, working values I can paste straight into BlazeHive:
```
Webhook URL: https://rufflands.com/api/blazehive/webhook
Secret: 9f2b7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a8f
```
**BAD** — placeholders, which are useless to me:
```
Webhook URL: https://your-site.com/api/blazehive/webhook
Secret: <your secret here>
```
# 5. Your task right now
Build the integration described in section 3, following the contract in
section 2 and the rules in 3.6.
Then validate it yourself. Do not report back until all of these pass:
1. **Every page type renders.** Feed your endpoint a fixture `publish.live` for each of `landing`, `faq`, `alternatives`, `vs`, `comparison` and `listicle`, then load each one. The `landing` fixture must include `rich_html` so you exercise that branch.
2. **The HTML is in the server response.** View source (or `curl`) each page — the body copy must be there, not injected after load. Compare against example 4.1.
3. **Head tags are present** on each page: `<title>`, meta description, canonical, and the JSON-LD script when `schema_json` was not null.
4. **Archives work.** Each type's archive URL lists its pages.
5. **Sitemaps work.** `/sitemap-index.xml` and every `/sitemap-{type}.xml` return valid XML, and `/sitemap.xml` redirects to the index.
6. **Re-delivery is idempotent.** Send the same `publish.live` twice — the second must update the existing page, not create a duplicate.
7. **Bad signatures are rejected.** A request with a tampered signature must get a 401.
8. **`unpublish` works.** The page stops being served and drops out of the archive and sitemap.
Then print exactly this block, with the real values filled in, as the last thing
you output:
Webhook URL: <the full public https URL of your endpoint>
Secret: <the value you stored in BLAZEHIVE_WEBHOOK_SECRET>
I copy those two straight into BlazeHive, so they must be the actual working
values — never placeholders or examples, as shown in 4.2. The URLs must be
HTTPS, publicly reachable, and must NOT redirect: BlazeHive treats a 3xx as a
failure, so if the apex domain redirects to `www`, print the `www` form.
If any check above failed, say so plainly instead of printing the block. - In app.blazehive.io, go to Integrations → Webhook.
- Paste your endpoint URL into Webhook URL. It must be HTTPS and publicly reachable (private and internal addresses are blocked), and it must not redirect.
- Paste a long random string into Signing Secret. It is required, and every request is signed with it (verification below). To make one, use the generator below the steps.
- Click Save & Test Connection. BlazeHive sends a
testevent and expects a2xxresponse.
Generate a signing secret here. It is created in your browser and never sent anywhere.
Not generated yet 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 already have a page with this id. |
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 when you click "Save & Test Connection", or "Test" on a saved integration. Respond 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. It changes 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. Store it and use it in lists or the <title> tag. Don't 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 use the comparison path, so you need no extra route.) |
url_path | string | Recommended path on your site, e.g. "/faq/{slug}" or "/solutions/{slug}". |
content | string | The page, as complete Markdown. 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 at that path. New types may be added
later, so route on url_path instead of hard-coding these prefixes.
| type | url_path | Archive | What it is |
|---|---|---|---|
landing | /solutions/{slug} | /solutions/ | Landing page. The only type with rich_html and 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, such as
marked, markdown-it, remark or python-markdown. It is plain CommonMark plus tables:
headings, paragraphs, lists, links, images, tables, blockquotes and code fences. No front
matter, no shortcodes. Wrap the output in your own prose container and style it like the
rest of your content. Give images max-width: 100%; height: auto. They are
absolute BlazeHive CDN URLs, about 1700px wide. (rich_html on landing pages is different: it includes its
own scoped stylesheet and needs no styling from you.)
content is the whole page and already
contains its H1. Render it and nothing else. title is metadata: use it in an
index, a card, a breadcrumb or the <title> tag. Printing
title above content gives the page two H1s. The H1 is not the
first line: pages open with a short ## TL;DR summary on purpose, because that
block is what AI Overviews and assistants quote. Render the Markdown in the order it arrives.
unpublish events carry id, slug, type,
url_path, and external_id (always null for webhook
integrations. It is used by CMS platforms that assign their own ids).
Landing page blocks
Pages with type: "landing" also include a block layout, in five extra fields:
| 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>. Put it in 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 | Per-page revision number that only goes up. Clear your cached output when it changes. |
You can render a landing page three ways, from least to most work: output
rich_html as-is, render content into your own template, or map
blocks and images onto your own components.
The block model
Every block has a t field 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 centered 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, and 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
Built by the same code that sends real deliveries. Long strings are shortened with
….
{
"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
Search engines only see a page once your server renders it 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.
// No <h1> of our own: content already opens with the page heading.
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 empty for crawlers.
- Never put
rich_htmlin an iframe. It is a fragment for your page 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, and in Liquid or Jinja the raw filter. - Never add an
<h1>of your own.contentalready contains the page heading, and so doesrich_html. 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.
rich_html is scoped under .bh-landing, including its reset, so it
does not leak into your theme.
Archives & sitemaps
Two more things make the pages crawlable. The WordPress plugin sets up both. Add them to any custom integration.
An archive page per type
Serve a listing page at each type's prefix (the Archive column above), for example
/comparisons/ or /solutions/, and link every published page of
that type from it. Without it, each page is reachable only from the sitemap.
Order newest first, paginate if the list grows past about 50 pages, and link each archive from a permanent place, such as a footer column.
One sitemap per type, behind an index
Split the sitemap by page type instead of shipping one flat file. Each file stays small
and its lastmod values stay accurate.
<!-- /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-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-comparison.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. It lists that type's archive and every published page of that type./sitemap-index.xml: a<sitemapindex>that points at each child sitemap./sitemap.xml: redirect it to the index. Crawlers try this path 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, so you can route without parsing the body. |
X-Blazehive-Delivery | Unique per delivery and the same across retries. Use it to deduplicate. |
X-Blazehive-Timestamp | Unix seconds when the delivery was signed. |
X-Blazehive-Signature | The HMAC signature. 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 with the sameX-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.