Skip to content
Dashboard

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

1

In your Ghost admin panel, go to Settings → Integrations → Add custom integration.

2

Name it Brainpercent and click Create.

3

Copy the Admin API key. It has the format <id>:<secret>, e.g. 64a3c8e1f2b3:8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d.

4

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.

ApproachPayload fieldNotes
Raw HTML (recommended)htmlSimplest — Ghost converts to Lexical on first open in editor
Lexical JSONlexicalComplex serialization — only if you need structured blocks
Mobiledoc (Ghost 4.x)mobiledocLegacy 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_image field accepts an absolute URL to any publicly accessible image. Brainpercent's featured_image_url is a permanent Supabase Storage URL — it will work. Avoid using generated_images[platform].url directly 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. Use slug instead of name for 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.