Ghost Integration
Push Brainpercent articles straight into Ghost via the Ghost Admin API. Ghost uses short-lived JWT tokens for Admin API authentication — this guide shows exactly how to generate them and map article fields including HTML body and feature image.
What You Need
- Ghost 5.x (self-hosted or Ghost Pro)
- Admin access to your Ghost publication
- A Ghost Admin API key (Staff → Integrations)
- A Brainpercent webhook set to fire on
article.published - Node.js 18+ for the handler (or adapt the JWT pattern to any language)
Step 1 — Create a Ghost Admin API Key
In your Ghost admin panel, go to Settings → Integrations → Add custom integration.
Name it Brainpercent and click Create.
Copy the Admin API key. It has the format <id>:<secret>, e.g. 64a3c8e1f2b3:8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d.
Also copy your Ghost API URL shown on the same page: https://your-publication.ghost.io.
Step 2 — Ghost Admin API JWT Authentication
Ghost Admin API does not accept static tokens. Instead you must generate a short-lived JWT (5-minute expiry) signed with the secret half of your Admin API key. This is required for every API request.
import jwt from 'jsonwebtoken';
/**
* Generate a Ghost Admin API JWT valid for 5 minutes.
* @param {string} adminApiKey - format "<id>:<secret>"
*/
function makeGhostToken(adminApiKey) {
const [id, secret] = adminApiKey.split(':');
const secretBuffer = Buffer.from(secret, 'hex');
return jwt.sign(
{},
secretBuffer,
{
keyid: id,
algorithm: 'HS256',
expiresIn: '5m',
audience: '/admin/',
}
);
}
// Usage
const token = makeGhostToken(process.env.GHOST_ADMIN_API_KEY);
// Use in: Authorization: Ghost <token>Dependencies: Install jsonwebtoken (npm install jsonwebtoken). The audience must be exactly /admin/ — Ghost validates this claim.
Step 3 — Choose a Content Format: HTML or Lexical
Ghost 5.x uses the Lexical editor by default but also accepts raw HTML via the html field. Using html is simpler and handles Brainpercent's content_html directly. Ghost converts it to Lexical internally on first save.
| Approach | Payload field | Notes |
|---|---|---|
| Raw HTML (recommended) | html | Simplest — Ghost converts to Lexical on first open in editor |
| Lexical JSON | lexical | Complex serialization — only if you need structured blocks |
| Mobiledoc (Ghost 4.x) | mobiledoc | Legacy format — deprecated in Ghost 5.x |
Step 4 — Webhook Handler
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import express from 'express';
const app = express();
const GHOST_URL = process.env.GHOST_API_URL; // e.g. https://your-blog.ghost.io
const GHOST_KEY = process.env.GHOST_ADMIN_API_KEY; // <id>:<secret>
const BP_SECRET = process.env.BP_WEBHOOK_SECRET;
function makeGhostToken(adminApiKey) {
const [id, secret] = adminApiKey.split(':');
const secretBuffer = Buffer.from(secret, 'hex');
return jwt.sign({}, secretBuffer, {
keyid: id, algorithm: 'HS256', expiresIn: '5m', audience: '/admin/',
});
}
function verifySignature(rawBody, signature) {
const expected = crypto
.createHmac('sha256', BP_SECRET)
.update(rawBody)
.digest('hex');
return `sha256=${expected}` === signature;
}
app.post('/webhooks/brainpercent', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifySignature(req.body, req.headers['x-brainpercent-signature'])) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body);
if (event.event !== 'article.published') return res.json({ skipped: true });
const article = event.data;
const token = makeGhostToken(GHOST_KEY);
// Build Ghost post payload
const ghostPost = {
title: article.title,
slug: article.slug,
html: article.content_html,
status: 'published', // or 'draft'
published_at: article.published_at ?? new Date().toISOString(),
custom_excerpt: article.excerpt ?? null,
...(article.featured_image_url && {
feature_image: article.featured_image_url,
feature_image_alt: article.title,
}),
tags: (article.tags ?? []).map((t) => ({ name: t })),
};
const ghostRes = await fetch(`${GHOST_URL}/ghost/api/admin/posts/`, {
method: 'POST',
headers: {
'Authorization': `Ghost ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ posts: [ghostPost] }),
});
if (!ghostRes.ok) {
const err = await ghostRes.text();
return res.status(502).json({ error: err });
}
const { posts } = await ghostRes.json();
res.json({ ghost_post_id: posts[0].id, url: posts[0].url });
});
app.listen(3000);Ghost API URL format: The posts endpoint is always /ghost/api/admin/posts/ (trailing slash required). Ghost Pro users: use https://your-publication.ghost.io as the base URL.
Common Gotchas
- 401 with "Invalid token"? The most common cause is forgetting that the Admin API key secret is hex-encoded — you must decode it with
Buffer.from(secret, 'hex')before passing it to the JWT library. - Slug collision (422 error)? Ghost returns a 422 Unprocessable Entity if the slug already exists. Append a timestamp or UUID suffix to deduplicate.
- Images not loading in Ghost editor? Ghost's
feature_imagefield accepts an absolute URL to any publicly accessible image. Brainpercent'sfeatured_image_urlis a permanent Supabase Storage URL — it will work. Avoid usinggenerated_images[platform].urldirectly as those are ephemeral GoAPI URLs that expire. - Tags created as new vs existing? Passing
tags: [{ name: 'Marketing' }]will create a new tag if one doesn't exist, or link to an existing one if the name matches exactly. Usesluginstead ofnamefor deterministic matching. - Ghost Pro rate limits: Ghost Pro enforces API rate limits. For high-volume pipelines, implement a queue with exponential backoff. Consult Ghost's docs at ghost.org/docs/admin-api.
For the full Brainpercent webhook payload schema, see Webhooks & Async guide.