4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LINE Messaging APIでクリニック予約リマインドを組む

4
Posted at

LINE Messaging APIでクリニック予約リマインドを組む

はじめに

歯科やクリニック向けの予約システムを考えると、予約を登録する機能だけでなく「予約日が近づいたら患者さんへ知らせたい」という要件が自然に出てきます。

メールやSMSという選択肢もありますが、LINE公式アカウントを運用している施設なら、Messaging APIを使って予約リマインドを送る構成も考えられます。

ここで私が先に考えるのは、メッセージをどう送るかよりも「予約システムのどこまでをLINE側につなぐか」です。

中小企業向けの業務システムやスマホアプリに関わっていると、外部サービスとの連携では、機能を増やすほど便利になるとは限らないと感じます。特に医療に関係するシステムなら、予約通知に必要のない情報まで一緒に扱う理由はありません。

そこで今回は、予約データから通知対象を抽出し、LINE Messaging APIのPush messageでリマインドする部分だけを小さく切り出します。

持ち帰れるものは、次の構成です。

  • FastAPIで作る最小構成の送信API
  • SQLiteを使った予約・LINEユーザーIDのサンプルデータ構造
  • 二重送信を避けるための送信履歴
  • LINE Messaging APIへのPush message実装
  • Webhookを追加するときの署名検証
  • 本番化するときに分離したい責務

完成形を一気に作るのではなく、まず「予約を読み、対象を決め、通知する」という細い経路から組みます。

結論

予約リマインドでは、LINEを予約管理の本体にせず、予約DBから作った通知対象だけをMessaging APIへ渡す構成にします。
通知処理は「対象抽出」「文面生成」「送信」「送信済み記録」に分け、再実行しても同じ通知を重複送信しない形にします。
診療内容など、リマインドに不要な情報は通知系へ持ち込まない方針にします。

🔧 環境

この記事では、2026年9月27日時点で次の構成を前提にします。

項目 バージョン・構成
OS Ubuntu 24.04 LTS
Python 3.14系
FastAPI 0.141.1
Uvicorn 0.54.0
line-bot-sdk 3.25.0
DB SQLite 3
LINE Messaging API

Python版LINE Messaging API SDKはPython 3.10以上を要件としており、3.x系では linebot.v3 が使われています。公式リポジトリでは、今後はv3モジュールを保守していく方針も示されています。

今回は仕組みを追いやすくするためSQLiteにしています。本番の予約システムがPostgreSQLやMySQLなら、DBアクセス部分を差し替える想定です。

まず仮想環境を作ります。

python -m venv .venv
source .venv/bin/activate

依存パッケージは固定しておきます。

fastapi==0.141.1
uvicorn==0.54.0
line-bot-sdk==3.25.0

インストールします。

pip install -r requirements.txt

LINE Developers Console側ではMessaging APIを利用できるチャネルを用意し、Channel access tokenを取得します。

トークンをソースコードへ直接書くのは避け、環境変数から読む形にします。

export LINE_CHANNEL_ACCESS_TOKEN="..."
export LINE_CHANNEL_SECRET="..."

この記事の中心はPush messageなので、LINE_CHANNEL_SECRET は送信処理そのものには使いません。ただし後半でWebhookを受ける場合の署名検証も扱うため、環境変数として分けています。

実装

LINE Messaging APIでクリニック予約リマインドを組む

まず全体を分けます。

大事なのは、予約DB全体をそのまま外部サービスへ渡す構成にしないことです。

今回LINEへ送るために必要なのは、大まかには次の情報だけです。

  • 送信先を特定するLINE user ID
  • 予約日時
  • 通知文

たとえば予約DBに診療上の情報が存在していたとしても、予約日時を知らせるだけなら通常は必要ありません。

私はこういう連携を作るとき、「取得できる情報」ではなく「その処理に必要な情報」からデータ構造を決めるようにしています。

ディレクトリを分ける

小さなサンプルですが、最初から送信処理をAPIエンドポイントへ全部書きません。

reminder-app/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── database.py
│   ├── reminder.py
│   └── line_client.py
├── init_db.py
└── requirements.txt

予約抽出とLINE送信を分離しておくと、後からLINE以外の通知経路を追加するときも扱いやすくなります。

予約データを用意する

サンプルでは、患者マスターそのものを通知処理へ渡さず、通知に必要な識別子を予約レコードに持たせます。

init_db.py を作ります。

import sqlite3

DB_PATH = "reminder.db"

