Guide · for developers and site owners
Showing posts on your site
Every post ContentRails publishes reaches your site as one JSON request. It includes the post as ready-made HTML, styles included, with interactive cards that work without JavaScript. This page shows how to put it on a page.
What your site receives
A POST to your publish URL for each change: post.published, post.updated, post.unpublished or post.deleted. The fields that matter for showing a post:
html: the post, ready to show. One<div class="cr-post">holding its own<style>and the article. Always there. Put it on your page as-is.cardsScript: a script URL when the post has a calculator or pricing card, otherwisenull. Load it to let readers type their own numbers. Without it, those cards still show preset values.content: the same post as Markdown, without the cards. You can ignore it.translations: the post in your other languages, each with its ownhtml.
{
"event": "post.published",
"projectId": "7c0e5a52-…",
"postId": "b1946ac9-2d0e-4c5f-9d11-6f0e8c2a7e10",
"title": "How much storage do a blog's images need?",
"excerpt": "Most blogs need far less image storage than hosting plans suggest…",
"thumbnailUrl": "https://cdn.contentrails.ai/thumbnails/b1946ac9-…/3f9c2a7b.png",
"publishedAt": "2026-10-09T09:30:00.000Z",
"html": "<div class=\"cr-post\" lang=\"en\" data-cr-version=\"1\"><style>.cr-post{…}</style><p>Most blogs need…</p>…</div>",
"cardsScript": "https://contentrails.ai/cards/v1.js",
"content": "# How much storage do a blog's images need?\n\nMost blogs need…",
"jsonLd": { "@context": "https://schema.org", "@type": "BlogPosting", … },
"translations": [
{
"language": "de",
"title": "Wie viel Speicher brauchen die Bilder eines Blogs?",
"content": "# Wie viel Speicher …",
"excerpt": "Die meisten Blogs brauchen …",
"html": "<div class=\"cr-post\" lang=\"de\" data-cr-version=\"1\">…</div>"
}
]
}Headers: X-ContentRails-Event, X-ContentRails-Timestamp, X-ContentRails-Idempotency-Key, and X-ContentRails-Signature when you set a signing secret in Settings. Answer with any 2xx. Anything else is retried three times.
The short version
- Show
titlein your own<h1>. The html leaves the title out, so your page's layout keeps its heading. - Put
htmlon the page exactly as it came: no Markdown conversion, no sanitizer that strips tags (see Troubleshooting). - If
cardsScriptisn't null, load it once with a<script src>tag. Scripts insideinnerHTMLnever run, which is why it comes separately.
Live example
A short sample post, rendered by the same code that writes your html. Change the calculator's numbers, tick the checklist, open the questions.
Most blogs need far less image storage than hosting plans suggest. You can work it out from three numbers you already know.
Work out your yearly total
Multiply the posts you publish a month by the images in each post and by their average size, then by twelve. Eight posts a month with five 400 KB images each comes to about 192 MB a year.
Calculate
Image storage per year
Change the numbers to match your blog.
- Storage a year
The post's own example. Worked from the figures in this post.
Before you upload
Resize and compress images before they go up. A photo straight from a phone is often 3 to 5 MB, ten times what a post needs.
Checklist
Before each upload
of 3 doneAll 3 done
Questions
Does a CDN change the total?
No. A CDN caches copies near readers; you still store each image once.
Should I keep the originals?
Keep them somewhere cheap, like cold storage, not on the site itself.
Recipes
Each recipe receives the webhook, stores the post by postId, and shows it. Storage is up to you. The helpers named savePost and getPost stand in for your own.
Next.js (App Router)
A route handler for the webhook, which checks the signature against the raw body:
// app/api/contentrails/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { deletePost, savePost } from "@/lib/posts"; // your own storage
export async function POST(req: Request) {
// Verify against the raw body: re-serialising parsed JSON changes the bytes.
const raw = await req.text();
const timestamp = req.headers.get("x-contentrails-timestamp") ?? "";
const signature = req.headers.get("x-contentrails-signature") ?? "";
const expected = createHmac("sha256", process.env.CONTENTRAILS_SECRET!).update(`${timestamp}.${raw}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
if (!fresh || signature.length !== expected.length || !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return new Response("Bad signature", { status: 401 });
}
const post = JSON.parse(raw);
if (post.event === "post.unpublished" || post.event === "post.deleted") {
await deletePost(post.postId);
} else {
// Keyed by postId: "post.updated" sends the same id, so this replaces the old version.
await savePost({
id: post.postId,
title: post.title,
excerpt: post.excerpt,
html: post.html,
cardsScript: post.cardsScript,
translations: post.translations,
jsonLd: post.jsonLd,
});
}
return Response.json({ ok: true });
}The post page. next/script loads the cards script once and keeps it across client-side navigation. The script finds posts rendered later, too.
// app/blog/[slug]/page.tsx
import Script from "next/script";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/posts";
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const post = await getPost((await params).slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
{post.cardsScript && <Script src={post.cardsScript} strategy="afterInteractive" />}
</article>
);
}Set your publish URL in Settings to https://your-site.com/api/contentrails and copy the signing secret into CONTENTRAILS_SECRET.
React (Vite, or any single-page app)
Your server receives the webhook as above. In the browser, the post is a div with the html and a hook that adds the script once:
import { useEffect } from "react";
// Loads the cards script once. It also picks up posts rendered later,
// so client-side navigation between posts needs nothing extra.
function useCardsScript(src: string | null) {
useEffect(() => {
if (!src || document.querySelector(`script[src="${src}"]`)) return;
const script = document.createElement("script");
script.src = src;
script.defer = true;
document.head.appendChild(script);
}, [src]);
}
export function Post({ post }: { post: { title: string; html: string; cardsScript: string | null } }) {
useCardsScript(post.cardsScript);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}WordPress
By hand: add a Custom HTML block and paste the html. Don't use a Paragraph block or the classic editor's Visual tab, since both rewrite the markup. Add the script line only for posts with a calculator or pricing card.
<!-- Custom HTML block: paste the value of "html" -->
<div class="cr-post" lang="en" data-cr-version="1"><style>…</style>…</div>
<!-- Only when "cardsScript" isn't null -->
<script src="https://contentrails.ai/cards/v1.js" defer></script>Only users with the unfiltered_html capability (Administrators, or only Super Admins on multisite) can save <style>, <input> and <script>. For an Editor or Author, WordPress strips them on save.
Automatically: this must-use plugin adds a webhook endpoint that creates and updates posts, keeping the html as a Custom HTML block. It also loads the script on the posts that need it. Generic "webhook to post" plugins usually run content through KSES or a Markdown converter, which breaks the cards.
<?php
/* wp-content/mu-plugins/contentrails.php
Publish URL: https://your-site.com/wp-json/contentrails/v1/posts
In wp-config.php: define('CONTENTRAILS_SECRET', 'your signing secret'); */
add_action('rest_api_init', function () {
register_rest_route('contentrails/v1', '/posts', [
'methods' => 'POST',
'permission_callback' => '__return_true', // the signature check below is the auth
'callback' => function (WP_REST_Request $req) {
$raw = $req->get_body();
$ts = (string) $req->get_header('x-contentrails-timestamp');
$sig = (string) $req->get_header('x-contentrails-signature');
$expected = hash_hmac('sha256', $ts . '.' . $raw, CONTENTRAILS_SECRET);
if (!hash_equals($expected, $sig) || abs(time() - (int) $ts) > 300) {
return new WP_Error('bad_signature', 'Bad signature', ['status' => 401]);
}
$p = json_decode($raw, true);
$found = get_posts(['post_status' => 'any', 'meta_key' => 'contentrails_id', 'meta_value' => $p['postId'], 'numberposts' => 1, 'fields' => 'ids']);
$id = $found[0] ?? 0;
if ($p['event'] === 'post.deleted') {
if ($id) wp_delete_post($id, true);
return ['ok' => true];
}
if ($p['event'] === 'post.unpublished') {
if ($id) wp_update_post(['ID' => $id, 'post_status' => 'draft']);
return ['ok' => true];
}
kses_remove_filters(); // KSES would strip <style>, <input> and <details>
$id = wp_insert_post(wp_slash([
'ID' => $id,
'post_author' => 1, // the user these posts appear under
'post_title' => $p['title'],
'post_excerpt' => $p['excerpt'],
// A Custom HTML block: the editor keeps it as-is and wpautop leaves it alone.
'post_content' => "<!-- wp:html -->\n" . $p['html'] . "\n<!-- /wp:html -->",
'post_status' => 'publish',
'meta_input' => ['contentrails_id' => $p['postId'], 'contentrails_cards_script' => $p['cardsScript'] ?? ''],
]), true);
kses_init_filters();
return is_wp_error($id) ? $id : ['ok' => true, 'id' => $id];
},
]);
});
// Load the cards script on the posts that need it.
add_action('wp_enqueue_scripts', function () {
$src = is_singular() ? get_post_meta(get_the_ID(), 'contentrails_cards_script', true) : '';
if ($src) wp_enqueue_script('contentrails-cards', $src, [], null, ['strategy' => 'defer']);
});Webflow
On a static page: drop a Code Embed element where the post goes and paste the html. Embeds have a character limit, and a long post with many cards can go over it. If it does, split the html at a section boundary into two embeds.
In the CMS: don't put the html in a Rich Text field. Webflow's rich text removes <style>, form inputs and <details>. Store it in a Plain Text field (raise its maximum length). Then, on the Collection template page, add a Code Embed and insert that field with Add Field.
The script goes in the site's custom code once. Custom code needs a Webflow plan that allows it.
<!-- Site settings → Custom code → Footer code
(or the Collection template page's own "Before </body> tag") -->
<script src="https://contentrails.ai/cards/v1.js" defer></script>Ghost
By hand: in the editor, type /html to add an HTML card and paste the html. For the script, open the post's settings, go to Code injection → Post footer, and add the script tag from the plain HTML recipe below.
Automatically, with the Admin API from your webhook receiver. Without the kg-card comments, Ghost converts the HTML into its own format and drops the inputs and styles. To update a post, call ghost.posts.edit with its id and updated_at and the same fields.
import GhostAdminAPI from "@tryghost/admin-api";
const ghost = new GhostAdminAPI({ url: "https://your-site.com", key: process.env.GHOST_ADMIN_KEY, version: "v5.0" });
// Inside your webhook receiver, after checking the signature:
await ghost.posts.add(
{
title: post.title,
custom_excerpt: post.excerpt,
// The kg-card comments keep the html as one HTML card instead of converting it.
html: `<!--kg-card-begin: html-->${post.html}<!--kg-card-end: html-->`,
codeinjection_foot: post.cardsScript ? `<script src="${post.cardsScript}" defer></script>` : null,
status: "published",
},
{ source: "html" },
);Plain HTML or a static site
Paste the html inside your article, and the script before </body> when the post needs it:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>How much storage do a blog's images need?</title>
</head>
<body>
<article>
<h1>How much storage do a blog's images need?</h1>
<!-- The value of "html", pasted as-is -->
<div class="cr-post" lang="en" data-cr-version="1"><style>…</style>…</div>
</article>
<!-- Only when "cardsScript" isn't null -->
<script src="https://contentrails.ai/cards/v1.js" defer></script>
</body>
</html>Static site generators need the raw-output form of their template tag, or they escape the html into visible text. The script tag goes in the layout, unchanged.
<!-- Astro -->
<Fragment set:html={post.html} />
<!-- Hugo (html stored in the page's front matter) -->
{{ .Params.html | safeHTML }}
<!-- Eleventy / Nunjucks -->
{{ post.html | safe }}Colours and fonts
The post's look comes from CSS variables on .cr-post. Its own CSS never reaches outside it, and your site's CSS only changes it through these variables.
The accent starts as your site's main colour, which we pick up when we read your site. To change it for every post, set Card colour in your project's Settings. It applies to posts published from then on, so press Publish again on older ones. To change the accent on your side, override the variables in your own stylesheet:
/* In your site's stylesheet */
.cr-post {
--cr-accent: #e0452b;
--cr-font: "Inter", system-ui, sans-serif;
--cr-heading-font: "Fraunces", Georgia, serif;
--cr-radius: 8px;
}
/* Using dark mode? Set the dark ones too. */
[data-cr-theme="dark"] .cr-post {
--cr-surface: #16181d;
}| Variable | Default | Used for |
|---|---|---|
| --cr-accent | Your site's colour, else #2459e0 | Buttons, selected tabs, links, badges |
| --cr-font | inherit | Body text |
| --cr-heading-font | var(--cr-font) | Headings |
| --cr-mono | ui-monospace, Menlo, monospace | Code and figures |
| --cr-ink | #0f172a | Text |
| --cr-muted | #5a6478 | Secondary text |
| --cr-bg | transparent | Behind the whole post |
| --cr-surface | #ffffff | Card background |
| --cr-line | #e2e7f0 | Borders |
| --cr-radius | 14px | Card corners |
| --cr-good / --cr-warn / --cr-bad | Green / amber / red | Right and wrong answers, warnings |
Fonts: --cr-font is inherit, so the post uses the font of the element you put it in. Headings use the same font unless you set --cr-heading-font. The post doesn't pick up your site's h2 styles.
Dark mode
Posts are light by default, because they sit on your page's background. On a dark site, add data-cr-theme="dark" to your <html> (or any element around the post). To follow the reader's system setting instead, use "auto". The accent stays your colour in both.
<!-- A dark site: on <html>, <body> or any wrapper around the post -->
<html data-cr-theme="dark">
<!-- A site that follows the reader's system setting -->
<html data-cr-theme="auto">
// A site with its own theme switch: set it when the theme changes
document.documentElement.dataset.crTheme = isDark ? "dark" : "light";Translations
When your project has other languages, each entry in translations has its own html. Show it the same way as the English. Its cards are translated too, labels included, when they can be; otherwise that language shows the post as text. Its lang attribute is set. One cardsScript covers every language.
Replace a post's translations on every publish. A language missing from the list has been removed.
// One page per language, e.g. /blog/my-post and /de/blog/my-post
for (const t of post.translations) {
await saveTranslation({ postId: post.postId, language: t.language, title: t.title, excerpt: t.excerpt, html: t.html });
}Troubleshooting
Cards look broken or don't respond
Something between the webhook and the page removed part of the html: a CMS editor, a Markdown converter, or an HTML sanitizer. The html comes from us and arrives signed, so you can show it without sanitizing. What breaks when each part is removed:
| Removed | What breaks |
|---|---|
| <style> | Everything shows unstyled, and tabbed cards show every tab at once. |
| <input>, <label>, or the id, for and name attributes | Tabs, choices, checklists and quizzes stop responding to clicks. |
| <details> and <summary> | FAQ and myth cards lose their open-and-close. |
| data-cr-* attributes, hidden | The script can't find the calculator and pricing cards; they stay on their preset values. |
| <script type="application/json"> | Calculators and pricing stay on preset values. This tag is data, not code: it never runs. |
If your stack runs every HTML string through DOMPurify, its defaults keep all of this except the JSON data tags. Add FORCE_BODY, or the leading <style> is dropped:
import DOMPurify from "dompurify";
// FORCE_BODY keeps the leading <style>; inputs, labels, <details> and data-* survive the defaults.
const clean = DOMPurify.sanitize(post.html, { FORCE_BODY: true });Content Security Policy blocks it
The cards script loads from https://contentrails.ai, and the post's styles are in an inline <style>. A strict policy needs both allowed:
Content-Security-Policy:
script-src 'self' https://contentrails.ai;
style-src 'self' 'unsafe-inline';// Without 'unsafe-inline' in style-src, give the post's <style> your page's nonce:
const html = post.html.replace("<style>", `<style nonce="${nonce}">`);The title shows twice
The html has no <h1>, so a doubled title comes from rendering content as well, or instead. content is Markdown and starts with # Title. Show html and your own heading, not both.
Fonts look different from the rest of the site
The post inherits the font of its container. If the container sets a different font from your article text, set --cr-font and --cr-heading-font to your site's fonts, as in Colours and fonts. The post doesn't load any web fonts of its own.
Calculators don't take my numbers
The inputs stay greyed out until the cards script runs. Check your browser's network tab for cards/v1.js. If it isn't there, the script tag is missing, or it was put inside the html (scripts in innerHTML don't run). If it's blocked, see Content Security Policy above. If it loads and the inputs stay grey, a sanitizer removed the data-cr-* attributes or the JSON data tags.
My endpoint rejects the signature
The signature is HMAC-SHA256, hex-encoded, of `${timestamp}.${rawBody}` with your signing secret. The timestamp comes from X-ContentRails-Timestamp. Hash the body exactly as it arrived: parsing the JSON and serialising it again changes the bytes. Reject a timestamp more than five minutes old. The secret is in your project's Settings, next to the publish URL.