LINE公式アカウントに届く問い合わせは、管理画面のチャットで読んで返すだけだと、あとから「先月どんな質問が何件来たか」「誰が返したか」「返し忘れはないか」を追えません。この記事は、友だちから届いたメッセージを Messaging API の Webhook で受け取り、Google Apps Script(以下GAS)のWebアプリでGoogleスプレッドシートに1件1行で書き込む仕組みを、最初から最後まで作る手順です。読み終わると、メッセージが届いた瞬間にシートへ行が増え、スタッフにGmailかGoogle Chatで通知が飛び、対応状況と担当をシートで管理できるようになります。例は、スタッフ6名の予約制の料理教室「サンプル料理教室」のLINE公式アカウントです。GoogleアカウントとLINE公式アカウントの管理者権限があれば、費用はかかりません。仕様は2026年10月時点の公式ドキュメントで確かめています。
この記事の内容
先に決めること
作り始める前に、次の4つを決めておきます。ここを曖昧にしたまま作ると、作り直しになります。
1つ目は、記録するだけか、自動で返信もするかです。この記事の仕組みは記録と通知だけで、返信はこれまでどおりスタッフが管理画面のチャットで行います。自動返信まで足すと、応答メッセージとの二重返信や返信の失敗まで考えることが増えるので、まず記録だけを動かします。
2つ目は、Webhookが空いているかです。Webhook URLに設定できる送り先は、1つのチャネルにつき1つだけです。予約システムや拡張ツールと連携済みなら、そのツールがWebhook URLを使っていることが多く、GASのURLに書き換えるとツール側が止まります。LINE Developersコンソールの「Messaging API設定」タブで、Webhook URLが空かを先に見ます。入っていたときの代わりの手は「できないことと、その代わり」に書きました。
3つ目は、使うGoogleアカウントの種類です。個人のGmail(@gmail.com)とGoogle Workspaceでは、GASでメールを送れる宛先数の上限が違い、Google Chatへの通知はWorkspaceでないと使えません。GASは作った人のアカウントで動き、その人が退職するとアカウントごと止まるので、個人ではなく教室として持つアカウントで作ります。
4つ目は、署名の検証をどこまで求めるかです。LINEの公式ドキュメントは、Webhookを受け取ったら必ず署名を検証してから処理するよう求めています。ところがGASのWebアプリは、検証に必要なリクエストヘッダーを読めません。GASで作る場合は、代わりの確認で妥協することになります。問い合わせの記録なら実用上は足りますが、予約の確定のような処理を動かすなら、署名を検証できる環境を選びます。
何ができるようになるか:全体の流れと作るもの
流れは次のとおりです。友だちがLINEでメッセージを送ると、LINEプラットフォームがWebhook URLへPOSTリクエストを送ります。そのURLをGASのWebアプリにしておけば、GASの doPost が動いてシートに1行書き、スタッフに通知します。
友だち(LINEアプリでメッセージを送る)
↓
LINEプラットフォーム
↓ Webhook(HTTPS POST・JSON)
GASのWebアプリ(doPostが受け取る)
├→ プロフィールAPIで表示名を取る
├→ Googleスプレッドシート「問い合わせ」に1行追加
└→ Gmail または Google Chat でスタッフに通知
作るものは5つです。
| 作るもの | 場所 | 役割 |
|---|---|---|
| Messaging APIチャネル | LINE Official Account Manager から有効化 | Webhookを送ってもらうための窓口 |
| チャネルアクセストークン(長期) | LINE Developersコンソール | GASから表示名を取りにいくときの鍵 |
| スプレッドシート「問い合わせ」シート | Googleドライブ | 1件1行の記録先。スタッフが対応状況と担当を書く |
| GASのWebアプリ | スプレッドシートの「拡張機能」>「Apps Script」 | Webhookを受けて書き込み、通知する |
| 通知先 | Gmail または Google Chat のスペース | 届いたことをスタッフに知らせる |