with sqlite3.connect(DB_PATH) as conn:
    conn.executescript(
        """
        CREATE TABLE IF NOT EXISTS appointments (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            line_user_id TEXT NOT NULL,
            appointment_at TEXT NOT NULL,
            status TEXT NOT NULL DEFAULT 'booked'
        );

        CREATE TABLE IF NOT EXISTS reminder_deliveries (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            appointment_id INTEGER NOT NULL,
            reminder_type TEXT NOT NULL,
            sent_at TEXT NOT NULL,
            UNIQUE(appointment_id, reminder_type),
            FOREIGN KEY(appointment_id) REFERENCES appointments(id)
        );
        """
    )

実行します。

python init_db.py

ここで reminder_deliveries を別テーブルにしたのには理由があります。

予約テーブルへ単純に reminded = true のようなフラグを置く方法でも、最初は動きます。しかし将来、

3日前通知
前日通知
当日通知

のように種類が増えると、一つの真偽値では足りません。

さらに「いつ送ったか」も残したくなります。

そのため今回は、

appointment_id + reminder_type

を一意にしています。

同じ予約について同じ種類のリマインドを二度登録できないので、バッチを再実行したときの防波堤になります。

DBアクセスを書く

app/database.py です。

import sqlite3
from contextlib import contextmanager

DB_PATH = "reminder.db"


@contextmanager
def get_connection():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row

    try:
        yield conn
        conn.commit()
    except Exception:
        conn.rollback()
        raise
    finally:
        conn.close()

サンプルなのでシンプルですが、コミットとロールバックの境界は作っておきます。

LINEへの送信部分を独立させる

次に app/line_client.py を作ります。

import os

from linebot.v3.messaging import (
    ApiClient,
    Configuration,
    MessagingApi,
    PushMessageRequest,
    TextMessage,
)

configuration = Configuration(
    access_token=os.environ["LINE_CHANNEL_ACCESS_TOKEN"]
)


def push_text_message(line_user_id: str, text: str) -> None:
    with ApiClient(configuration) as api_client:
        messaging_api = MessagingApi(api_client)

        messaging_api.push_message(
            PushMessageRequest(
                to=line_user_id,
                messages=[
                    TextMessage(
                        text=text
                    )
                ],
            )
        )

LINE Messaging APIにはReply messageとPush messageがあります。

予約リマインドは、患者さんから届いたメッセージにその場で返信する処理ではありません。予約日時を基準にサーバー側から送信するので、ここではPush messageを使います。

公式ドキュメントでは、Push messageは友だち追加済みのユーザーなど、定められた条件を満たす送信先に送れるとされています。

ここは「LINE user IDが分かれば誰にでも送れる」と考えないことが重要です。

通知対象を抽出する

次に app/reminder.py を作ります。

今回は分かりやすく「翌日の予約」を対象にします。

from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

from app.database import get_connection
from app.line_client import push_text_message

JST = ZoneInfo("Asia/Tokyo")
REMINDER_TYPE = "day_before"


def get_tomorrow_range() -> tuple[str, str]:
    now = datetime.now(JST)
    tomorrow = (now + timedelta(days=1)).date()

    start = datetime.combine(
        tomorrow,
        datetime.min.time(),
        tzinfo=JST,
    )

    end = start + timedelta(days=1)

    return start.isoformat(), end.isoformat()


def find_reminder_targets():
    start, end = get_tomorrow_range()

    with get_connection() as conn:
        rows = conn.execute(
            """
            SELECT
                a.id,
                a.line_user_id,
                a.appointment_at
            FROM appointments AS a
            LEFT JOIN reminder_deliveries AS d
                ON d.appointment_id = a.id
                AND d.reminder_type = ?
            WHERE
                a.status = 'booked'
                AND a.appointment_at >= ?
                AND a.appointment_at < ?
                AND d.id IS NULL
            ORDER BY a.appointment_at
            """,
            (
                REMINDER_TYPE,
                start,
                end,
            ),
        ).fetchall()

    return rows

ポイントは、Pythonで全予約を取得してから絞り込まず、DB側で対象を限定しているところです。

さらに LEFT JOIN を使い、すでに day_before が送信済みの予約を対象外にしています。

通知文を組み立てる

通知文の生成も関数として分けます。

def build_reminder_message(appointment_at: str) -> str:
    dt = datetime.fromisoformat(appointment_at)

    return (
        "ご予約日の前日になりました。\n"
        f"予約日時:{dt:%Y年%m月%d日 %H:%M}\n\n"
        "変更や確認が必要な場合は、"
        "クリニックの案内に沿ってお手続きください。"
    )

