結論
問い合わせ機能は、フォームを置くだけでは完了しません。最低限、次の経路を一気通貫で設計する必要があります。
訪問者が送信
↓
問い合わせと会話トークンを保存
↓
開発者へ通知
↓
開発者が問い合わせIDを指定して返信
↓
同じ会話トークンを持つ訪問者へ表示
個人開発では、以下のように役割を分けると小さく始められます。
- 受付:Webフォーム
- 保存:SQLite
- 通知:Webhook
- 返信:認証付きAPI
- 受信:ブラウザからのポーリング
最近の問い合わせ実装では、AIによる分類や返信下書きだけでなく、Webhook受信から実際の返信経路までE2Eで確認できる構成が重視されています。本記事でも自動回答は行わず、開発者が内容を確認して返信するところまでを実装します。
前提
このサンプルでは次の環境を使います。
- Node.js 20以降
- Express
- SQLite(
better-sqlite3) - 任意のWebhook受信先
訪問者のログイン機能は作りません。問い合わせ作成時に十分長いランダムトークンを発行し、そのトークンを持つブラウザだけが返信を取得できる設計にします。
ただし、このトークンは実質的な認証情報です。本番ではHTTPSを必須にし、URLやアクセスログへ不用意に記録しないでください。
手順
1. プロジェクトを作成する
mkdir inquiry-route
cd inquiry-route
npm init -y
npm install express better-sqlite3 express-rate-limit dotenv
mkdir public
.envを作成します。
PORT=3000
OPERATOR_TOKEN=replace-with-a-long-random-value
NOTIFY_WEBHOOK_URL=
OPERATOR_TOKENは、たとえば次のコマンドで生成できます。
openssl rand -hex 32
Webhookをまだ用意していない場合、NOTIFY_WEBHOOK_URLは空のままで構いません。その場合は通知内容をサーバーの標準出力に表示します。
2. APIサーバーを実装する
server.jsを作成します。
require('dotenv').config();
const crypto = require('node:crypto');
const path = require('node:path');
const express = require('express');
const rateLimit = require('express-rate-limit');
const Database = require('better-sqlite3');
const app = express();
const db = new Database('inquiries.db');
app.disable('x-powered-by');
app.use(express.json({ limit: '16kb' }));
app.use(express.static(path.join(__dirname, 'public')));
const createLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
});
db.exec(`
CREATE TABLE IF NOT EXISTS inquiries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_token TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_id INTEGER NOT NULL,
sender TEXT NOT NULL CHECK(sender IN ('visitor', 'operator')),
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(inquiry_id) REFERENCES inquiries(id)
);
`);
const insertInquiry = db.prepare(`
INSERT INTO inquiries (public_token) VALUES (?)
`);
const insertMessage = db.prepare(`
INSERT INTO messages (inquiry_id, sender, body) VALUES (?, ?, ?)
`);
const findInquiryByToken = db.prepare(`
SELECT id, status, created_at
FROM inquiries
WHERE public_token = ?
`);
const findInquiryById = db.prepare(`
SELECT id, status
FROM inquiries
WHERE id = ?
`);
const findMessages = db.prepare(`
SELECT id, sender, body, created_at
FROM messages
WHERE inquiry_id = ? AND id > ?
ORDER BY id ASC
`);
function normalizeBody(value) {
if (typeof value !== 'string') return null;
const body = value.trim();
if (body.length < 1 || body.length > 4000) return null;
return body;
}
function operatorOnly(req, res, next) {
const expected = process.env.OPERATOR_TOKEN;
const actual = req.get('authorization');
if (!expected || actual !== `Bearer ${expected}`) {
return res.status(401).json({ error: 'unauthorized' });
}
next();
}
async function notifyOperator(payload) {
const url = process.env.NOTIFY_WEBHOOK_URL;
if (!url) {
console.log('[new inquiry]', payload);
return;
}
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
throw new Error(`notification failed: ${response.status}`);
}
}
app.post('/api/inquiries', createLimiter, async (req, res) => {
const body = normalizeBody(req.body?.body);
if (!body) {
return res.status(400).json({ error: 'body must be 1-4000 characters' });
}
const publicToken = crypto.randomBytes(32).toString('base64url');
const create = db.transaction(() => {
const result = insertInquiry.run(publicToken);
insertMessage.run(result.lastInsertRowid, 'visitor', body);
return Number(result.lastInsertRowid);
});
const inquiryId = create();
notifyOperator({ inquiryId, body }).catch((error) => {
console.error(error);
});
res.status(201).json({
inquiryId,
publicToken,
status: 'open',
});
});
app.get('/api/inquiries/:token/messages', (req, res) => {
const inquiry = findInquiryByToken.get(req.params.token);
if (!inquiry) {
return res.status(404).json({ error: 'not found' });
}
const after = Number.parseInt(req.query.after ?? '0', 10);
const safeAfter = Number.isSafeInteger(after) && after >= 0 ? after : 0;
res.json({
status: inquiry.status,
messages: findMessages.all(inquiry.id, safeAfter),
});
});
app.post(
'/api/operator/inquiries/:id/replies',
operatorOnly,
(req, res) => {
const inquiryId = Number.parseInt(req.params.id, 10);
const body = normalizeBody(req.body?.body);
if (!Number.isSafeInteger(inquiryId) || !body) {
return res.status(400).json({ error: 'invalid request' });
}
const inquiry = findInquiryById.get(inquiryId);
if (!inquiry) {
return res.status(404).json({ error: 'not found' });
}
const result = insertMessage.run(inquiryId, 'operator', body);
res.status(201).json({
messageId: Number(result.lastInsertRowid),
});
}
);
app.listen(process.env.PORT || 3000, () => {
console.log(`http://localhost:${process.env.PORT || 3000}`);
});
この実装では、通知の失敗と問い合わせの保存を分離しています。Webhookが一時的に失敗しても、訪問者には受付成功を返し、問い合わせ自体はSQLiteに残します。
3. 訪問者向け画面を作る
public/index.htmlを作成します。
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>お問い合わせ</title>
<style>
body { max-width: 680px; margin: 40px auto; padding: 0 16px; font-family: sans-serif; }
textarea { box-sizing: border-box; width: 100%; min-height: 120px; }
button { margin-top: 8px; padding: 8px 16px; }
li { margin: 12px 0; white-space: pre-wrap; }
.operator { background: #eef6ff; padding: 10px; }
</style>
</head>
<body>
<h1>お問い合わせ</h1>
<form id="form">
<textarea id="body" maxlength="4000" required></textarea>
<br />
<button>送信</button>
</form>
<p id="status"></p>
<ul id="messages"></ul>
<script>
const form = document.querySelector('#form');
const body = document.querySelector('#body');
const status = document.querySelector('#status');
const messages = document.querySelector('#messages');
let publicToken = localStorage.getItem('inquiryPublicToken');
let lastMessageId = 0;
function appendMessage(message) {
const li = document.createElement('li');
li.className = message.sender;
li.textContent = `${message.sender === 'operator' ? '返信' : 'あなた'}: ${message.body}`;
messages.appendChild(li);
lastMessageId = Math.max(lastMessageId, message.id);
}
async function loadMessages() {
if (!publicToken) return;
const response = await fetch(
`/api/inquiries/${encodeURIComponent(publicToken)}/messages?after=${lastMessageId}`
);
if (response.status === 404) {
localStorage.removeItem('inquiryPublicToken');
publicToken = null;
return;
}
if (!response.ok) return;
const data = await response.json();
data.messages.forEach(appendMessage);
status.textContent = 'お問い合わせを受け付けています。返信はこの画面に表示されます。';
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const response = await fetch('/api/inquiries', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: body.value }),
});
if (!response.ok) {
status.textContent = '送信できませんでした。時間を置いて再度お試しください。';
return;
}
const data = await response.json();
publicToken = data.publicToken;
localStorage.setItem('inquiryPublicToken', publicToken);
body.value = '';
form.hidden = true;
await loadMessages();
});
if (publicToken) {
form.hidden = true;
loadMessages();
}
setInterval(loadMessages, 5000);
</script>
</body>
</html>
localStorageに会話トークンを保存しているため、同じブラウザでページを開き直した場合も返信を確認できます。一方、別端末への引き継ぎやストレージ削除には対応していません。必要ならメール通知やログイン機能を追加します。
確認方法
1. サーバーを起動する
node server.js
ブラウザで http://localhost:3000 を開き、問い合わせを送信します。
Webhookを設定していない場合、ターミナルに次のような通知が出ます。
[new inquiry] { inquiryId: 1, body: '料金について確認したいです' }
2. 開発者として返信する
通知に含まれるinquiryIdを指定し、認証付きAPIへ返信をPOSTします。
curl -X POST http://localhost:3000/api/operator/inquiries/1/replies \
-H "Authorization: Bearer $OPERATOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"body":"お問い合わせありがとうございます。確認してご案内します。"}'
ブラウザは5秒ごとに新着メッセージを取得するため、送信した返信が問い合わせ画面へ表示されます。
3. E2Eで確認する項目
単にAPIが200を返すことではなく、次の項目を順番に確認します。
- 問い合わせがSQLiteへ保存される
- 開発者側へ
inquiryId付きの通知が届く - 認証なしでは返信APIを実行できない
- 開発者の返信が同じ問い合わせへ保存される
- 正しい会話トークンを持つブラウザだけが返信を取得できる
- Webhook障害時にも問い合わせ本文が失われない
- ページを再読み込みしても会話を復元できる
SQLiteの内容は次のコマンドでも確認できます。
sqlite3 inquiries.db \
"SELECT i.id, m.sender, m.body, m.created_at FROM inquiries i JOIN messages m ON m.inquiry_id = i.id ORDER BY m.id;"
バックエンドを持たない実装例
ここまでの構成は自由に拡張できますが、DB、通知Webhook、返信API、ポーリング画面を自分で運用する必要があります。問い合わせ機能そのものがプロダクトの中核でない場合は、既存のコンタクト層へ置き換える方法もあります。
具体例としてKnocketでは、スクリプトタグでWebライブチャットを設置でき、専用バックエンドを用意せずに問い合わせを受け付けられます。訪問者はアカウントを作成せずにチャットを開始できます。
メッセージをTelegramへ転送し、対象メッセージを引用して返信すると、その返信をWebサイトの訪問者へ戻せます。つまり、上記サンプルにおける「通知を確認する」「問い合わせIDを指定する」「返信APIを呼ぶ」という運用を、Telegram上の引用返信へまとめる実装例です。
採用判断では、実装量だけでなく、データの保管場所、障害時の確認手段、サービス依存を許容できるかも比較してください。
注意点
公開トークンを連番にしない
問い合わせIDだけで会話を取得できる設計にすると、IDを順番に試すだけで第三者が問い合わせを読める可能性があります。訪問者向けの取得には、推測困難なランダムトークンを使います。
運営用トークンをフロントエンドへ置かない
OPERATOR_TOKENをHTMLやクライアント側JavaScriptへ埋め込んではいけません。返信操作は、運営者だけが使えるCLI、管理画面、または信頼できるワークフローから行います。
通知と保存を同一視しない
チャット通知やWebhook送信に成功しても、問い合わせが永続化されているとは限りません。逆に通知が失敗しても、保存済みなら後から回収できます。保存状態と通知状態を分け、必要に応じて通知の再送キューを追加してください。
ポーリング間隔を短くしすぎない
5秒間隔でも、同時接続数が増えるとリクエスト数が増加します。規模が大きくなったら、ページ表示中だけ取得する、待機中は間隔を延ばす、SSEやWebSocketへ移行するといった対策を検討します。
個人情報を必要以上に集めない
返信導線を作るためだけに、氏名、住所、電話番号を必須にする必要はありません。収集目的、保存期間、削除方法を決め、ログやWebhookにも問い合わせ本文が残ることを考慮してください。
まとめ
問い合わせ導線は「送信できた」で終わらせず、次の単位で設計すると再現しやすくなります。
- 問い合わせを永続化する
- 開発者へ識別子付きで通知する
- 開発者だけが返信できる経路を作る
- 訪問者が同じ会話を再取得できるようにする
- 通知障害や再読み込みを含めてE2Eで確認する
最初はSQLiteとポーリングでも十分に構造を検証できます。その後、必要に応じてメール通知、チャットアプリ連携、SSE、管理画面、外部サービスへ段階的に置き換えられます。
開示:筆者は Knocket の開発・運営に関わっています。本記事では中立的なランキングではなく、実装例の一つとして紹介します。