- 技術 💻
GASでSlack・Google Chatに通知|Webhookの書き方
GAS(Google Apps Script)からSlackやGoogle Chatに通知するには、それぞれのサービスで発行した Webhook URL に、UrlFetchApp.fetch でJSONをPOSTします。 送る側のコードは、URLと本文のJSONの形が違うだけで、SlackもGoogle Chatも同じ書き方になります。
この記事は「GASから送る側」だけを扱います。Webhook URL をスクリプト プロパティに置く・応答コードを確かめる・429 や 5xx は待ってから再送するという3点を1つの共通関数にまとめ、Slack(text・blocks)と Google Chat(text・cardsV2)の送信関数、Googleフォームの回答を通知するトリガーまでを、そのまま貼れるコードで並べました。
確認環境:メソッド名・引数・既定値・上限値は Google Apps Script、Slack、Google Chat の公式ドキュメント(2026年10月5日に閲覧)で確認。掲載コードは、UrlFetchApp などを置き換えた簡易スタブを使い Node.js 22 で動かして、再送の分岐と送信されるJSONを確かめています。「スタブでの実行ログ」と書いた出力は、このスタブで動かしたときのものです。
Slack・Google Chat通知の早見表
| 項目 | Slack | Google Chat |
|---|---|---|
| Webhook URL の形 | https://hooks.slack.com/services/T.../B.../XXXX |
https://chat.googleapis.com/v1/spaces/SPACE_ID/messages?key=KEY&token=TOKEN |
| URL を発行する場所 | Slack アプリの設定で Incoming Webhooks を有効にする | スペースの Apps & integrations から Webhook を追加する |
| 文字だけ送る | { "text": "本文" } |
{ "text": "本文" } |
| 見た目を整えて送る | blocks(Block Kit) |
cardsV2(カード) |
| 太字 | *太字* |
*太字* |
| リンク | <URL|表示する文字> |
<URL|表示する文字> |
| 成功の見分け方 | HTTP 200、本文は ok |
応答コードが200番台 |
| 送りすぎたとき | 429 が返ったら待って再送 | スペースごとに毎秒1リクエストまで。超えると 429 |
| GAS 側の共通の書き方 | UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: JSON.stringify(body), muteHttpExceptions: true }) |
同じ |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「GASでSlack・Google Chatに通知|Webhookの書き方」
全体の流れ
flowchart LR
A[フォーム送信・時間などのトリガー] --> B[GASの関数]
B --> C[スクリプト プロパティから Webhook URL を読む]
C --> D[UrlFetchApp.fetch で JSON を POST]
D --> E{応答コード}
E -->|200番台| F[完了]
E -->|429・500番台| G[待ってから再送]
G --> D
E -->|そのほかの400番台| H[内容を見直す]
用意するものは Webhook URL だけです。認証用のトークンをヘッダーに付ける必要はなく、URL そのものが鍵の役割を持ちます。Slack の公式ドキュメントにも、Webhook URL には秘密の値が含まれるので公開の場所に載せないこと、漏れた URL は Slack 側で無効化されることが書かれています。
Webhook URL を発行する
Slack
Slack の公式ドキュメントでは、次の4段階で説明されています。
- Slack アプリを作成する
- アプリの設定で Incoming Webhooks を有効にする
- ワークスペースに追加し、投稿先のチャンネルを選んで Webhook URL を発行する
- その URL に JSON を POST する
投稿先のチャンネル・表示名・アイコンは発行時の設定で決まり、送信するJSONから上書きすることはできません。チャンネルを分けたいときは、チャンネルごとに URL を発行します。
Google Chat
Google Chat の Webhook は、公式ドキュメントで Business または Enterprise の Google Workspace アカウントが前提とされ、組織の設定で Webhook の追加が許可されている必要があります。
- 通知したいスペースを開く
- スペース名の横のメニューから Apps & integrations を選ぶ(画面の表示言語によって名称が変わります)
- Add webhooks を選び、名前とアバターの URL を入力して保存する
- 表示された Webhook URL をコピーする
URL に含まれる key と token が鍵にあたります。
Webhook URL はスクリプト プロパティに置く
URL をコードに直接書くと、スクリプトを共有したりコードを貼り付けて相談したりしたときに、URL も一緒に渡ってしまいます。スクリプト プロパティに保存して、コードからは名前で読み出す形にしておくと、コードを安心して見せられます。
エディタ左側の「プロジェクトの設定」→「スクリプト プロパティ」で、次の2つを登録します。
| プロパティ | 値 |
|---|---|
| SLACK_WEBHOOK_URL | Slack で発行した URL |
| CHAT_WEBHOOK_URL | Google Chat で発行した URL |
読み出す関数は次のとおりです。getProperty は、名前が登録されていないと null を返すので、その場合は何を登録すればよいかをメッセージにして止めます。
// ===== 共通:Webhook URL をスクリプト プロパティから読む =====
function getWebhookUrl_(key) {
const url = PropertiesService.getScriptProperties().getProperty(key);
if (!url) throw new Error('スクリプト プロパティ ' + key + ' を登録してください');
return url;
}
| 知っておきたい点 | 内容(公式ドキュメントの記載) |
|---|---|
| 保存される形 | すべて文字列のキーと値 |
| 画面から登録できる数 | 50個まで(それ以上はコードから登録する) |
| 1つの値の大きさ | 9KB まで |
| 全体の大きさ | 1つのプロパティ ストアにつき 500KB まで |
| 読み書きの回数 | 個人のアカウントで1日 50,000回、Google Workspace で1日 500,000回 |
UrlFetchApp.fetch の params 一覧
UrlFetchApp.fetch(url, params) の2つ目の引数に指定できる項目です。
| 項目 | 型 | 既定値 | 内容 | 通知での使い方 |
|---|---|---|---|---|
| method | 文字列 | get | get・delete・patch・post・put のいずれか | 'post' |
| contentType | 文字列 | application/x-www-form-urlencoded | 本文の種類 | 'application/json; charset=UTF-8' |
| payload | 文字列・バイト配列・Blob・オブジェクト | なし | リクエストの本文。オブジェクトを渡すとフォームの項目として送られる | JSON.stringify(body) |
| headers | オブジェクト | なし | 追加するHTTPヘッダーのキーと値 | Webhook では不要なことが多い |
| muteHttpExceptions | 真偽値 | false | true にすると、失敗を示す応答コードでも例外にせず HTTPResponse を返す | true |
| followRedirects | 真偽値 | true | false にするとリダイレクトをたどらない | 指定しない |
| validateHttpsCertificates | 真偽値 | true | false にすると HTTPS の証明書の不備を無視する | 指定しない |
| escaping | 真偽値 | true | false にすると URL の予約文字をエスケープしない | 指定しない |
payload にオブジェクトをそのまま渡すと JSON にはなりません。 フォームの項目として送られるため、Webhook には JSON.stringify(body) で文字列にしてから渡し、contentType を application/json にします。
戻り値の HTTPResponse から読み取れるものは次のとおりです。
| メソッド | 戻り値 | 内容 |
|---|---|---|
getResponseCode() |
整数 | HTTP の応答コード(200 など) |
getContentText() |
文字列 | 応答の本文 |
getHeaders() |
オブジェクト | 応答ヘッダーのキーと値 |
getAllHeaders() |
オブジェクト | 同じ名前のヘッダーが複数あるとき、値を配列で返す |
UrlFetchApp を使うスクリプトは、初回の実行時に外部サービスへの接続の許可を求められます。
応答コードを見て再送する共通関数
muteHttpExceptions が false(既定)のままだと、400番台や500番台の応答で fetch が例外を投げ、応答コードや本文を落ち着いて調べられません。true にして応答を受け取り、getResponseCode で自分で判定する形にします。
// ===== 共通:JSON を POST し、429 と 5xx は待ってから再送する =====
function postJson_(url, body, maxTries) {
const tries = maxTries || 4;
const params = {
method: 'post',
contentType: 'application/json; charset=UTF-8',
payload: JSON.stringify(body),
muteHttpExceptions: true, // 4xx・5xx でも例外にせず、応答を受け取る
};
for (let n = 1; n <= tries; n++) {
let code = 0;
let text = '';
let headers = {};
try {
const res = UrlFetchApp.fetch(url, params);
code = res.getResponseCode();
text = res.getContentText();
headers = res.getHeaders();
} catch (e) {
text = e.message; // 応答を受け取れなかった場合(code は 0 のまま)
}
if (code >= 200 && code < 300) {
return { code: code, body: text, tries: n };
}
const retryable = code === 0 || code === 429 || code >= 500;
if (!retryable || n === tries) {
throw new Error('通知を送れませんでした(HTTP ' + code + '、' + n + '回目): ' + text.slice(0, 200));
}
const waitMs = waitMs_(headers, n);
console.log('HTTP ' + code + ' → ' + waitMs + 'ms 待って再送します(' + n + '回目)');
Utilities.sleep(waitMs);
}
}
// Retry-After(秒)があればそれに従い、無ければ 1秒→2秒→4秒 と延ばす
function waitMs_(headers, n) {
let retryAfter = '';
Object.keys(headers).forEach(function (k) {
if (k.toLowerCase() === 'retry-after') retryAfter = String(headers[k]);
});
const sec = /^\d+$/.test(retryAfter) ? Number(retryAfter) : Math.pow(2, n - 1);
return Math.min(sec, 30) * 1000;
}
判定のルールは次の表のとおりです。
| 応答 | 扱い | 理由 |
|---|---|---|
| 200番台 | 成功として返す | 受け付けられた |
| 429 | 待って再送 | 送りすぎ。少し待てば受け付けられる |
| 500番台 | 待って再送 | 相手側の一時的な不調の可能性がある |
| 応答なし(例外) | 待って再送 | 通信が成立しなかった |
| そのほかの400番台 | すぐ例外にする | JSON の形や URL に原因があり、同じ内容を送り直しても結果が変わらない |
待ち時間は、応答に Retry-After ヘッダー(待つ秒数)が付いていればその値を使い、無ければ1秒・2秒・4秒と倍に延ばします。Google Chat の公式ドキュメントも、429 が返ったときは待ち時間を延ばしながら再試行する方法(指数バックオフ)を勧めています。
スタブで再送の分岐を確かめた結果
応答を順番に差し替えられるスタブで、5つの場合を動かしました。
== 1回で成功
結果: {"code":200,"body":"ok","tries":1}
== 429(Retry-After: 2)→ 503 → 成功
HTTP 429 → 2000ms 待って再送します(1回目)
(sleep 2000ms)
HTTP 503 → 2000ms 待って再送します(2回目)
(sleep 2000ms)
結果: {"code":200,"body":"ok","tries":3}
== 400 は再送しない
例外: 通知を送れませんでした(HTTP 400、1回目): invalid_payload
== 500 が続く(4回で打ち切り)
HTTP 500 → 1000ms 待って再送します(1回目)
(sleep 1000ms)
HTTP 500 → 2000ms 待って再送します(2回目)
(sleep 2000ms)
HTTP 500 → 4000ms 待って再送します(3回目)
(sleep 4000ms)
例外: 通知を送れませんでした(HTTP 500、4回目):
== スクリプト プロパティが空
例外: スクリプト プロパティ TEAMS_WEBHOOK_URL を登録してください
429 のときは Retry-After の 2秒、ヘッダーの無い 503 のときは「2回目なので2秒」と、それぞれ別の理由で待っていることがログから分かります。4回送っても通らなければ例外にして、呼び出し側に任せます。
500番台で再送すると、相手側では受け付けが済んでいて同じ通知が2通届く場合があります。通知が重なって困る用途では、maxTries を 1 にして再送をやめるか、本文に受付番号を入れて見分けられるようにしておきます。
Slack に送る
// ===== Slack =====
function sendSlackText(text) {
return postJson_(getWebhookUrl_('SLACK_WEBHOOK_URL'), { text: text });
}
function sendSlackBlocks(title, fields, linkUrl) {
const blocks = [
{ type: 'section', text: { type: 'mrkdwn', text: '*' + escapeSlack_(title) + '*' } },
{ type: 'section',
fields: fields.map(function (f) {
return { type: 'mrkdwn', text: '*' + escapeSlack_(f.label) + '*\n' + escapeSlack_(f.value) };
}) },
];
if (linkUrl) {
blocks.push({ type: 'section', text: { type: 'mrkdwn', text: '<' + linkUrl + '|回答の一覧を開く>' } });
}
// text は通知のポップアップなどに使われる要約として一緒に送る
return postJson_(getWebhookUrl_('SLACK_WEBHOOK_URL'), { text: title, blocks: blocks });
}
// Slack の本文で特別な意味を持つ3文字を置き換える
function escapeSlack_(value) {
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
| 関数 | 送るもの | 使いどころ |
|---|---|---|
| sendSlackText(text) | 文字だけ | 「処理が終わりました」のような1行の通知 |
| sendSlackBlocks(title, fields, linkUrl) | 見出し・項目の一覧・リンク | フォームの回答のように、項目が並ぶ通知 |
blocks を使うときも text を一緒に送ります。Slack の公式ドキュメントの例でも、blocks と並べて text に要約の文が入っています。
section ブロックの fields は、項目を2列に並べて表示するための配列です。公式リファレンスでは、fields は最大10件、1件は2,000文字まで、text は3,000文字までとされています。項目が10件を超えるフォームでは、10件ずつ section を分けてください。
Slack の本文の書式
| 書きたいもの | 書き方 |
|---|---|
| 太字 | *太字* |
| 斜体 | _斜体_ |
| 取り消し線 | ~取り消し~ |
| コード | バッククォートで囲む |
| 改行 | \n |
| リンク | <https://example.com|表示する文字> |
| チャンネル全体への呼びかけ | <!channel>、<!here> |
| 特定の人への呼びかけ | <@ユーザーID> |
&、<、> の3文字は Slack が書式の記号として読むため、フォームの回答のように利用者が入力した文字をそのまま載せるときは置き換えます。上の escapeSlack_ がその処理です。
sendSlackBlocks が送る JSON(スタブで取り出したもの。備考に「A&B <至急>」と入力された場合):
{
"text": "備品予約の申請が届きました",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*備品予約の申請が届きました*"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*氏名*\n佐藤 花子"
},
{
"type": "mrkdwn",
"text": "*備考*\nA&B <至急>"
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "<https://example.com/sheet|回答の一覧を開く>"
}
}
]
}
Slack が返すエラーの例
Slack の公式ドキュメントに載っている応答のうち、応答コードが明記されているものです。
| 応答コード | 本文 | 見直すところ |
|---|---|---|
| 400 | invalid_payload | JSON の形。JSON.stringify を通しているか、blocks の入れ子が正しいか |
| 403 | action_prohibited | ワークスペースの設定で投稿が制限されていないか |
| 410 | channel_is_archived | 投稿先のチャンネルがアーカイブされていないか |
どれも送り直しても結果が変わらないので、postJson_ は再送せずに例外にします。
Google Chat に送る
// ===== Google Chat =====
function sendChatText(text) {
return postJson_(getWebhookUrl_('CHAT_WEBHOOK_URL'), { text: text });
}
function sendChatCard(title, fields, linkUrl) {
const widgets = fields.map(function (f) {
return { decoratedText: { topLabel: f.label, text: String(f.value) } };
});
if (linkUrl) {
widgets.push({ buttonList: { buttons: [
{ text: '回答の一覧を開く', onClick: { openLink: { url: linkUrl } } },
] } });
}
const body = {
cardsV2: [{
cardId: 'form-notify',
card: {
header: { title: title, subtitle: 'Googleフォームからの通知' },
sections: [{ widgets: widgets }],
},
}],
};
return postJson_(getWebhookUrl_('CHAT_WEBHOOK_URL'), body);
}
Slack 版と見比べると、違うのはプロパティの名前と本文の JSON の形だけで、送信は同じ postJson_ に任せています。
| 関数 | 送るもの | 使いどころ |
|---|---|---|
| sendChatText(text) | 文字だけ | 1行の通知 |
| sendChatCard(title, fields, linkUrl) | 見出しつきのカード・項目の一覧・ボタン | 項目が並ぶ通知 |
cardsV2 の構造は次のとおりです。
| キー | 内容 |
|---|---|
| cardsV2[].cardId | カードを見分ける名前 |
| card.header.title / subtitle | カードの見出しと副題 |
| card.sections[].header | セクションの見出し(省略できる) |
| card.sections[].widgets[] | 表示する部品の配列 |
| textParagraph.text | 文章の部品 |
| decoratedText.topLabel / text | 小さなラベルつきの文字。項目名と値の組に向く |
| buttonList.buttons[] | ボタン。text に表示名、onClick.openLink.url に開く URL |
sendChatCard が送る JSON(スタブで取り出したものから、項目を1件に絞って掲載):
{
"cardsV2": [
{
"cardId": "form-notify",
"card": {
"header": {
"title": "備品予約の申請が届きました",
"subtitle": "Googleフォームからの通知"
},
"sections": [
{
"widgets": [
{
"decoratedText": {
"topLabel": "氏名",
"text": "佐藤 花子"
}
},
{
"buttonList": {
"buttons": [
{
"text": "回答の一覧を開く",
"onClick": {
"openLink": {
"url": "https://example.com/sheet"
}
}
}
]
}
}
]
}
]
}
}
]
}
Google Chat の本文の書式
| 書きたいもの | 書き方 |
|---|---|
| 太字 | *太字* |
| 斜体 | _斜体_ |
| 取り消し線 | ~取り消し~ |
| コード | バッククォートで囲む |
| リンク | <https://example.com|表示する文字> |
| スペースの全員への呼びかけ | <users/all> |
同じ用件を1つのスレッドにまとめる
Google Chat では、本文に threadKey を付け、URL に messageReplyOption を足すと、同じキーの通知が1つのスレッドに集まります。キーに合うスレッドがまだ無いときは、新しいスレッドが作られます。
// 同じ threadKey の通知を1つのスレッドにまとめる(Google Chat)
function sendChatThread(text, threadKey) {
const url = getWebhookUrl_('CHAT_WEBHOOK_URL') +
'&messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD';
return postJson_(url, { text: text, thread: { threadKey: threadKey } });
}
スタブで取り出した送信先の URL と本文:
https://chat.example/v1/spaces/AAA/messages?key=K&token=T&messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD
{"text":"申請 #12 を受け付けました","thread":{"threadKey":"request-12"}}
Google Chat の上限
| 項目 | 値(公式ドキュメントの記載) |
|---|---|
| 送信の頻度 | スペースごとに毎秒1リクエスト。スペース内のすべての Webhook で共有 |
| 超えたときの応答 | 429(Too many requests) |
| 1通の大きさ | 文字とカードを合わせて 32,000 バイトまで |
ループで何通も続けて送るときは、1通ごとに Utilities.sleep(1000) を入れておくと、この頻度に収まります。
まず手動で1通送って確かめる
// ===== 動作確認用 =====
function testNotify() {
const fields = [
{ label: '氏名', value: '佐藤 花子' },
{ label: '借りたい備品', value: 'プロジェクター' },
{ label: '利用日', value: '2026/10/20' },
];
sendSlackText('GASからのテスト通知です');
sendSlackBlocks('備品予約の申請が届きました', fields, 'https://example.com/sheet');
sendChatText('GASからのテスト通知です');
sendChatCard('備品予約の申請が届きました', fields, 'https://example.com/sheet');
}
エディタで testNotify を選んで実行し、4通が届けば準備は完了です。届かないときは、実行ログに出る「通知を送れませんでした(HTTP 400、1回目): invalid_payload」のようなメッセージから、応答コードと本文を確かめます。
フォームの回答を通知する
Googleフォームの送信をきっかけに動かすトリガーには、スプレッドシート側に付けるものとフォーム側に付けるものの2種類があり、関数が受け取るイベントオブジェクト e の中身が違います。
スプレッドシート側とフォーム側のトリガーの違い
| 比べる点 | スプレッドシート側 | フォーム側 |
|---|---|---|
| スクリプトを書く場所 | 回答が記録されるスプレッドシート | フォーム |
| トリガーをコードで作る書き方 | ScriptApp.newTrigger(関数名).forSpreadsheet(ブック).onFormSubmit().create() |
ScriptApp.newTrigger(関数名).forForm(フォーム).onFormSubmit().create() |
| e に入っているもの | authMode、namedValues、range、values、triggerUid | authMode、response、source、triggerUid |
| 回答の取り出し方 | e.namedValues['質問名'][0] |
e.response.getItemResponses() |
| 回答が書かれた行 | e.range.getRow() で分かる |
e には含まれない |
| フォームの名前 | e には含まれない | e.source.getTitle() |
| 向いている場面 | 回答をシートに記録していて、行番号やシートの値も使いたい | シートを使わずフォームだけで完結させたい |
e.namedValues と e.response の違い
| 比べる点 | e.namedValues | e.response |
|---|---|---|
| 型 | 質問名をキー、回答の配列を値にしたオブジェクト | FormResponse オブジェクト |
| 1つの回答を読む | e.namedValues['氏名'][0] |
item.getResponse() |
| 質問名を読む | Object.keys(e.namedValues) |
item.getItem().getTitle() |
| 回答者のメールアドレス | メールアドレスの列があれば、その質問名で読む | e.response.getRespondentEmail()(メールアドレスを収集する設定のとき) |
| 送信日時 | タイムスタンプの列を読む | e.response.getTimestamp() |
| 質問の並び順 | オブジェクトのキーとして受け取る | フォームに並んでいる順の配列 |
namedValues の値は、回答が1つでも配列です。e.namedValues['氏名'] をそのまま文字として使うと配列が渡るので、[0] を付けるか join(', ') でつなげます。
スプレッドシート側のコード
// ===== フォーム送信トリガー:スプレッドシート側(e.namedValues を使う) =====
function onFormSubmitSheet(e) {
const fields = Object.keys(e.namedValues).map(function (question) {
return { label: question, value: e.namedValues[question].join(', ') };
});
const title = 'フォームに回答が届きました(' + e.range.getRow() + '行目)';
notifyAll_(title, fields);
}
フォーム側のコード
2つの送り先へまとめて送る notifyAll_ は、スプレッドシート側のコードと共通で使います。
// ===== フォーム送信トリガー:フォーム側(e.response を使う) =====
function onFormSubmitForm(e) {
const fields = e.response.getItemResponses().map(function (item) {
const answer = item.getResponse(); // チェックボックスは配列で返る
return { label: item.getItem().getTitle(), value: Array.isArray(answer) ? answer.join(', ') : answer };
});
const title = '「' + e.source.getTitle() + '」に回答が届きました';
notifyAll_(title, fields);
}
// 送り先ごとに try...catch で分け、片方が送れなくてももう片方へ送る
function notifyAll_(title, fields) {
const senders = [
function () { return sendSlackBlocks(title, fields); },
function () { return sendChatCard(title, fields); },
];
senders.forEach(function (send) {
try {
send();
} catch (err) {
console.error(err.message);
}
});
}
スタブでの実行ログ(それぞれのイベントオブジェクトを模した値を渡した結果):
== フォーム送信トリガー(シート側 e.namedValues)
hooks.slack.example <- フォームに回答が届きました(7行目) / 項目 3件
chat.example <- フォームに回答が届きました(7行目) / 項目 3件
== フォーム送信トリガー(フォーム側 e.response)
hooks.slack.example <- 「備品予約フォーム」に回答が届きました / *氏名*=佐藤 花子 | *借りたい備品*=プロジェクター, 延長コード
chat.example <- 「備品予約フォーム」に回答が届きました / 氏名=佐藤 花子 | 借りたい備品=プロジェクター, 延長コード
== 片方が送れないとき
[error] 通知を送れませんでした(HTTP 410、1回目): channel_is_archived
送信の試行 2件
最後の「片方が送れないとき」は、Slack が 410 を返した場合です。notifyAll_ が送り先ごとに try...catch で分けているので、Slack 側でつまずいても Google Chat への通知は送られています(送信の試行が2件)。片方だけに送りたいときは、senders の配列から使わないほうの行を消してください。
トリガーを設定する
画面から設定する場合は、エディタ左側の「トリガー」→「トリガーを追加」で、実行する関数とイベントの種類(フォーム送信時)を選んで保存します。コードから作る場合は、次のどちらか片方を1回だけ実行します。
// ===== トリガーをコードから作る(どちらか片方を1回だけ実行) =====
function createSheetSideTrigger() {
ScriptApp.newTrigger('onFormSubmitSheet')
.forSpreadsheet(SpreadsheetApp.getActive())
.onFormSubmit()
.create();
}
function createFormSideTrigger() {
ScriptApp.newTrigger('onFormSubmitForm')
.forForm(FormApp.getActiveForm())
.onFormSubmit()
.create();
}
両方のトリガーを付けると、1回の送信で通知が2通届きます。使うのは片方だけにしてください。
トリガーで動く関数は、トリガーを作成した人のアカウントで実行されます。トリガーで動くときは画面を見ている人がいないので、notifyAll_ のように console.error で記録しておくと、後から実行ログで原因を追えます。
送信数と大きさの上限
Google の公式ページ「Quotas for Google Services」に載っている、通知に関係する上限です(2026年10月5日に閲覧)。
| 項目 | 個人のアカウント | Google Workspace |
|---|---|---|
| URL Fetch の呼び出し | 20,000回/日 | 100,000回/日 |
| URL Fetch の POST の大きさ | 50MB/1回 | 50MB/1回 |
| URL Fetch のヘッダー数 | 100個/1回 | 100個/1回 |
| スクリプトの実行時間 | 6分/1回 | 6分/1回 |
| トリガーの合計実行時間 | 90分/日 | 6時間/日 |
再送も1回の呼び出しとして数えられます。上限はユーザーごとで、最初のリクエストから24時間後にリセットされます。
届かないときの確認表
| 起きていること | 確かめるところ |
|---|---|
| 「スクリプト プロパティ … を登録してください」と出る | プロパティの名前のつづりと、値が空でないか |
| HTTP 400(invalid_payload など) | payload を JSON.stringify で文字列にしているか。contentType が application/json か |
| HTTP 403・410 | 投稿先のチャンネルやワークスペースの設定 |
| HTTP 429 | 送る間隔。ループの中に Utilities.sleep を入れる |
| トリガーで動かない | トリガーの一覧に関数があるか。スプレッドシート側とフォーム側を取り違えていないか |
| 回答が「佐藤 花子」ではなく配列のように表示される | namedValues の値に [0] か join を付けているか |
| 記号を含む回答で Slack の表示が崩れる | &、<、> を置き換えているか |
よくある質問
Q. muteHttpExceptions は必ず true にしたほうがよいですか? A. 応答コードで動きを分けたいときは true にします。false のままだと、400番台・500番台で例外になり、再送するかどうかを応答コードで決める処理が書きにくくなります。true にしたら、getResponseCode の確認を必ず対にしてください。確認を省くと、送れていないことに気づけません。
Q. Webhook URL をコードに書いたままにしていました。どうすればよいですか? A. その URL を知っていれば誰でも投稿できるため、発行元のサービスで URL を作り直し、新しい URL をスクリプト プロパティに登録するのが確実です。コードからは URL の文字列を消して、getWebhookUrl_ で読む形に直します。
Q. Slack の投稿先チャンネルを JSON で切り替えられますか? A. Incoming Webhooks では、チャンネル・表示名・アイコンを JSON で上書きできません。チャンネルごとに URL を発行し、SLACK_WEBHOOK_URL_SALES のようにプロパティを分けて持つと切り替えられます。
Q. 一度に何十通も送りたいときはどうしますか? A. 1通にまとめられないかを先に考えます。項目を1つのメッセージに並べれば、送信は1回で済みます。分けて送る場合は、1通ごとに Utilities.sleep(1000) を入れ、スクリプトの実行時間(1回6分)に収まる件数にします。
まとめ
- 送り方は共通:
UrlFetchApp.fetch(url, { method: 'post', contentType: 'application/json', payload: JSON.stringify(body), muteHttpExceptions: true }) - 本文の形だけが違う:Slack は text と blocks、Google Chat は text と cardsV2
- Webhook URL はスクリプト プロパティに置く。URL そのものが鍵なので、コードには書かない
- getResponseCode で判定する。429 と 500番台は待って再送し、そのほかの400番台は内容を見直す
- 待ち時間は Retry-After を優先し、無ければ1秒・2秒・4秒と延ばす
- フォーム送信トリガーは2種類。スプレッドシート側は e.namedValues(値は配列)、フォーム側は e.response
- 送り先ごとに try...catch で分けると、片方がつまずいてももう片方には届く
参考・出典
- Google Apps Script リファレンス「Class UrlFetchApp」「Class HTTPResponse」「Class Properties」「Class FormResponse」(developers.google.com/apps-script/reference)
- Google Apps Script ガイド「Event Objects」「Installable Triggers」「Properties Service」「Quotas for Google Services」
- Slack「Sending messages using incoming webhooks」「Formatting message text」「Section block」(docs.slack.dev)
- Google Chat「Build a Google Chat app as a webhook」「Cards v2」「Format messages」「Usage limits」「Send a message」(developers.google.com/workspace/chat)
- MDN Web Docs「Retry-After」
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「GASでSlack・Google Chatに通知|Webhookの書き方」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。