🚀 Executive Summary

TL;DR: Migrating Substack newsletter subscribers to Ghost manually is cumbersome and lacks control over data. This guide provides a Python script solution to automate the export of Substack subscribers via CSV and import them into a Ghost instance using its Admin API, ensuring full data ownership and a smoother transition.

🎯 Key Takeaways

  • The migration process requires exporting a Substack subscribers CSV, generating a Ghost Admin API Key and URL, and setting up a Python 3 environment with `requests`, `python-dotenv`, and `PyJWT`.
  • The Python script authenticates with the Ghost Admin API using a dynamically generated JWT token from the provided API Key, allowing it to create new members with specified emails and labels.
  • Implementing a `time.sleep(0.5)` delay between API requests is crucial to prevent hitting Ghost API rate limits when importing a large number of subscribers.

Migrate Substack Newsletter Subscribers to Ghost

Migrate Substack Newsletter Subscribers to Ghost

Hey there, Darian Vance here. A while back, I was managing a personal project on Substack. It was great for getting started, but I quickly hit a ceiling. I wanted more control over the design, the monetization, and most importantly, the data. Manually exporting and importing was an option, but it felt clumsy and error-prone. That’s when I decided to build a simple, repeatable script to handle the migration. This process saved me a ton of headache, and I want to walk you through it so you can take back control of your audience data without the friction.

Let’s get this done right.

Prerequisites

Before we dive in, make sure you have the following ready to go. This will make the process much smoother.

  • Your Substack Subscribers CSV: You can get this from your Substack dashboard under Settings > Export your data.
  • A running Ghost Instance: I’m assuming you have a Ghost blog set up, either self-hosted or on Ghost(Pro). This script works best with Ghost v4.0 and newer.
  • Ghost Admin API Key & URL: We’ll generate this in the first step. It’s our key to the kingdom.
  • A local Python 3 environment: I’ll skip the standard virtualenv setup since you likely have your own workflow for that. We’ll jump straight to the Python logic. You will need to install a couple of libraries, specifically `requests` for API calls and `python-dotenv` for handling our credentials securely. You can add these using your standard package manager, like pip.

The Step-by-Step Guide

Alright, let’s get to the nuts and bolts. Follow these steps, and your subscribers will be in their new Ghost home in no time.

Step 1: Export Your Substack Subscribers

This part is straightforward. Log into your Substack account, navigate to your publication’s Settings page, and scroll down to the “Danger Zone”. Click on Export your data and download the subscribers CSV file. Save it to your project directory and rename it to `substack_export.csv` for simplicity.

Step 2: Get Your Ghost Admin API Credentials

Now, we need to tell Ghost that our script is allowed to add new members.

1. Log into your Ghost Admin dashboard.
2. Go to Settings > Integrations.
3. Click Add custom integration.
4. Give it a memorable name, like “Substack Migration Script”.
5. Ghost will generate two crucial pieces of information: the Admin API Key and the API URL.
6. Copy both of these. We’re going to use them in our configuration file. Treat the API Key like a password—don’t share it or commit it to version control.

Step 3: Setting Up the Python Script

This is where the magic happens. In your project folder, create two files: `config.env` to store our secrets and `migrate.py` for the script itself.

First, your `config.env` file. This keeps our credentials out of the main script. Populate it with the details you just copied from Ghost.

# config.env
GHOST_ADMIN_API_URL="https://your-ghost-blog.com"
GHOST_ADMIN_API_KEY="your_admin_api_key_goes_here"
SUBSTACK_CSV_PATH="substack_export.csv"

Now for the main event: the `migrate.py` script. I’ve added comments to explain what each part does. The core logic is simple: read each row from the CSV, format the data for Ghost’s API, and send it off.

import os
import csv
import time
import requests
from dotenv import load_dotenv

# Load environment variables from config.env
load_dotenv('config.env')

# --- Configuration ---
GHOST_API_URL = os.getenv('GHOST_ADMIN_API_URL')
GHOST_API_KEY = os.getenv('GHOST_ADMIN_API_KEY')
CSV_FILE_PATH = os.getenv('SUBSTACK_CSV_PATH')

def create_ghost_member(email, sub_type):
    """
    Creates a new member in Ghost via the Admin API.
    """
    if not GHOST_API_URL or not GHOST_API_KEY:
        print("Error: Ghost API URL or Key is not configured. Check your config.env file.")
        return

    # The members endpoint is at /ghost/api/admin/members/
    url = f"{GHOST_API_URL}/ghost/api/admin/members/"

    # We need to generate a JWT token from the Admin API Key
    # The key is split into id:secret
    try:
        key_id, key_secret = GHOST_API_KEY.split(':')
    except ValueError:
        print("Error: Invalid Ghost Admin API Key format. It should be 'id:secret'.")
        return

    # Ghost Admin API uses JWT for authentication
    # We create it on the fly here
    import jwt
    from datetime import datetime, timedelta
    
    header = {'alg': 'HS256', 'typ': 'JWT', 'kid': key_id}
    payload = {
        'iat': int(datetime.now().timestamp()),
        'exp': int((datetime.now() + timedelta(minutes=5)).timestamp()),
        'aud': '/admin/'
    }
    token = jwt.encode(payload, bytes.fromhex(key_secret), algorithm='HS256', headers=header)
    
    headers = {'Authorization': f'Ghost {token}'}

    # Prepare the data payload for the new member
    # We add a label to distinguish migrated users
    labels = ["migrated-from-substack"]
    if sub_type == 'paying':
        labels.append("substack-paid") # Optional: tag paid members

    member_data = {
        "members": [
            {
                "email": email,
                "labels": labels
            }
        ]
    }

    try:
        response = requests.post(url, json=member_data, headers=headers)
        response.raise_for_status() # This will raise an HTTPError for bad responses (4xx or 5xx)
        
        print(f"Successfully created member: {email}")
        return True
    except requests.exceptions.HTTPError as err:
        # Ghost API returns detailed error messages in the JSON response
        error_details = err.response.json().get('errors', [{}])[0]
        error_message = error_details.get('message', 'No message available')
        error_context = error_details.get('context', 'No context available')
        
        print(f"Error creating member {email}: {error_message} - {error_context}")
        return False
    except requests.exceptions.RequestException as e:
        print(f"A network error occurred: {e}")
        return False