スプレッドシートの列設計
列は、スタッフが毎日見る列を左に、機械が使う列を右に置きます。1行目は見出しにして、2行目から記録します。
| 列 | 見出し | 中身 | 書くのは |
|---|---|---|---|
| A | 受付日時 | イベントの発生時刻 | GAS |
| B | 表示名 | LINEの表示名 | GAS |
| C | 本文 | テキスト。画像やスタンプは [画像] などの印 | GAS |
| D | 対応状況 | 未対応/対応中/対応済み | GASが「未対応」で入れ、スタッフが変える |
| E | 担当 | 返信したスタッフの名前 | スタッフ |
| F | userId | 友だちを見分けるID(U から始まる33文字) | GAS |
| G | 種類 | メッセージ/編集/友だち追加/ブロック/ボタン | GAS |
| H | メッセージID | 送信取消のときに行を探すためのID | GAS |
| I | イベントID | 重複して届いたときに弾くためのID | GAS |
決めておくことが3つあります。表示名は友だちがいつでも変えられるので、同じ人かどうかは表示名ではなく userId で判断します。対応状況はD列にプルダウン(データの入力規則)を付け、3つの値以外を入れられないようにします。手入力にすると「済」「対応済」「OK」が混ざり、未対応の絞り込みが効かなくなります。F列からI列はスタッフが触らないので、列をグループ化して畳むか、保護しておきます。
userId と本文は個人情報です。スプレッドシートの共有は、教室のスタッフのアカウントだけにします。「リンクを知っている全員」にはしません。
作る手順
1. Messaging APIを有効にする(PCの管理画面)
Messaging APIチャネルは、2024年9月4日以降、LINE Developersコンソールから直接は作れません。LINE公式アカウントの管理画面(LINE Official Account Manager)から有効にします。操作できるのは管理者権限のある人だけで、スマホのLINE公式アカウントアプリからはできません。
- PCのブラウザで LINE Official Account Manager にログインし、サンプル料理教室のアカウントを開く
- 右上の「設定」を押す
- 左のメニューで「Messaging API」を押す
- 「Messaging APIを利用する」を押す
- 初めてなら開発者情報(名前とメールアドレス)を登録する
- プロバイダーを作るか、既存のプロバイダーを選ぶ
- 内容を確かめて「同意する」を押し、確認画面で「OK」を押す
プロバイダーはあとから変更も解除もできません。将来LINEログインやLIFFを同じ教室で使うなら、同じプロバイダーにそろえます。チャネルの細かい設定は「Messaging APIの始め方:チャネル・アクセストークン・Webhook」に書きました。

2. チャネルアクセストークンを発行する
表示名を取るAPIを呼ぶのに、チャネルアクセストークンが要ります。公式が推奨しているのは有効期間を指定できるトークン(v2.1)ですが、GASで毎回発行し直す処理を書くと手間が増えるので、ここでは有効期限のない長期のトークンを使います。
- LINE Developersコンソールに、管理画面と同じアカウントでログインする
- 手順1で選んだプロバイダーを押し、できたMessaging APIチャネルを押す
- 「Messaging API設定」タブを開く
- いちばん下の「チャネルアクセストークン(長期)」で「発行」を押す
- 表示された文字列をコピーしておく(手順5で使う)
長期のトークンは、再発行すると前のトークンが無効になります。再発行のときに前のトークンを最大24時間まで延ばすこともできるので、その間にGAS側を入れ替えます。トークンはパスワードと同じ扱いで、シートやチャットに貼りません。
3. スプレッドシートとGASを用意する
- Googleドライブで新しいスプレッドシートを作り、名前を「サンプル料理教室 LINE問い合わせ」にする
- 下のシート名を「問い合わせ」に変える
- 1行目のA列からI列に、列設計の表の見出しを入れる
- H列を選び、「表示形式」>「数字」>「書式なしテキスト」にする
- D列の2行目以降を選び、「データ」>「データの入力規則」でプルダウンを付け、「未対応」「対応中」「対応済み」を入れる
- 「ファイル」>「設定」で、タイムゾーンを「(GMT+09:00) Tokyo」にする
- 「拡張機能」>「Apps Script」を押す。エディタが開き、このシートに紐づいたGASのプロジェクトができる
タイムゾーンを東京にしておかないと、受付日時が9時間ずれて表示されることがあります。GASのエディタの「プロジェクトの設定」にもタイムゾーンがあるので、そちらも東京にそろえます。

