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 the editor
Lexical JSONlexicalComplex serialization, worth it 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, so 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, so 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.