Google AppScript, Google Sheet and WebHooks | Toucan Toco

Categories :

Table of Contents

In this article, I’ll tell you about a very simple way to use Google Sheet+Google AppScript to collect data from a webhook. You can also dive into the code of this spreadsheet that I made just for you :*

Some context - Our support workflow

Recently I wanted to use Google Spreadsheet to log support ticket handling and resolution time.

We use Talkus, combined with Slack in our workflow:

  1. Someone from the Toucan team needs technical help
  2. They open a ticket with the command /talkus_new_ticket help pls and a temporary channel is created (usually #z-silly-name)
  3. Our King or Queen of the week (=weekly anointed tech support team, see this talk done @Algolia’s tech lunch), can respond and handle the issue.
  4. When the problem is solved, the temporary channel is closed

** Warning - already made tools are available **

It is worth noting that Talkus offers a reporting feature, but because all our Talkus users are from the same Toucan slack team, the handling times were wrong (the person asking for help was counted as the person responding).

Also, Zapier has a recipe just for webhook and Google Sheets (but where’s the fun in that :p)

Let’s do this

Ze plan

I figured I would need :

  • a Google Web App that can receive POST requests with some data
  • to write this data into a spreadsheet
  • to configure Talkus so that for every new ticket/event, a POST is sent to my Google Web App
  • Connect this spreadsheet to a Toucan Toco dataviz application (for this step, pls contact one of our sales rep ;) )

Fairly simple :) First, I need to check how each piece works, then I’ll put the bricks together.

Adding a row in Google Sheet

From the docs :

  1. Create a Google Spreadsheet
  2. From the spreadsheet, Open the Scripts Editor : Tools > Script Editor...
  3. ???
  4. Profit!

In the script editor, I added a very simple function, to see if everything works as expected.