ここでは意図的に情報量を増やしていません。

たとえば診療内容、症状、処置名などを通知文へ入れれば親切に見える場面もあるかもしれません。しかしスマートフォンの通知はロック画面などに表示される可能性があります。

予約リマインドの目的が「明日の予約を思い出してもらうこと」なら、その目的に必要な範囲へ絞ります。

また、実運用では施設名や問い合わせ方法など、その施設の運用に必要な文面へ調整する必要があります。この記事の文面はあくまで実装例です。

送信後に履歴を記録する

続けて app/reminder.py に処理を追加します。

def mark_as_sent(appointment_id: int) -> None:
    sent_at = datetime.now(JST).isoformat()

    with get_connection() as conn:
        conn.execute(
            """
            INSERT INTO reminder_deliveries (
                appointment_id,
                reminder_type,
                sent_at
            )
            VALUES (?, ?, ?)
            """,
            (
                appointment_id,
                REMINDER_TYPE,
                sent_at,
            ),
        )


def send_reminders() -> dict[str, int]:
    targets = find_reminder_targets()

    sent = 0
    failed = 0

    for target in targets:
        message = build_reminder_message(
            target["appointment_at"]
        )

        try:
            push_text_message(
                line_user_id=target["line_user_id"],
                text=message,
            )

            mark_as_sent(target["id"])
            sent += 1

        except Exception:
            failed += 1

    return {
        "targets": len(targets),
        "sent": sent,
        "failed": failed,
    }

ここではLINEへの送信が成功した後に履歴を書いています。

ただし、この実装だけで完全なExactly Once配信になるわけではありません。

たとえばLINE側では受理されたものの、アプリ側が正常応答を受け取る前に通信が切れれば、「送信できているのに履歴がない」という状態が起こり得ます。

つまり、

LINEへのHTTPリクエスト
DBへのCOMMIT

を一つのDBトランザクションとして扱うことはできません。

この点は後の「ハマりどころ」で触れます。

FastAPIから実行できるようにする

app/main.py を作ります。

from fastapi import FastAPI

from app.reminder import send_reminders

app = FastAPI()


@app.get("/health")
def health():
    return {"status": "ok"}


@app.post("/jobs/reminders/day-before")
def run_day_before_reminders():
    return send_reminders()

起動します。

uvicorn app.main:app --host 127.0.0.1 --port 8000

これでリマインド処理をHTTP経由で呼び出せます。

ただし、本番環境でこのエンドポイントをインターネットへ公開し、誰でも呼べるようにする設計にはしません。

たとえばクラウド側のスケジューラーから内部的に呼ぶ、ジョブワーカーとして直接起動するなど、実行環境に応じた方法を選びます。

予約リマインドのような定期処理なら、Web APIとして公開すること自体が必須ではありません。

📝 スケジューラーとAPIを分けて考える

ここまで作ると、次にcronを書くところまで進めたくなります。

ただ、私は定期実行の責務をLINE送信コードの中へ混ぜない方が扱いやすいと考えています。

アプリケーションが担当するのは、

今実行されたら、送るべき予約を判断して送る

ところまでです。

一方、

毎日何時に実行するか
何回再試行するか
ジョブが止まったら誰に知らせるか

はジョブ実行基盤の責務として切り離します。

構造としては次のようになります。

この分離には、テストしやすいという利点もあります。

開発中は手動でジョブを呼び、本番ではスケジューラーから同じ処理を呼べます。

「時刻になった」という条件と、「誰へ何を送るか」という業務ルールが混ざらないので、翌日通知から3日前通知へ変更するときにも追いやすくなります。

日付ではなくタイムゾーンまで決める

予約処理で意外と後回しにされやすいのが時刻です。

今回のコードでは明示的に、

ZoneInfo("Asia/Tokyo")

を使いました。

サーバーのローカルタイムがたまたま日本時間だから動いている、という状態にはしません。

特にクラウド環境ではUTCで動く構成も珍しくありません。

予約日時については、

どのタイムゾーンの時刻なのか
DBには何を保存するのか
画面では何に変換するのか

を先に決めます。

サンプルでは説明を簡潔にするためISO 8601形式の文字列を使っていますが、本番では利用するDBの日時型やORMも含めてルールを統一した方が安全です。

Webhookも使うなら署名検証を先に入れる

予約リマインドを送るだけなら、Webhookを受けなくてもPush message部分は作れます。

