予約の前日と当日に「明日19時からです」と届くだけで、うっかり忘れの無断キャンセルはかなり防げます。電話やメールで1件ずつ送っていた連絡を、LINE公式アカウントから自動で送る形にするのがこの記事の内容です。例はいつもの「サンプル料理教室」で、Googleスプレッドシートの予約一覧から、前日18時に翌日の予約者へ、当日朝にその日の予約者へLINEを送る仕組みをGAS(Google Apps Script)とMessaging APIで作ります。読み終わると、コードを貼ってトリガーを入れれば翌日から動く状態になります。前提として、LINE公式アカウントとMessaging APIのチャネルはできていて、チャネルアクセストークン(長期)を発行できる状態とします。まだなら「Messaging APIの始め方:チャネル・アクセストークン・Webhook」を先に済ませてください。
この記事の内容
先に決めること
ここを決めずに作り始めると、送る仕組みはできたのに誰にも届かない、という状態になります。
- 予約台帳をどこに置くか。スプレッドシート、予約システム、kintoneのどれが「正」かを1つに決めます。リマインドはその台帳を読んで送るので、台帳が2つあるとキャンセルが反映されずに送ってしまいます
- 予約とLINEの友だちをどう結びつけるか。リマインドはLINEのユーザーID(userId)あてに送ります。予約一覧に名前と電話番号があっても、userIdが無ければ送れません。この記事で一番手間がかかるのはここです
- いつ、何回送るか。サンプル料理教室は、体験レッスンは前日18時と当日朝の2回、通常レッスンは前日18時の1回にしています
- 月に何通になるか。リマインドはプッシュメッセージなので、1人に1回送るごとに1通として数えられます。無料のコミュニケーションプランは月200通で、超えると送れなくなります(2026年10月時点)
- キャンセルを誰がどこに反映するか。電話で受けたキャンセルを台帳に入れ忘れると、キャンセルした人に「明日お待ちしています」が届きます
リマインドを送る方法の比較
送り方は大きく4つあります。どれも「予約した本人のLINEに、決まった時刻に送る」点は同じで、違うのはuserIdを誰が持つかと、毎月の費用です。
| 方法 | 送る仕組み | userIdの扱い | かかる費用 | 向く教室 |
|---|---|---|---|---|
| 予約システムのLINE連携 | 予約システムが予約日時を見て自動で送る | 予約システムが予約時に取って持つ | 予約システムの月額+LINEの通数 | 予約の受付から台帳まで1つのシステムに任せたい |
| 管理画面から手で送る | スタッフがチャットで1人ずつ送る、または配信を予約する | 使わない(チャットの相手に送る) | チャットは通数に入らない | 予約が1日数件で、スタッフが毎日決まった時間を取れる |
| Messaging APIで自動送信(GAS・n8n) | スプレッドシートを読んで、プッシュで1人ずつ送る | 自分でWebhookやLIFFで取り、台帳に入れる | 通数のみ(GASは無料枠で足りる) | 台帳はスプレッドシートのまま、送るところだけ自動にしたい |
| L Harnessのリマインダ配信 | 基準日時からの前後で決めた時刻に送る | L Harnessが友だち追加のWebhookで自動登録する | ソフト0円+Cloudflareの利用量+通数 | ステップ配信やフォームもまとめて自前で持ちたい |
LINEヤフーの「LINEで予約」は、LINE公式アカウントから来店予約を受ける機能ですが、サービス紹介のページでは飲食店が対象で、連携先も飲食店向けの予約サービスです(2026年10月時点)。料理教室のレッスン予約には使えない前提で考えます。予約の受け方そのものの比べ方は「LINEで予約を受ける:予約機能・外部の予約システム・自作の比べ方」にまとめています。
LINE Official Account Manager(管理画面)だけで回す場合、いちばん現実的なのはチャットで1人ずつ送るやり方です。LINEチャットの送受信は通数に数えられません。ただし、前日の夕方に必ず誰かが管理画面を開いて全員に送る運用は、休みの日や繁忙期に抜けます。チャットのタグからオーディエンスを作り、日時を指定したメッセージ配信で送る手もあります(PCでは「今すぐ配信」の選択を外して日時を入れ、アプリでは「配信予約」をオンにします)。ただ、オーディエンスは有効になるまで数時間かかる場合があり、種類によってはサイズが50以上ないと配信に使えないと書かれています。毎日入れ替わる翌日の予約者だけに送る用途には向きません。
userIdをどう手に入れるか(ここが一番の壁)
リマインドの仕組みで詰まる人のほとんどは、送るコードではなくここで止まります。Messaging APIのプッシュは、送り先に U で始まる33文字のuserIdを指定します。名前や電話番号では送れません。
表示名からはたどれない
管理画面で友だちの表示名は見えますが、予約一覧の「山田花子」と友だちの「はなこ🍳」が同じ人かは、名前からは決められません。表示名は本人がいつでも変えられ、同じ名前の人もいます。公式に案内されているuserIdの取り方は、Webhookで受け取る、友だち全員のuserId一覧を取得する(認証済アカウントかプレミアムアカウントのみ)、グループのメンバーを取得する、のいずれかです。友だち一覧を取れても、どのuserIdが予約したどの人かは分からないので、予約の時点で結びつけるしかありません。
もう1つの落とし穴は、userIdがプロバイダーごとに違う値になることです。同じ人でも、別のプロバイダーで作ったLINEログインやLIFFのチャネルで取ったuserIdは、リマインドを送るMessaging APIのチャネルでは使えず、プッシュが 400 で返ります。LIFFやLINEログインのチャネルは、Messaging APIのチャネルと同じプロバイダーの下に作ります。
結びつけ方は4つ
| 結びつけ方 | 仕組み | 作る手間 | 確実さ |
|---|---|---|---|
| チャットで予約を受ける | 友だちがトークで予約を送る。Webhookに source.userId と本文が届くので、スタッフが予約一覧に貼る | 小さい | スタッフの貼り間違いが起きる |
| 予約番号を送ってもらう | Webフォームで予約→完了画面と確認メールで「LINEで予約番号を送ってください」→Webhookで受けた番号と台帳を照合 | 中くらい | 送ってくれない人には届かない |
| LIFFの予約フォーム | LINEの中で開くフォームで予約を受け、IDトークンをサーバーで検証してuserIdを取る | 大きい | 確実。友だちでない人も予約はできる |
| LINEログイン付きのWeb予約 | 予約フォームの前にLINEでログインしてもらい、友だち追加も同じ画面で促す | 大きい | 確実。友だち追加を断られることがある |
Webhookにはもう1つ注意があります。ユーザーがプロフィール情報の取得に同意していない場合、WebhookにuserIdは含まれません。チャットで予約を受ける方式でも、userIdが空の行は出ると考えておきます。
LIFFを使う場合、liff.getProfile() で取ったuserIdをそのままサーバーへ送るのはやめます。公式の案内どおり、liff.getIDToken() で取ったIDトークンをサーバーへ送り、POST https://api.line.me/oauth2/v2.1/verify で検証してから、中の sub(ユーザーID)を台帳に入れます。ブラウザから届いたuserIdを信じると、他人のuserIdで予約を入れられてしまいます。LINEログインで友だち追加を促すには、認可URLに bot_prompt=normal(同意画面に友だち追加の選択肢を出す)か bot_prompt=aggressive(同意の後に別画面で聞く)を付け、LINE Developersの「チャネル基本設定」で「リンクされたLINE公式アカウント」を設定します。
サンプル料理教室のやり方
サンプル料理教室は2段で運用しています。体験レッスンはリッチメニューの「体験レッスンの予約」からトークで受けるので、Webhookで届いたuserIdをそのまま予約一覧に入れます。Webhookで1件ずつシートに記録する仕組みは「LINEの問い合わせをスプレッドシートに貯める:Webhookで1件ずつ記録する」で作ったものをそのまま使います。通常レッスンは会員が予約フォームから取るので、予約完了画面に「LINEで予約番号を送ると前日にお知らせが届きます」と出し、番号が届いたら照合してuserIdを埋めます。
実際に運用していると、予約番号を送ってくれない人は必ず一定数います。その人には従来どおりメールで送ります。全員をLINEに寄せようとせず、userIdが入っている人だけLINEで送る、と割り切ると止まりません。

