โ† Back to Connections

Connect Ghost to PostPenguin

Easy SetupCMS: GhostTime: 10 minutes

Ghost is a powerful publishing platform with a robust Admin API. This guide shows you how to create a webhook handler that publishes PostPenguin posts directly to your Ghost site.

Requirements: Ghost 4.0+ with Admin API access. You'll need to create a Custom Integration in Ghost to get your API key.

๐Ÿ”‘ Get Ghost Admin API Key

  1. Go to your Ghost Admin โ†’ Settings โ†’ Integrations
  2. Click Add custom integration
  3. Name it "PostPenguin"
  4. Copy the Admin API Key (it looks like abc123:def456...)
  5. Note your API URL (e.g., https://your-site.ghost.io)

๐Ÿš€ Webhook Handler

Create a webhook handler that receives posts from PostPenguin and publishes them to Ghost:

// ghost-webhook.js
const express = require('express')
const crypto = require('crypto')
const jwt = require('jsonwebtoken')

const app = express()
app.use(express.json())

// Ghost Admin API configuration
const GHOST_URL = process.env.GHOST_URL // e.g., https://your-site.ghost.io
const GHOST_ADMIN_KEY = process.env.GHOST_ADMIN_KEY // e.g., abc123:def456...

// Generate Ghost Admin API token
function generateGhostToken() {
  const [id, secret] = GHOST_ADMIN_KEY.split(':')
  
  const token = jwt.sign({}, Buffer.from(secret, 'hex'), {
    keyid: id,
    algorithm: 'HS256',
    expiresIn: '5m',
    audience: '/admin/'
  })
  
  return token
}

// Verify PostPenguin webhook signature
function verifySignature(payload, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex')
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature.replace('sha256=', ''))
  )
}

// Create or update post in Ghost
async function publishToGhost(postData) {
  const token = generateGhostToken()
  
  // Check if post exists by looking for mobiledoc with postpenguin ID
  const searchUrl = `${GHOST_URL}/ghost/api/admin/posts/?filter=slug:${postData.slug}`
  
  const searchResponse = await fetch(searchUrl, {
    headers: {
      'Authorization': `Ghost ${token}`,
      'Content-Type': 'application/json'
    }
  })
  
  const searchData = await searchResponse.json()
  const existingPost = searchData.posts?.[0]
  
  // Convert HTML to Ghost's mobiledoc format
  const mobiledoc = JSON.stringify({
    version: '0.3.1',
    atoms: [],
    cards: [['html', { html: postData.html }]],
    markups: [],
    sections: [[10, 0]]
  })
  
  const ghostPost = {
    posts: [{
      title: postData.title,
      slug: postData.slug,
      mobiledoc: mobiledoc,
      status: 'published',
      meta_title: postData.meta_title || postData.title,
      meta_description: postData.meta_description || '',
      feature_image: postData.featured_image || null,
      tags: postData.tags?.map(tag => ({ name: tag })) || [],
      custom_excerpt: postData.meta_description || null,
    }]
  }
  
  let response
  
  if (existingPost) {
    // Update existing post
    ghostPost.posts[0].updated_at = existingPost.updated_at
    
    response = await fetch(`${GHOST_URL}/ghost/api/admin/posts/${existingPost.id}/`, {
      method: 'PUT',
      headers: {
        'Authorization': `Ghost ${token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(ghostPost)
    })
  } else {
    // Create new post
    response = await fetch(`${GHOST_URL}/ghost/api/admin/posts/`, {
      method: 'POST',
      headers: {
        'Authorization': `Ghost ${token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(ghostPost)
    })
  }
  
  if (!response.ok) {
    const error = await response.json()
    throw new Error(`Ghost API error: ${JSON.stringify(error)}`)
  }
  
  const result = await response.json()
  return {
    postId: result.posts[0].id,
    postUrl: result.posts[0].url,
    action: existingPost ? 'updated' : 'created'
  }
}

// PostPenguin webhook endpoint
app.post('/api/webhooks/postpenguin', async (req, res) => {
  try {
    // Verify signature
    const signature = req.headers['x-postpenguin-signature']
    const secret = process.env.POSTPENGUIN_WEBHOOK_SECRET
    
    if (secret && signature) {
      if (!verifySignature(JSON.stringify(req.body), signature, secret)) {
        return res.status(401).json({ error: 'Invalid signature' })
      }
    }
    
    const { title, slug, html, meta_title, meta_description, featured_image, tags } = req.body
    
    if (!title || !slug || !html) {
      return res.status(400).json({ error: 'Missing required fields' })
    }
    
    const result = await publishToGhost({
      title,
      slug,
      html,
      meta_title,
      meta_description,
      featured_image,
      tags
    })
    
    console.log(`โœ… Post ${result.action} in Ghost: ${title}`)
    
    res.json({
      success: true,
      ...result
    })
    
  } catch (error) {
    console.error('Webhook error:', error)
    res.status(500).json({ error: 'Internal server error' })
  }
})

const PORT = process.env.PORT || 3001
app.listen(PORT, () => console.log(`Ghost webhook handler running on port ${PORT}`))

๐Ÿ“ฆ Dependencies

{
  "dependencies": {
    "express": "^4.18.2",
    "jsonwebtoken": "^9.0.0"
  }
}
npm install express jsonwebtoken

โš™๏ธ Environment Variables

# .env
GHOST_URL=https://your-site.ghost.io
GHOST_ADMIN_KEY=abc123def456:789xyz...
POSTPENGUIN_WEBHOOK_SECRET=your-secret-key-here
PORT=3001

๐Ÿงช Testing

Test the Webhook

curl -X POST http://localhost:3001/api/webhooks/postpenguin \
  -H "Content-Type: application/json" \
  -d '{
    "postPenguinId": "test_ghost_123",
    "title": "Test Ghost Post",
    "slug": "test-ghost-post",
    "html": "<p className="text-gray-700">This is a test post published to Ghost.</p>",
    "meta_title": "Test Post",
    "meta_description": "Testing Ghost webhook integration",
    "tags": ["test", "ghost"]
  }'

Verify in Ghost

After sending the webhook, check your Ghost admin panel at /ghost/#/posts to see the new post.

๐Ÿš€ Deployment Options

Deploy to Vercel

// api/webhooks/postpenguin.js (Vercel serverless function)
const jwt = require('jsonwebtoken')

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }
  
  // ... same logic as above
}

Deploy to Railway/Render

Deploy the Express app directly with the environment variables configured in the dashboard.

๐Ÿ“ Advanced: Using Ghost's HTML Card

The example above uses Ghost's HTML card to render the content. For richer formatting, you can convert HTML to Ghost's native mobiledoc format:

// For more control, use @tryghost/html-to-mobiledoc
const htmlToMobiledoc = require('@tryghost/html-to-mobiledoc')

const mobiledoc = htmlToMobiledoc(postData.html)

Note: Ghost's Admin API has rate limits. For high-volume publishing, consider implementing a queue system.

Need Help?

Check our webhook documentation for technical details, or contact support for custom integrations.