4. コードを貼る
エディタの「コード.gs」の中身を消して、次のコードをそのまま貼り、保存します。チャネルアクセストークンなどの秘密の値はコードに書かず、手順5のスクリプトプロパティに入れます。
// サンプル料理教室:LINEの問い合わせを「問い合わせ」シートに1件1行で記録する
const SHEET_NAME = '問い合わせ';
const COL_BODY = 3; // C列:本文
const COL_MESSAGE_ID = 8; // H列:メッセージID
const COL_EVENT_ID = 9; // I列:イベントID
function doPost(e) {
const props = PropertiesService.getScriptProperties();
// 合言葉が合わないリクエストは何もしない(GASでは署名を読めないための代わり)
if (!e || !e.parameter || e.parameter.key !== props.getProperty('WEBHOOK_KEY')) {
return ContentService.createTextOutput('ignored');
}
const body = JSON.parse(e.postData.contents);
// 宛先が自分の公式アカウントか確かめる(BOT_USER_IDを入れたときだけ)
const botUserId = props.getProperty('BOT_USER_ID');
if (botUserId && body.destination !== botUserId) {
return ContentService.createTextOutput('ignored');
}
// 「検証」ボタンや疎通確認はイベントが空で届く
const events = body.events || [];
if (events.length === 0) {
return ContentService.createTextOutput('ok');
}
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
const added = [];
const lock = LockService.getScriptLock();
lock.waitLock(20000);
try {
const seen = getRecentIds_(sheet);
events.forEach(function (ev) {
if (seen.has(ev.webhookEventId)) return; // 同じイベントは2回書かない
if (ev.type === 'unsend') {
markUnsent_(sheet, ev.unsend.messageId);
return;
}
const row = toRow_(ev, props);
if (row) {
added.push(row);
seen.add(ev.webhookEventId);
}
});
if (added.length > 0) {
sheet.getRange(sheet.getLastRow() + 1, 1, added.length, added[0].length).setValues(added);
SpreadsheetApp.flush();
}
} finally {
lock.releaseLock();
}
added.forEach(function (row) { notify_(row, props); });
return ContentService.createTextOutput('ok');
}
// Webhookイベント1件を、シートの1行(A〜I列)に変える
function toRow_(ev, props) {
const userId = (ev.source && ev.source.userId) ? ev.source.userId : '';
let kind = '';
let text = '';
let messageId = '';
if (ev.type === 'message' || ev.type === 'messageEdited') {
const m = ev.message;
kind = (ev.type === 'message') ? 'メッセージ' : '編集';
messageId = "'" + m.id; // 先頭の ' で数値に変換されないようにする
if (m.type === 'text') text = m.text;
else if (m.type === 'image') text = '[画像]';
else if (m.type === 'video') text = '[動画]';
else if (m.type === 'audio') text = '[音声]';
else if (m.type === 'file') text = '[ファイル] ' + m.fileName;
else if (m.type === 'location') text = '[位置情報] ' + (m.address || '');
else if (m.type === 'sticker') text = '[スタンプ]';
else text = '[' + m.type + ']';
} else if (ev.type === 'follow') {
kind = '友だち追加';
} else if (ev.type === 'unfollow') {
kind = 'ブロック';
} else if (ev.type === 'postback') {
kind = 'ボタン';
text = ev.postback.data;
} else {
return null; // それ以外のイベントは記録しない
}
const name = (userId && ev.type !== 'unfollow') ? getDisplayName_(userId, props) : '';
const status = (kind === 'メッセージ') ? '未対応' : '';
return [new Date(ev.timestamp), name, text, status, '', userId, kind, messageId, ev.webhookEventId];
}
// プロフィールAPIで表示名を取る(6時間キャッシュ)
function getDisplayName_(userId, props) {
const cache = CacheService.getScriptCache();
const hit = cache.get('name_' + userId);
if (hit) return hit;
const res = UrlFetchApp.fetch('https://api.line.me/v2/bot/profile/' + encodeURIComponent(userId), {
headers: { Authorization: 'Bearer ' + props.getProperty('CHANNEL_ACCESS_TOKEN') },
muteHttpExceptions: true
});
if (res.getResponseCode() !== 200) return '(取得できず)';
const name = JSON.parse(res.getContentText()).displayName;
cache.put('name_' + userId, name, 21600);
return name;
}
// 直近500行のイベントIDを集める(重複を弾くため)
function getRecentIds_(sheet) {
const last = sheet.getLastRow();
if (last < 2) return new Set();
const from = Math.max(2, last - 499);
const values = sheet.getRange(from, COL_EVENT_ID, last - from + 1, 1).getValues();
return new Set(values.map(function (r) { return String(r[0]); }));
}
// 送信取消されたメッセージの本文を消す
function markUnsent_(sheet, messageId) {
const last = sheet.getLastRow();
if (last < 2) return;
const ids = sheet.getRange(2, COL_MESSAGE_ID, last - 1, 1).getValues();
for (let i = ids.length - 1; i >= 0; i--) {
if (String(ids[i][0]) === String(messageId)) {
sheet.getRange(i + 2, COL_BODY).setValue('(送信取消されました)');
return;
}
}
}
// スタッフへの通知(メッセージのときだけ)
function notify_(row, props) {
if (row[6] !== 'メッセージ') return;
const url = SpreadsheetApp.getActiveSpreadsheet().getUrl();
const text = 'LINEに問い合わせが届きました\n' + row[1] + ':' + row[2] + '\n' + url;
const to = props.getProperty('NOTIFY_EMAIL');
if (to) {
MailApp.sendEmail(to, '【LINE問い合わせ】' + row[1], text);
}
const chatUrl = props.getProperty('CHAT_WEBHOOK_URL');
if (chatUrl) {
UrlFetchApp.fetch(chatUrl, {
method: 'post',
contentType: 'application/json; charset=UTF-8',
payload: JSON.stringify({ text: text }),
muteHttpExceptions: true
});
}
}
// エディタから1回だけ実行して、権限の承認と通知の届き方を確かめる
function testNotify() {
notify_([new Date(), 'テスト', '通知の確認です', '未対応', '', '', 'メッセージ', '', ''],
PropertiesService.getScriptProperties());
}
補足は4つです。1つのWebhookには別々の友だちのイベントがまとめて入ることがあるので、events は全部回します。同じイベントが重複して届くことがあるので、イベントIDが直近の行にあれば書きません。同時に来たリクエストの書き込みがぶつからないよう、LockService で順番に書きます。送信取消イベントが届いたら、公式の推奨どおり保存した本文を消します。
5. スクリプトプロパティを入れる
- エディタの左のメニューで「プロジェクトの設定」(歯車)を押す
- いちばん下の「スクリプト プロパティ」で「スクリプト プロパティを追加」を押す
- 下の表のプロパティを1つずつ入れる
- 「スクリプト プロパティを保存」を押す
| プロパティ | 入れる値 | 必須 |
|---|---|---|
CHANNEL_ACCESS_TOKEN | 手順2でコピーした長期のトークン | 必須 |
WEBHOOK_KEY | 推測されにくい英数字。例 k7Qm2xR9pL4w のような20文字前後 | 必須 |
NOTIFY_EMAIL | 通知を受けるアドレス。例 staff@example.co.jp。複数はカンマ区切り | 通知に使うとき |
CHAT_WEBHOOK_URL | Google ChatのWebhookのURL | 通知に使うとき |
BOT_USER_ID | 公式アカウントのユーザーID(U から始まる) | 任意 |
BOT_USER_ID は、ボットの情報を取得するAPIで調べられます。PCのターミナルで次を実行し、返ってきたJSONの userId を入れます。
curl -s https://api.line.me/v2/bot/info \
-H 'Authorization: Bearer YOUR_CHANNEL_ACCESS_TOKEN'
最後に、エディタ上部の関数の選択で testNotify を選んで「実行」を押します。初回は権限の承認画面が出るので、自分のアカウントを選んで許可します。「このアプリは Google で確認されていません」と出たら、「詳細」から自分のプロジェクトへ進みます。通知メールが届けば、メール送信の権限は通っています。