function testRun() {
  var sheet = SpreadsheetApp.getActiveSheet();
  sheet.appendRow(['some', 'fake', 'data']);
  1. Save (cmd+S), and fill in your project name when prompted
  2. In the script editor menus, go to Run > Run function... > testRun

Google Script will ask you to authorize the script to write on the spreadsheet, do so.

Tadaa… it worked, a row was added!

That was easy! Now let’s turn this script into a Web App so it can handle requests.

Handle POST requests

From the docs, we see Google Web Apps have the ability to answer to POST requests. RTFM :

Add a doPost method

A Web app can handle GET and POST requests using a doPost and a doGet method: Let’s try this with very simple methods:

function doPost(e) {
  var sheet = SpreadsheetApp.getActiveSheet();
  sheet.appendRow(['post test']);
function doGet(e) {
  var sheet = SpreadsheetApp.getActiveSheet();
  sheet.appendRow(['get test']);

Publish your Web App

In the script editor, go to Publish > Deploy as Web App

I chose :

  • to execute the app as “Me”
  • Who has access to the app “Anyone, even anonymous”

Deploy web app form screenshot

Go to Troubleshooting below if you don’t see the “Anyone, even anonymous” option.

After clicking the deploy button, I was provided with a script url :

If you access it from your browser, your doGet function should be executed (try it!).

Going POSTal

Using the POST office was actually harder than I thought

Though here it seems quite simple, making this POST work was actually what took me the longest. In the Google Documentation, I kept reading about options unavailable to me. First I brushed it off as lost in translation (the doc was in English, my interface was in French). Turns out, I was using an enterprise Google App account, and was logged in to multiple accounts at the same time each causing their own set of problems :). It took me some time to figure out, so I’ve added a Troubleshooting section.

I want to see how my app reacts to POST. So I do a simple CURL post :

curl -X POST

Let’s add data:

curl -d '{"test":"supertest", "test2":"supertest2"}' -H "Content-Type: application/json" -X POST

To test this I modified my code:

function doPost(e) {
  var sheet = SpreadsheetApp.getActiveSheet();

Then I re-published my app:

Deploy new version form screenshot

Be careful here to select the ‘New’ version option in the dropdown.

Note that using the /dev URL (found if you follow the latest code link) doesn’t seem to work for POSTs, so for every change you make to your post, you need to publish a new version.

Tadaa! The result is visible in the spreadsheet !

Screenshot of the rows added to the spreadsheet #proof #itworks


  • You do not see the “Anyone, even anonymous” option

At this point when I was exploring, I got stalled because the permissions of my script and spreadsheet where restricted. This is due to the fact I was doing all this from an enterprise GSuite account. You need to be able to publish documents publicly to anyone in the web for this to work. If you are a Google Admin for the domain, you can enable this. I personally chose to create the spreadsheet from my own consumer account, because I didn’t want to lower the security settings of all our Google Apps.

  • Page not found when accessing the url // doGet not working

When trying to access the script from the browser, I was greeted with a page not found error… It turns out since I was logged with multiple google accounts, the URL given was faulty : instead of (cf. issue on StackOverflow)

To fix this, you need to take out the /u/x part of the URL to test the script, like this :

  • Page not found when accessing a resource

This generally means the permissions aren’t set right for you to access the said resource.

Let’s collect data

With a Talkus WebHook

Here my goal is to use the Google spreadsheet as a database for talkus ticket data.

From the talkus app, in the Integration menu, I just copy-pasted my script url, and pressed the “Send a test message to your webhook” button.

Now I see that the postData content form is this :

Content from talkus webhook test ``` [ { "event": "start", "createdAt": "2017-02-16T16:21:12.895Z", "channelName": "z-denholm-reynholm", "visitorName": "Denholm Reynholm", "visitorId": "AP3qSeAG7d5uTAacH", "identity": { "userAgent": "Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit\/527.36 (KHTML, like Gecko) Chrome\/55.0.2883.95 Safari\/537.36", "location": "http:\/\/\/", "id": "U0AFE3Z12", "name": "den", "visitorId": "AP3qSeAG7d5uTAacH", "ip": "", "email": "", "languages": "en", "title": "Talkus Admin" }, "appId": "oKJJXAsT5PprBAQ7W", "messages": [ { "userName": "Denholm Reynholm", "text": "Where are the nice girl of the 5th floor?", "userPicture": "\/\/\/theitcrowd\/images\/0\/0b\/Denholm_declares_war.webp\/revision\/latest?cb=20100425163116", "createdAt": "2017-01-18T15:42:11.000Z" } ], "message": "Denholm Reynholm: Where are the nice girl of the 5th floor?" }, (...) { "event": "end", "createdAt": "2017-02-16T16:49:12.995Z", "channelName": "z-denholm-reynholm", "visitorName": "Denholm Reynholm", "visitorId": "AP3qSeAG7d5uTAacH", "identity": { "userAgent": "Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_12_1) AppleWebKit\/527.36 (KHTML, like Gecko) Chrome\/55.0.2883.95 Safari\/537.36", "location": "http:\/\/\/", "id": "U0AFE3Z12", "name": "den", "visitorId": "AP3qSeAG7d5uTAacH", "ip": "", "email": "", "languages": "en", "title": "Talkus Admin" }, "appId": "oKJJXAsT5PprBAQ7W", "messages": [ { "userName": "Denholm Reynholm", "text": "Where are the nice girl of the 5th floor?", "userPicture": "\/\/\/theitcrowd\/images\/0\/0b\/Denholm_declares_war.webp\/revision\/latest?cb=20100425163116", "createdAt": "2017-01-18T15:42:11.000Z" }, { "userName": "Roy", "text": "At the canteen, at the 4th floor.", "userPicture": "\/\/\/theitcrowd\/images\/9\/9e\/Roy2.webp\/revision\/latest\/scale-to-width-down\/250?cb=20090403154035", "createdAt": "2017-01-18T15:53:23.000Z" } ], "message": "Denholm Reynholm: Where are the nice girl of the 5th floor?\nRoy: At the canteen, at the 4th floor." } ] ```

The actual code

I did some tests by creating tickets in slack and figured I would only need to log end events, because they contained all relevant info. I would need :

  • the end event date (createdAt field)
  • to parse the messages and get
    • The first userName to talk in the channel + the message time > that’s the user asking for support
    • The second userName to talk in the channel + the message time > that’s the user responding

I also eliminated from my search the users with :

  • userName == 'slackbot' > Usually automated response to expressions.
  • userPicture == '' > The Talkus Bot
  • userName == 'Toucan Tech support' > Usually automated response from Talkus
  function doPost(e) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Data");
  var toLog = handleTalkusEvent(e.postData.contents);
  if (!toLog) {
  // Improve this by getting first row column names
  sheet.appendRow([toLog.endTime, toLog.channelName, toLog.userAsking, toLog.userAskingJoinTime, toLog.firstResponder, toLog.firstResponseTime, toLog.messages]);
  // Get only the useful/actionable events and info
  function handleTalkusEvent(eventString) {
  var e = JSON.parse(eventString);
  if (!e) {
  if (e.length <= 0) {
  if (e[0].event != 'end') {
  var usefulInfo = searchMessages(e[0].messages);
  usefulInfo.channelName = e[0].channelName;
  usefulInfo.messages = JSON.stringify(e[0].messages); // logging messages for debugging
  usefulInfo.endTime = e[0].createdAt;
  return usefulInfo;
  function ignoreUser(msg) {
  return (msg.userPicture == '' || msg.userName == 'slackbot' || msg.userName == 'Toucan Tech support');
  function searchMessages(msgs) {
  // separate the messages to get
  // time of first person to get in
  // time of first responder
  var userAsking, userAskingJoinTime, firstResponder, firstResponseTime;
  for (var i=0; i < msgs.length ; i++) {
  var msg = msgs[i];
  if (!userAsking && !ignoreUser(msg)) {
  userAsking = msg.userName;
  userAskingJoinTime = msg.createdAt;
  if (!!userAsking && !firstResponder && msg.userName != userAsking && !ignoreUser(msg)) {
  firstResponder = msg.userName;
  firstResponseTime = msg.createdAt;
  return {
  userAsking: userAsking,
  userAskingJoinTime: userAskingJoinTime,
  firstResponder: firstResponder,
  firstResponseTime: firstResponseTime
view raw hosted with ❤ by GitHub

Visualize your data

For this part I used our own Toucan application to easily connect to the spreadsheet and visualize the handling time. You can contact our sales rep team for more info, or use some of the free tools available online.

With the data as is, I added a little python / pandas script to compute time deltas, it’s very easy :) Here are the useful lines to convert time<>strings

import pandas as pd

# Dates from Talkus are of the form 2018-02-12T10:55:54.369Z df['userAskingJoinTime'] = pd.to_datetime(df['userAskingJoinTime'], format="%Y-%m-%dT%H:%M:%S", exact=False, errors='coerce')
df['firstResponseTime'] = pd.to_datetime(df['firstResponseTime'], format="%Y-%m-%dT%H:%M:%S", exact=False, errors='coerce')

# Compute difference and convert to seconds df['handlingTime'] = df['firstResponseTime'] - df['userAskingJoinTime']
df['handlingTime'] = df['handlingTime'].astype('timedelta64[s]')

Now for some ridiculous viz : Average handling times / day, in minutes

Why ridiculous? Because the script has been running for a week so there aren’t a lot of data points, hence 1 extra long ticket will break the score. Also tickets created out of business hours have longer handling times, maybe the next iteration of the script could account for that. I’ll do a followup once we have more data :)

What I found here already is that sometimes our tech support team joins a channel but does not write to indicate they are working on the problem: this greatly increases measured handling times. This is coherent with our qualitative analysis of Toucan support workflow which hinted at the need for early communication on an issue, even a simple acknowledgment of the problem. So it sounds like keeping an eye on this metric should lead to interesting results!

Hope this will help, have fun collecting data ;)

This is a heading 2

This is a paragraph

This is a heading 2

This is a paragraph

Table of Contents