Uploading images to Ghost using Python 3 and the admin API.
2 min read

Uploading images to Ghost using Python 3 and the admin API.

I wrote something in Python recently to take images from my Instagram account and post them to a blog running Ghost CMS.

You'd be right to ask 'why bother', when perfectly good Zapier integrations purport to do this already but the answer is that those automations are not perfect.

Instagram changed their API to make this more difficult. The result is that now if you use the Zapier integrations to post images to Ghost CMS, after a wee while you'll find those images expired.

The solution I went for, which definitely breaks the terms of service, is to copy them off Instagram the moment they get posted and upload them to Ghost where they can also live forever independantly. No expiry.

Getting them from instagram was reasonably simple, by heading to facebook and generating a user-access-token for myself, then writing something that uses requests to fetch them.

Getting them uploaded to Ghost turned out to be another matter.

{'errors': [{'message': 'Please select an image.', 
    'context': None, 
    'type': 'ValidationError', 
    'details': None, 
    'property': None, 
    'help': None, 
    'code': None, 
    'id': 'f433410-d8d3-11eb-888d-0367eed631f9'}]
Dear Lord, please accept my offer of a jpeg

{'errors': [{'message': 'Please select a valid image.',
	'context': None,
	'type': 'UnsupportedMediaTypeError',
    'details': None,
    'property': None, 
    'help': None, 
    'code': None, 
    'id': '513cb280-d8d8-11eb-558d-8e8a896fff43'}]
Forgive me Lord, for my unsupported offering of a jpeg.

The file would upload but would then be rejected as invalid or unsupported.

The Ghost Admin API docs offer just a single CURL command as an example, which also failed with these message. The docs also states you need so send a purpose with your request so that Ghost can decide where to store it, but it turns out you don't need to do this.

Here are the relevant upload bits, that worked for me.  


def upload_image(local_file):
    token = generate_token()

    headers = {
        'Authorization': f"Ghost {token}",

    files = {
        'file': (local_file, open(local_file, 'rb'), 'image/jpeg')

    response = requests.post(f"https://{blog_url}/ghost/api/v3/admin/images/upload/", headers=headers, files=files)

def generate_token():
    ghost_id, ghost_secret = ghost_key.split(':')
    iat = int(date.now().timestamp())

    header = {'alg': 'HS256', 'typ': 'JWT', 'kid': ghost_id}

    payload = {
        'iat': iat,
        'exp': iat + 5 * 60,
        'aud': '/v3/admin/'

    token = jwt.encode(payload, bytes.fromhex(ghost_secret), algorithm='HS256', headers=header)

Ensure you use files in your request. Make sure the value of file is a tuple containing the file-name, the opened binary file and then the content-type. Don't try to specify a purpose field.

Easy when you know how eh.