IT技術ブログフィットネス・健康

LINE予約システムを自作する手順コード付きの実装記録

LINE予約システムを自作する順番を、チャネル作成から店側の画面まで最小のコードで書きました。手元のWorkers・D1で動かし、同時30件の申込や再送を確かめています。自分で作れる範囲と運用が重くなる線も。

この記事の結論:LINE予約システムの自作は、チャネルの作成、Webhookの受け口、リッチメニュー、枠の確保、二重予約の防止、確認とリマインド、キャンセル、店側の画面の順に作ります。お客様はボタンを押すだけにすれば、手元のCloudflare WorkersとD1で、設定ファイルを除く9ファイル418行で一通り動きました(本物のLINEでは未確認です)。自分で作れるのは、1つの枠に決まった定員があり、店舗が1つで、受け口がLINEと電話だけの予約までです。担当者と部屋の組み合わせや、外部の予約サイトとの同期が入ると、作る量より作った後に見続ける手間が重くなります。

この記事の内容のご相談は株式会社bundlyzeへbundlyzeのシステム開発について見てみる

この記事は、自社の予約の仕組みを作りたい小さな店の担当者やエンジニアに向けて、作り方を順番に書いたものです。どの形で予約を受けるかを選ぶ段階なら、会社のコラムLINEで予約を受ける3つの形が先です。ここでは「自分で作る」と決めた後の話だけを書きます。

載せたコードは、2026年10月31日に手元のwrangler devと手元のD1で動かしたものです。LINEのAPIの代わりに、応答を真似る小さなサーバーを立てました。本物のLINEのチャネルは作っておらず、本物のLINEには一度も送っていません。 スマホの画面でも見ていません。確かめたことと確かめていないことは、後半の「動かして確かめた範囲」に分けて書きます。

LINE予約システムは8つの手順で作る

作る順番は、前の手順ができていないと次を試せない順にしています。各段階の詳しい理由は別の記事に書いたので、この記事では全体をつなぐコードと、つないだときに初めて出る詰まる所を書きます。

手順 作るもの 詰まる所 詳しい記事
1 チャネルとトークン コンソールからは作れない。プロバイダーは後から変えられない 公式LINE連携の進め方
2 Webhookの受け口 JSONにしてから検証すると署名が合わない Webhookの受け方
3 リッチメニュー 画像だけドメインが違う。作った後に画像を替えられない 段階で切り替える設計
4 枠を見せて選んでもらう ボタンは13個まで。日付は日本時間で切る 日本時間の扱い
5 二重予約の防止 同じSQLがPostgreSQLでは定員を超える DB制約とロックの比較
6 確認とリマインド 応答トークンは1回かぎりで時間の制限がある 前日リマインドの実装
7 キャンセル 古いボタンは押せてしまう。本人の確認が要る キャンセルの決まり
8 店側の画面 表示名をそのまま出すと画面に悪さができる 店舗ごとの分離

ファイルの構成は次のとおりです。

line-booking/
├── src/index.js      … 入口(Webhook・店の画面・定期実行に振り分ける)
├── src/line.js       … 署名の検証とメッセージの送信
├── src/webhook.js    … イベントごとの処理
├── src/booking.js    … 枠と予約のSQL
├── src/time.js       … 日本時間の計算
├── src/remind.js     … 前日のリマインド
├── src/admin.js      … 店側の画面
├── schema.sql        … 表の定義
├── richmenu.mjs      … リッチメニューを作るスクリプト
└── wrangler.jsonc

手順1. Messaging APIのチャネルは、公式アカウント側から作る

Messaging APIのチャネルは、LINE Developersコンソールからは直接作れません。LINEのドキュメントには、先にLINE公式アカウントを作り、LINE Official Account ManagerでMessaging APIの利用を有効にすると、チャネルが作られると書かれています。

進め方は次の順です。ここは画面の作業で、コードはありません。この記事の確認ではこの手順を実施しておらず、2026年10月31日時点の公式の手順を書き写しています。

  1. LINE公式アカウントを作る
  2. LINE Official Account Managerで、Messaging APIの利用を有効にする。このときプロバイダーを選ぶ
  3. LINE Developersコンソールの「チャネル基本設定」で、チャネルシークレットを控える
  4. 「Messaging API設定」で、チャネルアクセストークンを発行する
  5. LINE Official Account Managerで、あいさつメッセージと応答メッセージを切る

詰まりやすいのは2番と5番です。プロバイダーは一度選ぶと後から変えられません。ユーザーIDはプロバイダーごとに違う値になるので、あとで予約の画面にLINEログインを足す予定があるなら、同じプロバイダーを選びます。5番を忘れると、公式アカウントの自動応答とこちらの返事が混ざります。

トークンには種類があります。コンソールから発行できる長期のチャネルアクセストークンは有効期限がなく、最初の1本として手軽です。その代わり、漏れたときの影響が長く続くので、運用に入るなら有効期間を指定できるチャネルアクセストークンv2.1を検討します。こちらは最大30日で切れるので、発行し直す処理が1つ増えます。

秘密の値は、コードと設定ファイルに書きません。本番はwrangler secret putで登録し、手元は.dev.varsに置いて.gitignoreに入れます。手元の動かし方は次のとおりです。

wrangler.jsonc(database_id は手元用の仮の値)jsonc
{
  "name": "line-booking",
  "main": "src/index.js",
  "compatibility_date": "2026-10-01",
  "triggers": { "crons": ["0 9 * * *", "0 10 * * *"] }, // UTC。日本時間の18時と19時
  "vars": {
    "LINE_API_BASE": "https://api.line.me"
  },
  "d1_databases": [
    { "binding": "DB", "database_name": "line-booking", "database_id": "00000000-0000-0000-0000-000000000000" }
  ]
}
.dev.vars(gitに入れない。値は手元の確認用)
LINE_CHANNEL_SECRET=test-channel-secret-for-local-only
LINE_CHANNEL_ACCESS_TOKEN=test-token
LINE_API_BASE=http://127.0.0.1:8790
ADMIN_USER=staff
ADMIN_PASSWORD=local-only-password

LINE_API_BASEを手元のサーバーに向けているのが、本物のLINEに送らずに試すための仕掛けです。

手順2. Webhookは、文字列のまま署名を確かめてから読む

