Sheets that track deployments, support tickets, or approvals become far more useful when a change in the sheet immediately shows up in the Slack channel where the team already works, instead of waiting for someone to remember to check the sheet.
This tutorial posts messages to a Slack channel using the chat.postMessage endpoint and a bot token, which is the supported way to send messages on behalf of an app rather than relying on an incoming webhook tied to one fixed channel.
A bot token starting with xoxb- is generated once when a Slack app is installed to a workspace, and it authorizes every call this script makes, so it belongs in Script Properties rather than pasted into the function body.
The example also shows how to trigger a Slack post from a sheet edit, turning postSlackMessage into a lightweight notification layer for whatever workflow already lives in the spreadsheet.
Need this built? Hire a Google Apps Script developer →
Sheet / project setup
| Config | Value | Purpose |
|---|---|---|
| SLACK_BOT_TOKEN | Script property xoxb-… | Bearer token |
| Channel | C0123456789 | chat.postMessage channel |
| Sheet | Slack Log | Optional delivery log |
What it does
postSlackMessage sends a JSON payload containing a channel ID and a text string to chat.postMessage, authenticated with a Bearer bot token, and returns the timestamp Slack assigns to the new message.
Prerequisites
Slack's chat.postMessage endpoint always responds with HTTP 200, even on failure, so the script cannot rely on the response code alone and instead checks the ok field inside the parsed JSON body to detect errors like an invalid channel.
Walkthrough
Create a Slack app at api.slack.com/apps, add the chat:write bot scope, install the app to the workspace, invite the bot to the target channel, and store the resulting bot token under SLACK_BOT_TOKEN in Script Properties.
Edge cases
The payload uses the channel's ID rather than its display name because IDs are stable even if a channel gets renamed, and unfurl_links is set to false so pasted URLs in automated messages do not generate distracting link previews.
Testing
When result.ok is false, the function throws using result.error, which Slack documents as a short machine-readable code such as channel_not_found or not_in_channel, making it clear whether the fix is inviting the bot or correcting the channel ID.
Hardening
Post a test message to a private scratch channel first, confirm the bot appears as the sender, and only then point SLACK_CHANNEL_ID at the real channel the workflow should notify.
Variations
Slack enforces per-workspace rate limits on chat.postMessage, so a script that posts on every single edit in a busy sheet should debounce or batch updates rather than calling postSlackMessage from an onEdit trigger on every keystroke.
Full code: postSlackMessage()
Store the bot token in Script Properties, invite the bot to the target channel, then call postSlackMessage directly or through notifySlackOnSheetChange.
var SLACK_BOT_TOKEN_PROPERTY = 'SLACK_BOT_TOKEN';
var SLACK_CHANNEL_ID = 'C0123456789';
function postSlackMessage(messageText) {
var token = PropertiesService.getScriptProperties().getProperty(SLACK_BOT_TOKEN_PROPERTY);
if (!token) throw new Error('Missing SLACK_BOT_TOKEN script property.');
var payload = {
channel: SLACK_CHANNEL_ID,
text: messageText || 'Automated update from Apps Script.',
unfurl_links: false
};
var options = {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + token },
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
var response = UrlFetchApp.fetch('https://slack.com/api/chat.postMessage', options);
var result = JSON.parse(response.getContentText());
if (!result.ok) {
throw new Error('Slack API error: ' + result.error);
}
return result.ts;
}
function notifySlackOnSheetChange(e) {
var sheetName = e.range.getSheet().getName();
var cell = e.range.getA1Notation();
var summary = 'Sheet "' + sheetName + '" changed at ' + cell + ' by ' + Session.getActiveUser().getEmail();
postSlackMessage(summary);
}- Line 5: Reads the Slack bot token from Script Properties.
- Line 9: Uses a stable channel ID instead of a channel name.
- Line 17: Sends the Bearer bot token in the Authorization header.
- Line 25: Checks Slack's ok field instead of trusting the HTTP status code.
- Line 30: Returns the message timestamp Slack assigns.
- Line 34: Builds a change summary and reuses postSlackMessage from an edit handler.
Deploy this example
- 01
Open Apps Script
In the bound spreadsheet: Extensions → Apps Script. For standalone projects, create one at script.google.com and link your Sheet by ID.
- 02
Paste and save
Add a .gs file, paste the code below, rename constants at the top (sheet names, column letters, API property keys), then save.
- 03
Authorize once
Run the main function from the editor. Accept OAuth scopes when prompted — triggers cannot run until authorization succeeds once.
- 04
Add the trigger
Triggers → Add trigger → choose the handler function and event (time-driven, on edit, or on form submit). Delete test triggers before production.
Before you run: post Slack messages
- 1Slack app created with the chat:write bot scope
- 2App installed to the workspace and bot invited to the target channel
- 3Bot token stored under SLACK_BOT_TOKEN in Script Properties
- 4SLACK_CHANNEL_ID updated to the real channel ID, not a channel name
- 5Test message confirmed in a private scratch channel first
- 6result.ok checked before assuming the message sent
- 7Any onEdit-triggered posting reviewed for rate-limit risk
Frequently asked questions
A bot token works across any channel the bot has been invited to and supports the full chat.postMessage feature set, while an incoming webhook is locked to a single channel chosen when the webhook was created.
Channel names can be edited by workspace members, but channel IDs never change, so hardcoding the ID keeps the script working even if someone renames the channel later.
Open the channel in Slack's desktop or web client, click the channel name to open its details panel, and the ID is shown near the bottom of that panel or in the browser URL.
chat.postMessage will return ok: false with the error not_in_channel, and inviting the bot to the channel from Slack resolves it without any code changes.
Yes, chat.postMessage accepts a blocks array for rich formatting in addition to the plain text field used in this tutorial for simplicity.
The same UrlFetchApp pattern works with a webhook URL, but you would POST a payload with only a text field to that URL and skip the Authorization header entirely, since webhooks encode their own permission in the URL.