スプレッドシートから前日18時に送る仕組みを作る
ここからが本体です。GASはスプレッドシートに付いているスクリプトなので、サーバーを用意せずに動かせます。
予約一覧シートの列
シート名は「予約一覧」、1行目を見出しにして、次の列にします。列の順番を変えるときはコードの COL も合わせて直します。
| 列 | 見出し | 入れるもの | 例 |
|---|---|---|---|
| A | 予約番号 | 予約ごとに重ならない番号 | R-1105-01 |
| B | 開始日時 | 日付と時刻を1つのセルに | 2026/11/05 19:00 |
| C | コース | レッスン名 | 体験・和食の基本 |
| D | 氏名 | 予約者の名前 | 山田 花子 |
| E | userId | U で始まる33文字 | U4af4980629... |
| F | ステータス | 予約 キャンセル 来店 | 予約 |
| G | 前日送信 | 送った日時(スクリプトが書く) | 空欄 |
| H | 当日送信 | 送った日時(スクリプトが書く) | 空欄 |
| I | 返答 | 参加・キャンセル希望(応用で使う) | 空欄 |
| J | エラー | 送れなかった理由(スクリプトが書く) | 空欄 |
開始日時を日付と時刻の2列に分けると、時刻の列が「1899年12月30日 19:00」という日時として読まれ、翌日の判定がずれます。1つのセルに入れて、「表示形式」>「数字」>「日時」にしておきます。見た目を 2026/11/08 19:00 の形にそろえたいときは、同じメニューの「カスタム日時」で yyyy/MM/dd HH:mm にしてもかまいません。その場合、メニューの「日時」にはチェックが付きませんが、日時として読まれます。

