appscript.dev
Automation Advanced Sheets

Build a two-way Slack-to-Sheet logger

Capture Northwind Slack messages into a sheet and post sheet updates back to Slack.

Published Jul 15, 2025

Northwind runs its day in Slack, but Slack history is hard to report on — search is fuzzy, and there is no way to chart what happened in #support last month. Meanwhile the spreadsheets that track that work never make it back into Slack, so the team has to go and look.

This automation closes the loop in both directions. One half is a web app endpoint that catches Slack messages and appends each to a logging sheet, so the conversation becomes rows you can filter, pivot, and chart. The other half is a postToSlack helper that pushes a line from a sheet back into a channel. Wire both up and the sheet and the channel stay in sync.

What you’ll need

  • A Google Sheet to log messages into, and its ID.
  • A Slack app in your workspace with:
    • Event Subscriptions enabled, so Slack can POST messages to your web app.
    • An Incoming Webhook for the channel you want to post back into.
  • The webhook URL saved as SLACK_WEBHOOK in Script Properties — see Store API keys and secrets securely.
  • This script deployed as a web app (Deploy → New deployment), with access set to Anyone, so Slack can reach the endpoint.

The script

// The spreadsheet that collects logged Slack messages.
const SLACK_LOG_SHEET_ID = '1abcSlackLogId';

/**
 * Web app endpoint. Slack POSTs an event here for each message; this
 * appends one row per message to the logging sheet.
 *
 * Slack also sends a one-time "url_verification" challenge when you
 * first register the endpoint — we echo it back so Slack accepts the URL.
 *
 * @param {Object} e  The web app POST event.
 * @return {TextOutput} A response Slack accepts.
 */
function doPost(e) {
  // Guard against an empty or malformed request.
  if (!e || !e.postData || !e.postData.contents) {
    return ContentService.createTextOutput('No data');
  }

  const payload = JSON.parse(e.postData.contents);

  // 1. Answer Slack's initial URL-verification handshake.
  if (payload.type === 'url_verification') {
    return ContentService.createTextOutput(payload.challenge);
  }

  // 2. The actual message sits under payload.event for Events API calls.
  const event = payload.event || payload;

  // 3. Ignore anything that is not a user message with text — this
  //    skips bot echoes, edits, and system events.
  if (event.type !== 'message' || event.bot_id || !event.text) {
    return ContentService.createTextOutput('Ignored');
  }

  // 4. Append the message as a row: when, who, where, what.
  SpreadsheetApp.openById(SLACK_LOG_SHEET_ID)
    .getSheets()[0]
    .appendRow([
      new Date(),
      event.user || event.user_name || '',
      event.channel || event.channel_name || '',
      event.text,
    ]);

  return ContentService.createTextOutput('OK');
}

/**
 * Posts a line of text back into Slack via an Incoming Webhook.
 *
 * @param {string} text  The message to post.
 */
function postToSlack(text) {
  const hook = PropertiesService.getScriptProperties().getProperty('SLACK_WEBHOOK');
  if (!hook) {
    throw new Error('SLACK_WEBHOOK is not set in Script Properties.');
  }

  UrlFetchApp.fetch(hook, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text: text }),
    muteHttpExceptions: true,
  });
}

How it works

  1. doPost is the web app endpoint Slack calls. It first guards against an empty request, then parses the JSON body Slack sends.
  2. The first time you register the endpoint, Slack sends a url_verification event with a challenge string. The script echoes that string straight back, which is how Slack confirms the URL is yours.
  3. For real events, the message lives under payload.event. The script reads that, falling back to the top-level payload for older slash-command-style posts.
  4. It then filters: only a message event, with no bot_id, and with text, gets logged. That skips the bot’s own posts and system noise — without this, postToSlack could trigger a logging loop.
  5. A surviving message is appended as one row — timestamp, user, channel, text — so the channel becomes a tidy, filterable table.
  6. postToSlack is the reverse direction: it reads the Incoming Webhook URL from Script Properties and POSTs a small JSON { text } payload, which Slack renders as a message in the webhook’s channel.

Example run

A teammate posts in #support:

Customer #4012 is asking about the export bug.

Slack POSTs that event to the web app, and doPost appends a row to the log sheet:

TimestampUserChannelText
2026-05-25 09:14:02U03ABCDC01SUPPORTCustomer #4012 is asking about the export bug.

Going the other way, a sheet that tracks resolved tickets calls:

postToSlack(':white_check_mark: Ticket #4012 closed — export bug fixed.');

and #support shows:

:white_check_mark: Ticket #4012 closed — export bug fixed.

Trigger it

The two halves are triggered differently:

  • Slack → Sheet runs whenever Slack sends an event. After deploying the web app, copy its URL into your Slack app’s Event Subscriptions → Request URL. Slack sends the verification challenge; once it goes green, subscribe to the message.channels bot event. No Apps Script trigger is needed — Slack calls the endpoint directly.
  • Sheet → Slack runs whenever you call postToSlack. Call it from an onEdit trigger, a time-driven trigger, or another script — wherever a sheet change should be announced.

Watch out for

  • Mind the logging loop. If postToSlack writes into the same channel the web app listens on, every post you make gets logged too. The bot_id filter in doPost stops a bot post from looping, but a message your webhook posts as a plain user could still come back — keep the listened and posted-to channels separate, or filter harder.
  • Slack expects a fast reply. doPost must respond within three seconds or Slack retries the event, which can create duplicate rows. Keep the handler light — append and return, nothing slow.
  • Re-deploying changes the web app URL unless you update the existing deployment. A new URL means updating the Request URL in Slack again.
  • Slack sends user and channel IDs (U03ABCD, C01SUPPORT), not names, in Events API payloads. If you want readable names you must look them up via the Slack Web API and cache the result.
  • The webhook posts to one fixed channel — the one it was created for. The channel field in a webhook payload is ignored by modern Slack apps; make a separate webhook per channel.
  • Web app access must be Anyone for Slack to reach it. The endpoint is public, so do not log anything sensitive without verifying Slack’s request signature first.

Related