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:


CTA Image

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.

Get the eBook

📦 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:

Mug Trust Me Prompt Engineer Sarcastic Design - $14.95 - Developer Service Shop
Buy this product for only $14.95

🔒 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: