How to automate Ghost/Medium cross-posting via Zapier


It’s come to my attention that Medium’s made a choice to remove integration token access from settings by default and hide it behind official support requests. I’m rarely a fan of hurdles like this, but found it pretty easy to regain access through support with a brief statement on my use case.

With this blog, I’m eager to automate a lot of the tedium that comes with cross-posting to other platforms. Medium was first on the docket due to its popularity - and I quickly came across this fantastic article by Christoph Michel on how he automates the process.

At the time of writing, this blog uses Ghost for publishing content, so I had to do things a little differently than Christoph. I also wanted to sidestep the need to host the automation code, myself, so using Ghost’s built-in Zapier integration made the most sense.

Automating Ghost can be frightening

(NOTE: you can use the repo ghost-crosspost-medium without Ghost or Zapier, but it was originally written with these in mind)

Before we get started on anything, we’ll need an integration token from Medium. Make sure you’re logged into Medium and go to the “Integration tokens” section of your settings.


Get that integration token - you’ll need it as a parameter as we head over to Zapier. Sign up or login to Zapier and “Make a Zap!”


Select Ghost as your trigger app; you can search for it if it’s not right there. If you haven’t connected Ghost before, Zapier will walk you through it.


After Ghost is connected and selected, select “New Story” as the Ghost trigger.


When asked to select the status for the trigger, pick “Published” unless you have reasons to go with something else.


Next stage is selecting the action app. We’re going with “Code” and “Run Python” (which is, unfortunately, Python 2.7).



Configuring the template is where the real setup happens. The required fields for my script are “integrationToken”, “content”, “title”, and “canonicalUrl”, but including “tags” is also adviseable. “integrationToken” is the token you set up in Medium earlier; it’ll be the same for all requests.

Each of the other fields correspond to values provided by Ghost. This will be easiest if you already have at least 1 post up (even if you post it just for this exercise) since Zapier will show you an example of the input.


Input Name Ghost Name
title Title
canonicalUrl URL
content HTML Formatted Content
tags Tags Slug

The order you add the items in doesn’t matter - and if you’re a more advanced user, any additional fields you specify will also be passed on to the Medium call (see the Medium post API docs for more info).


Once the inputs are set up, it’s time for the code, itself. I’ve set up a repository for this project on my GitHub under ghost-crosspost-medium, but to get this working on Zapier you just have to copy and paste this file:

# This import is redundant on Zapier
import requests

class MediumCrosspost(object):
    required_fields = [u"title", u"canonicalUrl", u"integrationToken", u"content"]
    query_blacklist = [u"integrationToken"]
    def __init__(self, input_data):
        self._input_data = None
        self._headers = None
        self._user_id = None
        self.input_data = input_data
    def input_data(self):
        return self._input_data
    def input_data(self, input_data):
        self._input_data = input_data.copy()
        tags = self._input_data.pop(u"tags", None)
        if tags:
            # For some reason, we may get a list containing
            # a single concatenated string
            tags = tags[0] if len(tags) == 1 else tags
            if isinstance(tags, basestring):
                self._input_data[u"tags"] = tags.split(u",")
            elif not isinstance(tags, list):
                self._input_data[u"tags"] = [tags]
    def check_fields(self):
        for field in self.required_fields:
            if not self.input_data.get(field):
                raise Exception(u"field {} required as input data".format(field))
    def headers(self):
        if not self._headers:
            self._headers = {
                u"Authorization": " ".join(
                    [u"Bearer", self.input_data.get(u"integrationToken")]
        return self._headers
    def user_id(self):
        if not self._user_id:
            # pylint: disable=undefined-variable
            response = requests.get(
                u"https://api.medium.com/v1/me", headers=self.headers
            response.raise_for_status()  # in case the call fails
            self._user_id = response.json()[u"data"][u"id"]
        return self._user_id
    def query_data(self):
        # base values that are needed, but standard
        data = {u"contentFormat": u"html", u"publishStatus": u"draft"}
        # apply all input data
        # override base values, if specified
        for key, val in self.input_data.items():
            if key not in self.query_blacklist:
                data[key] = val
        return data
    def post(self):
        response = requests.post(
        return response.json()
        # pylint: disable=invalid-name,undefined-variable
# Zapier has its own way to populate input_data
if "input_data" in locals():
    crosspost = MediumCrosspost(input_data)
    output = crosspost.post()

Take the code above (or follow the link to the repo file, as that may be more up-to-date) and paste it into the “Code” section at the bottom of the Zapier template page.

By this point, you should be good to wrap it up. Scroll to the bottom and continue. You’ll then have the opportunity to test the connection.

If everything checks out, click “Finish” and turn on your Zap!

If you’re having problems, feel free to open an issue on that GitHub repo. The error messages returned from the Medium API are incredibly vague, so debugging it can be a major pain.

In the future, I’d like to add automatic relative link resolution like Christoph includes in his tutorial. For Ghost, this doesn’t appear to be necessary; relative links get resolved by the time they hit Zapier.

Join me in becoming a little less terrible every day.