Send Slack Alerts From a Google Sheet Cell with Custom Functions
Most spreadsheet-to-Slack tutorials show the same thing: an Apps Script function you run manually, or a trigger that fires daily. Both are useful, but neither is what spreadsheet-native people actually want. What they want is to type =NOTIFY_SLACK("#alerts", "Inventory low: " & A2) in a cell and have Slack get a message.
That pattern is possible, with caveats. This post is the working version - the right way to build it, the gotchas that Google's docs don't lead with, and the alternative pattern that you should usually use instead for anything that needs to be reliable.
The custom function version (formula-driven)
Apps Script lets you define custom functions that work like built-in spreadsheet functions. You write JavaScript, give it a name, and now =MY_FUNCTION(args) works in any cell of the sheet. Here's the Slack-posting version:
const SLACK_WEBHOOK_URL = 'https://hooks.slack.com/services/T000/B000/xxx';
/**
* Posts a message to a Slack channel from a cell.
* @param {string} channel Slack channel name, e.g. "#alerts"
* @param {string} message Message text
* @customfunction
*/
function NOTIFY_SLACK(channel, message) {
if (!channel || !message) return 'missing args';
const payload = { channel: channel, text: String(message) };
UrlFetchApp.fetch(SLACK_WEBHOOK_URL, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
return 'sent ' + new Date().toLocaleTimeString();
}
Save the script. In a cell, type =NOTIFY_SLACK("#alerts", "Test"). Press Enter. A Slack message appears in #alerts. The cell returns the string "sent 14:32:11", so you can see when it fired.
Set up the Slack webhook URL in the Slack incoming webhooks page. Each webhook is bound to a single channel by default, but you can override it per request (as above) if your app is configured to allow it.
The huge gotcha nobody warns you about
Custom functions in Google Sheets cannot access services that require user authorization. Email, Drive, Calendar - all forbidden inside a custom function. The exceptions are limited: UrlFetchApp (which is what we use above) and a handful of utility services.
But there's a second, sneakier limitation: custom functions are recalculated automatically by Sheets, on its own schedule. Open the sheet, custom functions re-run. Edit a referenced cell, custom functions re-run. Sometimes Sheets just decides to re-run them.
If your formula is =NOTIFY_SLACK("#alerts", A2) and A2 changes once a day, you might think Slack gets one message per day. Wrong. Slack might get a message every time someone opens the sheet, every time A2 is recalculated as part of a larger formula chain, every time Sheets decides to refresh. We've watched a single cell formula fire 40 Slack messages in an afternoon. It is extremely embarrassing.
The right pattern: idempotent + last-sent tracking
The fix is to make the function deterministic about whether to actually fire. Use PropertiesService to remember the last message content per cell, and only fire if it's changed:
function NOTIFY_SLACK(channel, message, key) {
if (!key) return 'need unique key';
const props = PropertiesService.getDocumentProperties();
const lastSent = props.getProperty('slack_' + key);
const current = channel + '|' + message;
if (lastSent === current) return 'no change';
props.setProperty('slack_' + key, current);
UrlFetchApp.fetch(SLACK_WEBHOOK_URL, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ channel: channel, text: String(message) })
});
return 'sent';
}
Now use it as =NOTIFY_SLACK("#alerts", "Inventory low: " & A2, "inv_a2"). The third argument is a unique key per logical alert. Repeated recalculations with the same channel+message combination are no-ops. Only a real change in the message text triggers a real Slack post.
This isn't perfect - in particular, two cells with the same key will fight - but it eliminates 99% of the duplicate-spam problem.
The trigger-based alternative (recommended for most cases)
Honestly, for anything important, skip the custom function entirely and use a time-based trigger that reads the sheet and posts alerts. The pattern looks like this:
function checkAndAlert() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Inventory');
const data = sheet.getRange('A2:C100').getValues();
const alerts = [];
data.forEach((row, i) => {
const [item, qty, threshold] = row;
if (item && qty < threshold) {
alerts.push(`• ${item}: ${qty} (threshold ${threshold})`);
}
});
if (alerts.length === 0) return;
const message = `*Low inventory:*\n` + alerts.join('\n');
UrlFetchApp.fetch(SLACK_WEBHOOK_URL, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ text: message })
});
}
Schedule it to run every hour (or every 15 minutes) using a time-based trigger (see our triggers post). It's predictable, it batches alerts together so the channel doesn't get flooded, and it never fires from a sheet recalculation. For real production use, this is the right pattern about 90% of the time.
The cases where custom functions still win
Where the =NOTIFY_SLACK() pattern is genuinely the right answer:
Quick experiments. You want to test "what if this triggered a Slack?" without setting up a trigger. Throw it in a cell, see the result, decide.
Demo or training contexts. Showing a colleague how something works, where the formula-as-action makes the demo feel magical.
Cells that are themselves the trigger. If you have a sheet where a user manually types a number into a specific cell and that should fire a notification, and you've tested that the custom function only fires on actual edits (not on every reopen), it can work. Use the idempotent pattern above to be safe.
Other custom functions worth building
The same pattern - JSDoc @customfunction tag, simple input → output, no side effects ideally - works for a bunch of useful things:
// Word count of a cell
function WORDCOUNT(text) {
if (!text) return 0;
return String(text).trim().split(/\s+/).length;
}
// Read time estimate (200 wpm)
function READTIME(text) {
if (!text) return '0 min';
const words = String(text).trim().split(/\s+/).length;
return Math.ceil(words / 200) + ' min';
}
// Slugify for URLs
function SLUGIFY(text) {
return String(text || '').toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// HMAC-SHA256 hash (useful for signing or quick fingerprints)
function HMAC(text, key) {
const sig = Utilities.computeHmacSha256Signature(String(text), String(key));
return sig.map(b => (b & 0xff).toString(16).padStart(2, '0')).join('');
}
These are all pure functions - they have no side effects, just compute and return. That's the ideal shape for a custom function. The Slack-posting one violates that rule, which is why it's tricky; the four above are safe.
Quick limits to remember
- Custom functions have a 30-second execution timeout (much shorter than regular Apps Script's 6 minutes).
- Cannot access user-scoped services (Gmail, Drive personal data, Calendar).
- Cannot ask the user for authorization - they run with limited scopes only.
- Results are cached. Force a recalc with
Ctrl+Shift+F9(Windows) or insert a small change. - Custom function names must be ALL_CAPS to appear in autocomplete (technically not required, but conventional).
Summing up
Custom functions that post to Slack are technically possible, intuitively appealing, and a footgun if you don't handle the recalculation problem. The right path for most teams is:
- Use pure custom functions for computation (formatting, slugs, hashes, calculations).
- Use time-based triggers for alerts and notifications.
- Use the cell-firing custom function pattern only for demos, experiments, or with the idempotent guard.
Slack is a forgiving target for automation experiments because you can always mute the channel - but a flooded #alerts channel teaches the team to ignore alerts, which is the opposite of what you wanted. The boring trigger pattern is what gets used in real production.