しかし実際のLINE連携では、友だち追加やメッセージ受信などを契機にユーザーをひも付ける設計が必要になることがあります。

そこでWebhookを追加するときに、後回しにしたくないのが署名検証です。

LINE PlatformからWebhookが送られると、x-line-signature ヘッダーが付与されます。公式ドキュメントでは、Channel secretを使ったHMAC-SHA256による検証が説明されています。

SDKを使うなら WebhookParser などに検証を任せられます。

FastAPIなら、たとえば次のようにします。

import os

from fastapi import FastAPI, Header, HTTPException, Request
from linebot.v3 import WebhookParser
from linebot.v3.exceptions import InvalidSignatureError

app = FastAPI()

parser = WebhookParser(
    os.environ["LINE_CHANNEL_SECRET"]
)


@app.post("/webhook")
async def webhook(
    request: Request,
    x_line_signature: str = Header(...),
):
    body = (await request.body()).decode("utf-8")

    try:
        events = parser.parse(
            body,
            x_line_signature,
        )
    except InvalidSignatureError:
        raise HTTPException(
            status_code=400,
            detail="invalid signature",
        )

    for event in events:
        # 必要なイベントだけ処理する
        pass

    return {"status": "ok"}

ここで注意したいのが、署名検証より先にJSONを加工しないことです。

公式ドキュメントでも、受信したrequest bodyをデシリアライズしたり整形したりしてから検証すると、署名が一致しなくなると説明されています。

つまり、これは避けます。

data = await request.json()

# dataを再度JSON化して署名検証する

署名は受信したbodyそのものに対して検証します。

また、LINE PlatformがWebhook送信に使うIPアドレスは公開されていないため、送信元IPだけを許可する方式の代わりとして署名検証が必要です。

LINE user IDとのひも付けを雑に作らない

もう一つ重要なのが、予約データとLINE user IDをどう結び付けるかです。

今回のサンプルでは話をLINE送信処理へ絞るため、

appointments.line_user_id

を直接持たせています。

本番では、この部分をそのまま採用するとは限りません。

予約システム側の利用者とLINE上のユーザーが同一人物であることを、どの手順で確認するかを設計する必要があります。

避けたいのは、表示名や自己申告だけで機械的に予約情報と結び付けるような構成です。

予約番号などを利用する場合でも、

番号だけ知っていれば結び付けられないか
有効期限は必要か
一度使用したコードを再利用できないようにするか
解除時はどうするか

まで考えます。

この認証・アカウント連携部分は施設側の既存システムによって条件が変わるため、今回のコードには含めていません。

⚠️ ハマりどころ

Push APIが200でも「患者さんが読んだ」とは限らない

Messaging APIのHTTPレスポンスを、そのまま「患者さんへの通知完了」と解釈しない方がよいです。

公式のMessaging APIリファレンスでは、たとえばLINE公式アカウントをブロックしているユーザーなど、Push messageのリクエストに対してステータスコード200が返ってもメッセージを受信しない場合があることが説明されています。

そのためDBの sent_at は、

患者が確認した日時

ではなく、

こちらの送信処理がMessaging APIへの要求を正常に完了した日時

として扱う方が意味が明確です。

「リマインドを送ったから来院するはず」と業務側で判断する仕組みにもしません。

二重送信対策はSELECTだけでは足りない

今回、

AND d.id IS NULL

で送信済みを除外しました。

しかし複数のジョブが同時に走れば、両方が送信前の予約を取得する可能性があります。

サンプルでは最後の防波堤として、

UNIQUE(appointment_id, reminder_type)

も設定しています。

それでも外部APIへの送信とDB更新を完全に一体化できるわけではありません。

本番で重複送信をより厳密に扱うなら、

pending
processing
sent
failed

のような送信ジョブテーブルを作り、ワーカーが処理を確保してから送る構成を検討します。

たとえば、

appointments
      ↓
reminder_jobs
      ↓
worker
      ↓
Messaging API

という形です。

この方が、予約データそのものと配信状態を切り離せます。

例外を握りつぶさない

先ほどのコードは説明を簡潔にするため、

except Exception:
    failed += 1

としています。

本番ではこれだけでは足りません。

少なくとも、

どのジョブで
いつ
どのAPI呼び出しが
どのHTTPステータスになったか

を後から確認できるようにします。

LINEのMessaging API開発ガイドラインでも、APIリクエストについて x-line-request-id、リクエスト時刻、HTTPメソッド、呼び出したエンドポイント、ステータスコードなどをログへ残すことが推奨されています。

