予約のリマインドをLINEで自動送信する:前日・当日の通知と無断キャンセル対策

公開 書いた人 大林 諒

予約の前日と当日に「明日19時からです」と届くだけで、うっかり忘れの無断キャンセルはかなり防げます。電話やメールで1件ずつ送っていた連絡を、LINE公式アカウントから自動で送る形にするのがこの記事の内容です。例はいつもの「サンプル料理教室」で、Googleスプレッドシートの予約一覧から、前日18時に翌日の予約者へ、当日朝にその日の予約者へLINEを送る仕組みをGAS(Google Apps Script)とMessaging APIで作ります。読み終わると、コードを貼ってトリガーを入れれば翌日から動く状態になります。前提として、LINE公式アカウントとMessaging APIのチャネルはできていて、チャネルアクセストークン(長期)を発行できる状態とします。まだなら「Messaging APIの始め方:チャネル・アクセストークン・Webhook」を先に済ませてください。

この記事の内容
  1. 先に決めること
  2. リマインドを送る方法の比較
  3. userIdをどう手に入れるか(ここが一番の壁)
  4. スプレッドシートから前日18時に送る仕組みを作る
  5. 文面例と、無断キャンセルを減らす工夫
  6. 通数と料金への影響
  7. 応用
  8. できないことと、その代わり
  9. よく詰まるところ
  10. 次にやること

先に決めること

ここを決めずに作り始めると、送る仕組みはできたのに誰にも届かない、という状態になります。

  • 予約台帳をどこに置くか。スプレッドシート、予約システム、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で送る、と割り切ると止まりません。

予約一覧シート。userId列が埋まっている行と空の行が混ざっている状態
予約一覧シート。userId列が埋まっている行と空の行が混ざっている状態

スプレッドシートから前日18時に送る仕組みを作る

ここからが本体です。GASはスプレッドシートに付いているスクリプトなので、サーバーを用意せずに動かせます。

予約一覧シートの列

シート名は「予約一覧」、1行目を見出しにして、次の列にします。列の順番を変えるときはコードの COL も合わせて直します。

列見出し入れるもの例
A予約番号予約ごとに重ならない番号R-1105-01
B開始日時日付と時刻を1つのセルに2026/11/05 19:00
Cコースレッスン名体験・和食の基本
D氏名予約者の名前山田 花子
EuserIdU で始まる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のブラウザで)

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

トークンをコードに直接書くと、スクリプトを共有した相手やコピーした人に見えてしまいます。スクリプトプロパティに入れておけば、コードを人に見せても漏れません。

Apps Scriptの「プロジェクトの設定」でスクリプト プロパティにLINE_CHANNEL_ACCESS_TOKENを入れたところ(値は伏せる)
Apps Scriptの「プロジェクトの設定」でスクリプト プロパティにLINE_CHANNEL_ACCESS_TOKENを入れたところ(値は伏せる)

コード

// 予約一覧から、前日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 が返るので、送れた扱いにします
  • 400 401 429 はやり直しても通らないので、エラー列に書いて終わります。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をつなぐ:コードを書かずに自動化する」で扱います。

動作を確かめる

  1. 予約一覧に、自分のuserIdを入れたテスト行を1行足す(開始日時は明日、ステータスは 予約、コースは 体験・テスト)
  2. エディタで関数 sendPrevDayReminders を選んで「実行」を押す
  3. 自分のLINEに前日のお知らせが届き、ボタン2つが出るか見る
  4. テスト行の「前日送信」に日時が入ったか見る
  5. もう一度「実行」を押し、2通目が届かないことを見る
  6. テスト行の開始日時を今日に変え、sendTodayReminders を実行して当日のお知らせが届くか見る
  7. テスト行を消す

自分のuserIdは、自分のスマホから公式アカウントにメッセージを1通送り、Webhookに届いたイベントの source.userId で確かめます。Webhookの受け口をまだ置いていなければ、「Messaging APIの始め方:チャネル・アクセストークン・Webhook」の受け口でログに出します。LINE Developersの「チャネル基本設定」タブには、管理画面から有効にしたチャネルだと自分のユーザーIDの欄は出ていません(2026年10月時点)。

スマホのLINEに届いた前日リマインドと、下に「参加します」「キャンセルします」のボタンが出ているところ
スマホのLINEに届いた前日リマインドと、下に「参加します」「キャンセルします」のボタンが出ているところ
Apps Scriptの「トリガー」画面に2つのトリガーが並んでいるところ
Apps Scriptの「トリガー」画面に2つのトリガーが並んでいるところ

時刻のトリガーは、作った人の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回無断キャンセルした人は予約時に電話で確認する。リマインドで減らせるのは忘れによるものだけです
トーク一覧に前日リマインドが届き、先頭の2行に名前と日時が見えている状態
トーク一覧に前日リマインドが届き、先頭の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のとき送る内容
前日のお知らせ-150011月4日18:00持ち物・場所・キャンセルの連絡先
当日の朝-66011月5日8:00本日のお知らせ
翌日のお礼144011月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で自動にする

よく詰まるところ

症状原因直し方
何も届かず、エラー列に 400userIdが別のプロバイダーで取ったもの、または桁が欠けている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 を実行し直す
予約一覧のエラー列に「userIdなし」「ブロックされた」が入っている行
予約一覧のエラー列に「userIdなし」「ブロックされた」が入っている行

次にやること

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

あわせて読む