I recently settled on using Ghost.org to run this blog. I chose Ghost for their built in members feature to grow the newsletter for this blog. On closer inspection, I found their email marketing features lacking. No worries though, Ghost has tons of integrations with popular services. They should have an integration for Sendgrid.

Turns out Ghost does not integrate with Sendgrid. The integrations page promotes an integration with Mailchimp, but requires Zapier. I'm annoyed that I would need to give yet another company my information in order to transfer my Ghost members to Sendgrid.

I sat annoyed for a moment. There has to be a better way to integrate Ghost with Sendgrid that does not involve another company. When I looked at the problem a different way, Zapier catches event information from Service A and forwards the data to Service B. All they do is act as a middleman for event data.

How much different could Zapier be from using a NodeJS app that forwards event data from Ghost to Sendgrid? Well I'm glad to report its not different. After 3 hours of coding, I managed to create a self-hosted Zapier service that forwards my Ghost members to Sendgrid! Let's dive into the code.

Building the Zapier clone

For this app I needed a Google Cloud account with GCR, GCS, and Cloud Run enabled, the gcloud-cli tool, Docker, and NPM. The following code snippets have been shortened to highlight the most important areas. The complete source code can be found on my Github.

Once I created a new express application, I added a route to receive webhook events from Ghost. I parsed req.body for the target information, in this case I wanted the member's email. With the email, I called addContactToSendgridList to add a new contact to my Sendgrid list.

var express = require('express');
var router = express.Router();
const logger = require('../logger')

// ...

router.post('/members/new', async function(req, res, next) {
  logger.info(req.body)

  const { 
    email
  } = req.body.member.current

  try {
    await addContactToSendgridList(email)

  } catch (e) {
    logger.error(e)

    return res.status(500).json({
      message: "bad"
    });
  }

  return res.status(200).json({
    message: "ok"
  });
});

module.exports = router;

Here is the code to add a contact to a Sendgrid list. The SENDGRID_TOKEN is the api key created in the Sendgrid settings. SENDGRID_LIST is list id.

const axios = require('axios')
const logger = require('../logger')

const addContactToSendgridList = async (email) => {
  const SENDGRID_ENDPOINT = "https://api.sendgrid.com/v3/marketing/contacts"
  const SENDGRID_TOKEN = process.env.SENDGRID_TOKEN
  const SENDGRID_LIST = process.env.SENDGRID_LIST

  const token = SENDGRID_TOKEN
  const options = { 
    headers: { 
      Authorization: `Bearer ${token}`,
      'content-type': 'application/json'
    }
  }

  var data = JSON.stringify({
    "list_ids": [
      SENDGRID_LIST,
    ],
    "contacts": [{
      "email": email,
    }]
  })

  let response = undefined
  
  try {
    response = await axios.put(SENDGRID_ENDPOINT, data, options)

    logger.info(response)
  } catch (e) {
    throw e
  } 
}

With my code in place, I built a docker image, pushed the image to GCR, and then deployed a Cloud Run container to using the newly created image. I passed the required token and list id via environment variables.

#!/bin/bash

set -e

DOCKER_IMAGE=us.gcr.io/MY_PROJECT/ghost-webhook-catcher:0.1

docker build -t $DOCKER_IMAGE .
docker push $DOCKER_IMAGE

gcloud run deploy ghost-webhook-catcher \
  --platform=managed \
  --region=us-central1 \
  --image=$DOCKER_IMAGE \
  --port=3000 \
  --memory=2Gi \
  --cpu=2 \
  --max-instances=50 \
  --concurrency=80 \
  --timeout=900 \
  --allow-unauthenticated \
  --set-env-vars="NODE_ENV=production" \
  --set-env-vars="SENDGRID_TOKEN=token" \
  --set-env-vars="SENDGRID_LIST=demo-23242"

Once deployed, I created a custom integration in the Ghost panel, added the endpoint to my Zapier replacement i.e. https://catcher.2er-avsd23.a.run.app/sendgrid/members/new. I added myself as a moment, and cried tears of joy as my code worked on the first try!

Is my self-hosted Zapier worth it?

My Zapier clone is customizable. Cloud Run is great as a platform because I get logs, metrics, and an uptime of three 9's (99.95%) based on their SLA. A 99.9% uptime is possible with Zapier but I would need to be on their business plan.

For hosting costs, I'll be within their free tier for a long time based on Cloud Run pricing. These costs beat every Zapier price tier. Overall, I am beyond happy with this solution, and found it to be worth my time. I hope you got found value in this blog post.


Hi! My name is Steven Natera. I'm a Kubernetes specialist. I teach Kubernetes to professionals of all levels, and help startups build their business on GKE, and EKS. If you want to chat follow me on Twitter @stevennatera.