Zenex CMSDocs

Publishing to your website

Zenex can copy every published article to your site, so the site never depends on Zenex to show it. Choose how in your blog → Settings → Web integration.

How it works

  • Zenex is the source of truth. Publishing an article sends it; editing it sends it again; unpublishing or deleting it removes the copy.
  • Nothing is lost when your site is down. Failed deliveries are retried with growing pauses (up to once a day), and Settings → Web integration shows what failed and why.
  • Copies are keyed by the article id, so a repeated delivery updates the same copy instead of creating another.
  • Links between articles use the article address your site reports when you connect it.

WordPress

Install the Zenex plugin (Website → Download), paste the connection key in Settings → Zenex and check the connection. Each article becomes a regular post: images go to your media library, the cover becomes the featured image, categories and tags are created as needed, and SEO fields go to Yoast SEO, Rank Math or SEOPress. With Polylang, every language is published and translations are linked; without it, only the main language is sent. Removed articles go to the trash.

PrestaShop

Upload the Zenex Blog module in Modules → Module Manager, paste the key in its configuration and check the connection. The module adds a blog to your store at /blog (you can change the path), in every language the store has, with its own pages, meta tags, hreflang links and entries in the Google sitemap module. It needs PrestaShop 1.7.6 or later.

GitHub (Next.js, Astro and other static sites)

Zenex commits each article as Markdown to your repository: content/blog/{slug}.md, orcontent/blog/{language}/{slug}.md when the blog has several languages. Images are committed topublic/blog and referenced as /blog/…. Your host redeploys on each commit. Use a fine-grained token limited to the repository with Contents: Read and write.

content/blog/how-to-plan-a-content-calendar.md
---
title: "How to plan a content calendar"
description: "A short summary."
slug: "how-to-plan-a-content-calendar"
language: "en"
translationKey: "6f1c…"
date: "2026-10-05T09:00:00.000Z"
updated: "2026-10-05T09:12:00.000Z"
author: "Ada Lovelace"
cover: "/blog/3f9a1c2b7d4e5f60.webp"
categories:
- "Guides"
tags:
- "Planning"
featured: false
zenexId: "clx1234567890"
---
## Why plan ahead
Body in **Markdown**, with images copied to public/blog.

In Astro, a content collection reads these files directly:

src/content.config.ts
// src/content.config.ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./content/blog" }),
schema: z.object({
title: z.string(),
description: z.string().optional(),
date: z.coerce.date(),
cover: z.string().optional(),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { blog };

Webhook

For anything else, Zenex sends each change to an endpoint you build, as a POST with a JSON body and these headers:

  • X-Zenex-Event: ping, post.upsert or post.delete
  • X-Zenex-Timestamp: Unix time in seconds
  • X-Zenex-Signature: v1= followed by the hex HMAC-SHA256 of {timestamp}.{raw body} with your signing key

Reject requests whose signature doesn’t match or whose timestamp is more than 5 minutes off. Answer with any 2xx status; forpost.upsert you can return { "url": "…" } so Zenex links to the article on your site. Any other status is retried. ping is sent when you check the connection.

app/api/zenex/route.ts
// app/api/zenex/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export async function POST(request: Request) {
const body = await request.text();
const timestamp = request.headers.get("x-zenex-timestamp") ?? "";
const signature = request.headers.get("x-zenex-signature") ?? "";
const expected = `v1=${createHmac("sha256", process.env.ZENEX_SIGNING_KEY!)
.update(`${timestamp}.${body}`).digest("hex")}`;
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
const valid = fresh && expected.length === signature.length
&& timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
if (!valid) return Response.json({ error: "Invalid signature" }, { status: 401 });
const delivery = JSON.parse(body);
if (delivery.event === "post.upsert") {
const { article } = delivery;
// Store it however you like; article.id is the key, so retries are harmless.
await db.article.upsert({
where: { zenexId: article.id },
create: { zenexId: article.id, slug: article.slug, language: article.language, title: article.title, html: article.html },
update: { slug: article.slug, language: article.language, title: article.title, html: article.html },
});
return Response.json({ url: `https://example.com/blog/${article.slug}` });
}
if (delivery.event === "post.delete") {
await db.article.deleteMany({ where: { zenexId: delivery.article.id } });
}
return Response.json({ ok: true });
}
post.upsert body
{
"version": 1,
"event": "post.upsert",
"remoteId": null,
"article": {
"id": "clx1234567890",
"translationGroupId": "6f1c…",
"language": "en",
"slug": "how-to-plan-a-content-calendar",
"title": "How to plan a content calendar",
"excerpt": "A short summary.",
"html": "<h2 class=\"zenex-cms__header zenex-cms__h2\">…</h2><p class=\"zenex-cms__paragraph\">…</p>",
"publishedAt": "2026-10-05T09:00:00.000Z",
"updatedAt": "2026-10-05T09:12:00.000Z",
"featured": false,
"coverImage": { "url": "https://media.zenex.dev/…/cover.webp", "alt": "How to plan a content calendar" },
"author": { "name": "Ada Lovelace", "slug": "ada-lovelace" },
"categories": [{ "name": "Guides", "slug": "guides" }],
"tags": [{ "name": "Planning", "slug": "planning" }],
"seo": {
"title": "Plan a content calendar in 5 steps",
"description": "…",
"canonicalUrl": null,
"ogTitle": null,
"ogDescription": null,
"ogImage": "https://media.zenex.dev/…/cover.webp",
"keywords": "content calendar"
},
"translations": [{ "id": "clx0987654321", "language": "es", "slug": "como-planificar-un-calendario-de-contenidos" }],
"images": ["https://media.zenex.dev/…/cover.webp", "https://media.zenex.dev/…/diagram.webp"]
}
}

post.delete carries article.id, article.language and theremoteId you returned, if any.