Using Google Apps Script as a Webhook

A logo for webhooks. A triangle with nodes on each end, connected to one another.

Google Apps Script projects can be published as web apps when you’re done with them. This is helpful if you’re building a tool with a simple UI (using HTMLService), but they can also be used as webhooks to do work within a user account.

Google provides the option of service accounts, so why use webhooks?

For my project, it was because I needed to send calendar invites to other people. Service accounts are generally the way to handle those kinds of jobs, but it needed to have full account delegation, which means it can act on behalf of any user in the domain. That wasn't an option in this case, so a webhook became the next best option.

What is a webhook?

In simple terms, it’s an application that can do something when it receives a standard HTTP request. They generally work on a subscription model, where the webhook service listens for an action on a service. It sits in the middle, listening for an HTTP call and then emits another action in response.

For this example, I have our events management app running in Python on a local server. When there’s an action requiring an update to the calendar, it hits the Apps Script webhook and the script does some work before returning a response to the events app.

The Code

In Google Apps Script, doPost and doGet are functions which respond to POST and GET requests respectively. Because I’m calling the script from the server with a payload for the calendar event, I used doPost to listen for events.

The simplest hook you can set up is a function which listens for a POST request from somewhere and returns a response:

function doPost(e) {
    return ContentService.createTextOutput(JSON.stringify(
        {
            "message": "Yo."
        }
    )
)}

When you deploy the script as a web app, make sure it is set to “Anyone” can access but that the script runs as you. And that’s where the magic is.

With a traditional service account, it needs access as another user. There’s no way to limit that permission to a single user from the admin console, which is why I couldn’t take that approach with my project. In this case, the webhook is triggered by a request and then runs as the scoped user.

Using this method, we're able to achieve the same permissions but without giving it access to every other user in the domain.

A Quick Note on Deploying

Update 11/29/2021
Many thanks to Joseph in the comments for sharing how to update a deployment without generating a new URL. This section of the post remains becuase it is a good example of how not to manage deployments.

With the new Apps Script editor, deploys are a pain. You can't use the dev endpoint to test the hook (I don't know why), meaning there's no way to execute the HEAD version of the script in this instance. So, to test your changes, you need to deploy a new version and that gives a new URL for the endpoint. You'll have to update your API request in your other codebase...it's a mess.

If you can swing it, either build in the legeacy editor (still using the V8 runtime) or use clasp to manage your deploys. If I could change one thing about this whole project, this would be it.

Security

Now, how to secure something like this? There’s nothing foolproof, obviously, but there are a few advantages to using Apps Script in this way:

  1. The server handles all communication. I'm not taking direct requests from a frontend client, so there's more control over what is sent.
  2. Google’s deploy URLs are complex, reducing the risk of guessing the direct URL.
  3. Your script can have it’s own validation (ie, an API key) before processing requests.
  4. You can parse incoming requests for specific data structures, throwing errors when the received structure doesn’t match the expected.
  5. you could have a pseudo-CSP implemented by checking request headers for the correct domains and throw errors if they don’t match.

It all depends on how you’re expecting the webhook to be used with that third party.

When a request comes in, it includes an event parameter which holds information for the task. Even though every request is a POST, I listen for different methods in the post body to determine what happens next.

Here’s the same application as above with more detail added:

function doPost(e) {
    const accessKey = 'someBigLongString';
    let result;
    let params = JSON.parse(e.postData.contents)
    let method = params.method;
    let token = params.token;
    let userId = params.userId;
    // Add whatever other params you want

    if(token === accessKey) {
        switch method {
            case method === 'POST':
                result = {
                    // ...
                }
            case method === 'PUT':
                // etc...
        }
    } else {
        result = {
            'status': 'Forbidden',
            'statusCode': 403,
            'message': 'You do not have access to this resource.'
        }
    }
    return ContentService.createTextOutput(JSON.stringify(result))
}

In Practice

The general structure for any web hook is the same:

  • receive a request
  • process the method
  • process the payload
  • perform some task
  • return a response

You have the double benefit of hosting the script and user-scoped permissions for individual projects. In the future, it may be worth finding some kind of parsing library for handling incoming requests to cut down on boilerplate code for new projects. But if you’re looking for a way to interact with Google resources from the outside, this is one way that has worked well for me.