手順(PCのブラウザで)
- 予約一覧のスプレッドシートを開き、「ファイル」→「設定」で「タイムゾーン」が「(GMT+09:00) 東京」になっているか見る
- 「拡張機能」→「Apps Script」を開く
- 左の「プロジェクトの設定」を開き、「「appsscript.json」マニフェスト ファイルをエディタで表示する」にチェックを入れる
- エディタに戻り、
appsscript.jsonのtimeZoneをAsia/Tokyoにする - 「プロジェクトの設定」の「スクリプト プロパティ」で、プロパティ
LINE_CHANNEL_ACCESS_TOKENに、LINE Developersで発行したチャネルアクセストークン(長期)を入れて保存する コード.gsの中身を消し、下のコードを貼って保存する- 関数の選択で
setupTriggersを選んで「実行」を押す - 初回だけ出る権限の確認で、自分のGoogleアカウントを選んで許可する
- 左の「トリガー」を開き、
sendPrevDayRemindersとsendTodayRemindersの2つが並んでいるか見る
トークンをコードに直接書くと、スクリプトを共有した相手やコピーした人に見えてしまいます。スクリプトプロパティに入れておけば、コードを人に見せても漏れません。

コード
// 予約一覧から、前日18時台と当日8時台にLINEのリマインドを送る
const SHEET_NAME = '予約一覧';
const COL = { id: 1, start: 2, course: 3, name: 4, userId: 5, status: 6, sentPrev: 7, sentToday: 8, answer: 9, error: 10 };
const PUSH_URL = 'https://api.line.me/v2/bot/message/push';
const SCHOOL_TEL = '03-0000-0000';
const SCHOOL_MAP = 'https://example.com/access';
const TRIAL_PREFIX = '体験';
function sendPrevDayReminders() { sendReminders_('prev'); }
function sendTodayReminders() { sendReminders_('today'); }
function sendReminders_(kind) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(30000)) return; // 前の実行が終わっていなければ今回は見送る
try {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
const values = sheet.getDataRange().getValues();
const tz = Session.getScriptTimeZone();
const target = new Date();
if (kind === 'prev') target.setDate(target.getDate() + 1);
const targetDay = Utilities.formatDate(target, tz, 'yyyy-MM-dd');
const sentCol = kind === 'prev' ? COL.sentPrev : COL.sentToday;
for (let r = 1; r < values.length; r++) {
const row = values[r];
const start = row[COL.start - 1];
if (!(start instanceof Date)) continue;
if (Utilities.formatDate(start, tz, 'yyyy-MM-dd') !== targetDay) continue;
if (row[COL.status - 1] !== '予約') continue;
if (row[sentCol - 1]) continue; // 送信済みは二度送らない
if (String(row[COL.answer - 1]).indexOf('キャンセル') === 0) continue;
const isTrial = String(row[COL.course - 1]).indexOf(TRIAL_PREFIX) === 0;
if (kind === 'today' && !isTrial) continue; // 当日朝は体験レッスンだけ
const userId = String(row[COL.userId - 1]).trim();
if (!/^U[0-9a-f]{32}$/.test(userId)) {
sheet.getRange(r + 1, COL.error).setValue('userIdなし(メールか電話で連絡)');
continue;
}
const result = push_(userId, buildMessages_(kind, row, tz));
if (result.ok) {
sheet.getRange(r + 1, sentCol).setValue(new Date());
sheet.getRange(r + 1, COL.error).setValue('');
} else {
sheet.getRange(r + 1, COL.error).setValue(result.code + ' ' + result.body);
}
}
} finally {
lock.releaseLock();
}
}
function buildMessages_(kind, row, tz) {
const start = row[COL.start - 1];
const youbi = '日月火水木金土'.charAt(start.getDay());
const when = Utilities.formatDate(start, tz, 'M月d日') + '(' + youbi + ')' + Utilities.formatDate(start, tz, 'H:mm');
const name = row[COL.name - 1];
const course = row[COL.course - 1];
const id = row[COL.id - 1];
if (kind === 'prev') {
const text = name + '様\n明日 ' + when + 'から「' + course + '」のレッスンです。\n\n'
+ '持ち物:エプロン・ハンドタオル・筆記用具\n'
+ '開始10分前から入室できます。\n'
+ '場所:' + SCHOOL_MAP + '\n\n'
+ 'ご都合が悪くなった場合は、下の「キャンセルします」を押すか、'
+ SCHOOL_TEL + 'までご連絡ください。\n予約番号:' + id;
return [{
type: 'text',
text: text,
quickReply: { items: [
{ type: 'action', action: { type: 'postback', label: '参加します', data: 'a=join&id=' + id, displayText: '参加します' } },
{ type: 'action', action: { type: 'postback', label: 'キャンセルします', data: 'a=cancel&id=' + id, displayText: 'キャンセルします' } }
] }
}];
}
const text = name + '様\n本日 ' + when + 'から「' + course + '」です。お待ちしています。\n'
+ '遅れそうなときは ' + SCHOOL_TEL + ' か、このトークでお知らせください。';
return [{ type: 'text', text: text }];
}
function push_(userId, messages) {
const token = PropertiesService.getScriptProperties().getProperty('LINE_CHANNEL_ACCESS_TOKEN');
const params = {
method: 'post',
contentType: 'application/json',
headers: {
Authorization: 'Bearer ' + token,
'X-Line-Retry-Key': Utilities.getUuid() // 再試行しても二重に届かないようにする
},
payload: JSON.stringify({ to: userId, messages: messages }),
muteHttpExceptions: true
};
let res;
for (let i = 0; i < 2; i++) {
res = UrlFetchApp.fetch(PUSH_URL, params);
const code = res.getResponseCode();
if (code === 200 || code === 409) return { ok: true, code: code }; // 409は同じキーで受理済み
if (code < 500) break; // 400・401・429はやり直しても通らない
Utilities.sleep(3000);
}
return { ok: false, code: res.getResponseCode(), body: res.getContentText().slice(0, 200) };
}
function setupTriggers() {
ScriptApp.getProjectTriggers().forEach(function (t) {
const f = t.getHandlerFunction();
if (f === 'sendPrevDayReminders' || f === 'sendTodayReminders') ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('sendPrevDayReminders').timeBased().atHour(18).everyDays(1).inTimezone('Asia/Tokyo').create();
ScriptApp.newTrigger('sendTodayReminders').timeBased().atHour(8).everyDays(1).inTimezone('Asia/Tokyo').create();
}
コードの中で判断を入れているのは次のところです。
- 送信済みの列に日時が入っている行は送りません。トリガーが同じ日に2回動いても、二重には届きません
X-Line-Retry-Keyは、通信の失敗でやり直したときに同じメッセージが2回届かないようにするための値です。同じキーで既に受理されていれば409が返るので、送れた扱いにします400401429はやり直しても通らないので、エラー列に書いて終わります。429は月の上限を超えたときにも返ります- 当日朝は体験レッスンだけに送ります。通常レッスンの会員は前日の1通で足りていて、2通にすると通数が倍になるからです
- 1回のプッシュに入れられるメッセージは5つまでです。吹き出しをいくつ入れても、1人に送れば1通です
18時ちょうどには動かない
GASの時刻指定のトリガーは、atHour(18) で18時から19時の間のどこかで動きます。分まで決める nearMinute() も前後15分の幅があります。一度決まった時刻は翌日以降もほぼ同じになりますが、「18時00分に送る」とは書けません。サンプル料理教室では前日のお知らせが18時台に届けばよいので、このままにしています。平日19時開始のレッスンが多い教室で「前日19時」を指定すると、レッスン開始とほぼ同じ時刻に翌日分が届いて紛らわしいので、18時台にしています。
分まで揃えたいときはn8nを使います。n8nの「Schedule Trigger」で「Days」を選び、「Trigger at Hour」を18、「Trigger at Minute」を0にすれば、ワークフローのタイムゾーンで18時0分に動きます。その後ろにGoogle Sheetsのノードで予約一覧を読み、条件で翌日の行に絞り、HTTP Requestのノードで上と同じ POST https://api.line.me/v2/bot/message/push を送ります。ワークフローは保存して公開しておきます。自前で立てたn8nは、タイムゾーンを設定しないとニューヨーク時間になる点に注意します。n8nの組み方は「LINEとn8n・Make・Zapierをつなぐ:コードを書かずに自動化する」で扱います。
動作を確かめる
- 予約一覧に、自分のuserIdを入れたテスト行を1行足す(開始日時は明日、ステータスは
予約、コースは体験・テスト) - エディタで関数
sendPrevDayRemindersを選んで「実行」を押す - 自分のLINEに前日のお知らせが届き、ボタン2つが出るか見る
- テスト行の「前日送信」に日時が入ったか見る
- もう一度「実行」を押し、2通目が届かないことを見る
- テスト行の開始日時を今日に変え、
sendTodayRemindersを実行して当日のお知らせが届くか見る - テスト行を消す
自分のuserIdは、自分のスマホから公式アカウントにメッセージを1通送り、Webhookに届いたイベントの source.userId で確かめます。Webhookの受け口をまだ置いていなければ、「Messaging APIの始め方:チャネル・アクセストークン・Webhook」の受け口でログに出します。LINE Developersの「チャネル基本設定」タブには、管理画面から有効にしたチャネルだと自分のユーザーIDの欄は出ていません(2026年10月時点)。


時刻のトリガーは、作った人のGoogleアカウントで動きます。担当者が退職してアカウントが止まるとリマインドも止まるので、教室で共有している管理用のアカウントで setupTriggers を実行しておきます。失敗したときは、そのアカウントあてにGoogleから失敗の通知メールが届きます。
文面例と、無断キャンセルを減らす工夫
前日18時(体験レッスン)
山田 花子様
明日 11月5日(木)19:00から「体験・和食の基本」のレッスンです。
持ち物:エプロン・ハンドタオル・筆記用具
開始10分前から入室できます。
場所:https://example.com/access
ご都合が悪くなった場合は、下の「キャンセルします」を押すか、03-0000-0000までご連絡ください。
予約番号:R-1105-01
当日の朝
山田 花子様
本日 11月5日(木)19:00から「体験・和食の基本」です。お待ちしています。
遅れそうなときは 03-0000-0000 か、このトークでお知らせください。
キャンセルを受けたときの返信
キャンセルのご連絡ありがとうございます。スタッフが確認して、このトークでご連絡します。
別の日程をご希望でしたら、ご都合のよい曜日をお知らせください。
実際に運用してみて効いたこと
- キャンセルのボタンを付ける。無断キャンセルの多くは「電話するのが気まずい」から起きています。ボタン1つで伝えられるようにすると、当日になって来ないのではなく、前日の夜にキャンセルが届くようになります。前日に分かれば、キャンセル待ちの人に声をかけられます
- 1通に全部入れる。日時、持ち物、場所、連絡先を1通にまとめます。2通に分けると2通目を読まずに来る人が出ます
- 先頭に名前と日時を入れる。トーク一覧やスマホの通知では先頭の1〜2行しか見えません。「山田 花子様 明日 11月5日(木)19:00から」まで先頭に来るようにしています
- 曜日を必ず書く。「11月5日」だけだと、カレンダーを見ずに次の週と勘違いする人がいます
- 送る時刻を生活に合わせる。平日夜のレッスンに来る人は、仕事が終わる18時台にスマホを見ます。土日の朝10時のレッスンなら、前日のお知らせは金曜・土曜の18時台、当日朝は8時台で間に合います
- キャンセル料の話は書きすぎない。規定を長く書くと、来られなくなった人が連絡せずに黙るほうを選びます。規定は予約時の案内に書き、リマインドには連絡先だけを置きます
- 返答がない体験の人には当日昼に電話する。前日のボタンに反応がない体験の予約者は、来ない率が高いと感じています。返答列が空の体験予約だけ、当日の昼にスタッフが電話する運用にしています
- 2回無断キャンセルした人は予約時に電話で確認する。リマインドで減らせるのは忘れによるものだけです

通数と料金への影響
リマインドはプッシュメッセージなので、送った相手の人数がそのまま通数になります。応答メッセージ(ユーザーの操作への返信)は通数に入りません。キャンセルのボタンを押した人への返信は応答メッセージで送るので、何通返しても通数は増えません。ブロックしている人や存在しないuserIdへのプッシュは通数に数えられません。
料金プランは2026年10月時点で次のとおりです(税別)。
| プラン | 月額 | 無料メッセージ通数 | 超えたとき |
|---|---|---|---|
| コミュニケーションプラン | 0円 | 200通 | 200通を超える配信はできない |
| ライトプラン | 5,000円 | 5,000通 | 5,000通を超える配信はできない |
| スタンダードプラン | 15,000円 | 30,000通 | 追加メッセージを従量課金で送れる(1通最大3円) |
サンプル料理教室の1か月で数えると、こうなります。
| 送るもの | 件数 | 通数 |
|---|---|---|
| 体験レッスンの前日・当日リマインド | 体験の予約20件×2回 | 40通 |
| 通常レッスンの前日リマインド | 予約240件×1回 | 240通 |
| 友だち全員へのお知らせ(月2回) | 友だち500人×2回 | 1,000通 |
| 合計 | 1,280通 |
リマインドだけで280通になり、コミュニケーションプランの200通を超えます。上限を超えるとエラーが返ってメッセージは送られず、月の後半の予約者にだけ届かない状態になります。予約が月100件を超えるなら、ライトプランにするか、通常レッスンのリマインドを「初回の3回だけ」に絞ります。管理画面から送ったメッセージもMessaging APIのプッシュも、同じ月の通数を分け合う点も忘れずに見ておきます。プランと通数の数え方の全体は「LINE公式アカウントの料金:無料と有料プランの違い、メッセージ通数の数え方」にまとめています。
今月あと何通送れるかは、APIで確かめられます。月の上限の目安と、今月使った通数の2つです。
curl -s -H 'Authorization: Bearer YOUR_CHANNEL_ACCESS_TOKEN' https://api.line.me/v2/bot/message/quota
curl -s -H 'Authorization: Bearer YOUR_CHANNEL_ACCESS_TOKEN' https://api.line.me/v2/bot/message/quota/consumption

応用
「参加します」「キャンセルします」のボタンを受け取る
前日のメッセージに付けたボタンはポストバックという種類で、押されるとWebhookに postback のイベントが届きます。GASをウェブアプリとして公開し、そのURLをLINE DevelopersのWebhook URLに入れると、同じスクリプトで受け取れます。Webhookの設定の手順は「Messaging APIの始め方:チャネル・アクセストークン・Webhook」を見てください。上のコードの下に足します。
function doPost(e) {
const body = JSON.parse(e.postData.contents);
(body.events || []).forEach(function (ev) {
if (ev.type === 'postback') handleAnswer_(ev);
if (ev.type === 'unfollow') markBlocked_(ev.source.userId);
});
return ContentService.createTextOutput('OK');
}
function handleAnswer_(ev) {
const p = {};
ev.postback.data.split('&').forEach(function (kv) {
const pair = kv.split('=');
p[pair[0]] = pair[1];
});
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
const values = sheet.getDataRange().getValues();
for (let r = 1; r < values.length; r++) {
if (String(values[r][COL.id - 1]) !== p.id) continue;
if (values[r][COL.userId - 1] !== ev.source.userId) continue; // 本人の予約だけ受け付ける
if (p.a === 'cancel') {
sheet.getRange(r + 1, COL.answer).setValue('キャンセル希望 ' + Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'M/d H:mm'));
reply_(ev.replyToken, 'キャンセルのご連絡ありがとうございます。スタッフが確認して、このトークでご連絡します。');
} else {
sheet.getRange(r + 1, COL.answer).setValue('参加');
reply_(ev.replyToken, 'ありがとうございます。明日お待ちしています。');
}
return;
}
}
function markBlocked_(userId) {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
const values = sheet.getDataRange().getValues();
for (let r = 1; r < values.length; r++) {
if (values[r][COL.userId - 1] === userId) sheet.getRange(r + 1, COL.error).setValue('ブロックされた(電話かメールで連絡)');
}
}
function reply_(replyToken, text) {
const token = PropertiesService.getScriptProperties().getProperty('LINE_CHANNEL_ACCESS_TOKEN');
UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + token },
payload: JSON.stringify({ replyToken: replyToken, messages: [{ type: 'text', text: text }] }),
muteHttpExceptions: true
});
}
キャンセルのボタンでは、ステータスを直接 キャンセル にせず、返答の列に「キャンセル希望」と書くだけにしています。GASのウェブアプリはリクエストのヘッダーを読めないので、LINEから届いたことを確かめる署名の検証ができません。予約を消すような操作は、スタッフが目で見てからにしておくほうが安全です。前日に「キャンセル希望」が入った予約は、当日朝のリマインドを送らないようにコード側で止めています。
ブロックされたときは unfollow のイベントが届くので、その人の行のエラー列に書きます。ブロックしている人へのプッシュは 200 が返るのに届かないので、ここで拾わないと「送信済み」なのに本人は知らない、という行が残ります。
スタッフにも同じ時刻に一覧を送る
前日18時台の実行の最後に、翌日の予約者の一覧をスタッフのグループトークへプッシュで送っておくと、userIdが無い人やブロックした人への電話の抜けが減ります。LINE公式アカウントをスタッフのグループに招待しておき、Webhookで届いたグループの groupId を to に入れれば送れます。グループへのプッシュも通数に数えられるので、通数が気になるならメールで送ります。
L Harnessのリマインダ配信で組む
ステップ配信やフォームもまとめて自前で持つなら、オープンソースの拡張ツールL Harness(GitHubのShudesu/line-harness-oss、MITライセンス)のリマインダ配信が使えます。ソフトの利用料は0円で、Cloudflareの無料枠で動かせます(データベースのD1は無料枠の上限に達するとエラーになる点は注意)。友だち追加のWebhookで友だちが自動で登録されるので、userIdを自分で台帳に入れる手間がありません。
リマインダは「基準の日時から何分前・何分後に送るか」を並べる仕組みです。管理画面の左メニュー「リマインダ」(ページの見出しは「リマインダ配信」)で「+新規リマインダー」を押し、ステップごとに「オフセット (分)」を入れます。マイナスが前、プラスが後です。一覧の画面には「オフセット」の表記は出ず、ステップの画面はリマインダを作ると開きます(ステップの画面の項目名はGitHubのドキュメントの表記で、この記事では画面で確かめていません)。
| ステップ | オフセット (分) | 開始が11月5日19:00のとき | 送る内容 |
|---|---|---|---|
| 前日のお知らせ | -1500 | 11月4日18:00 | 持ち物・場所・キャンセルの連絡先 |
| 当日の朝 | -660 | 11月5日8:00 | 本日のお知らせ |
| 翌日のお礼 | 1440 | 11月6日19:00 | 入会の案内 |
基準の日時は予約ごとに違うので、予約が入ったら友だちをリマインダに登録します。登録はAPIで行い、targetDate にレッスンの開始日時を入れます。
curl -X POST "https://your-worker.your-subdomain.workers.dev/api/reminders/REMINDER_UUID/enroll/FRIEND_UUID" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "targetDate": "2026-11-05T19:00:00+09:00" }'
送るのは定期処理で、送る時刻を過ぎたステップを数分おきに拾って送ります。同じステップは1人に1回しか送りません。キャンセルになった予約は、友だちのリマインダの登録を取り消せば、それ以降のステップは送られません。GitHubのドキュメントには、Googleカレンダーと連携した予約で前日と開始1時間前にLINEでリマインドを送る仕組みも書かれています(2026年10月時点の管理画面の左メニューには、予約の項目は出ていません)。教室の例で予約からリマインドまで組む手順は「L Harnessで予約・フォーム・ステップ配信を組む:教室の例で一通り」で書きます。
できないことと、その代わり
| できないこと | 理由 | 代わりの手 |
|---|---|---|
| 友だちでない人に送る | プッシュは友だちか、7日以内にトークを送ってきた人にしか届かない | 予約時に友だち追加を促す。届かない人にはメールかSMS |
| ブロックした人に届ける | 200 が返るが実際には届かない | unfollow のイベントを台帳に書き、電話かメールに切り替える |
| 電話番号だけで送る | プッシュはuserIdあて | LINE通知メッセージ(認証済アカウントかプレミアムアカウント、かつ認証プロバイダーが条件。導入は販売・技術パートナー経由) |
| 名前からuserIdを調べる | 表示名とuserIdは結びつかない | 予約の時点でWebhook・LIFF・LINEログインで取る |
| 既読かどうかで出し分ける | プッシュの結果で相手が読んだかは返らない | 前日のボタンの返答の有無で判断し、返答のない人に電話する |
| 無料プランで月200通を超えて送る | 上限を超えるとエラーで送られない | ライトプランにするか、リマインドを送る予約を絞る |
| GASで分ちょうどに送る | 時刻のトリガーは1時間の幅で動く | n8nの「Schedule Trigger」か、L Harnessの定期処理 |
| 管理画面で翌日の予約者だけに自動で送る | 配信先の絞り込みは毎日の予約の入れ替わりに追いつかない | チャットで手で送るか、Messaging APIで自動にする |
よく詰まるところ
| 症状 | 原因 | 直し方 |
|---|---|---|
何も届かず、エラー列に 400 | userIdが別のプロバイダーで取ったもの、または桁が欠けている | LIFF・LINEログインのチャネルをMessaging APIと同じプロバイダーに作り直し、userIdを取り直す |
エラー列に 401 | チャネルアクセストークンが違う、または失効した | LINE Developersで発行し直し、スクリプト プロパティを上書きする |
エラー列に 429 | 月の通数の上限を超えた | 今月の利用状況をAPIで見て、プランを上げるか送る相手を絞る |
| 「前日送信」に日時が入るのに本人に届かない | ブロックされている、またはLINEのアカウントを消している | unfollow を台帳に書く仕組みを足し、その人は電話で連絡する |
| 1日ずれた人に届く | スプレッドシートとスクリプトのタイムゾーンが東京になっていない | 「ファイル」→「設定」と appsscript.json の両方を東京にする |
| 開始日時が「1899/12/30」と読まれて誰にも送られない | 日付と時刻を別の列に入れている | 開始日時を1つのセルにまとめ、表示形式を日時にする |
| 18時ちょうどに届かない | GASの時刻のトリガーは18時から19時の間で動く | 18時台で困らない文面にするか、n8nで分まで指定する |
| 同じ人に2回届いた | トリガーを2回作っている、または送信済みの列を消した | 「トリガー」画面で重複を消す。setupTriggers は古いトリガーを消してから作る |
| キャンセルした人に届いた | 電話で受けたキャンセルを台帳に入れていない | キャンセルの電話を受けたら、その場でステータスを キャンセル にする決まりにする |
| ボタンを押しても返答の列が変わらない | Webhook URLがウェブアプリの最新のデプロイを指していない、または「Webhookの利用」がオフ | デプロイを更新してURLを入れ直し、LINE Developersの「Messaging API設定」タブで「Webhookの利用」をオンにする |
| userId列が空の行が多い | Webhookで受けた人がプロフィール情報の取得に同意していない、または予約番号を送っていない | その人はメールで送る。予約完了画面でLINEに予約番号を送る案内を目立たせる |
| 前任者が辞めたら止まった | トリガーは作った人のアカウントで動く | 教室の共有アカウントで setupTriggers を実行し直す |

次にやること
- 予約の受け方から決め直すなら「LINEで予約を受ける:予約機能・外部の予約システム・自作の比べ方」
- userIdを自動で台帳に入れるなら「LINEの問い合わせをスプレッドシートに貯める:Webhookで1件ずつ記録する」
- チャネルやトークン、Webhookの設定がまだなら「Messaging APIの始め方:チャネル・アクセストークン・Webhook」
- リマインドから入会の案内までまとめて自前で持つなら「L Harnessで予約・フォーム・ステップ配信を組む:教室の例で一通り」
- 通数がプランに収まるか見直すなら「LINE公式アカウントの料金:無料と有料プランの違い、メッセージ通数の数え方」