Matt White

Matt White


Matt White


How to automate Ghost/Medium cross-posting via Zapier

UPDATE 2020-05-22

The Medium API hasn’t worked for me for a long time, now. I’ve tried generating new integration keys and so on, but the endpoints used in this post now return a 401 code and claim that the user doesn’t exist. This issue appears to be widespread, e.g.

I’ve contacted Medium support to get clarity, but they would only give me form responses on how to generate integration tokens (which continue to be rejected). If the API is defunct, support appears to be unaware or unwilling to confirm it. Yet they were thoughtful enough to put my account “under investigation” for asking about it.

For the time being, manually submitting articles may be the only way to go.

I was already on the fence with Medium’s constant prompts to login, their silly email-based authentication, and the ever-decreasing amount of content outside the paywall. I don’t think the Medium platform is worth it, at this point.

Leaving this up in case the API problems are more limited than they appear.

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:

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"", 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 =
        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 =

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.

A little less terrible every day.