6. ウェブアプリとしてデプロイする
- エディタ右上の「デプロイ」>「新しいデプロイ」を押す
- 「種類の選択」の歯車を押して「ウェブアプリ」を選ぶ
- 説明に「LINE問い合わせ記録 v1」と入れる
- 「次のユーザーとして実行」は「自分」にする
- 「アクセスできるユーザー」は「全員」にする
- 「デプロイ」を押し、表示された「ウェブアプリ」のURL(末尾が
/exec)をコピーする
「アクセスできるユーザー」を「全員」にしないと、LINEプラットフォームはGoogleにログインしていないので、ログイン画面に飛ばされて届きません。「全員」にしても、合言葉が合わないリクエストはコードで弾きます。

7. Webhook URLを設定して検証する
Webhook URLは、コピーしたURLの末尾に合言葉をつけた形にします。
https://script.google.com/macros/s/XXXXXXXXXXXXXXXX/exec?key=k7Qm2xR9pL4w
- LINE Developersコンソールでチャネルを開き、「Messaging API設定」タブを開く
- 「Webhook URL」の「編集」を押し、上のURLを入れて「更新」を押す
- 「検証」を押す
- 「Webhookの利用」をオンにする
- 「エラーの統計情報」をオンにする(あとで失敗を見るため)
- 「Webhookの再送」はオフのままにする
URLを入れて「更新」を押すまでは、「Webhook設定」の欄には「Webhook URL」と「編集」しか出ていません。「検証」「Webhookの利用」「Webhookの再送」「エラーの統計情報」は、URLを保存したあとに出ます。保存した直後は「Webhookの利用」がオン、「Webhookの再送」と「エラーの統計情報」はオフになっていました。
再送をオフにする理由は「GASの制限と、n8nに置き換える場合」に書きました。URLは500文字以内で、HTTPSである必要があります。GASのURLはどちらも満たしています。

