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
- Go to your Ghost Admin โ Settings โ Integrations
- Click Add custom integration
- Name it "PostPenguin"
- Copy the Admin API Key (it looks like
abc123:def456...) - 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.