Webhookの受け口で守ることは、本文を文字列のまま受け取って署名を確かめ、それからJSONにして、先に200を返すことの3つです。入口のファイルは、この3つと振り分けだけを持ちます。

src/index.jsjs
import { handleAdmin } from "./admin.js";
import { verifySignature } from "./line.js";
import { sendReminders } from "./remind.js";
import { handleEvents } from "./webhook.js";

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    if (url.pathname.startsWith("/admin")) return handleAdmin(request, env);
    if (request.method !== "POST" || url.pathname !== "/webhook") return new Response("Not Found", { status: 404 });

    // 本文は JSON にする前に、文字列のまま署名を確かめる
    const rawBody = await request.text();
    const ok = await verifySignature(env.LINE_CHANNEL_SECRET, rawBody, request.headers.get("x-line-signature") ?? "");
    if (!ok) return new Response("Unauthorized", { status: 401 });

    const { events = [] } = JSON.parse(rawBody);
    ctx.waitUntil(handleEvents(env, events)); // 先に 200 を返し、処理は後に回す
    return new Response("OK");
  },

  async scheduled(controller, env, ctx) {
    ctx.waitUntil(sendReminders(env, controller.scheduledTime));
  },
};
src/line.js(署名の検証)js
export async function verifySignature(secret, rawBody, signature) {
  if (!secret || !signature) return false;
  const enc = new TextEncoder();
  const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
  let sig;
  try {
    sig = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
  } catch {
    return false; // Base64 として読めない値
  }
  return crypto.subtle.verify("HMAC", key, sig, enc.encode(rawBody));
}

なぜこの形なのか、再送や重複の扱いをどう考えるかはWebhookの記事に書きました。今回は、LINEのドキュメントにあるopensslの計算と、Web Cryptoの検証が同じ結果になるかを確かめました。

openssl: Qi+ynCRA2cQkBJOS75gA29VG8zLhHIkJtOdd9dBT3BU=
node   : Qi+ynCRA2cQkBJOS75gA29VG8zLhHIkJtOdd9dBT3BU=
verifySignature(openssl の値): true

Workerに送った試験では、正しい署名は200、違う鍵・本文の書き換え・整形し直し・ヘッダーなし・Base64でない値は、どれも401でした。

詰まる所は、イベントが0件のリクエストです。LINEのリファレンスには、疎通の確認のためにeventsが空の配列で届くことがあり、そのときも200を返すよう書かれています。Webhook URLはHTTPSが必須で、自己署名の証明書は使えません。URLを設定したらコンソールの「検証」を押し、「Webhookの利用」を有効にします。Webhookの再送は既定では切れているので、使うならここで有効にします。

手順3. リッチメニューは、スクリプトで作って画像を付ける

リッチメニューは、メニューの定義を送り、画像を付け、全員の既定にする、の3回のAPI呼び出しで作ります。ここでは「予約する」と「予約の確認」の2つのボタンにしました。どちらもポストバックで、押すとWebhookにaction=datesのような文字列が届きます。

richmenu.mjsjs
// richmenu.mjs:リッチメニューを作り、画像を付け、全員の既定にする
import { readFileSync } from "node:fs";

const API = process.env.LINE_API_BASE ?? "https://api.line.me";
const DATA = process.env.LINE_DATA_BASE ?? "https://api-data.line.me"; // 画像だけドメインが違う
const auth = { Authorization: `Bearer ${process.env.LINE_CHANNEL_ACCESS_TOKEN}` };

const menu = {
  size: { width: 2500, height: 843 },
  selected: true,
  name: "booking-v1",
  chatBarText: "メニュー",
  areas: [
    { bounds: { x: 0, y: 0, width: 1250, height: 843 }, action: { type: "postback", data: "action=dates", displayText: "予約する" } },
    { bounds: { x: 1250, y: 0, width: 1250, height: 843 }, action: { type: "postback", data: "action=mine", displayText: "予約の確認" } },
  ],
};

async function call(url, init) {
  const res = await fetch(url, init);
  const body = await res.text();
  if (!res.ok) throw new Error(`${res.status} ${url}: ${body}`);
  return body ? JSON.parse(body) : {};
}

const { richMenuId } = await call(`${API}/v2/bot/richmenu`, {
  method: "POST",
  headers: { ...auth, "Content-Type": "application/json" },
  body: JSON.stringify(menu),
});
await call(`${DATA}/v2/bot/richmenu/${richMenuId}/content`, {
  method: "POST",
  headers: { ...auth, "Content-Type": "image/png" },
  body: readFileSync("richmenu.png"),
});
await call(`${API}/v2/bot/user/all/richmenu/${richMenuId}`, { method: "POST", headers: auth });
console.log("default rich menu:", richMenuId);

環境変数LINE_CHANNEL_ACCESS_TOKENを入れ、同じ場所にrichmenu.pngを置いて、node richmenu.mjsで動かします。真似るサーバーに対して動かした結果は次のとおりで、3回の呼び出しが順に通りました。

default rich menu: richmenu-test

詰まる所は4つあります。

  • 画像だけドメインが違う。 画像のアップロードはapi-data.line.meで、ほかはapi.line.meです
  • 画像の条件が細かい。 JPEGかPNG、幅800〜2500ピクセル、高さ250ピクセル以上、幅÷高さが1.45以上、1MBまでです
  • 一度付けた画像は替えられない。 画像を更新するには、新しいリッチメニューを作り直します。スクリプトにしておくのはこのためです
  • タップできる領域は20個まで、chatBarTextは14文字まで。 ポストバックのdataは300文字までです

メニューの日付を選ぶのに日時選択アクションを使う手もありますが、ここでは使っていません。休みの日も選べてしまううえ、選べる範囲のminとmaxはメニューを作るときに書き込む値なので、日がたつと古くなるからです。ボタンを押すたびに空きのある日だけを返すほうが、古い情報を見せずに済みます。

予約前と予約後でメニューを変えたくなったら、ユーザー単位のリッチメニューに進みます。分け方はリッチメニューの記事で扱っています。

手順4. 枠は表に持ち、空きのある日と時間だけをボタンで見せる

枠は1行1時間帯の表に持ち、残りの数は持ちません。予約の表でstatusがbookedの行を数えれば残りが出るので、取り消しのときに数を戻す処理が要らなくなり、ずれる場所が1つ減ります。