8. 自分のスマホから送って確かめる
- 「Messaging API設定」タブのQRコードをスマホで読み、公式アカウントを友だち追加する(すでに友だちなら飛ばす)
- スマホのLINEから「体験レッスンは土曜もありますか」と送る
- スプレッドシートを開き、2行目に受付日時・表示名・本文・「未対応」が入ったことを確かめる
- 通知メールかGoogle Chatのメッセージが届いたことを確かめる
- スタンプを1つ送り、本文が [スタンプ] の行が増えることを確かめる
- 送ったメッセージを送信取消し、本文が「(送信取消されました)」に変わることを確かめる
行が増えないときは、GASエディタの左のメニューの「実行数」を開きます。doPost が動いていればLINEからは届いていて、問題はコードかシートの側です。doPost が1件も無ければ、届いていないので、Webhook URLとデプロイの設定を見直します。「種類」の列は、エディタで実行した分が「エディタ」、LINEから呼ばれた分が「ウェブアプリ」と出るので、LINEから届いたかはここで見分けます。

署名の検証をGASでどう扱うか
LINEの公式ドキュメントでは、Webhookのリクエストヘッダー x-line-signature に入った署名を、チャネルシークレットを鍵にしたHMAC-SHA256でリクエストボディから計算した値と比べ、一致したものだけ処理するよう求めています。一致すれば、LINEプラットフォームから送られ、途中で改ざんされていないことが確かめられます。
GASには、HMAC-SHA256を計算する Utilities.computeHmacSha256Signature も、Base64にする Utilities.base64Encode もあります。計算はできます。できないのは、比べる相手の署名を受け取ることです。GASのWebアプリの doPost が受け取るイベントオブジェクトには、クエリの値(e.parameter)とボディ(e.postData.contents)はありますが、リクエストヘッダーの項目がありません。署名がヘッダーで届く以上、GASだけでは公式どおりの検証ができません。
そこで、このコードでは代わりに2つを確かめています。
- Webhook URLの末尾につけた合言葉(
key)が、スクリプトプロパティの値と一致するか - ボディの
destination(受け取るべき公式アカウントのユーザーID)が、自分の公式アカウントか
合言葉は、URLを知らない第三者のリクエストを弾くだけのものです。URLが漏れれば効かず、改ざんも検出できません。合言葉入りのURLはマニュアルに貼らず、漏れたと思ったら合言葉を変えてWebhook URLを更新します。この仕組みが起こすのは「1行書く」と「通知する」だけなので、なりすましが来ても偽の行が増える以上のことは起きません。予約の確定やクーポンの発行のように、偽のリクエストで実害が出る処理をWebhookで動かすなら、署名を検証できるn8nやクラウドのサーバーで受けます。
スタッフへの通知:GmailとGoogle Chat
通知はスクリプトプロパティに値を入れたほうだけ動きます。両方入れれば両方に届きます。
Gmailで通知する場合は、NOTIFY_EMAIL にスタッフのアドレスを入れるだけです。GASの MailApp.sendEmail で送るので、送信元はGASを作った人のGmailになります。1日に送れる宛先数には上限があり、個人のGoogleアカウントで1日100、Google Workspaceで1日1,500です(2026年10月時点)。宛先3人に送ると1件で3と数えるので、個人アカウントで3人に送ると、1日33件を超えたあたりで止まります。問い合わせが多い日がある教室は、宛先を共有のアドレス1つにまとめます。
Google Chatで通知する場合は、Business版かEnterprise版のGoogle Workspaceが必要で、組織の管理者がWebhookの利用を許可している必要があります。設定はPCのブラウザで行います。スマホのGoogle Chatアプリからは設定できません。
- PCのブラウザで Google Chat を開き、「LINE問い合わせ」というスペースを作ってスタッフを入れる
- スペース名の横の下向き矢印を押し、「アプリと統合」を押す
- 「Webhookを追加」を押す
- 名前に「LINE問い合わせ通知」と入れて「保存」を押す
- 作ったWebhookの縦3点のメニューから「リンクをコピー」を押す
- コピーしたURLをGASのスクリプトプロパティ
CHAT_WEBHOOK_URLに入れる
「Webhookを追加」が押せないときは、組織でWebhookが禁止されています。Workspaceの管理者に許可を頼むか、Gmailで通知します。Google ChatのWebhookは、1つのスペースにつき毎秒1回までです。同じ秒に何件も届くと取りこぼすことがあるので、一斉配信のあとに返信が殺到する教室では、Gmailと併用しておきます。WebhookのURLには秘密の値が含まれるので、スプレッドシートやマニュアルに貼りません。Slackへ通知したい場合は「LINEとn8n・Make・Zapierをつなぐ:コードを書かずに自動化する」で扱います。

