はじめに
医療系のLINE連携を考えるとき、最初に悩むのはメッセージの送り方よりも「LINE上の利用者と、院内システムの患者をどう結び付けるか」です。
Messaging APIのWebhookからはLINEのuserIdを取得できます。一方、院内には診察券番号や患者IDがあります。技術的には二つをDBの同じ行に保存すれば紐付けられますが、それだけでは本人確認になりません。
さらに、紐付けが正しくても、LINEへ何を送るかという別の問題があります。病歴や診療情報などは要配慮個人情報に該当し得るため、「送信APIがあるから送る」ではなく、LINE側に出してよい情報を先に決める必要があります。
この記事では、LINE Messaging APIのアカウント連携機能を使い、LINEのuserIdと院内側の内部IDを結び付ける構成を考えます。持ち帰れるものとして、FastAPIによる最小実装と、送信可否をコードで固定するルールを載せます。
結論
診察券番号をLINEトークへ入力させて、その場でuserIdと結び付ける設計にはしません。
LINEの公式アカウント連携フローで本人確認済みの院内アカウントと結び付け、LINE側には内部IDだけを対応付けます。
通知本文はホワイトリスト方式にして、診療内容をLINEへ流さない境界をコードで作ります。
環境
この記事のコードは、2026年9月27日時点の公開情報を前提にしたサンプルです。
OS: Ubuntu 24.04 LTS
Python: 3.12
FastAPI: 0.116系を想定
Uvicorn: 0.35系を想定
PostgreSQL: 17系を想定
External Service:
- LINE Messaging API
- LINE Official Account
ライブラリの細かなバージョンに依存する部分を減らすため、Messaging APIへのHTTPリクエストはhttpxを使う形で示します。
fastapi
uvicorn
httpx
psycopg[binary]
なお、医療情報の安全管理については、2026年6月に厚生労働省から「医療情報システムの安全管理に関するガイドライン 第7.0版」が公開されています。実運用ではこの記事のサンプルだけで安全性を判断せず、組織の規程、委託関係、利用するサービスの契約条件なども含めて確認する必要があります。
🔧 実装
まず「LINE ID」という言葉を分解する
現場の会話では「LINE IDと診察券番号を紐付けたい」と言われることがあります。
ここでいう「LINE ID」を、そのまま設計書の項目名にしないようにしています。
Messaging APIでユーザーを識別するときに扱うのは、Webhookイベントなどから得られるLINEのuserIdです。利用者が友だち検索などに使う文字列を収集して紐付ける設計とは分けて考えます。
DB上も曖昧なline_idではなく、意味が分かる名前にします。
CREATE TABLE line_account_links (
id BIGSERIAL PRIMARY KEY,
patient_internal_id UUID NOT NULL,
line_user_id VARCHAR(64) NOT NULL UNIQUE,
linked_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
unlinked_at TIMESTAMPTZ,
UNIQUE (patient_internal_id)
);
ここで意識しているのは、patient_internal_idを使っている点です。
診察券番号をそのまま外部連携の主キーにすると、その番号がURL、ログ、通知処理などへ広がりやすくなります。診察券番号は院内システム側で患者を検索するために使えても、外部連携テーブルまで同じ識別子で統一する必要はありません。
たとえば院内側を次のように分けます。
CREATE TABLE patients (
id UUID PRIMARY KEY,
chart_number VARCHAR(64) NOT NULL UNIQUE
);
LINE連携側が知るのはpatients.idです。
診察券番号
↓ 院内で検索
patient_internal_id
↓
line_account_links
↓
line_user_id
こうすると、Messaging APIを扱う処理が診察券番号そのものを必要とする範囲を狭くできます。
紐付けの流れ
LINE Messaging APIには、サービス側のアカウントとLINEアカウントを結び付けるためのUser account linking機能があります。
独自に「診察券番号をトークへ送ってください」と実装するより、こちらを土台にします。
LINE公式ドキュメントのアカウント連携では、BotサーバーがuserIdに対してlink tokenを発行し、サービス側で利用者を認証した後にnonceを生成します。その後、account linkイベントに含まれるLINEのuserIdとnonceを使ってサービス側のユーザーを対応付けます。
この形の大事なところは、利用者がLINEトークへ診察券番号を投稿する必要がないことです。
link tokenを発行する
link tokenの発行先は公式ドキュメントで次のエンドポイントとして定義されています。
POST https://api.line.me/v2/bot/user/{userId}/linkToken
Pythonでは次のような関数にできます。
import httpx
LINE_API_BASE = "https://api.line.me"
async def issue_link_token(
line_user_id: str,
channel_access_token: str,
) -> str:
url = (
f"{LINE_API_BASE}/v2/bot/user/"
f"{line_user_id}/linkToken"
)
headers = {
"Authorization": f"Bearer {channel_access_token}",
}
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(url, headers=headers)
response.raise_for_status()
body = response.json()
return body["linkToken"]
公式ドキュメントではlink tokenは一度だけ使用でき、有効期間は10分とされています。
そのため、自前DBへ長期保存して「あとで再利用するトークン」のようには扱いません。
nonceに診察券番号を入れない
次に重要なのがnonceです。
これは推測しにくい一回限りの値にします。LINEの公式ドキュメントでは、nonceについて10〜255文字で、予測可能なサービス側ユーザーIDなどを使わず、安全な乱数生成器を使うことが案内されています。
つまり、次のような実装にはしません。
# この設計にはしない
nonce = patient.chart_number
診察券番号をハッシュ化しただけの値も避けます。
# これも採用しない
nonce = sha256(patient.chart_number.encode()).hexdigest()
ハッシュは「ランダムな一回限りの値」にはなりません。同じ入力なら同じ出力になるためです。
secretsでランダム値を作り、サーバー側で内部IDとの対応を保持します。
import secrets
from datetime import datetime, timedelta, timezone
LINK_SESSION_TTL = timedelta(minutes=10)
def create_nonce() -> str:
return secrets.token_urlsafe(32)
def create_link_session(patient_internal_id: str) -> dict:
now = datetime.now(timezone.utc)
return {
"nonce": create_nonce(),
"patient_internal_id": patient_internal_id,
"created_at": now,
"expires_at": now + LINK_SESSION_TTL,
"used_at": None,
}
DBはたとえば次の形です。
CREATE TABLE line_link_sessions (
nonce VARCHAR(255) PRIMARY KEY,
patient_internal_id UUID NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
本番ではnonceそのものをDBに残すか、nonceのダイジェストだけを保存するかも検討対象になります。
後者なら、DBが参照されたときに未使用nonceをそのまま利用されるリスクを減らせます。
import hashlib
def nonce_digest(nonce: str) -> str:
return hashlib.sha256(nonce.encode("utf-8")).hexdigest()
DB側を次のようにします。
CREATE TABLE line_link_sessions (
nonce_digest CHAR(64) PRIMARY KEY,
patient_internal_id UUID NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
受信したnonceを同じ方法でダイジェスト化し、該当セッションを検索します。
WebhookはJSONを読む前に署名検証する
Messaging APIのWebhook処理で外せないのが署名検証です。
LINEはWebhookのx-line-signatureをHMAC-SHA256で検証する方法を公開しています。注意したいのは、JSONとしてパースした後のデータではなく、受信したリクエストボディそのものを使うことです。
FastAPIなら次のようにできます。
import base64
import hashlib
import hmac
import json
import os
from fastapi import FastAPI, Header, HTTPException, Request
app = FastAPI()
CHANNEL_SECRET = os.environ["LINE_CHANNEL_SECRET"]
def verify_line_signature(
body: bytes,
signature: str,
) -> bool:
digest = hmac.new(
CHANNEL_SECRET.encode("utf-8"),
body,
hashlib.sha256,
).digest()
expected = base64.b64encode(digest).decode("utf-8")
return hmac.compare_digest(expected, signature)
@app.post("/webhook")
async def webhook(
request: Request,
x_line_signature: str = Header(...),
):
body = await request.body()
if not verify_line_signature(body, x_line_signature):
raise HTTPException(
status_code=400,
detail="invalid signature",
)
payload = json.loads(body)
for event in payload.get("events", []):
await handle_event(event)
return {"ok": True}
順番は、
request.body()
↓
署名検証
↓
JSONパース
↓
イベント処理
です。
先にJSONへ変換して整形し、それを再び文字列化して署名検証すると、空白やエスケープなどが変化して検証に失敗する可能性があります。
また、LINEはWebhook送信元のIPアドレスを公開していません。IPアドレスによる許可リストを署名検証の代わりにはできません。
accountLinkイベントだけで紐付けを確定する
アカウント連携が完了すると、Webhookへaccount linkイベントが届きます。
ここで初めてLINE側と院内側の対応を確定します。
処理の骨格は次のようになります。
async def handle_event(event: dict) -> None:
if event.get("type") != "accountLink":
return
link = event.get("link", {})
if link.get("result") != "ok":
return
line_user_id = event["source"]["userId"]
nonce = link["nonce"]
session = await consume_link_session(nonce)
if session is None:
return
await save_account_link(
patient_internal_id=session["patient_internal_id"],
line_user_id=line_user_id,
)
consume_link_session()では、単純なSELECTだけでは足りません。
同じnonceを二度処理しないようにします。
UPDATE line_link_sessions
SET used_at = CURRENT_TIMESTAMP
WHERE nonce_digest = %(nonce_digest)s
AND used_at IS NULL
AND expires_at > CURRENT_TIMESTAMP
RETURNING patient_internal_id;
更新できた行が0件なら、そのnonceでは連携を成立させません。
これで、
存在する
かつ
期限内
かつ
未使用
という条件をDB側でまとめて判定できます。
Webhookの再送や並行処理を考えると、「SELECTしてからUPDATE」より、このように状態変更と取得を一つのSQLで行う方が扱いやすくなります。
診察券番号をどこで確認するか
では診察券番号は一切使わないのかというと、そうではありません。
院内アカウントをまだ持たない仕組みなら、Web側の本人確認手続きの一部として診察券番号を利用するケースは考えられます。ただし、診察券番号を知っていることだけを本人確認とみなす設計にはしません。
診察券番号はカードを見れば分かる場合がありますし、番号体系によっては推測可能性もあります。
ここはシステムだけで決める場所ではありません。
LINEアカウントを誰と結び付けるのか
↓
本人確認をどの強度で行うのか
↓
連携後に何の機能を許可するのか
をセットで決めます。
たとえば「一般的な来院案内だけを通知する連携」と、「個別の医療情報へアクセスできる連携」では、求める本人確認を同じにする理由はありません。
この記事のコードでは、本人確認が完了した後にpatient_internal_idを取得できる、というところまでを前提にしています。具体的な本人確認方法を診察券番号だけで代用する意図はありません。
⚠️ LINEへ送る情報をホワイトリストにする
紐付けができると、次に作りたくなるのが通知処理です。
ここで私は、「送ってはいけない項目を後から列挙する」より「送ってよいメッセージ種別だけを定義する」形にします。
個人情報保護委員会と厚生労働省の医療・介護分野のガイダンスでは、病歴、診療や調剤の過程で医療従事者が知り得た身体状況、病状、治療などの情報は要配慮個人情報の例として挙げられています。
そのため、アプリケーションコードのどこからでも自由な文字列をpushできる形にはしません。
from enum import Enum
class NotificationType(str, Enum):
GENERIC_REMINDER = "generic_reminder"
ACTION_REQUIRED = "action_required"
MESSAGE_TEMPLATES = {
NotificationType.GENERIC_REMINDER: (
"お知らせがあります。"
"内容は所定の方法でご確認ください。"
),
NotificationType.ACTION_REQUIRED: (
"ご確認いただきたいお知らせがあります。"
"内容は所定の方法でご確認ください。"
),
}
そして、通知関数は任意のtextを受け取らないようにします。
def build_message(
notification_type: NotificationType,
) -> str:
return MESSAGE_TEMPLATES[notification_type]
呼び出し側はこうです。
message = build_message(
NotificationType.GENERIC_REMINDER
)
対して、次のようなAPIは作りません。
# 自由文をそのままLINEへ渡せるため採用しない
async def send_line_message(
line_user_id: str,
text: str,
):
...
もちろん最下層のLINEクライアントでは最終的に文字列を送ります。
重要なのは、業務ロジックからその低レベル関数を直接呼べない構造にすることです。
予約・受付などの業務処理
↓
NotificationType
↓
承認済みテンプレート
↓
LINE送信アダプター
↓
Messaging API
病名、検査結果、処方内容、診療内容などを業務オブジェクトから文字列補間できる作りにしなければ、「気を付けて実装する」より一段強い制約になります。
なお、「この文言なら法的に必ず送信可能」という意味ではありません。通知内容、利用目的、本人同意、契約関係、組織の運用などによって判断は変わります。ここで示しているのは、技術側で情報量を増やしにくくする設計です。
連携解除も最初から作る
アカウント連携は作って終わりではありません。
LINEのUser account linkingドキュメントでは、利用者がいつでも連携解除できるようにすること、連携時に解除可能であることを知らせることが示されています。
DBでは物理削除だけにせず、状態を持たせる方法があります。
UPDATE line_account_links
SET unlinked_at = CURRENT_TIMESTAMP
WHERE patient_internal_id = %(patient_internal_id)s
AND unlinked_at IS NULL;
送信対象を取得するときは、解除済みを除外します。
SELECT line_user_id
FROM line_account_links
WHERE patient_internal_id = %(patient_internal_id)s
AND unlinked_at IS NULL;
解除処理を後付けすると、通知バッチ、管理画面、キャッシュなど複数箇所へ修正が広がります。
「紐付ける」を作るときに「外す」まで同時に設計しておく方が整理しやすいです。
ログへ何を残すか
セキュリティ対応としてログを増やすと、別の問題が出てきます。
たとえば次のログです。
logger.info(
"linked line_user_id=%s chart_number=%s",
line_user_id,
chart_number,
)
調査には便利ですが、LINE側の識別子と診察券番号の対応がアプリケーションログへ複製されます。
そこで、通常ログでは内部のイベントIDを中心にします。
logger.info(
"line_account_linked event_id=%s",
event_id,
)
エラー時もWebhookボディ全体を無条件に吐かないようにします。
# 避けたい例
logger.exception(
"webhook error payload=%s",
payload,
)
ログに必要な情報は、障害調査や監査の要件から逆算します。
「後で困りそうだから全部残す」は、医療情報を扱うシステムでは特に慎重に考えたいところです。
ハマりどころ
「診察券番号を入力できた=本人」と考えてしまう
一番単純な実装は、
ユーザーがLINEで診察券番号を送信
↓
DB検索
↓
WebhookのuserIdと保存
です。
コード量は少ないのですが、診察券番号の知識だけで他人のLINEアカウントと患者情報を結び付けられるなら、本人確認として弱い可能性があります。
さらに診察券番号がトーク履歴やWebhook処理、アプリケーションログへ流れる経路も増えます。
「紐付け処理」と「本人確認処理」を同じものとして扱わないことが重要です。
LINEのuserIdを院内の患者IDにしてしまう
userIdはLINE Platform上の識別子です。
院内データモデルの患者主キーとして採用するのではなく、
院内のpatient_internal_id
↕
外部サービスとの対応表
↕
LINE userId
と分けます。
将来、別の通知チャネルを追加したときも、この方が拡張しやすくなります。
patients
└ patient_internal_id
├ LINE
├ メール
└ 別の通知サービス
外部サービスの識別子を業務ドメインの中心へ入れない、という普通の設計がここでも大切です。
通知文をDBから自由編集できるようにする
管理画面から通知テンプレートを自由編集できる機能は便利です。
ただ、医療系では便利さと同時に「誰かが患者ごとの情報を差し込み始める」余地も生まれます。
{{patient_name}}
{{disease_name}}
{{medicine_name}}
{{test_result}}
のような変数が増えていけば、LINE側へ流れる情報も増えます。
テンプレート編集を提供するなら、利用可能な変数を限定する、承認フローを置く、変更履歴を残すなど、運用と一緒に設計する必要があります。
私なら最初は自由度を上げません。通知種別と固定文面を少数用意し、必要性が確認できてから拡張します。
LINEだけ安全にして終わらせる
LINEへ診療情報を送らない設計にしても、連携用Webページのアクセスログへ診察券番号をURLクエリとして出していたら意味がありません。
たとえば、次のように診察券番号をURLへ含める設計は避けます。
連携ページのURL + ?chart_number=診察券番号
URLの具体例を実在しないドメインで示す必要はありません。問題なのはドメインではなく、識別情報をクエリ文字列へ載せる設計そのものです。
ブラウザ履歴、アクセスログ、解析基盤、Refererなど、URLは思った以上に多くの場所へ残る可能性があります。
URLには一時トークンを使い、本人確認情報は適切な方法でサーバーへ送ります。
また、LINEのchannel secretやchannel access tokenもソースコードへ直書きしません。
import os
CHANNEL_SECRET = os.environ["LINE_CHANNEL_SECRET"]
CHANNEL_ACCESS_TOKEN = os.environ[
"LINE_CHANNEL_ACCESS_TOKEN"
]
実運用では秘密情報管理サービスなども含め、環境に合った管理方法を選びます。
「LINEに送らない」だけでは要件が完成しない
もう一つ大事なのは、LINEへ情報を出さなければ自動的に安全になるわけではないことです。
連携DBには、
patient_internal_id ↔ line_user_id
という対応関係があります。
これは患者と外部アカウントを結び付ける情報なので、アクセス制御、保存期間、バックアップ、ログ、運用者の権限、削除・解除時の扱いまで考える必要があります。
厚生労働省の「医療情報システムの安全管理に関するガイドライン 第7.0版」は、概説編、経営管理編、企画管理編、システム運用編、保守委託機関編に分かれています。
実装担当だけで閉じず、どの情報をどのサービスで扱うのかをシステム全体で確認する必要があります。
✅ まとめ
医療系のLINE連携では、Messaging APIを呼ぶコードそのものはそれほど複雑ではありません。
難しいのは境界を決めることです。
私は設計時に、少なくとも次を確認します。
- LINEトークへ診察券番号を入力させる前提になっていないか
- LINEの
userIdと院内の患者IDを直接同一視していないか - 本人確認とID紐付けを分けているか
- nonceがランダム、一回限り、期限付きになっているか
- Webhookをパースする前に署名検証しているか
- 紐付け確定をaccount linkイベントの成功後にしているか
- 通知処理が自由文ではなく許可済みテンプレートから作られるか
- 診療情報などを安易に通知本文へ差し込めないか
- 診察券番号やWebhook本文をログへ無条件に残していないか
- 利用者が連携を解除できるか
- 解除済みアカウントへの送信を止められるか
- LINE連携DB自体のアクセス制御を考えているか
特に大切なのは、「何を送るか」を実装者の注意力だけに任せないことだと考えています。
Messaging APIの呼び出し口を一つに絞り、送信可能な通知種別をコードで限定する。LINEのuserIdと院内IDは対応表で管理し、診察券番号を外部連携へ広げない。本人確認は別の工程として扱う。
こうした小さな制約を積み重ねると、後から機能が増えたときにも境界を保ちやすくなります。
医療系の連携では「送れる情報」を増やすより、まず「ここから先へは出さない」をコードとデータモデルで表現するところから始めるのがよいと考えています。
参考文献
以下はいずれも2026年9月27日時点で参照する公式資料です。

