What to build
Implement these capabilities end-to-end to support Donkey SEO publishing and long-term maintenance.
- A signed webhook endpoint that receives content.article.publish_requested, validates signatures, and processes idempotently via event_id.
- A webhook-first storage model with one canonical local row per article_id and no separate local article-version rows.
- A modular_document block renderer that maps by block_type, preserves order, and safely renders markdown fields.
- Publication sync callbacks that PATCH publish_status, published_at, and published_url to Donkey SEO.
- Pillar-aware navigation that renders active pillars in the footer and links to disambiguation routes.
Quickstart
Set credentials
Generate integration credentials in Donkey SEO and keep them server-side only.
- DONKEY_SEO_API_KEY is sent as X-API-Key to protected integration endpoints.
- DONKEY_SEO_WEBHOOK_SECRET is only used for HMAC signature verification on webhooks.
- Never expose either secret in browser bundles.
Implement webhook-first ingestion
Treat webhook payloads as canonical records and process retries safely.
- Verify signature: HMAC SHA256 over {X-Donkey-Timestamp}.{raw_request_body}.
- Upsert article storage by article_id and keep modular_document raw and unchanged.
- Use event_id idempotency records to discard duplicate deliveries.
Render and publish
Build your own modular renderer and publish from structured data, not rendered_html.
- Render by block_type with semantic HTML and markdown-safe parsing.
- Emit modular_document.structured_data entries as JSON-LD script tags without transforming keys.
- Copy signed image URLs to your own permanent bucket because signed URLs expire.
Sync publication state
After scheduling/publishing/failure, callback Donkey SEO with the latest state.
- PATCH /api/v1/integration/article/{article_id}/publication?project_id=...
- Set publish_status to scheduled, published, or failed.
- When published, include published_at (ISO datetime) and published_url.
DONKEY_SEO_API_KEY=replace-with-project-integration-api-key
DONKEY_SEO_WEBHOOK_SECRET=replace-with-project-webhook-signing-secretArchitecture flow (webhook-first)
Receive publish request webhook
Accept raw JSON, verify X-Donkey-Signature, and reject invalid signatures with non-2xx.
Run idempotency gate
Check event_id before doing work. Duplicate event_id values are retries and should return success without reprocessing.
Persist canonical article
Upsert one local article record by article_id. Store modular_document raw for replay, auditing, and deterministic rendering.
Render modular blocks
Parse block_type -> component mapping, preserve order, and render markdown fields safely without raw HTML injection.
Publish and store stable assets
Push content to CMS/viewer, copy signed assets to your own permanent storage, and save stable public URLs.
PATCH publication callback
Send scheduled/published/failed status back to Donkey SEO with timestamps/URLs to close the publication loop.
Modular article renderer
Parse by block_type, preserve order exactly, render markdown safely, and emit each structured_data entry as JSON-LD script output.
Renderer implementations
Framework-specific patterns for semantically rendering modular_document blocks.
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeSanitize from "rehype-sanitize";
type BlockType =
| "hero"
| "summary"
| "section"
| "list"
| "comparison_table"
| "steps"
| "faq"
| "cta"
| "conclusion"
| "sources";
type LinkItem = { anchor?: string; href?: string };
type FaqItem = { question?: string; answer?: string };
type CtaItem = { label?: string; href?: string } | null;
type Block = {
block_type?: string | null;
semantic_tag?: string | null;
heading?: string | null;
level?: number | null;
body?: string | null;
items?: unknown;
ordered?: boolean;
links?: unknown;
faq_items?: unknown;
table_columns?: unknown;
table_rows?: unknown;
cta?: unknown;
// Keep extension fields for schema evolution.
[key: string]: unknown;
};
type ModularDocument = {
seo_meta?: { h1?: unknown };
structured_data?: unknown;
blocks?: unknown;
};
function safeString(value: unknown): string {
if (value === null || value === undefined) return "";
return String(value);
}
function safeArray<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isJsonLdObject(value: unknown): value is Record<string, unknown> {
if (!isRecord(value)) return false;
return typeof value["@type"] === "string" || typeof value["@context"] === "string";
}
function safeStructuredData(value: unknown): Array<Record<string, unknown>> {
if (Array.isArray(value)) return value.filter(isJsonLdObject);
return isJsonLdObject(value) ? [value] : [];
}
function serializeJsonLd(data: Record<string, unknown>): string {
// Prevent script breakouts while keeping values intact.
return JSON.stringify(data).replace(/</g, "\u003c");
}
function normalizeHeadingLevel(level: unknown): 2 | 3 | 4 {
const raw = typeof level === "number" ? level : 2;
if (raw <= 2) return 2;
if (raw === 3) return 3;
return 4;
}
function toBlockType(value: unknown): BlockType | "unknown" {
const blockType = safeString(value);
switch (blockType) {
case "hero":
case "summary":
case "section":
case "list":
case "comparison_table":
case "steps":
case "faq":
case "cta":
case "conclusion":
case "sources":
return blockType;
default:
return "unknown";
}
}
function MarkdownText({ text }: { text?: unknown }) {
const normalized = safeString(text).trim();
if (!normalized) return null;
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
{normalized}
</ReactMarkdown>
);
}
function Heading({ heading, level }: { heading: unknown; level: unknown }) {
const text = safeString(heading).trim();
if (!text) return null;
const normalized = normalizeHeadingLevel(level);
if (normalized === 2) return <h2>{text}</h2>;
if (normalized === 3) return <h3>{text}</h3>;
return <h4>{text}</h4>;
}
function BlockLinks({ links }: { links: unknown }) {
const safeLinks = safeArray<LinkItem>(links).filter(
(link) => safeString(link?.href).trim().length > 0
);
if (safeLinks.length === 0) return null;
return (
<nav aria-label="Related links">
<ul>
{safeLinks.map((link, index) => {
const href = safeString(link.href);
const label = safeString(link.anchor) || href;
return (
<li key={href + "-" + index}>
<a href={href}>{label}</a>
</li>
);
})}
</ul>
</nav>
);
}
function SharedContent({ block }: { block: Block }) {
const items = safeArray<string>(block.items).map((item) => safeString(item)).filter(Boolean);
const ordered = block.ordered === true;
return (
<>
<Heading heading={block.heading} level={block.level} />
<MarkdownText text={block.body} />
{items.length > 0
? ordered
? (
<ol>
{items.map((item, index) => (
<li key={index}>
<MarkdownText text={item} />
</li>
))}
</ol>
)
: (
<ul>
{items.map((item, index) => (
<li key={index}>
<MarkdownText text={item} />
</li>
))}
</ul>
)
: null}
<BlockLinks links={block.links} />
</>
);
}
function ComparisonTableBlock({ block }: { block: Block }) {
const columns = safeArray<string>(block.table_columns).map((col) => safeString(col)).filter(Boolean);
const rows = safeArray<unknown[]>(block.table_rows);
if (columns.length === 0) return null;
return (
<section>
<Heading heading={block.heading} level={block.level} />
<table>
<thead>
<tr>{columns.map((col, index) => <th key={index}>{col}</th>)}</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => {
const cells = safeArray<string>(row).map((cell) => safeString(cell));
return (
<tr key={rowIndex}>
{cells.map((cell, cellIndex) => (
<td key={cellIndex}>
<MarkdownText text={cell} />
</td>
))}
</tr>
);
})}
</tbody>
</table>
<BlockLinks links={block.links} />
</section>
);
}
function FaqBlock({ block }: { block: Block }) {
const faqItems = safeArray<FaqItem>(block.faq_items).filter(
(item) => safeString(item?.question).trim().length > 0
);
if (faqItems.length === 0) return null;
return (
<section>
<Heading heading={block.heading} level={block.level} />
{faqItems.map((item, index) => (
<details key={index}>
<summary>{safeString(item.question)}</summary>
<MarkdownText text={item.answer} />
</details>
))}
<BlockLinks links={block.links} />
</section>
);
}
function CtaBlock({ block }: { block: Block }) {
const cta = isRecord(block.cta) ? (block.cta as CtaItem) : null;
const label = safeString(cta?.label);
const href = safeString(cta?.href);
return (
<aside>
<SharedContent block={block} />
{label && href ? <a href={href}>{label}</a> : null}
</aside>
);
}
function FallbackBlock({ block }: { block: Block }) {
if (!safeString(block.heading) && !safeString(block.body)) return null;
return (
<section data-block-type={safeString(block.block_type)}>
<p>Unknown block type: {safeString(block.block_type)}</p>
<SharedContent block={block} />
</section>
);
}
function renderBlock(block: Block, index: number, articleHasH1: boolean) {
const blockType = toBlockType(block.block_type);
switch (blockType) {
case "comparison_table":
return <ComparisonTableBlock key={index} block={block} />;
case "faq":
return <FaqBlock key={index} block={block} />;
case "cta":
return <CtaBlock key={index} block={block} />;
case "hero":
return (
<header key={index}>
{articleHasH1 ? (
<>
<MarkdownText text={block.body} />
<BlockLinks links={block.links} />
</>
) : (
<SharedContent block={block} />
)}
</header>
);
case "summary":
case "section":
case "list":
case "steps":
case "conclusion":
case "sources": {
const semantic = safeString(block.semantic_tag);
const tagName =
semantic === "header" || semantic === "section" || semantic === "aside" || semantic === "footer"
? semantic
: "section";
return React.createElement(tagName, { key: index }, <SharedContent block={block} />);
}
default:
return <FallbackBlock key={index} block={block} />;
}
}
export function ArticleRenderer({ document }: { document: ModularDocument }) {
const h1 = safeString(document.seo_meta?.h1).trim();
const blocks = safeArray<Block>(document.blocks);
const structuredData = safeStructuredData(document.structured_data);
return (
<>
<article>
{h1 ? <h1>{h1}</h1> : null}
{blocks.map((block, i) => renderBlock(block, i, Boolean(h1)))}
</article>
{structuredData.map((schema, i) => (
<script
key={"jsonld-" + i}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: serializeJsonLd(schema),
}}
/>
))}
</>
);
}API client examples
Use these templates to call protected integration routes and send publication callbacks from your CMS or worker layer.
Client implementations
Node, Python, and cURL implementations for endpoint consumption and publication PATCH calls.
type PublicationPatch = {
publish_status?: "scheduled" | "published" | "failed";
published_at?: string;
published_url?: string;
};
export class DonkeySeoClient {
constructor(private baseUrl: string, private apiKey: string) {}
private headers() {
return {
"X-API-Key": this.apiKey,
"Content-Type": "application/json",
};
}
private async request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(this.baseUrl + path, {
...init,
headers: { ...this.headers(), ...(init?.headers ?? {}) },
});
if (!response.ok) {
const body = await response.text();
throw new Error("Donkey SEO request failed: " + response.status + " " + body);
}
return response.json() as Promise<T>;
}
listArticles(projectId: string, page = 1, pageSize = 20) {
return this.request(
"/api/v1/integration/articles?project_id=" + projectId + "&page=" + page + "&page_size=" + pageSize,
);
}
getPillars(projectId: string, includeArchived = false) {
return this.request(
"/api/v1/integration/pillars?project_id=" + projectId + "&include_archived=" + includeArchived,
);
}
getLatestArticle(projectId: string, articleId: string) {
return this.request(
"/api/v1/integration/article/" + articleId + "?project_id=" + projectId,
);
}
getArticleVersion(projectId: string, articleId: string, versionNumber: number) {
return this.request(
"/api/v1/integration/article/" + articleId + "/versions/" + versionNumber + "?project_id=" + projectId,
);
}
patchPublication(projectId: string, articleId: string, payload: PublicationPatch) {
return this.request(
"/api/v1/integration/article/" + articleId + "/publication?project_id=" + projectId,
{
method: "PATCH",
body: JSON.stringify(payload),
},
);
}
}Webhook validation and idempotency
Donkey SEO retries deliveries on network or non-2xx responses (max 5 attempts). Signature validation and idempotency are mandatory for safe processing.
- Headers:
X-Donkey-Event,X-Donkey-Delivery-Id,X-Donkey-Timestamp,X-Donkey-Signature. - Signed message format:
{timestamp}.{raw_request_body}. - Backoff schedule: 60s, 120s, 240s, 480s between retries.
import crypto from "crypto";
import express from "express";
const app = express();
// Raw body is required for stable signature verification.
app.use("/webhooks/donkey", express.raw({ type: "application/json" }));
function verifySignature(rawBody: Buffer, timestamp: string, incoming: string, secret: string) {
const signedPayload = timestamp + "." + rawBody.toString("utf8");
const digest = crypto.createHmac("sha256", secret).update(signedPayload).digest("hex");
const expected = "sha256=" + digest;
const incomingBuf = Buffer.from(incoming);
const expectedBuf = Buffer.from(expected);
return incomingBuf.length === expectedBuf.length && crypto.timingSafeEqual(incomingBuf, expectedBuf);
}
app.post("/webhooks/donkey", async (req, res) => {
const timestamp = String(req.header("X-Donkey-Timestamp") || "");
const signature = String(req.header("X-Donkey-Signature") || "");
const secret = process.env.DONKEY_SEO_WEBHOOK_SECRET || "";
const rawBody = req.body as Buffer;
if (!timestamp || !signature || !secret || !rawBody) {
return res.status(400).send("missing webhook headers or body");
}
const isValid = verifySignature(rawBody, timestamp, signature, secret);
if (!isValid) return res.status(401).send("invalid signature");
const event = JSON.parse(rawBody.toString("utf8"));
// Idempotency guard: duplicate event_id should be treated as retry.
if (await alreadyProcessed(event.event_id)) {
return res.status(200).send("ok");
}
// Persist canonical article payload by article_id before transformations.
await upsertCanonicalArticle(event.article.article_id, {
project: event.project,
article: event.article,
modular_document: event.modular_document,
rendered_html: event.rendered_html,
});
await publishFromModularDocument(event.modular_document);
await markProcessed(event.event_id);
return res.status(200).send("ok");
});Endpoint reference
Full endpoint surface for documentation + content integration paths under/api/v1/integration.
| Method | Path | Auth | Description | Query params |
|---|---|---|---|---|
| GET | /api/v1/integration/articles | X-API-Key | Paginated lightweight article list for selection/sync. Excludes heavy modular payload fields. |
|
| GET | /api/v1/integration/article/{article_id} | X-API-Key | Fetch latest immutable article version payload with modular_document. |
|
| GET | /api/v1/integration/article/{article_id}/versions/{version_number} | X-API-Key | Fetch an explicit immutable version of an article. |
|
| PATCH | /api/v1/integration/article/{article_id}/publication | X-API-Key | Publication callback endpoint. Send scheduled/published/failed plus published timestamp and URL. |
|
Payloads and implementation checklist
Use sample payloads for parser and contract tests, then enforce required and recommended integration rules.
Payload examples
Reference payloads for webhook ingestion, article retrieval, and publication callbacks.
{
"event_id": "evt_01JMR5V6QAJ46R12VYAAW4M5A7",
"event_type": "content.article.publish_requested",
"occurred_at": "2026-03-02T09:32:00Z",
"project": {
"id": "proj_123",
"domain": "example.com",
"locale": "en-US"
},
"article": {
"article_id": "c38f848d-66d6-4c47-b89a-30aa5a6dc881",
"brief_id": "ddf6c8d8-7a7c-4cf8-b7d8-8f84a80af872",
"version_number": 3,
"title": "How to Scale Programmatic SEO Content",
"slug": "how-to-scale-programmatic-seo-content",
"primary_keyword": "programmatic seo",
"proposed_publication_date": "2026-03-03"
},
"modular_document": {
"schema_version": "1.0",
"seo_meta": {
"h1": "How to Scale Programmatic SEO Content",
"meta_title": "Scale Programmatic SEO: A Practical Guide",
"meta_description": "Operational workflow and architecture for scaling programmatic SEO.",
"slug": "how-to-scale-programmatic-seo-content",
"primary_keyword": "programmatic seo"
},
"blocks": [
{
"block_type": "hero",
"semantic_tag": "header",
"heading": "How to Scale Programmatic SEO Content",
"level": 1,
"body": "A production-ready workflow for planning, generating, and publishing at scale."
}
]
},
"rendered_html": "<h1>How to Scale Programmatic SEO Content</h1><p>...</p>"
}Webhook-first canonical storage
RequiredPersist webhook article payload as your canonical local article by article_id. Do not create separate local article-version rows.
Signature verification
RequiredVerify X-Donkey-Signature with HMAC SHA256 over {X-Donkey-Timestamp}.{raw_request_body} using DONKEY_SEO_WEBHOOK_SECRET.
Idempotency by event_id
RequiredTreat duplicate event_id deliveries as retries and return success without re-running publication logic.
modular_document renderer
RequiredBuild your own block renderer by block_type, preserve block and nested-item order, and render markdown fields safely.
JSON-LD emission
RequiredEmit each structured_data object as application/ld+json script tags and keep keys/values unchanged except escaping </.
Pillar disambiguation pages
RecommendedFetch pillars and expose active pillar links in your footer to category pages like /pillars/{pillar.slug}.
Stable media storage
RecommendedCopy featured and author signed URLs to your own public bucket and store permanent URLs/keys.
Schema-aware parser tests
RecommendedAdd parser tests for every supported block type and assert schema_version-aware behavior.