一方で、ログへ通知本文や個人情報を何でも残すのも避けたいところです。

障害解析に必要な情報と、業務データそのものを分けて考えるのが重要です。

Channel access tokenをログへ出さない

デバッグ中にHTTPヘッダー全体をログ出力すると、Authorizationヘッダーへ入れたトークンまで残してしまうことがあります。

Authorization: Bearer ...

はログへ残しません。

環境変数を使っていても、ログに出してしまえば意味がありません。

また、Gitへ .env などの秘密情報をコミットしないようにします。

リマインド文に情報を載せすぎない

予約システムが持っている情報を、そのまま通知へ差し込む設計にはしません。

たとえば、

予約日時
診療内容
症状
担当情報
備考

がDBに存在していても、それらすべてが予約リマインドに必要とは限りません。

私は外部通知を作るとき、テンプレートへ変数を追加する前に、

この値は通知目的に本当に必要か

を一度考えるようにしています。

特に医療に関係する情報では、便利さだけで項目を増やさない方がよいです。

Webhookは先にJSONへ変換しない

Webhook署名検証では、受信bodyをそのまま使います。

公式ドキュメントには、受信bodyの文字列置換、デシリアライズ、エスケープ処理などを署名検証前に行わないよう明記されています。

FastAPIでは request.json() が便利なので、普段のAPI実装の感覚で先に呼びたくなります。

しかしWebhookだけは、

raw body取得
↓
署名検証
↓
イベント解析

という順序を崩さないようにします。

Webhookは重い処理を抱え込ませない

LINEの公式ドキュメントでは、Webhookイベントを非同期で処理することも推奨されています。

Webhookを受けたそのHTTPリクエストの中で、

DBを何度も検索
外部APIを複数呼び出す
重い集計をする

といった処理を全部行うと、応答が遅くなります。

Webhookはイベントを受け取り、必要ならキューなどへ渡して早く応答する構成を考えます。

予約リマインドについても同じです。

LINEは通知チャネルであって、予約業務全体をそこで実行する必要はありません。

✅ 本番へ進める前のチェックリスト

最後に、今回の構成を実際のシステムへ持っていく前に確認したい項目をまとめます。

[ ] LINEへ渡す情報を予約通知に必要な範囲へ絞った
[ ] Channel access tokenをソースコードへ書いていない
[ ] 秘密情報がアプリケーションログへ出ない
[ ] 予約日時のタイムゾーンを明示した
[ ] キャンセル済み予約を通知対象から外している
[ ] 同一予約の二重送信を防ぐ仕組みがある
[ ] API成功と患者の閲覧を同じ意味として扱っていない
[ ] LINE user IDと予約利用者を結び付ける手順を決めた
[ ] 通知文へ不要な診療情報を含めていない
[ ] 送信失敗を後から確認できる
[ ] 再試行時の挙動を決めた
[ ] 定期実行処理と通知の業務ルールを分離した
[ ] Webhookを使う場合は署名を検証している
[ ] 署名検証前にrequest bodyを加工していない
[ ] Webhook内で重い処理を同期実行しない

特に大切なのは、Messaging APIを呼び出せた時点で完成と考えないことです。

予約リマインドという機能は短いコードでも作れますが、運用では、

誰を通知対象にするか
何を送るか
いつ送るか
失敗したらどうするか
同じ通知を再送してよいか
誰とLINEアカウントを結び付けるか

の方が長く残ります。

この部分を先に決めておくと、通知チャネルが変わっても設計を使い回しやすくなります。

まとめ

LINE Messaging APIで予約リマインドを作る場合、中心になるのはPush messageの呼び出しそのものではありません。

私なら、予約システムをLINEへ大きく接続するのではなく、

予約DB
↓
対象抽出
↓
必要最小限の通知データ
↓
LINE送信
↓
送信履歴

という小さな経路から作ります。

特に医療・介護に近いシステムでは、「持っているデータを使う」より「この機能には何が必要か」を先に考えた方が設計が整理しやすくなります。

LINEを予約台帳にする必要も、診療情報の置き場所にする必要もありません。通知という役割に限定すれば、予約管理側とMessaging API側の境界も分かりやすくなります。

また、送信処理は一度成功させて終わりではありません。二重送信、再試行、キャンセル、タイムゾーン、ログ、Webhook署名検証まで含めて初めて運用できる形に近づきます。

機能を足す前に境界を決める。外部サービスへつなぐときほど、この順番を崩さないようにしています。

参考文献

以下はいずれも2026年9月27日に確認した公式ドキュメントです。

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?