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)