Webサイトの問い合わせフォームをメール通知だけで運用していると、迷惑メールフォルダに紛れる・気づくのが翌日になる、といった取りこぼしが起きます。そもそも送信元の設定が原因でメールが届いていないこともあります。問い合わせフォームの通知をLINEに送るようにすれば、スマホの通知で即座に気づけますし、チームで共有するならグループトークに流すだけで情報共有が完結します。
この記事では、Next.js(App Router)+ TypeScript で構築した実際のサイトを題材に、問い合わせフォームの内容をLINEグループへ通知する仕組みを、コード付きで最初から最後まで解説します。単に飛ばすだけではなく、
- 管理者が内容を確認して承認したものだけをグループに流す
- botやスパム投稿がLINEに流れ込まないようにする
という、実運用に耐える構成まで踏み込みます。
この記事で作るもの
題材にしたのは、音楽クルーの公式サイトに設置した「アーティストへの応援メッセージフォーム」です。誰でも送信できる公開フォームなので、届いた内容をそのままメンバーのLINEグループに流すわけにはいきません。そこで次のフローにしました。
[訪問者] フォーム送信
↓
[サーバー] バリデーション・スパム判定 → 承認待ちとして保存
↓
[管理者のLINE] 「承認 / 却下」ボタン付きで通知
↓ 承認を押すと
[LINEグループ] メンバー全員にメッセージが届く
ポイントは管理者のLINEアプリだけで完結することです。管理画面にログインする必要がありません。LINEの「ポストバックアクション(ボタン)」を使うと、これがそのまま実現できます。
LINE Notifyは終了。今はMessaging APIを使う
「LINEに通知を飛ばす」で検索すると LINE Notify を使った記事が大量に出てきますが、LINE Notifyは2025年3月31日をもってサービスを終了しました。notify-bot.line.me や notify-api.line.me を含む全機能が停止しており、古い記事のコードは動きません。
LINEヤフー公式も、代替として Messaging API(LINE公式アカウントからメッセージを送るAPI)の利用を案内しています。本記事もMessaging APIで実装します。
費用はかかる?
LINE公式アカウントの コミュニケーションプラン(月額0円) で、月200通まで無料で送信できます。個人サイトや小規模サイトの問い合わせ通知であれば、まず超えません。
さらに実装上のコツとして、応答メッセージ(Reply API)は配信数としてカウントされません。今回の構成では「管理者への承認依頼」と「グループへの本送信」だけがPush(カウント対象)で、承認ボタンを押した後の「✅ 送信しました」といったフィードバックはReplyで返しているため、1件あたりの消費は実質2通です。月200通なら月100件の問い合わせまで無料枠で回る計算になります。
事前準備
1. LINE公式アカウントとMessaging APIチャネルを作る
- LINE Developers にログイン
- プロバイダーを作成 → Messaging API チャネルを新規作成
- チャネル基本設定から チャネルシークレット(
LINE_CHANNEL_SECRET) を取得 - Messaging API設定から チャネルアクセストークン(
LINE_CHANNEL_ACCESS_TOKEN) を発行 - 同じ画面の「グループトーク・複数人トークへの参加を許可する」を オン
2. 自動応答をオフにする
これは必ずハマるポイントなので先に潰しておきます。初期状態のLINE公式アカウントは「応答メッセージ」がオンになっており、グループで誰かが発言するたびに定型文(「メッセージありがとうございます!」など)を返してしまいます。
LINE Official Account Manager → 設定 → 応答設定 から、
- 応答メッセージ:オフ
- あいさつメッセージ:オフ(友だち追加時の自動送信。不要なら切る)
- Webhook:オン
にしてください。コードを疑う前にここを確認しましょう。
3. 通知先のIDを取得する
Messaging APIで送信するには、送信先の userId(管理者)と groupId(グループ)が必要です。これは LINE IDとは別物で、U や C から始まる33文字の内部識別子です。LINE Developersの画面には表示されません。
取得方法は「Webhookでイベントを受け取り、ログに出す」のが確実です。後述のWebhookハンドラに、次のログ出力を仕込んでおきます。
if (src?.userId) {
console.log(`[line-webhook] userId: ${src.userId} (event: ${event.type})`);
}
if (src?.groupId) {
console.log(`[line-webhook] groupId: ${src.groupId} (event: ${event.type})`);
}
この状態で、① 公式アカウントを友だち追加して1:1で何か送る → userId が出る、② グループに公式アカウントを招待して何か発言する → groupId が出る、という手順でVercelのログから拾えます。
4. 環境変数
# .env.local
LINE_CHANNEL_ACCESS_TOKEN="..."
LINE_CHANNEL_SECRET="..."
LINE_ADMIN_USER_ID="U..." # 承認する人
LINE_GROUP_ID="C..." # 通知先グループ
TURNSTILE_SECRET_KEY="..." # Cloudflare Turnstile(後述)
NEXT_PUBLIC_TURNSTILE_SITE_KEY="..."
KV_REDIS_REDIS_URL="rediss://..." # Upstash Redis
CONTACT_IP_SALT="..." # IPハッシュ用のソルト(後述)
シークレットに NEXT_PUBLIC_ を付けないでください。 NEXT_PUBLIC_ が付いた変数はクライアントのJSバンドルに埋め込まれ、ブラウザから丸見えになります。公開してよいのはTurnstileのサイトキーだけです。
実装① LINE送信ユーティリティ
まずLINE APIを叩く薄いラッパーを作ります。src/lib/contact/line.ts。
import crypto from "crypto";
const LINE_API = "https://api.line.me/v2/bot";
type LineMessage = Record<string, unknown>;
function accessToken(): string {
const t = process.env.LINE_CHANNEL_ACCESS_TOKEN;
if (!t) throw new Error("LINE_CHANNEL_ACCESS_TOKEN が未設定です");
return t;
}
/** push送信(グループ / 1:1 共通)。無料通数を消費する */
export async function pushMessages(
to: string,
messages: LineMessage[],
): Promise<void> {
const res = await fetch(`${LINE_API}/message/push`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken()}`,
},
body: JSON.stringify({ to, messages }),
});
if (!res.ok) {
throw new Error(`LINE push failed (${res.status}): ${await res.text()}`);
}
}
/** reply送信(無料通数を消費しない。承認操作への応答に使用) */
export async function replyMessages(
replyToken: string,
messages: LineMessage[],
): Promise<void> {
const res = await fetch(`${LINE_API}/message/reply`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken()}`,
},
body: JSON.stringify({ replyToken, messages }),
});
if (!res.ok) {
console.error(`LINE reply failed (${res.status}): ${await res.text()}`);
}
}
export function textMessage(text: string): LineMessage {
return { type: "text", text };
}
承認ボタンを作る
LINEの confirmテンプレート に postbackアクション を持たせると、「承認 / 却下」の2択ボタンが出せます。data に入れた文字列がWebhookに返ってくるので、ここにメッセージIDを埋め込みます。
/** 承認/却下ボタン付きconfirmテンプレート */
export function approvalTemplate(id: string): LineMessage {
return {
type: "template",
altText: "メッセージの承認確認",
template: {
type: "confirm",
text: "このメッセージを グループに送りますか?",
actions: [
{
type: "postback",
label: "承認",
data: `approve:${id}`,
displayText: "承認",
},
{
type: "postback",
label: "却下",
data: `reject:${id}`,
displayText: "却下",
},
],
},
};
}
Webhook署名の検証
LINEからのWebhookは、誰でもPOSTできる公開エンドポイントです。必ず X-Line-Signature ヘッダを検証してください。検証しないと、第三者が偽の「承認」イベントを投げて任意のメッセージをグループに流せてしまいます。
export function verifyLineSignature(
rawBody: string,
signature: string | null,
): boolean {
const secret = process.env.LINE_CHANNEL_SECRET;
if (!secret || !signature) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("base64");
try {
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
} catch {
return false;
}
}
比較に timingSafeEqual を使うのは、タイミング攻撃を避けるためです。=== では文字列の一致していく長さによって処理時間が変わり、理論上は署名を推測される余地が生まれます。また、署名の計算には生のリクエストボディ(パース前の文字列)が必要です。req.json() を先に呼ぶと元の文字列が取れなくなるので、req.text() で受けてから自分で JSON.parse します。
実装② ワンタイムトークンの発行
フォームを開いた時点でサーバーから使い捨てトークンを配り、送信時にそれを消費させます。これだけで「フォームページを開かずにAPIを直接叩く」タイプのbotを弾けます。保存先はUpstash Redis(Vercelから1クリックで導入可)を使いました。
src/app/api/contact/token/route.ts:
import { NextResponse } from "next/server";
import { issueToken, isPaused } from "@/lib/contact/store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
if (await isPaused()) {
return NextResponse.json({ paused: true }, { status: 503 });
}
return NextResponse.json({ token: await issueToken() });
} catch (e) {
// Redis障害時にフォームをハングさせない
console.error("[contact] token issue failed:", e);
return NextResponse.json({ error: "unavailable" }, { status: 503 });
}
}
ストア側(src/lib/contact/store.ts)の要点:
import { createClient } from "redis";
import crypto from "crypto";
const TOKEN_TTL = 60 * 15; // 15分
export async function issueToken(): Promise<string> {
const redis = await getRedis();
const token = crypto.randomUUID();
await redis.set(`contact:token:${token}`, String(Date.now()), {
EX: TOKEN_TTL,
});
return token;
}
/** トークンを消費して発行時刻を返す(無効ならnull) */
export async function consumeToken(token: string): Promise<number | null> {
if (!token || token.length > 64) return null;
const redis = await getRedis();
const issuedAt = await redis.getDel(`contact:token:${token}`); // 取得と同時に削除
return issuedAt ? Number(issuedAt) : null;
}
getDel を使うのが肝です。取得と削除がアトミックに行われるため、同じトークンでの二重送信を確実に防げます。
また、接続そのものが落ちたときにフォームが固まらないよう、タイムアウトを明示しておきます。
const client = createClient({
url: REDIS_URL,
socket: {
connectTimeout: 3000,
reconnectStrategy: (retries) => (retries > 1 ? false : 300),
},
});
実装③ 送信API(検証 → 保存 → 承認依頼)
src/app/api/contact/route.ts。検証の順番が重要です。コストの安い判定から並べ、外部API(Turnstile)呼び出しは後ろに置きます。
async function handlePost(req: NextRequest) {
// 0. キルスイッチ(荒らされたら即停止できる)
if (await isPaused()) {
return err("paused", "ただいま受付を一時停止しています。", 503);
}
const body = (await req.json()) as Body;
// 1. ハニーポット:botには成功を装う
if (body.website) {
return NextResponse.json({ ok: true });
}
// 2. ワンタイムトークン
const issuedAt = await consumeToken(body.token ?? "");
if (issuedAt === null) {
return err(
"token",
"フォームの有効期限が切れました。再読み込みしてください。",
);
}
// 3. 時間チェック:表示から3秒未満は機械的な送信
if (Date.now() - issuedAt < 3000) {
return err("too_fast", "送信が速すぎます。もう一度お試しください。");
}
// 4. 入力長
const name = (body.name ?? "").trim();
const message = (body.message ?? "").trim();
if (!name || name.length > 30)
return err("name", "名前は1〜30文字で入力してください。");
if (!message || message.length > 500)
return err("message", "メッセージは1〜500文字で入力してください。");
// 5. URL・メールアドレスを含む投稿は拒否(宣伝スパムの大半がここで落ちる)
if (/https?:\/\/|www\.|[\w.+-]+@[\w-]+\.[\w.]+/i.test(message)) {
return err("content", "URL・メールアドレスは記載できません。");
}
const ip =
(req.headers.get("x-forwarded-for") ?? "").split(",")[0].trim() ||
"unknown";
const country = req.headers.get("x-vercel-ip-country") ?? "??";
// 6. Cloudflare Turnstile
if (!(await verifyTurnstile(body.turnstileToken, ip))) {
return err("turnstile", "認証に失敗しました。再読み込みしてください。");
}
// 7. レート制限
if (!(await checkGlobalRate()))
return err("rate_global", "本日の受付上限に達しました。", 429);
if (!(await checkIpRate(hashIp(ip))))
return err("rate_ip", "送信が多すぎます。", 429);
// 8. 同一本文の重複
if (!(await checkDuplicate(message)))
return err("duplicate", "同じ内容が送信済みです。");
// 9. 要注意フラグ(拒否はせず、通知に印を付ける)
const flags: string[] = [];
if (country !== "JP") flags.push(`海外IP: ${country}`);
if (!/[-ヿ一-鿿]/.test(message)) flags.push("非日本語");
// 保存して管理者に承認依頼
const pending = {
id: crypto.randomUUID(),
name,
message,
country,
flags,
createdAt: Date.now(),
};
await savePending(pending);
const flagLine = flags.length ? `\n⚠ ${flags.join(" / ")}` : "";
const summary = `📮 新着メッセージ(承認待ち)\n名前: ${pending.name}\n国: ${country}${flagLine}\n──────\n${pending.message}`;
const adminId = process.env.LINE_ADMIN_USER_ID;
if (!adminId) throw new Error("LINE_ADMIN_USER_ID が未設定です");
await pushMessages(adminId, [
textMessage(summary),
approvalTemplate(pending.id),
]);
return NextResponse.json({ ok: true });
}
ハニーポットで「成功を装う」理由
ハニーポット(CSSで画面外に飛ばした、人間には見えない入力欄)に値が入っていたらbotです。ここでエラーを返すと、bot側が「この項目を埋めなければ通る」と学習してしまいます。{ ok: true } を返して成功したふりをするのが定石です。
レート制限はINCR + EXPIREで
Redisの INCR はアトミックなので、同時リクエストでもカウントがずれません。
export async function checkIpRate(ipHash: string): Promise<boolean> {
const redis = await getRedis();
const key = `contact:rl:ip:${ipHash}`;
const n = await redis.incr(key);
await redis.expire(key, 3600); // 毎回TTLを張り直す
return n <= 2; // 1時間に2件まで
}
TTLを n === 1 のときだけ張る書き方をよく見かけますが、INCRとEXPIREの間で処理が落ちるとキーにTTLが付きません。カウントが上限を超えたまま残り、そのIPが恒久的にブロックされます。EXPIREは毎回打ってもコストが軽いので、素直に毎回張り直すほうが安全です。
IPは生のまま保存せず、ハッシュ化して保存します。個人情報の保持量を減らすためです。
export function hashIp(ip: string): string {
const salt = process.env.CONTACT_IP_SALT ?? "";
return crypto
.createHash("sha256")
.update(`${salt}:${ip}`)
.digest("hex")
.slice(0, 16);
}
ソルトは必ず環境変数に置いてください。コードに直書きすると、リポジトリやこういう記事を見た人にソルトが知られます。IPv4のアドレス空間は2の32乗しかないので、ソルトが分かっていれば総当たりでハッシュから元のIPを復元できてしまい、ハッシュ化した意味がなくなります。
Turnstileは「フェイルクローズ」に
Cloudflare Turnstileは、reCAPTCHAのように利用者へ画像クイズを出さずにbot判定できる無料のサービスです。フロントで得たトークンを、必ずサーバー側で siteverify に投げて検証します(フロントのトークンだけ見て通すのは無意味です)。
async function verifyTurnstile(
token: string | undefined,
ip: string,
): Promise<boolean> {
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) {
// 本番でキー未設定なら素通りさせない(フェイルクローズ)
if (process.env.NODE_ENV === "production") {
console.error(
"[contact] TURNSTILE_SECRET_KEY が未設定のため送信を拒否しました",
);
return false;
}
return true; // ローカル開発のみスキップ
}
if (!token) return false;
const res = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ secret, response: token, remoteip: ip }),
},
);
const data = (await res.json()) as { success?: boolean };
return !!data.success;
}
設定漏れのときに「とりあえず通す」実装にしてしまうと、環境変数を入れ忘れた瞬間に無防備になります。迷ったら閉じる方向に倒すのが安全側の設計です。
実装④ Webhookで承認・却下を処理する
src/app/api/line-webhook/route.ts。管理者がLINE上でボタンを押すと、ここにpostbackイベントが飛んできます。
export async function POST(req: NextRequest) {
const rawBody = await req.text();
if (!verifyLineSignature(rawBody, req.headers.get("x-line-signature"))) {
return NextResponse.json({ ok: false }, { status: 401 });
}
const { events } = JSON.parse(rawBody) as { events: LineEvent[] };
for (const event of events ?? []) {
if (event.type !== "postback" || !event.postback) continue;
// 管理者以外からのpostbackは無視
const adminId = process.env.LINE_ADMIN_USER_ID;
if (adminId && event.source?.userId !== adminId) continue;
const m = event.postback.data.match(/^(approve|reject):([0-9a-f-]{36})$/);
if (!m) continue;
const [, action, id] = m;
// 取得と同時に削除=二重承認を防ぐ
const pending = await takePending(id);
if (!pending) {
await replyMessages(event.replyToken!, [
textMessage("このメッセージは処理済みか、期限切れです。"),
]);
continue;
}
if (action === "approve") {
const text = `📮 ファンメッセージ\n名前: ${pending.name}\n──────\n${pending.message}`;
await pushMessages(process.env.LINE_GROUP_ID!, [textMessage(text)]);
await replyMessages(event.replyToken!, [
textMessage("✅ グループに送信しました。"),
]);
} else {
await replyMessages(event.replyToken!, [
textMessage("🗑 却下しました。グループには送信されません。"),
]);
}
}
return NextResponse.json({ ok: true });
}
安全性の要点は3つです。
- 署名検証を最初に行う — 通らなければ即401
- postbackの送信者が管理者本人か確認する — 他人が承認ボタンを押せない
takePendingは取得と同時に削除 — 連打しても二重投稿にならない
data のパースに正規表現を使い、UUID形式以外を弾いている点も地味に効きます。想定外の文字列がそのままキーとして使われるのを防げます。
Webhook URLの設定
LINE Developers → Messaging API設定 → Webhook URL に https://あなたのドメイン/api/line-webhook を設定し、「検証」ボタンで200が返ることを確認します。
デプロイ前に検証すると404になります。 エンドポイントが本番に存在していないので当然ですが、意外と見落とします。先にデプロイしてから検証してください。
実装⑤ フロント側
ページを開いた時点でトークンを取りに行き、Turnstileを描画します。
const fetchToken = useCallback(async () => {
try {
// サーバーが応答しなくても画面を固まらせない
const res = await fetch("/api/contact/token", {
signal: AbortSignal.timeout(8000),
});
if (res.status === 503) {
const data = await res.json().catch(() => ({}));
setStatus(data.paused ? "paused" : "error");
return;
}
const data = (await res.json()) as { token: string };
setToken(data.token);
setStatus("ready");
} catch {
setStatus("error");
setErrorMsg("読み込みに失敗しました。再読み込みしてください。");
}
}, []);
ハニーポット欄は display: none ではなく、画面外に飛ばすのがおすすめです。display: none は一部のbotに「無視すべき項目」と判定されてしまいます。
<input
type="text"
name="website"
value={website}
onChange={(e) => setWebsite(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{ position: "absolute", left: -9999, width: 1, height: 1, opacity: 0 }}
/>
tabIndex={-1} と aria-hidden を付けて、キーボード操作やスクリーンリーダーの利用者が誤って入力しないようにしています。アクセシビリティを犠牲にしないハニーポットの作法です。
スパム対策のまとめ
ここまでで積み上げた防御層を整理します。単体で完璧な対策はないので、安いものから順に重ねるのが基本方針です。
| 層 | 対策 | 防げるもの |
|---|---|---|
| 1 | キルスイッチ | 荒らし発生時の緊急停止 |
| 2 | ハニーポット | 単純なフォーム自動入力bot |
| 3 | ワンタイムトークン | API直接POST |
| 4 | 3秒の時間チェック | 機械的な即時送信 |
| 5 | 入力長バリデーション | 長文爆撃 |
| 6 | URL・メール拒否 | 宣伝・誘導スパム |
| 7 | Cloudflare Turnstile | 高度なbot |
| 8 | レート制限(IP 2件/時・全体 20件/日) | 連投・物量攻撃 |
| 9 | 重複本文チェック | 同一文面の再投稿 |
| 10 | 海外IP・非日本語フラグ | 判断材料として通知に付記 |
海外IPや非日本語を自動で拒否しないのは意図的です。海外在住の日本人ファンや、日本語が書けない海外のリスナーを締め出してしまうためです。拒否ではなく「⚠ 海外IP: US」と通知に印を付け、最終判断を人間に委ねる設計にしています。承認フローがあるからこそ、機械判定を緩めにできるという関係です。
ローカルでWebhookを検証する
Webhookは外部から到達できるURLが必要なので、ローカル開発ではトンネルを使います。Cloudflareの cloudflared なら、アカウント登録なしでワンコマンドです。
# macOS
brew install cloudflared
# 開発サーバーが3000番で動いている状態で
cloudflared tunnel --url http://localhost:3000
表示された https://xxxx-xxxx.trycloudflare.com を、LINE DevelopersのWebhook URLに /api/line-webhook を付けて一時的に設定します。これで手元のコードで承認フローの通しテストができます。
URLは起動のたびに変わるので、本番デプロイ後は必ず正式なURLに戻してください。
つまずきやすいポイント
グループで発言するたびに自動返信が来る
前述の「応答メッセージ」です。コードのバグではありません。LINE Official Account Manager 側の設定で切ります。
Turnstileの管理画面に「Siteverify isn’t being called」と出る
サーバー側検証を呼んでいるのに出る場合、ウィジェットのドメイン設定に本番ドメインが登録されていないか、まだ本番にデプロイしていないケースがほとんどです。ローカルだけで試していると、Cloudflare側にはsiteverifyの実績が記録されません。
Redisが落ちるとフォームが固まる
トークン取得がタイムアウトなしだと、接続不能時にフォームが永久にローディング表示になります。
- サーバー側:接続に
connectTimeout、失敗時は503を返す - クライアント側:
AbortSignal.timeout()でfetchを打ち切る
この両方を入れておくと、「壊れているがユーザーには分かるように壊れる」状態を作れます。
429が返ってきた
自分でテストしていると、実装したレート制限に自分が引っかかります。バグではありません。開発用にリセットスクリプトを用意しておくと快適です。
// scripts/reset-contact-limits.mjs
const keys = await client.keys("contact:rl:*");
for (const k of keys) await client.del(k);
よくある質問
Q. メール通知とLINE通知、どちらがいい?
併用が現実的です。LINEは即時性に優れますが、履歴の検索性や長期保存はメールに分があります。まずLINEで気づき、記録はメールやデータベースに残す、という役割分担がおすすめです。
ただしメール側が本当に届いているかは別途確認が要ります。送信元の設定次第で、自動返信は届くのに管理者宛だけ届かないという状態が起こります。原因と直し方はContact Form 7で自動返信は届くのに管理者宛だけ届かない原因にまとめました。
Q. WordPressでも同じことはできる?
できます。Contact Form 7 の wpcf7_mail_sent フックなどから、本記事の pushMessages 相当の処理(https://api.line.me/v2/bot/message/push へのPOST)を wp_remote_post() で呼べば同じ通知が実現できます。ただし承認フローまで作るならWebhookの受け口が必要なので、実装量はそれなりに増えます。
Q. 承認なしで直接グループに流してもいい?
社内の問い合わせフォームなど、送信者が限定されている場合は不要です。不特定多数が送信できる公開フォームでは、誹謗中傷や不適切な画像URLがメンバー全員の端末に届くリスクがあるため、承認を挟むことを強くおすすめします。
Q. 月200通を超えそうなときは?
上位の「ライトプラン」「スタンダードプラン」への変更を検討することになります(料金と無料通数は改定されることがあるため、公式の料金ページで最新の条件を確認してください)。プラン変更の前に、複数の問い合わせをまとめて1通で送る(バッチ通知)、Replyで済む部分をPushで送っていないか見直す、といった削減が有効です。
まとめ
問い合わせフォームのLINE通知は、Messaging APIのPush送信だけなら30分ほどで組めます。ただし公開フォームで実運用するなら、
- 承認フロー(postbackアクション + サーバー側の一時保存)
- 多層のスパム対策(ハニーポット・ワンタイムトークン・Turnstile・レート制限)
- 署名検証(Webhookの正当性確認)
この3点はセットで考える必要があります。特に署名検証を省くと、誰でもグループに任意のメッセージを流せる状態になるため、必ず入れてください。
LINE Notifyの終了で移行先を探している方、メール通知の見落としに困っている方の参考になれば幸いです。
参考リンク
- LINE Developers – Messaging API
- LINE Notify サービス終了のお知らせ
- LINE公式アカウント 料金プラン
- Messaging APIの通数カウント方法
- Cloudflare Turnstile