管理画面のチャットと同時に使うときの注意
Webhookを使っても、管理画面のチャットは使えます。LINE Developersには2022年12月に「LINE公式アカウントでWebhookとチャットが併用できるようになりました」というお知らせが出ています。サンプル料理教室の運用は、Webhookで記録と通知を受け、返信はこれまでどおり管理画面のチャットかLINE公式アカウントアプリのチャットで行う形です。
同時に使うときに知っておくことを表にします。
| 気になること | どうなるか | どうするか |
|---|---|---|
| チャットで届いたメッセージ | Webhookがオンなら、友だちが送ったメッセージはWebhookでも届き、シートに記録される | そのままでよい |
| スタッフがチャットで送った返信 | Webhookのイベントの種類に、スタッフの返信を表すものは無い。シートには記録されない | 返したらシートの対応状況と担当を変える |
| 既読 | チャットがオンだと、自動では既読が付かない設定になる | 既読の付き方はチャットの運用に任せる |
| 応答メッセージ | 有効にしていれば、メッセージを受けたときに公式アカウントが自動で応答する | 記録だけのこの仕組みなら、今の応答設定のままでよい |
| あいさつメッセージ | 有効にしていれば、友だち追加のときに自動で送られる | そのままでよい。シートには「友だち追加」の行が入る |
注意したいのは、GASで自動返信を足すときです。応答メッセージがオンのまま、GASからも返信すると、友だちには2通届きます。公式も、Messaging APIで応答するなら管理画面の応答メッセージとあいさつメッセージはオフにするよう勧めています。記録だけのうちは触らなくて構いません。
もう1つは、返信した記録がシートに自動では残らないことです。実際に運用していると、チャットで返したのにシートが「未対応」のまま残り、翌朝別のスタッフが同じ人に二重に返す、ということが起きます。サンプル料理教室では「チャットで返したら、その場でシートのD列を対応済みにする」を決まりにし、毎日の閉店前に「未対応」で絞り込んで残りを確かめています。チャットの運用そのものは「チャットの運用:手動返信と自動応答の切り替え、通知、対応漏れを防ぐ」に書きました。

