I run the blog at Developer-Service.blog with a self-hosted Ghost instance.
As much as I love Ghost and its editor, I've encountered a handful of limitations in the Ghost Admin interface that have slowed me down.
Things like:
- Re-categorizing posts in bulk.
- Quickly searching posts by title.
- And many others...
Doing some of these things manually, one by one, through the Ghost admin panel wasn’t just inefficient—it was also error-prone.
So I wrote three small but powerful Python scripts that leverage the Ghost Admin API using JWT authentication.
These scripts saved me hours of clicking and let me manage my content programmatically, with zero guesswork.
GitHub repository for all the scripts: https://github.com/nunombispo/GhostContentAPI
If you prefer the video version, check out the YouTube video:
This book offers an in-depth exploration of Python's magic methods, examining the mechanics and applications that make these features essential to Python's design.
📦 Setup: Ghost Admin API + Python Environment
Before running the scripts, here’s what you need:
Create an Integration in Ghost Admin:
- Go to Settings → Integrations → Add custom integration.
- Note the Admin API Key (it looks like
XXXXX:YYYYYYYYYYYYY...
), - Keep in mind that the API URL for your Ghost site is normally your blog URL.
Create a .env
file for your credentials:
GHOST_URL=https://your-blog.com
GHOST_ADMIN_API_KEY=YOUR_KEY_ID:YOUR_SECRET
Install dependencies:
pip install python-dotenv pyjwt requests
Every script uses this shared boilerplate for JWT auth and getting the full API URL:
def get_ghost_token():
"""
Generate JWT token for Ghost Admin API authentication.
Returns:
str: JWT token for API authentication
Raises:
ValueError: If GHOST_ADMIN_API_KEY is not found in environment variables
Exception: If token generation fails
"""
try:
# Get API key from environment
key = os.getenv('GHOST_ADMIN_API_KEY')
if not key:
raise ValueError("GHOST_ADMIN_API_KEY not found in environment variables")
# Split the key into ID and SECRET
id, secret = key.split(':')
# Prepare header and payload
iat = int(datetime.now().timestamp())
header = {'alg': 'HS256', 'typ': 'JWT', 'kid': id}
payload = {
'iat': iat,
'exp': iat + 5 * 60, # Token expires in 5 minutes
'aud': '/admin/'
}
# Create the token
token = jwt.encode(
payload,
bytes.fromhex(secret),
algorithm='HS256',
headers=header
)
return token
except Exception as e:
logger.error(f"Failed to generate Ghost token: {str(e)}")
raise
def get_ghost_api_url():
"""
Get the Ghost Admin API URL from environment variables.
Returns:
str: Complete Ghost Admin API URL
Raises:
ValueError: If GHOST_URL is not found in environment variables
"""
base_url = os.getenv('GHOST_URL')
if not base_url:
raise ValueError("GHOST_URL not found in environment variables")
# Remove trailing slash if present
base_url = base_url.rstrip('/')
return f"{base_url}/ghost/api/admin"
Support the blog by getting one of the 'Prompt Engineer Sarcastic' mugs:

🔒 Archiving Older Posts for Paid Members
I wanted to convert older posts - anything published before this year - into exclusive content for paying subscribers.
This script does exactly that:
📁 Script: update_posts_to_paid.py
🔍 Filter: posts before Jan 1 this year, not already "paid"
🔄 Change: visibility → paid
How it works:
Here is the update posts function used in that script:
def update_posts_to_paid():
"""
Update non-paid posts from previous years to paid status.
This function:
1. Retrieves all posts that are not paid and were published before the current year
2. Updates each post's visibility to 'paid'
3. Maintains the post's updated_at timestamp to prevent conflicts
Returns:
int: Number of successfully updated posts
Raises:
Exception: If any error occurs during the update process
"""
try:
# Get authentication token and API URL
token = get_ghost_token()
api_url = get_ghost_api_url()
# Set up headers
headers = {
'Authorization': f'Ghost {token}',
'Content-Type': 'application/json',
'Accept-Version': 'v5.0'
}
# Calculate the start of the current year
current_year = datetime.now().year
start_of_current_year = f"{current_year}-01-01T00:00:00Z"
# Get posts from previous years that are not paid
posts_url = f"{api_url}/posts/"
response = requests.get(
posts_url,
headers=headers,
params={
'limit': 'all',
'filter': f'visibility:-paid+published_at:<{start_of_current_year}'
}
)
response.raise_for_status()
posts = response.json()['posts']
logger.info(f"Found {len(posts)} posts from previous years that are not paid")
# Update each post to paid status
updated_count = 0
for post in posts:
try:
# Get the latest version of the post to ensure we have the current updated_at
post_url = f"{api_url}/posts/{post['id']}/"
post_response = requests.get(post_url, headers=headers)
post_response.raise_for_status()
current_post = post_response.json()['posts'][0]
# Create the update data
update_url = f"{api_url}/posts/{post['id']}/"
update_data = {
'posts': [{
'id': post['id'],
'visibility': 'paid',
'updated_at': current_post['updated_at']
}]
}
# Update the post to paid status
update_response = requests.put(
update_url,
headers=headers,
json=update_data
)
update_response.raise_for_status()
updated_count += 1
logger.info(f"Updated post: {post['title']}")
except Exception as e:
logger.error(f"Failed to update post {post['title']}: {str(e)}")
continue
logger.info(f"Successfully updated {updated_count} posts to paid status")
return updated_count
except Exception as e:
logger.error(f"An error occurred: {str(e)}")
raise
Ghost exposes a query language called NQL for filtering API results - see the Content API docs for full details.
Its syntax works much like filters in Gmail, GitHub, or Slack: you specify a field and a value separated by a colon.
A filter expression takes the form property:operator_value
where:
- property is the field path you want to query,
- operator (optional) defines the comparison (a bare
:
acts like=
), and - value is the term you’re matching against.
So, for my case, I use these filters:
params={
'limit': 'all',
'filter': f'visibility:-paid+published_at:<{start_of_current_year}'
}
Which represents not paid (-paid
) and published before the start of the current year. In this case, I am getting all the records without pagination (limit
= all).
Then for each post, I get its details:
# Get the latest version of the post to ensure we have the current updated_at
post_url = f"{api_url}/posts/{post['id']}/"
post_response = requests.get(post_url, headers=headers)
post_response.raise_for_status()
current_post = post_response.json()['posts'][0]
This is because the update API below requires a updated_at
field for collision detection. In my case, I don't want to change the update date.
And updating is done with a PUT
method by which the body is:
This article is for subscribers only
To continue reading this article, just register your email and we will send you access.
Subscribe NowAlready have an account? Sign In