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
SPONSORED By Python's Magic Methods - Beyond init and str
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"
🔒 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:
'posts': [{
'id': post['id'],
'visibility': 'paid',
'updated_at': current_post['updated_at']
}]
This updates all the previous posts to paid, keeping the update date.
🧱 Moving Public Posts This Year to “Members Only”
Another update that I wanted to do in bulk was to update some posts published this year to be members only and that were still public.
I needed to fix that, but only for current-year content, not the archive.
📁 Script: update_posts_member.py
🔍 Filter: published this year + visibility = "public"
🔄 Change: visibility → members
Why?
I offer a “members only” tier that’s free with signup, and I want this year’s posts to be accessible only after registration.
Here is the update posts function used in that script:
def update_posts_to_paid():
"""
Update public posts from the current year to members-only status.
This function:
1. Retrieves all public posts published in the current year
2. Updates each post's visibility to 'members'
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 current year that are public
posts_url = f"{api_url}/posts/"
response = requests.get(
posts_url,
headers=headers,
params={
'limit': 'all',
'filter': f'visibility:public+published_at:>={start_of_current_year}'
}
)
response.raise_for_status()
posts = response.json()['posts']
logger.info(f"Found {len(posts)} posts from current year that are public")
# 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': 'members',
'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 members status")
return updated_count
except Exception as e:
logger.error(f"An error occurred: {str(e)}")
raise
For the filters, this time I used:
params={
'limit': 'all',
'filter': f'visibility:public+published_at:>={start_of_current_year}'
}
Which represents public and published after (or at) the start of the current year. I am getting again all the records without pagination (limit
= all).
Then, to update:
'posts': [{
'id': post['id'],
'visibility': 'members',
'updated_at': current_post['updated_at']
}]
This updates all the previous posts to members
, keeping the update date. It is not shown here, but just like before, I get the updated_at
from each post.
🔍 Searching Posts + Getting Direct Edit Links
This was a major pain point for me.
I often need to jump into Ghost to edit an old post, but Ghost has no “search posts by title” function.
📁 Script: post_search.py
🔎 Input: a search term
📋 Output: list of matching posts with direct edit URLs
Let's see the function that I used:
def search_posts(search_term):
"""
Search for posts in Ghost that match the given search term in their title.
This function:
1. Searches for posts with titles containing the search term
2. Displays detailed information about each matching post
3. Provides both public URL and admin edit URL for each post
Args:
search_term (str): The term to search for in post titles
Returns:
list: List of matching post objects from the Ghost API
Raises:
Exception: If any error occurs during the search 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'
}
# Search for posts
posts_url = f"{api_url}/posts/"
response = requests.get(
posts_url,
headers=headers,
params={
'limit': 'all',
'filter': f"title:~'{search_term}'"
}
)
response.raise_for_status()
posts = response.json()['posts']
logger.info(f"Found {len(posts)} posts matching search term: '{search_term}'")
# Display matching posts
if posts:
print("\nMatching Posts:")
print("-" * 50)
for post in posts:
edit_url = get_edit_url(post['id'])
print(f"Title: {post['title']}")
print(f"Published: {post['published_at']}")
print(f"Status: {post['status']}")
print(f"Visibility: {post['visibility']}")
print(f"URL: {post['url']}")
print(f"Edit URL: {edit_url}")
print("-" * 50)
else:
print(f"\nNo posts found matching: '{search_term}'")
return posts
except Exception as e:
logger.error(f"An error occurred: {str(e)}")
raise
Similar to filtering the posts by visibility, the Ghost Admin API also allows filtering by title:
params={
'limit': 'all',
'filter': f"title:~'{search_term}'"
}
The ~
means that the title contains the search_term
. So it doesn't need to be an exact match.
This will output something like this, in my case, searching for jinja2
:
Enter search term: jinja2
2025-05-02 15:54:26,790 - INFO - Found 2 posts matching search term: 'jinja2'
Matching Posts:
--------------------------------------------------
Title: How to Build Dynamic Frontends with FastAPI and Jinja2
published: 2025-02-21T07:40:38.000Z
Status: published
Visibility: members
URL: https://developer-service.blog/how-to-build-dynamic-frontends-with-fastapi-and-jinja2/
Edit URL: https://xxxxxx.xxx/ghost/#/editor/post/69f91be4d20f7e/
--------------------------------------------------
Title: Creating a Web Application for Podcast Search using FastAPI, Jinja2, and Podcastindex.org
published: 2024-06-06T09:12:23.000Z
Status: published
Visibility: paid
URL: https://developer-service.blog/creating-a-web-application-for-podcast-search-using-fastapi-jinja2-and-podcastindex-org/
Edit URL: https://xxxxxx.xxx/ghost/#/editor/post/125171af2e15e59/
--------------------------------------------------
This script saves me endless time. I can search by partial title, copy the direct Ghost editor link, and jump straight into editing.
✅ Summary
With just three small scripts, I improved tremendously my entire editorial workflow:
Each script takes just a few seconds to run, and I now have more control over my Ghost posts.
If you’re running a content-heavy site on Ghost or offering tiered memberships, I highly recommend building your own admin tools like this.
Ghost’s API makes it easy, and Python makes it powerful.
GitHub repository for all the scripts: https://github.com/nunombispo/GhostContentAPI
Follow me on Twitter: https://twitter.com/DevAsService
Follow me on Instagram: https://www.instagram.com/devasservice/
Follow me on TikTok: https://www.tiktok.com/@devasservice
Follow me on YouTube: https://www.youtube.com/@DevAsService
Top comments (0)