応用
未対応だけの一覧を別のシートに出す
スタッフ用に「未対応」というシートを作り、A1に次の式を入れます。元の「問い合わせ」シートから、対応状況が未対応の行だけが受付の新しい順に出ます。
=SORT(FILTER('問い合わせ'!A2:E, '問い合わせ'!D2:D="未対応"), 1, FALSE)
条件付き書式で、受付から24時間を過ぎた未対応の行を赤くしておくと、返し忘れに気づけます。範囲A2:Eに「カスタム数式」で =AND($D2="未対応", NOW()-$A2>1) を指定します。
画像を受け取ったらドライブに保存する
本文が [画像] の行は、画像そのものはシートに残りません。必要なら、メッセージIDを使って https://api-data.line.me/v2/bot/message/{messageId}/content からデータを取り、Googleドライブに保存してURLをシートに書けます。ただし動画や音声は準備に時間がかかることがあり、準備中はデータが取れません。メニューの写真や領収書の画像を受け取る教室だけ足します。
顧客管理のkintoneにも入れる
体験から入会までを顧客ごとに追いたくなったら、スプレッドシートではなくkintoneに入れたほうが扱いやすくなります。その作り方は「LINEとkintoneをつなぐ:友だちとやりとりを顧客管理に入れる」に書きました。
GASの制限と、n8nに置き換える場合
GASは無料で始められる反面、Webhookの受け口としては弱いところがあります。2026年10月時点の主な制限です。
| 制限 | 内容 | この仕組みへの影響 |
|---|---|---|
| LINEの応答待ち | LINEは2秒以内に応答が無いと request_timeout として数える | GASは起動に時間がかかることがあり、エラーの統計情報にタイムアウトが並ぶことがある |
| 応答の形 | GASの返却は script.googleusercontent.com の一度きりのURLへリダイレクトされる | LINEは200番台以外を再送の条件にしている。再送をオンにすると同じイベントが何度も届くおそれがある |
| 署名の検証 | doPost からリクエストヘッダーを読めない | 公式どおりの署名の検証ができない |
| 同時実行数 | 1ユーザーあたり30 | 一斉配信の直後に返信が殺到すると、詰まることがある |
| 外部へのリクエスト | 個人アカウント1日20,000回、Workspace1日100,000回 | 表示名の取得と通知の回数。小さな教室なら届かない |
| メール送信 | 個人アカウント1日100宛先、Workspace1日1,500宛先 | 通知の宛先数で数える |
実際に運用していると、エラーの統計情報にタイムアウトや302が記録されていても、シートには行が書けていることがほとんどです。LINE側が待ちきれずに切っても、GASの処理はそのまま最後まで動くからです。だから再送はオフにし、念のためイベントIDで重複を弾いています。再送をオンにしたいなら、GASの外で受けます。
置き換えを考えるのは、次のどれかに当てはまったときです。
- 予約の確定など、署名の検証が欠かせない処理をWebhookで動かしたい
- 一斉配信のあとに数百件の返信が同時に届き、取りこぼしが出始めた
- スプレッドシートだけでなく、Slackやkintoneにも同時に流したい
- 拡張ツールとGASの両方でWebhookを受けたい
n8nなら、Webhookノードでヘッダーとボディをそのまま受け取れるので、署名の検証を公式どおりに書け、2秒以内に200を返してから後ろの処理を進める組み方もできます。セルフホストなら月額のツール代もかかりません。作り方は「LINEとn8n・Make・Zapierをつなぐ:コードを書かずに自動化する」に書きました。シートの列設計はそのまま使えるので、受け口だけを差し替えれば済みます。
できないことと、その代わり
| できないこと | 理由 | 代わりの手 |
|---|---|---|
| 仕組みを作る前に届いたメッセージをシートに入れる | 受け取ったあとでテキストを取り直すAPIは無い | 過去分は管理画面のチャットで見る。必要な分だけ手で転記する |
| Webhookが落ちていた間のメッセージをあとから取る | 同上。再送は確実ではなく、GASでは使いにくい | 毎朝、管理画面のチャットの未読とシートの件数を見比べる |
| スタッフがチャットで送った返信を自動で記録する | Webhookのイベントに含まれない | 対応状況と担当をスタッフがシートで変える |
| スタンプの画像を残す | WebhookではスタンプのIDは取れるが画像は取れない | 本文は [スタンプ] とだけ残す |
| ブロックした人の表示名を取る | ブロック中の人のプロフィールは取得できない | 友だち追加のときの行の表示名で見る |
| 他のツールが使っているWebhookと同時に受ける | Webhook URLは1チャネルに1つだけ | そのツールにWebhookを転送する機能があるか公式ページで確かめる。無ければn8nで受けて振り分ける |
よく詰まるところ
| 症状 | 原因 | 直し方 |
|---|---|---|
| 「検証」を押すとエラーになり、302と出る | GASの返却がリダイレクトされる仕様のため | 「実行数」に doPost が出ていれば受信はできている。スマホから送ってシートに行が増えるかで判断する |
| 「検証」でエラーになり、「実行数」にも何も出ない | 「アクセスできるユーザー」が「全員」になっておらず、ログイン画面に飛ばされている | デプロイを管理で「全員」に直す |
| 「検証」は通るのに、メッセージを送っても行が増えない | 「Webhookの利用」がオフ | 「Messaging API設定」タブで「Webhookの利用」をオンにする |
| コードを直したのに動きが変わらない | /exec のURLは、デプロイしたときのバージョンで動き続ける | 「デプロイ」>「デプロイを管理」で鉛筆を押し、バージョンを「新バージョン」にして「デプロイ」。URLは変わらない |
| 再デプロイしたら一切届かなくなった | 「新しいデプロイ」を作ったのでURLが変わった | 新しいURLにWebhook URLを入れ直す。普段は「デプロイを管理」から更新する |
「実行数」に doPost が出るが、すぐ終わって行が増えない | 合言葉が違う。URLの ?key= とスクリプトプロパティの値が一致していない | 前後の空白も含めて同じ値にそろえる |
| 「実行数」に失敗があり、「権限が必要です」と出る | コードに通知を足したあと、権限を承認していない | エディタから testNotify を実行して承認し、新バージョンでデプロイし直す |
| 表示名が「(取得できず)」になる | チャネルアクセストークンが違うか、再発行で無効になった。友だちでない人やブロック中の人は取れない | トークンをスクリプトプロパティに入れ直す |
| 受付日時が9時間ずれる | スプレッドシートかGASのタイムゾーンが東京でない | 両方の設定を「(GMT+09:00) Tokyo」にする |
| 同じメッセージが2行入る | 再送がオン、またはネットワークの都合で重複して届いた | 再送をオフにする。イベントIDの重複チェックが入ったコードを使う |
| メッセージIDの下の桁が0になる | 長い数字がシートで数値に変換された | H列を「書式なしテキスト」にする。コードの "'" + を消さない |
| 通知メールが途中から届かなくなる | 1日のメール送信の上限に達した | 宛先を1つにまとめる。Google Chatの通知と併用する |
| 行が書き込まれる位置がずれる | シートの下のほうに書式や値だけ入ったセルがあり、最終行と判定されている | 2行目以降の空いている行を削除して詰める |
次にやること
問い合わせがシートに残るようになったら、次は記録の行き先と、返信の運用を整えます。
- LINEとkintoneをつなぐ:友だちとやりとりを顧客管理に入れる
- LINEとn8n・Make・Zapierをつなぐ:コードを書かずに自動化する
- Messaging APIの始め方:チャネル・アクセストークン・Webhook
- チャットの運用:手動返信と自動応答の切り替え、通知、対応漏れを防ぐ