schema.sqlsql
-- 予約枠。時刻は UTC で持ち、画面と判定のときだけ日本時間に直す
CREATE TABLE slots (
  id        INTEGER PRIMARY KEY,
  starts_at TEXT    NOT NULL UNIQUE,            -- '2026-11-05T01:00:00.000Z'
  capacity  INTEGER NOT NULL CHECK (capacity >= 0) -- 0 にすると店が閉じた枠になる
);

-- 予約。残りの数は持たず、status が booked の行を数える
CREATE TABLE bookings (
  id            TEXT PRIMARY KEY,
  slot_id       INTEGER NOT NULL REFERENCES slots(id),
  line_user_id  TEXT,                            -- 電話の予約は NULL
  customer_name TEXT NOT NULL,
  status        TEXT NOT NULL DEFAULT 'booked',  -- booked / cancelled
  retry_key     TEXT NOT NULL,                   -- リマインドの X-Line-Retry-Key
  reminded_at   TEXT
);
CREATE INDEX bookings_by_slot ON bookings (slot_id, status);

-- 同じ人が同じ枠に2件入らない。取り消した行は対象の外
CREATE UNIQUE INDEX one_booking_per_person
  ON bookings (slot_id, line_user_id)
  WHERE status = 'booked' AND line_user_id IS NOT NULL;

-- 受け取った Webhook の記録。再送を飛ばすために使う
CREATE TABLE line_events (
  webhook_event_id TEXT PRIMARY KEY
);

枠の行は、まず手で入れます。営業時間と休業日から自動で作る処理は、最初は要りません。

sql
-- 11月5日 10:00(日本時間)、定員3の枠
INSERT INTO slots (starts_at, capacity) VALUES ('2026-11-05T01:00:00.000Z', 3);

時刻の計算はファイルを1つに分けました。保存はUTC、判定と表示は日本時間にします。考え方は日本時間の記事のとおりです。

src/time.jsjs
// 日本時間の日付と時刻。日本は夏時間がないので UTC+9 で固定できる
const JST_MS = 9 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;

export const jstDate = (ms) => new Date(ms + JST_MS).toISOString().slice(0, 10); // '2026-11-05'
export const jstTime = (iso) => new Date(Date.parse(iso) + JST_MS).toISOString().slice(11, 16); // '10:00'
export const jstLabel = (iso) => `${jstDate(Date.parse(iso)).slice(5).replace("-", "/")} ${jstTime(iso)}`; // '11/05 10:00'

// 日本時間の1日を、UTC の [始まり, 次の日の始まり) にする
export function jstDayRange(date) {
  const from = Date.parse(`${date}T00:00:00+09:00`);
  return [new Date(from).toISOString(), new Date(from + DAY_MS).toISOString()];
}

// 取り消しの締め切り:枠の前日の18時(日本時間)
export function cancelDeadline(startsAt) {
  const day = jstDate(Date.parse(startsAt) - DAY_MS);
  return Date.parse(`${day}T18:00:00+09:00`);
}

空きのある枠は次の関数で取り出します。

src/booking.js(空きのある枠)js
import { cancelDeadline, jstDayRange } from "./time.js";

const FREE = `s.capacity - (SELECT count(*) FROM bookings b WHERE b.slot_id = s.id AND b.status = 'booked')`;

// 空きのある枠を、今より後から days 日ぶん取り出す
export async function openSlots(env, nowMs, days = 14) {
  const { results } = await env.DB.prepare(
    `SELECT s.id, s.starts_at, ${FREE} AS free
       FROM slots s
      WHERE s.starts_at > ?1 AND s.starts_at < ?2 AND ${FREE} > 0
      ORDER BY s.starts_at`,
  )
    .bind(new Date(nowMs).toISOString(), new Date(nowMs + days * 86_400_000).toISOString())
    .all();
  return results;
}

ポストバックを受ける処理の全体です。キャンセルや確認の分岐も入っていますが、後の手順で説明します。

src/webhook.jsjs
import { cancelByCustomer, myBookings, openSlots, reserve } from "./booking.js";
import { respond } from "./line.js";
import { jstDate, jstLabel, jstTime } from "./time.js";

const text = (t, quick) => ({
  type: "text",
  text: t,
  ...(quick?.length ? { quickReply: { items: quick } } : {}),
});
const button = (label, data) => ({ type: "action", action: { type: "postback", label, data, displayText: label } });

export async function handleEvents(env, events) {
  for (const event of events) {
    // 同じイベントは1回だけ処理する
    const seen = await env.DB.prepare(
      "INSERT INTO line_events (webhook_event_id) VALUES (?1) ON CONFLICT DO NOTHING",
    )
      .bind(event.webhookEventId)
      .run();
    if (seen.meta.changes === 0) continue;

    try {
      await handleEvent(env, event);
    } catch (err) {
      console.log(`failed ${event.webhookEventId}: ${err.message}`);
    }
  }
}

async function handleEvent(env, event) {
  if (event.type === "follow") {
    return respond(env, event, [text("友だち追加ありがとうございます。下のメニューの「予約する」から日付を選べます。")]);
  }
  if (event.type !== "postback" || !event.source.userId) return;

  const p = new URLSearchParams(event.postback.data);
  const nowMs = Date.now();
  const userId = event.source.userId;

  switch (p.get("action")) {
    case "dates": {
      const days = [...new Set((await openSlots(env, nowMs)).map((s) => jstDate(Date.parse(s.starts_at))))];
      if (days.length === 0) return respond(env, event, [text("いま予約できる枠がありません。")]);
      const quick = days.slice(0, 13).map((d) => button(d.slice(5).replace("-", "/"), `action=times&date=${d}`));
      return respond(env, event, [text("日付を選んでください。", quick)]);
    }
    case "times": {
      const date = p.get("date");
      const slots = (await openSlots(env, nowMs)).filter((s) => jstDate(Date.parse(s.starts_at)) === date);
      if (slots.length === 0) return respond(env, event, [text("この日の空きはなくなりました。もう一度「予約する」から選んでください。")]);
      const quick = slots.slice(0, 13).map((s) => button(`${jstTime(s.starts_at)}(残り${s.free})`, `action=book&slot=${s.id}`));
      return respond(env, event, [text(`${date.slice(5).replace("-", "/")} の時間を選んでください。`, quick)]);
    }
    case "book": {
      const name = await displayName(env, userId);
      const r = await reserve(env, { slotId: Number(p.get("slot")), lineUserId: userId, name, nowMs });
      if (r.ok) return respond(env, event, [text("ご予約を受け付けました。確認と取り消しは、メニューの「予約の確認」からできます。")]);
      if (r.reason === "already") return respond(env, event, [text("この時間はすでにご予約いただいています。")]);
      return respond(env, event, [text("申し訳ありません。この時間は埋まりました。ほかの時間を選んでください。", [button("日付を選び直す", "action=dates")])]);
    }
    case "mine": {
      const list = await myBookings(env, userId, nowMs);
      if (list.length === 0) return respond(env, event, [text("いまのご予約はありません。")]);
      const lines = list.map((b) => jstLabel(b.starts_at)).join("\n");
      const quick = list.slice(0, 13).map((b) => button(`${jstLabel(b.starts_at)} 取消`, `action=cancel&booking=${b.id}`));
      return respond(env, event, [text(`ご予約\n${lines}`, quick)]);
    }
    case "cancel": {
      const r = await cancelByCustomer(env, { bookingId: p.get("booking"), lineUserId: userId, nowMs });
      const msg = {
        ok: "ご予約を取り消しました。",
        late: "取り消しの締め切り(前日18時)を過ぎています。お電話でご連絡ください。",
        none: "取り消せるご予約が見つかりませんでした。",
      };
      return respond(env, event, [text(msg[r])]);
    }
  }
}