def main():
    """
    Main function to read the CSV and process subscribers.
    """
    try:
        with open(CSV_FILE_PATH, mode='r', encoding='utf-8') as csvfile:
            reader = csv.DictReader(csvfile)
            for row in reader:
                email = row.get('email')
                subscription_type = row.get('subscription_type') # 'free' or 'paying'

                if not email:
                    print("Skipping row with no email address.")
                    continue

                print(f"Processing: {email} ({subscription_type})")
                create_ghost_member(email, subscription_type)
                
                # Pro Tip: Add a small delay to avoid hitting API rate limits
                time.sleep(0.5) # 0.5 seconds delay between requests

    except FileNotFoundError:
        print(f"Error: The file {CSV_FILE_PATH} was not found.")
    except Exception as e:
        print(f"An unexpected error occurred: {e}")

if __name__ == "__main__":
    print("Starting Substack to Ghost migration...")
    # Note: This script uses the `PyJWT` library for token generation.
    # Make sure you have it installed alongside `requests` and `python-dotenv`.
    main()
    print("Migration process finished.")

Pro Tip: Notice the `time.sleep(0.5)` call? This is critical. If you have thousands of subscribers and you hit the Ghost API with all of them at once, you’ll get rate-limited. This small pause between each request keeps things running smoothly. In my production setups, I often make this delay configurable.

Step 4: Run the Migration

With everything in place, open your terminal in the project directory and run the script.

`python3 migrate.py`

You should see output in your terminal as it processes each subscriber from the CSV file. Let it run, and once it’s finished, check your Ghost Admin dashboard under “Members” to see your newly imported subscribers.

Common Pitfalls (Where I Usually Mess Up)

Even with a good script, things can go sideways. Here are a few things to watch out for:

  • Incorrect API URL: Make sure your `GHOST_ADMIN_API_URL` in `config.env` is the root URL of your blog (e.g., `https://my-blog.com`) and not the admin panel URL. The script appends the correct API path.
  • CSV Formatting: The script assumes your CSV has columns named `email` and `subscription_type`. Substack exports are pretty standard, but if you’ve modified the file, double-check those column headers.
  • Duplicate Members: The script will print an error if you try to add an email that already exists in Ghost. This is expected behavior and not a bug. It’s just Ghost telling you, “I’ve already got this one.” The script will simply move on to the next subscriber.
  • Missing Python Libraries: Remember to install `requests`, `python-dotenv`, and `PyJWT` before running the script. A `ModuleNotFoundError` is a clear sign one of them is missing from your environment.

Conclusion

And that’s it. You’ve successfully moved your audience from Substack to Ghost, giving you full ownership and a much more powerful platform to build on. This script is a solid starting point; you can easily extend it to add more custom labels or handle other fields from the Substack export. The key takeaway is that with a little bit of scripting, you can automate these tedious tasks and take full control of your technical stack. Happy publishing!

– Darian Vance

Darian Vance - Lead Cloud Architect

Darian Vance

Lead Cloud Architect & DevOps Strategist

With over 12 years in system architecture and automation, Darian specializes in simplifying complex cloud infrastructures. An advocate for open-source solutions, he founded TechResolve to provide engineers with actionable, battle-tested troubleshooting guides and robust software alternatives.


🤖 Frequently Asked Questions

âť“ How do I migrate my Substack subscribers to Ghost?

Export your Substack subscribers as a CSV, obtain your Ghost Admin API Key and URL from Ghost settings, then use a Python script to read the CSV and import members into Ghost via its Admin API, authenticating with a JWT token generated from your API key.

âť“ How does this compare to alternatives?

This script-based approach offers significant advantages over manual import by automating the process, reducing human error, efficiently handling bulk migrations, and allowing for custom tagging (e.g., ‘migrated-from-substack’, ‘substack-paid’), providing greater control and scalability.

âť“ Common implementation pitfall?

Common pitfalls include incorrect `GHOST_ADMIN_API_URL` format, mismatched CSV column headers (expecting ’email’ and ‘subscription_type’), hitting API rate limits (mitigated by `time.sleep`), and missing Python libraries (`requests`, `python-dotenv`, `PyJWT`). Duplicate member errors are expected and indicate the member already exists in Ghost.

Leave a Reply

Discover more from TechResolve - SaaS Troubleshooting & Software Alternatives

Subscribe now to keep reading and get access to the full archive.

Continue reading