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_WEBHOOKin 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
doPostis the web app endpoint Slack calls. It first guards against an empty request, then parses the JSON body Slack sends.- The first time you register the endpoint, Slack sends a
url_verificationevent with achallengestring. The script echoes that string straight back, which is how Slack confirms the URL is yours. - 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. - It then filters: only a
messageevent, with nobot_id, and withtext, gets logged. That skips the bot’s own posts and system noise — without this,postToSlackcould trigger a logging loop. - A surviving message is appended as one row — timestamp, user, channel, text — so the channel becomes a tidy, filterable table.
postToSlackis 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:
| Timestamp | User | Channel | Text |
|---|---|---|---|
| 2026-05-25 09:14:02 | U03ABCD | C01SUPPORT | Customer #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.channelsbot event. No Apps Script trigger is needed — Slack calls the endpoint directly. - Sheet → Slack runs whenever you call
postToSlack. Call it from anonEdittrigger, a time-driven trigger, or another script — wherever a sheet change should be announced.
Watch out for
- Mind the logging loop. If
postToSlackwrites into the same channel the web app listens on, every post you make gets logged too. Thebot_idfilter indoPoststops 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.
doPostmust 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
channelfield 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
Build a two-factor SMS verification step
Add phone verification to a Northwind workflow — code via Twilio, validated by web app.
Updated Dec 18, 2025
Bridge Sheets to Zapier or Make
Trigger external automations from Northwind Sheets via webhooks — no Apps Script logic needed downstream.
Updated Nov 8, 2025
Send rich notifications to Discord
Push Northwind deploy alerts and KPI updates to a Discord channel — embeds, not plain text.
Updated Oct 15, 2025
Build a payment-webhook receiver
Catch Stripe payment events into a Northwind sheet — paid invoices flip status instantly.
Updated Oct 11, 2025
Build a WhatsApp notification sender
Push Northwind updates via the WhatsApp Business API — for client billing milestones.
Updated Jul 23, 2025