async function displayName(env, userId) {
  const res = await fetch(`${env.LINE_API_BASE}/v2/bot/profile/${userId}`, {
    headers: { Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}` },
    signal: AbortSignal.timeout(5_000),
  });
  return res.ok ? (await res.json()).displayName : "(名前を取得できず)";
}

試すと、リッチメニューの「予約する」に当たるイベントで、次のボタンが返りました。

日付:["11/01","11/05","11/06","11/07","11/10"]
時間:[{"label":"10:00(残り1)","data":"action=book&slot=1"},{"label":"11:00(残り3)","data":"action=book&slot=2"}]

先頭の11/01は、UTCの日付では10月31日の枠も含んでいます。日本時間の日付で切っているので正しく11/01に入り、10月30日の過ぎた枠は出ていません。

詰まる所は、クイックリプライのボタンが1つのメッセージに13個までなことです。わざと1日に15枠入れた日で試すと、slice(0, 13)で切った13個だけが返り、後ろの2枠は選べませんでした。1日の枠が14以上ある店は、午前と午後でボタンを分けるなど、1段増やします。

もう1つ、ボタンの「残り1」は押された時点の話ではありません。見せた後に埋まるので、次の手順で登録するときに必ず数え直します。

手順5. 二重予約は、空きの確認と登録を1文にして防ぐ

二重予約を防ぐには、「空きを確かめる」と「登録する」を2つの処理に分けず、データベースの1回の処理にまとめます。D1では、INSERT ... SELECTの条件に空きの計算を入れました。

src/booking.js(枠の確保)js
// 枠を1つ確保する。空きの確認と登録を 1 文で行う
export async function reserve(env, { slotId, lineUserId, name, nowMs }) {
  const id = crypto.randomUUID();
  try {
    const r = await env.DB.prepare(
      `INSERT INTO bookings (id, slot_id, line_user_id, customer_name, retry_key)
       SELECT ?1, s.id, ?2, ?3, ?4
         FROM slots s
        WHERE s.id = ?5 AND s.starts_at > ?6 AND ${FREE} > 0`,
    )
      .bind(id, lineUserId, name, crypto.randomUUID(), slotId, new Date(nowMs).toISOString())
      .run();
    if (r.meta.changes === 1) return { ok: true, id };
    // 埋まった枠を取ったのが本人なら、「満席」ではなく「予約済み」と返す
    const mine = await env.DB.prepare(
      `SELECT 1 FROM bookings WHERE slot_id = ?1 AND line_user_id = ?2 AND status = 'booked'`,
    )
      .bind(slotId, lineUserId)
      .first();
    return { ok: false, reason: mine ? "already" : "full" };
  } catch (err) {
    // 空きがある枠に、同じ人の2件目が来た(ボタンの2度押しなど)
    if (String(err.message).includes("UNIQUE constraint failed")) return { ok: false, reason: "already" };
    throw err;
  }
}

空きがなければ条件に合う行がなく、0行の登録で終わります。one_booking_per_personの一意インデックスは、同じ人の2件目を止めます。ボタンの2度押しは別々のイベントとして届き、webhookEventIdも別の値になるので、再送の判定では止まりません。

手元のD1で、別々の30人が同時に申し込んだ結果です(3回実行し、3回とも同じ結果でした)。

試したこと 結果
定員3の枠に同時30件 受付3件、満席27件
定員1の枠に同時30件 受付1件、満席29件
同じ人が同じボタンを同時に2回 受付1件、「すでにご予約」1件
同じwebhookEventIdの再送 返事を送らず飛ばした

作っている途中で不具合も1つ出ました。定員1の枠を取った本人がボタンをもう一度押すと、最初の版では「この時間は埋まりました」と返っていました。枠が埋まっているので、一意インデックスに当たる前に0行で終わるためです。お客様から見ると予約が消えたように読めるので、0行のときは本人の予約があるかを調べ、「すでにご予約」と返すように直しました。上のコードは直した後のものです。

この1文はPostgreSQLに持っていけない

D1でこの1文が守れるのは、D1の各データベースが1度に1つずつクエリを処理するからです。Cloudflareのドキュメントには「シングルスレッドで、クエリを1つずつ処理する」とあります。

PostgreSQLは同時に動くので、同じ形のSQLでは守れません。本物のPostgreSQL 18.6のサーバーを手元で立て、30本の接続から定員3の枠へ同時に申し込みました。分離レベルは既定のRead Committedで、1回30件を5回ずつ、2回に分けて計10回試しています。

書き方 入った件数(定員3)
空きを数えながら登録(D1と同じ形) 4・25・14・16・22、5・20・9・20・11件
条件つきのUPDATEのあとで登録 10回とも3件
FOR UPDATEで枠の行をロックしてから数えて登録 10回とも3件

1行目は定員を大きく超えました。件数は回によって変わり、別に動かし直した5回では3・19・10・28・10件で、定員で止まる回もありました。1回の試験で超えなかったことは、安全の証拠になりません。PostgreSQLのドキュメントでは、Read Committedの文はその文が始まった時点のスナップショットを見ると説明されています。同時に動くINSERTは、互いのまだ確定していない行を数えられません。一方、UPDATEは同じ行を更新する相手の確定を待ち、更新後の行でWHEREの条件を評価し直します。そのため、枠の行を更新する形なら守れます。

PostgreSQLで書くなら次の1文にします。枠の表に予約済みの数(booked)の列が要るので、取り消しではbookedを1つ戻します。

PostgreSQLでの枠の確保(上の10回で動かしたもの)sql
WITH got AS (
  UPDATE slots SET booked = booked + 1
   WHERE id = $1 AND booked < capacity
  RETURNING id
)
INSERT INTO bookings (slot_id, customer) SELECT id, $2 FROM got;

二重予約の記事では、手元にPostgreSQLのサーバーがなく、同時の動きを確かめられていませんでした。今回の結果で、条件つきのUPDATEとFOR UPDATEがサーバーでも定員を守ることを確かめられました。ほかの方式の比べ方は、そちらの記事を見てください。

手順6. 確認は応答で返し、リマインドはプッシュで送る

お客様の操作への返事は応答メッセージで、こちらから送る連絡はプッシュメッセージで送ります。LINEのドキュメントでは、応答メッセージは月の通数に数えられず、プッシュは送信対象の人数で数えられるとされています。金額はプランで変わるので、LINE公式アカウントの料金プランで確かめます。

src/line.js(送信)js
function post(env, path, body, headers = {}) {
  return fetch(`${env.LINE_API_BASE}${path}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`,
      ...headers,
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(10_000),
  });
}

export function push(env, to, messages, retryKey = crypto.randomUUID()) {
  return post(env, "/v2/bot/message/push", { to, messages }, { "X-Line-Retry-Key": retryKey });
}

// 応答で返し、応答トークンが使えなかったときだけ push に切り替える
export async function respond(env, event, messages) {
  const res = await post(env, "/v2/bot/message/reply", { replyToken: event.replyToken, messages });
  if (res.ok) return;
  if (res.status !== 400) throw new Error(`reply ${res.status}`); // 5xx は届いた可能性があるので重ねて送らない
  const pushed = await push(env, event.source.userId, messages);
  if (!pushed.ok) throw new Error(`push ${pushed.status}`);
}

応答トークンは1回しか使えず、LINEのリファレンスにはWebhookを受けてから1分以内に使うよう書かれています。時間の制限は予告なく変わることがあり、それに頼る作りにしないようにとも書かれています。この作りでは予約の処理のすぐ後に返すので、ふつうは間に合います。使えなかったとき(400)だけプッシュに切り替えます。真似るサーバーで400を返すと、同じ文面がプッシュで届きました。5xxのときに切り替えないのは、応答が届いている可能性があり、2通になるからです。X-Line-Retry-Keyが使えるのはプッシュなどの送信で、応答メッセージの再試行には使えません。

前日のリマインドは、定期実行で明日の予約を選び、送る前に印を付けます。

src/remind.jsjs
import { push } from "./line.js";
import { jstDate, jstDayRange, jstTime } from "./time.js";

// 明日(日本時間)の予約に1回だけ送る。送る前に印を付け、取れた実行だけが送る
export async function sendReminders(env, scheduledMs) {
  const [from, to] = jstDayRange(jstDate(scheduledMs + 86_400_000));
  const { results } = await env.DB.prepare(
    `SELECT b.id, b.line_user_id, b.retry_key, s.starts_at
       FROM bookings b JOIN slots s ON s.id = b.slot_id
      WHERE b.status = 'booked' AND b.line_user_id IS NOT NULL AND b.reminded_at IS NULL
        AND s.starts_at >= ?1 AND s.starts_at < ?2`,
  )
    .bind(from, to)
    .all();

  for (const b of results) {
    const claim = await env.DB.prepare(
      "UPDATE bookings SET reminded_at = ?1 WHERE id = ?2 AND reminded_at IS NULL AND status = 'booked'",
    )
      .bind(new Date(scheduledMs).toISOString(), b.id)
      .run();
    if (claim.meta.changes === 0) continue;

    const res = await push(env, b.line_user_id, [{ type: "text", text: `明日 ${jstTime(b.starts_at)} からのご予約のお知らせです。` }], b.retry_key).catch(() => null);
    // 届いたか分からない失敗は印を戻し、次の回に同じ retry key で送り直す
    if (!res || res.status >= 500) {
      await env.DB.prepare("UPDATE bookings SET reminded_at = NULL WHERE id = ?1").bind(b.id).run();
    }
    console.log(`remind ${b.id} ${res?.status ?? "no response"}`);
  }
}

ここは最小の形です。送信の記録を別の表に持つ作り、失敗の分け方、同意の持ち方は前日リマインドの記事に書きました。1点だけ違うのは、X-Line-Retry-Keyに使うUUIDを予約を受けたときに作って持っておくことで、これでリマインドの表を増やさずに済みます。

10月31日18時(日本時間)として2つの実行を同時に起こし、次に19時の回を起こしました。真似るサーバーが受けた記録です(retry keyは先頭8文字)。

18時  200 U_g        897a17ab
18時  500 U_500once  a761b15f
18時  200 U_f        d777d3d0
19時  200 U_500once  a761b15f

2つの実行が重なっても宛先ごとに1回で、500だった人だけが19時に同じretry keyで送り直されました。U_gの予約は日本時間の11月1日1時で、UTCの日付は10月31日ですが、正しく「明日」に入っています。店が取り消した予約と、LINEのIDがない電話の予約には送られていません。

なお、200は「LINEが受け付けた」という意味で、読まれたことまでは分かりません。ブロックされている相手でも200が返ります。

手順7. キャンセルは、本人と締め切りを確かめてから状態を変える

キャンセルで確かめることは、本人の予約か、締め切り前か、まだ有効か、の3つです。残りの数は持っていないので、statusを変えるだけで枠が空きます。

src/booking.js(お客様の取り消し)js
// お客様の取り消し。本人の予約で、締め切り前のときだけ通す
export async function cancelByCustomer(env, { bookingId, lineUserId, nowMs }) {
  const b = await env.DB.prepare(
    `SELECT s.starts_at FROM bookings b JOIN slots s ON s.id = b.slot_id
      WHERE b.id = ?1 AND b.line_user_id = ?2 AND b.status = 'booked'`,
  )
    .bind(bookingId, lineUserId)
    .first();
  if (!b) return "none";
  if (nowMs >= cancelDeadline(b.starts_at)) return "late";
  const r = await env.DB.prepare(`UPDATE bookings SET status = 'cancelled' WHERE id = ?1 AND status = 'booked'`)
    .bind(bookingId)
    .run();
  return r.meta.changes === 1 ? "ok" : "none";
}

「予約の確認」で出す一覧は、次の関数で取り出します。

src/booking.js(自分の予約)js
export async function myBookings(env, lineUserId, nowMs) {
  const { results } = await env.DB.prepare(
    `SELECT b.id, s.starts_at FROM bookings b JOIN slots s ON s.id = b.slot_id
      WHERE b.line_user_id = ?1 AND b.status = 'booked' AND s.starts_at > ?2
      ORDER BY s.starts_at`,
  )
    .bind(lineUserId, new Date(nowMs).toISOString())
    .all();
  return results;
}

取り消しのボタンの中身はaction=cancel&booking=予約IDです。トークに残った古いボタンも押せるので、「押された=有効」と考えません。試した結果は次のとおりです。

試したこと 結果
別の人が同じ予約IDの取り消しを送る 「見つかりませんでした」で、予約は残る
本人が取り消す 取り消せる。空いた枠に次の人が入れた
同じボタンをもう一度押す 「見つかりませんでした」
締め切りを過ぎてから押す 「お電話でご連絡ください」

締め切りは「前日の18時」にしました。18:00:00ちょうどから受け付けません。cancelDeadlineを手元で直接呼び、月末、年末、うるう年、朝9時より前で日付がずれる枠の境界を確かめました。

OK 枠 2026-11-05T10:00:00 / 今 2026-11-04T17:59:59 → 取り消せる
OK 枠 2026-11-05T10:00:00 / 今 2026-11-04T18:00:00 → 締め切り後
OK 枠 2026-11-01T08:30:00 / 今 2026-10-31T17:59:59 → 取り消せる
OK 枠 2026-11-01T08:30:00 / 今 2026-10-31T18:00:00 → 締め切り後
OK 枠 2027-01-01T10:00:00 / 今 2026-12-31T17:59:59 → 取り消せる
OK 枠 2028-03-01T10:00:00 / 今 2028-02-29T18:00:00 → 締め切り後

何時までを受け付けるか、締め切り後の連絡をどうするかは、コードの前に店の決まりとして文章にします。決め方はキャンセルの決まりの記事にまとめました。空いた枠を待っている人に案内したくなったら、キャンセル待ちの記事の作りが要ります。

手順8. 店側の画面は、一覧と取り消しと電話の予約から作る

店側の画面に最初に要るのは、日ごとの一覧、取り消し、電話の予約の登録、枠を閉じる操作の4つです。見た目は後回しにして、表が1枚あれば始められます。

src/booking.js(店側の取り消しと一覧)js
// 店側の取り消し。締め切りは見ない
export function cancelByStaff(env, bookingId) {
  return env.DB.prepare(`UPDATE bookings SET status = 'cancelled' WHERE id = ?1 AND status = 'booked'`)
    .bind(bookingId)
    .run();
}

// 店の画面に出す、その日の枠と予約
export async function dayBoard(env, date) {
  const [from, to] = jstDayRange(date);
  const { results } = await env.DB.prepare(
    `SELECT s.id AS slot_id, s.starts_at, s.capacity, b.id AS booking_id, b.customer_name, b.line_user_id
       FROM slots s LEFT JOIN bookings b ON b.slot_id = s.id AND b.status = 'booked'
      WHERE s.starts_at >= ?1 AND s.starts_at < ?2
      ORDER BY s.starts_at`,
  )
    .bind(from, to)
    .all();
  return results;
}
src/admin.jsjs
import { cancelByStaff, dayBoard, reserve } from "./booking.js";
import { jstDate, jstTime } from "./time.js";

const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);

function authorized(request, env) {
  const header = request.headers.get("authorization") ?? "";
  if (!env.ADMIN_PASSWORD || !header.startsWith("Basic ")) return false; // 入れ忘れたまま開かないようにする
  const enc = new TextEncoder();
  let a;
  try {
    a = enc.encode(atob(header.slice(6)));
  } catch {
    return false;
  }
  const b = enc.encode(`${env.ADMIN_USER}:${env.ADMIN_PASSWORD}`);
  // 長さが違うときも同じ比較を通し、処理時間の差から長さが分からないようにする
  return a.byteLength === b.byteLength ? crypto.subtle.timingSafeEqual(a, b) : !crypto.subtle.timingSafeEqual(a, a);
}

export async function handleAdmin(request, env) {
  if (!authorized(request, env)) {
    return new Response("Unauthorized", { status: 401, headers: { "WWW-Authenticate": 'Basic realm="admin"' } });
  }
  const url = new URL(request.url);

  if (request.method === "POST") {
    // ほかのサイトからのフォーム送信を断る
    if (request.headers.get("origin") !== url.origin) return new Response("Forbidden", { status: 403 });
    const f = await request.formData();
    if (url.pathname === "/admin/cancel") {
      await cancelByStaff(env, f.get("booking"));
    } else if (url.pathname === "/admin/book") {
      // 電話の予約も、LINE と同じ関数で同じ枠を取る
      const r = await reserve(env, { slotId: Number(f.get("slot")), lineUserId: null, name: f.get("name"), nowMs: Date.now() });
      if (!r.ok) return new Response("この枠は埋まっています", { status: 409, headers: { "Content-Type": "text/plain; charset=utf-8" } });
    } else if (url.pathname === "/admin/close") {
      await env.DB.prepare("UPDATE slots SET capacity = 0 WHERE id = ?1").bind(Number(f.get("slot"))).run();
    } else {
      return new Response("Not Found", { status: 404 });
    }
    return Response.redirect(`${url.origin}/admin?date=${encodeURIComponent(f.get("date"))}`, 303);
  }

  const date = url.searchParams.get("date") ?? jstDate(Date.now());
  const rows = await dayBoard(env, date);
  const hidden = (name, value) => `<input type="hidden" name="${name}" value="${esc(value)}">`;
  const body = rows
    .map((r) => {
      const who = r.booking_id
        ? `${esc(r.customer_name)}(${r.line_user_id ? "LINE" : "電話"})
           <form method="post" action="/admin/cancel">${hidden("booking", r.booking_id)}${hidden("date", date)}<button>取消</button></form>`
        : "空き";
      return `<tr><td>${jstTime(r.starts_at)}</td><td>定員${r.capacity}</td><td>${who}</td>
        <td><form method="post" action="/admin/book">${hidden("slot", r.slot_id)}${hidden("date", date)}<input name="name" required placeholder="電話のお客様"><button>入れる</button></form>
        <form method="post" action="/admin/close">${hidden("slot", r.slot_id)}${hidden("date", date)}<button>閉じる</button></form></td></tr>`;
    })
    .join("");
  const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>予約 ${esc(date)}</title>
    <h1>${esc(date)} の予約</h1><table>${body}</table>`;
  return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
}

いちばん大事なのは、電話の予約もreserveという同じ関数を通すことです。満席の枠に電話の予約を入れようとすると、409で止まりました。受け口ごとに別の処理にすると、ここが数え違いの入口になります。

画面で気をつけた点は3つです。

  • 表示名はエスケープする。 LINEの表示名はお客様が自由に決められます。<script>alert(1)</script>という表示名で試すと、&#60;script&#62;として文字のまま出ました
  • ほかのサイトからの送信を断る。 別のOriginからのPOSTは403にしました。ブラウザーがBasic認証の情報を覚えていても、ほかのサイトのフォームからは操作できません
  • ログインはBasic認証のままにしない。 ここで使ったのは手元で試すための形です。Cloudflareのドキュメントでも、Basic認証の例は本番向けではないと書かれ、本番向けの認証にはCloudflare Accessが勧められています。本番では、Accessで/adminの前に許可したメールアドレスのログインを置きます。この設定は試していません

もう1つ、LINEの表示名は本名とは限らないので、店の画面では誰の予約か分からないことがあります。電話番号も持っていません。本名や連絡先が要る店は、予約のときに入力してもらう画面が必要で、そこがLINEミニアプリ(LIFF)の出番です。LINEのドキュメントは、LIFFアプリの新規作成ではLINEミニアプリとしての作成を推奨しています。プロフィールをそのままサーバーへ送らず、IDトークンを送ってサーバーで検証する点も決まっています。予約の表とreserveは、そのまま使えます。

業種ごとに変わるのは、枠の持ち方と聞く項目

8つの手順は同じでも、ジム、結婚式場、リフォーム会社では、枠の単位と予約のときに聞くことが変わります。変えるのは主に手順4の枠の表と、手順8の店側の画面です。

業種 枠の単位 聞く項目 先に決めること
パーソナルジム・スタジオ・整体院 担当者ごとの時間、またはレッスンごとの定員 体験か会員か、指名の有無 指名を受けるか。受けるなら担当者ごとの枠になり、「自分で作れる範囲」を超えやすい
結婚式場 見学の時間帯とフェアの回ごとの組数 希望日、人数、結婚式の時期 会場の仮押さえと見学の枠を分けて持つこと
リフォーム会社 担当者が動ける日時の幅 住所、工事の場所、困っている内容、写真 移動の時間をどう見るか。確定は電話や返信で行い、LINEでは希望日を受けるだけにする形もある

どの業種でも、LINEの表示名だけでは誰の予約か分からないことがあります。担当者が折り返す業種ほど、名前と連絡先を受ける画面を早めに足します。

ここまでは自分で作れる、ここからは運用が重い

自分で作るかどうかの線は、コードの量ではなく、作った後に毎週かかる作業で引きます。ここまでのコードは、表の定義とスクリプトを含め、設定ファイル(wrangler.jsonc)を除いて9ファイル418行です。試験のコードは別に223行書きました。行数としては、1人で読み切れる大きさです。

自分で作れる範囲は、次の条件が全部そろう予約です。

  • 1つの枠に決まった定員があり、所要時間が同じ
  • 担当者や部屋を選ばない
  • 店舗が1つ
  • 受け口がLINEと、店が手で入れる電話だけ
  • 決済がない

どれかが外れると、作業の性質が変わります。

足したいこと 増える作業 続く手間
担当者と部屋の組み合わせ、メニューごとの時間 枠の表が使えなくなり、重なりの判定に作り替える シフトが変わるたびに枠の見直し
日時の変更 新しい枠を取れたときだけ元を放す処理 変更の決まりと問い合わせへの対応
外部の予約サイトとの同期 枠を1か所で数えられなくなり、ずれを埋める処理 食い違いを毎日見る担当
複数の店舗 すべての表に店舗の列と権限 店ごとの設定の問い合わせ
事前の決済 取り消しと返金の処理 入金と予約の突き合わせ

時間の長さが違う予約は排他制約やシフトから枠を作る記事、複数店舗はRLSの記事が入口です。

もう1つの線は、機能を足さなくても残る運用です。この記事のコードには、次の仕組みが入っていません。

  • 失敗したイベントに気づく見張り。先に200を返しているので、失敗してもLINEは再送しません。ログを読む人か、通知が要ります
  • トークンを発行し直す手順と、担当者が変わるときの引き継ぎ
  • LINEの仕様が変わったときの追従
  • line_eventsの古い行を消す処理と、予約の記録を残す期間の決まり
  • 友だち解除の扱い。ブロックした人にはリマインドが届かないので、別の連絡手段が要ります
  • データの書き出しと、戻す手順の練習

予約の受付が止まると、その間の予約をそのまま逃します。これらを誰が見るかを決められないうちは、公開を待ったほうが安全です。

やってはいけないこと

  • 署名の検証の前にJSONへ変換する。 LINEのドキュメントでは、検証の前にパースや整形をしないよう書かれています。中身が同じでも署名が合わなくなり、この記事の試験でも401になりました
  • 応答トークンを取っておいて後で使う。 1回かぎりで時間の制限があります。リマインドのように後から送るものはプッシュにします
  • 枠の空きを、見せたときの数字で判断する。 ボタンの「残り1」は表示した時点の数字です。登録の1文の中で数え直します
  • PostgreSQLへ移るときにSQLをそのまま持っていく。 上の結果のとおり定員を超えます
  • トークンやシークレットをコードに書く。 漏れた疑いがあれば、LINEのドキュメントのとおり取り消して発行し直します

公開前に確かめること

  • 本物のチャネルで、コンソールの「検証」が成功する
  • iPhoneとAndroidの両方で、リッチメニューが出てボタンが押せる
  • 友だち追加から予約、確認、取り消しまでを自分のスマホで通す
  • 応答メッセージとあいさつメッセージの自動応答を切った
  • 秘密の値がwrangler secretに入り、gitの履歴に残っていない
  • 同じ枠に、2台のスマホからほぼ同時に申し込んで、定員どおりに止まる
  • 取り消した枠に、次の人が入れる
  • リマインドが日本時間の決めた時刻に届く
  • /adminがログインなしで開かない
  • 失敗に気づく見張りと、見る人を決めた
  • 止まったときの連絡先を、リッチメニューかプロフィールに書いた

動かして確かめた範囲

2026年10月31日に、手元のMacで確かめました。

項目 バージョン・内容
OS macOS 26
Node.js 24.22.1
wrangler 4.156.0(wrangler dev --test-scheduled、手元のD1)
compatibility_date 2026-10-01
PostgreSQL 18.6(embedded-postgresで手元に立てたサーバー、pg 8.23.1)
LINEのAPI 手元の真似るサーバー。本物のLINEには送っていない

確かめたこと:

  • wrangler devのWorkerへ署名付きのイベントを送り、41項目の試験を3回続けて流して、3回とも全項目が通りました。内容は、署名、予約の流れ、再送、2度押し、同時30件、応答トークン切れ、取り消し、締め切り、店の画面の認証とエスケープ、リマインドです
  • 署名の計算がopensslと一致すること
  • リッチメニューの3回の呼び出しの順番と、画像の大きさや領域の範囲が公式の条件に収まること(条件の判定は自作の真似るサーバーで行ったものです)
  • 締め切りの計算の境界
  • 本物のPostgreSQLのサーバーでの同時の申込

確かめていないこと:

  • 本物のLINEとのやり取りのすべて。チャネルの作成、コンソールの「検証」、実際のWebhookの形、応答とプッシュの到着、リッチメニューの表示、スマホでのボタンの見え方は見ていません。真似るサーバーはドキュメントを読んで書いたもので、本物と同じ動きとは限りません
  • Cloudflareへのデプロイ。本番のD1、本番のCron Triggers、wrangler secret put、Cloudflare Accessは使っていません。D1の同時の動きは手元のD1での結果で、本番のD1は公式の説明に頼っています
  • 予約が多いときの速さ、D1から外へ出る時間
  • 試験のコード(test.mjs)そのものは長いので載せていません。署名付きのイベントの作り方はWebhookの記事のtest.mjsと同じです

本番に出す手順は次のとおりです。ここは実行していません。

bash
npx wrangler d1 create line-booking          # 出たIDを wrangler.jsonc の database_id に入れる
npx wrangler d1 execute line-booking --remote --file=schema.sql
npx wrangler secret put LINE_CHANNEL_SECRET
npx wrangler secret put LINE_CHANNEL_ACCESS_TOKEN
npx wrangler secret put ADMIN_USER
npx wrangler secret put ADMIN_PASSWORD
npx wrangler deploy

自社でやる場合と、任せる場合

自社で進める場合は、この記事のコードを読んで直せる開発の担当が1人要ります。作るより長く続くのは、上に書いた見張りとトークンの管理で、担当が抜けても止まらないように手順を残しておきます。予約の決まりを文章にする仕事は、どの進め方でも店でしかできません。

任せる場合、株式会社bundlyzeでは、Web・システムの開発を広くお受けしていて、公式LINEと予約・顧客管理をつなぐ仕組みも開発しています。自作した仕組みの続きだけ、運用の重い部分だけ、といった頼み方もできます。大阪・兵庫を中心とした関西のほか、遠方のご相談もオンラインでお受けしています。入口はシステム開発のページです。

出典(一次情報)

確認日はどれも2026年10月31日です。LINEの仕様は変わることがあるので、作り始める時点で確かめてください。

よくある質問

LINEの予約システムは、LIFFやLINEミニアプリがなくても自作できますか?

作れます。この記事の作りは、リッチメニューとクイックリプライのボタンだけで、日付と時間を選んで予約します。お客様に文字を入力してもらう必要がない予約なら、これで足ります。名前や電話番号の入力、カレンダーの表示が欲しくなった時点で、予約の画面だけをLINEミニアプリとして足します。予約の表と枠を取る処理はそのまま使えます。

予約システムを自作するとき、二重予約はどう防げばよいですか?

空きの確認と登録を分けず、データベースの1回の処理にまとめます。手元のD1では空きを数えながら登録する1文で、定員3の枠に同時30件を送っても3件だけ受け付けました。ただし同じ文を手元のPostgreSQLで動かすと、定員を超える回が続きました(10回で4〜25件)。PostgreSQLでは、条件つきのUPDATEで枠の行を更新してから登録します。

予約管理システムの自作で、どこまでが現実的ですか?

担当者を選ばず、1つの枠に決まった定員があり、店舗が1つで、予約の受け口がLINEと電話だけの形なら、自作できる範囲です。この記事のコードは設定ファイルを除いて9ファイル418行で、この範囲を手元で動かしています。担当者と部屋の組み合わせ、日時の変更、外部の予約サイトとの同期が入ると、作る量よりも、作った後に見続ける手間が急に増えます。

予約の確認やリマインドのメッセージは、月の通数に数えられますか?

LINEのドキュメントでは、応答メッセージは通数に数えられず、プッシュメッセージは送信対象の人数で数えられるとされています。お客様の操作への返事は応答メッセージで返し、前日のリマインドのようにこちらから送るものはプッシュになります。金額はプランで変わるので、LINEの公式の料金ページで確かめます。

店側の画面には、最低限なにが要りますか?

日ごとの枠と予約の一覧、取り消し、電話で受けた予約の登録、枠を閉じる操作の4つです。電話の予約もLINEと同じ関数で同じ枠を取る形にすると、数え違いが起きません。画面にはログインを必ず付け、LINEの表示名のようにお客様が決められる文字は、エスケープしてから表示します。

この記事の書き方と確認の方針