LINE公式アカウントの友だちは、管理画面の中にいるだけでは顧客台帳になりません。誰が体験を予約し、誰が入会し、誰が何を問い合わせたかをスタッフ全員で見るには、kintoneのような顧客管理に入れます。この記事では、予約制の小さな料理教室「サンプル料理教室」を例に、友だち追加とトークの中身をkintoneに自動で入れ、入会したらkintoneのボタンからLINEで案内を送るところまで組みます。読み終わると、アプリを2つ作り、Webhookの受け口を動かせます。Messaging APIのチャネルと、kintoneのアプリを作れる権限が前提です。
この記事の内容
先に決めること
kintoneのコースを確かめる
2026年10月時点で、ライトコース(月額1,000円/1ユーザー)ではWebhookとJavaScriptによるカスタマイズが使えず、料金表のAPIリクエスト数も「-」です。スタンダードコース(月額1,800円/1ユーザー、税抜、最低10ユーザー)ならAPIは1アプリあたり1日1万回までです。
Webhookの受け口を誰が持つか
Webhookを受けるURLは、1つのチャネルに1つしか設定できません。予約システムや拡張ツールがすでに使っているなら、kintone用のURLは足せず、そのツールから転送してもらうことになります。いま何が入っているかを、LINE Developersコンソールの「Messaging API設定」タブで先に見ておきます。
顧客を何で見分けるか
突き合わせの鍵はLINEのユーザーIDです。U から始まる33文字の値で、表示名と違って変わりません。ただし同じ人でもプロバイダーが違えば別のIDになるので、つなぐチャネルのプロバイダーをそろえます。
自動で入れる項目と、人が入れる項目を分ける
LINEから自動で入るのは、ユーザーID、表示名、友だち追加とブロック、トークの本文までです。お名前、電話番号、体験の日、入会したかどうかは人が入れます。同じ項目に混ぜると、自動の上書きでスタッフの入力が消えます。
つなぎ方の比較
| つなぎ方 | かかるお金 | 作る手間 | 向く場面 | 気をつけること |
|---|---|---|---|---|
| kintoneの連携サービス | サービスごと。見積もり制もある | 小さい | 作れる人がいない | LINE WORKS向けが混ざる。Webhookを取り合う |
| n8n・Makeを挟む | n8nの稼働費、Makeの月額 | 中くらい | Slackやメールの通知も同じ流れで回したい | n8nの「Line」ノードは終了したLINE Notify用 |
| Messaging APIからkintone REST APIへ直接 | Cloudflareの無料枠から始められる | 中くらい。コードを置く | 月額を増やしたくない | 署名の検証と重複を自分で持つ |
| L Harnessからkintoneへ | 本体は0円。Cloudflareの利用量は別 | 中くらい | 配信やタグも自分で持ちたい | 送信Webhookをn8nかWorkerで受けて書く |
連携サービスはkintoneの「プラグイン・連携サービス」のサイトで探せますが、LINE WORKS向けが一緒に出ます。LINE WORKSは社内向けの別の製品で、教室の公式アカウントとはつながりません。Makeには「LINE」と「Kintone (Legacy)」のアプリがあり、n8nはWebhookノードとHTTP Requestノードで組みます。この記事はコードを置く3つ目の形で書きます。他の形でもアプリ設計と詰まりどころは同じです。
kintoneのアプリ設計
アプリは、1人1レコードの「LINE顧客」と、1通1レコードの「LINEやりとり」に分けます。テーブルに足すと1人のレコードが際限なく伸びます。
LINE顧客アプリ
| フィールド名 | 種類 | フィールドコード | 設定と中身 |
|---|---|---|---|
| LINEユーザーID | 文字列(1行) | line_user_id | 必須、値の重複を禁止する |
| LINE表示名 | 文字列(1行) | line_name | 自動。メッセージのたびに上書き |
| お名前 | 文字列(1行) | name | 予約フォームで本人が書いた名前 |
| ステータス | ドロップダウン | status | 友だち、体験予約、体験済み、入会、見送り。初期値は友だち |
| 体験レッスン日 | 日付 | trial_date | スタッフが入れる |
| 友だちの状態 | ドロップダウン | friend_state | 友だち、ブロック。自動 |
| 友だち追加日時 | 日時 | followed_at | 自動 |
| 最終やりとり日時 | 日時 | last_at | 自動 |
| 入会案内の送信日時 | 日時 | join_sent_at | 二重送信を止めるために使う |
| メモ | 文字列(複数行) | memo | スタッフが入れる |
| やりとり | 関連レコード一覧 | なし | 下で設定 |
ステータスはプロセス管理ではなくドロップダウンにします。プロセス管理のステータスはレコード登録や更新のAPIで書けません。
LINEやりとりアプリ
| フィールド名 | 種類 | フィールドコード | 設定と中身 |
|---|---|---|---|
| LINEユーザーID | 文字列(1行) | line_user_id | 必須 |
| 日時 | 日時 | at | 送受信の時刻 |
| 向き | ドロップダウン | direction | 受信、送信 |
| 本文 | 文字列(複数行) | body | テキストの中身 |
| イベントID | 文字列(1行) | event_id | 値の重複を禁止する |
LINEは同じWebhookを重ねて送ることがあり、webhookEventId で見分けるよう案内しています。イベントIDに重複禁止を付けておけば、2件目は登録で弾かれます。
2つをつなぐ
LINE顧客アプリに関連レコード一覧を置き、「参照するアプリ」をLINEやりとり、「表示するレコードの条件」を両方のLINEユーザーID同士にします。文字列(1行)同士なので条件に指定できます。「レコードのソート」は日時の降順にします。
個人情報の扱い
トークには頼んでいないものまで送られてきます。入れる前に線を引きます。
| 情報 | kintoneに入れるか | 理由と扱い |
|---|---|---|
| LINEユーザーID | 入れる | 突き合わせの鍵。閲覧はスタッフに限る |
| LINE表示名 | 入れる | 本人がいつでも変える。名前の正にはしない |
| お名前、電話番号 | 予約に要る分だけ入れる | Messaging APIでは取れない。予約フォームで本人に書いてもらう |
| トークの本文 | 入れる | 問い合わせの記録。閲覧はスタッフに限る |
| プロフィール画像 | 入れない | 業務で使わない |
| アレルギーなど体のこと | 要るときだけ | フィールドのアクセス権で、閲覧できる人を講師に絞る |
| カード番号、パスワード | 入れない | 送られてきたらkintoneのレコードから消す |
プライバシーポリシーに「LINEでのお問い合わせ内容を顧客管理に記録します」と書いておきます。ガイドラインは、第三者の個人情報を不正に集めたり渡したりすることを禁じています。ブロックされた人のレコードを残す期間も決めます。1年動きのない人を消す決まりがあるだけで、説明しやすくなります。
Webhookで受けてkintoneに入れる手順
受け口はCloudflare Workersに置きます。無料枠で始められます。
kintone側の準備
- LINE顧客アプリとLINEやりとりアプリを上の表のとおりに作る。
- それぞれのアプリIDを控える。URLの
/k/の後ろの数字です。 - LINE顧客アプリの「アプリの設定」から「設定」タブを開く。
- 「カスタマイズ/サービス連携」の「APIトークン」を開く。
- 「生成する」を押す。
- アクセス権でレコードの閲覧、追加、編集にチェックを入れる。
- 「保存」を押し、画面右上の「アプリを更新」を押す。
- LINEやりとりアプリでも同じようにトークンを作る。アクセス権はレコードの追加だけでよい。
最後の「アプリを更新」を押さないと、トークンは効きません。
Workerを作る
- PCに Node.js を入れておく。
- ターミナルで
npm create cloudflare@latest line-kintoneを実行し、「Hello World」の Worker を選ぶ。 - 次のシークレットを
npx wrangler secret put 名前で1つずつ登録する。
| 名前 | 中身 |
|---|---|
LINE_CHANNEL_SECRET | チャネル基本設定のチャネルシークレット |
LINE_CHANNEL_ACCESS_TOKEN | 「Messaging API設定」タブで発行する長期のトークン |
KINTONE_DOMAIN | example.cybozu.com |
KINTONE_APP_CUSTOMER | LINE顧客アプリのID |
KINTONE_TOKEN_CUSTOMER | LINE顧客アプリのAPIトークン |
KINTONE_APP_LOG | LINEやりとりアプリのID |
KINTONE_TOKEN_LOG | LINEやりとりアプリのAPIトークン |
PUSH_KEY | 長いランダムな文字列 |
src/index.jsを次のコードに置き換える。npx wrangler deployで公開し、表示されたURLを控える。
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method === 'POST' && url.pathname === '/line') return handleLine(request, env, ctx);
if (request.method === 'POST' && url.pathname === '/push') return handlePush(request, env);
return new Response('not found', { status: 404 });
},
};
const enc = new TextEncoder();
const iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
async function verifySignature(secret, body, signature) {
const key = await crypto.subtle.importKey('raw', enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(body)));
let bin = '';
for (const b of mac) bin += String.fromCharCode(b);
return btoa(bin) === signature;
}
async function handleLine(request, env, ctx) {
const body = await request.text();
const signature = request.headers.get('x-line-signature') || '';
if (!(await verifySignature(env.LINE_CHANNEL_SECRET, body, signature))) {
return new Response('invalid signature', { status: 401 });
}
const events = JSON.parse(body).events || [];
ctx.waitUntil(Promise.all(events.map((ev) =>
handleEvent(ev, env).catch((e) => console.log('failed', ev.webhookEventId, e.message)))));
return new Response('ok');
}
async function handleEvent(ev, env) {
if (!ev.source || ev.source.type !== 'user') return;
const userId = ev.source.userId;
const at = iso(ev.timestamp);
if (ev.type === 'unfollow') {
await saveCustomer(env, userId, { friend_state: { value: 'ブロック' } });
return;
}
if (ev.type !== 'follow' && ev.type !== 'message') return;
const fields = { friend_state: { value: '友だち' } };
const profile = await getProfile(env, userId);
if (profile) fields.line_name = { value: profile.displayName };
fields[ev.type === 'follow' ? 'followed_at' : 'last_at'] = { value: at };
await saveCustomer(env, userId, fields);
if (ev.type === 'message') {
const m = ev.message;
await kintone(env, 'POST', '/k/v1/record.json', env.KINTONE_TOKEN_LOG, {
app: env.KINTONE_APP_LOG,
record: {
line_user_id: { value: userId },
at: { value: at },
direction: { value: '受信' },
body: { value: m.type === 'text' ? m.text : `(${m.type})` },
event_id: { value: ev.webhookEventId },
},
});
}
}
async function getProfile(env, userId) {
const res = await fetch(`https://api.line.me/v2/bot/profile/${userId}`, {
headers: { Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}` },
});
return res.ok ? res.json() : null;
}
async function saveCustomer(env, userId, fields) {
const query = encodeURIComponent(`line_user_id = "${userId}" limit 1`);
const found = (await kintone(env, 'GET',
`/k/v1/records.json?app=${env.KINTONE_APP_CUSTOMER}&query=${query}`,
env.KINTONE_TOKEN_CUSTOMER)).records[0];
if (found) {
await kintone(env, 'PUT', '/k/v1/record.json', env.KINTONE_TOKEN_CUSTOMER,
{ app: env.KINTONE_APP_CUSTOMER, id: found.$id.value, record: fields });
} else {
await kintone(env, 'POST', '/k/v1/record.json', env.KINTONE_TOKEN_CUSTOMER,
{ app: env.KINTONE_APP_CUSTOMER, record: { line_user_id: { value: userId }, ...fields } });
}
}
async function kintone(env, method, path, token, payload) {
const init = { method, headers: { 'X-Cybozu-API-Token': token } };
if (payload) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(payload);
}
const res = await fetch(`https://${env.KINTONE_DOMAIN}${path}`, init);
const text = await res.text();
if (!res.ok) throw new Error(`kintone ${res.status} ${text}`);
return JSON.parse(text);
}
署名は、届いた本文を加工する前の文字列で、チャネルシークレットを鍵にしたHMAC-SHA256で確かめます。JSONに直してから確かめると一致しません。kintoneへの書き込みは ctx.waitUntil に回し、LINEには先に200を返します。LINEは非同期の処理を勧めていて、waitUntil は返したあと30秒まで続けられます。顧客はユーザーIDで探してあれば更新、なければ登録し、ステータスやお名前には触りません。handlePush は次の節で足します。
LINE側の設定
- LINE Developersコンソールで、公式アカウントのチャネルの「Messaging API設定」タブを開く。
- 「Webhook URL」の「編集」を押し、
https://line-kintone.example.workers.dev/lineのように、控えたURLの末尾に/lineを付けて入れ、「更新」を押す。「検証」と「Webhookの利用」は、URLを保存したあとに出る。 - 「検証」を押し、「成功」と出ることを確かめる。
- 「Webhookの利用」がオンになっているか確かめる。
- LINE Official Account Manager(管理画面)の「設定」>「応答設定」で、チャットがオンか確かめる。
Webhookとチャットは併用できるので、スタッフは今までどおり管理画面のチャットで返信できます。

動いたか確かめる
- スタッフのスマホで公式アカウントをブロックして解除する。
- LINE顧客アプリにレコードができ、友だちの状態が「友だち」になっているか見る。
- トークで「土曜の体験レッスンは空いていますか」と送る。
- 顧客のレコードの関連レコード一覧に、受信したトークが出るか見る。
入らないときは npx wrangler tail を開いたまま送り直すと、kintoneのエラーがそのまま出ます。

kintoneからLINEに送る
入会した人へのお礼は、スタッフがkintoneのボタンを押して送ります。kintoneの画面から直接LINEのAPIを呼ぶと、チャネルアクセストークンをJavaScriptに書くことになり、アプリを開ける人なら誰でも見られます。そこでボタンはWorkerにレコードIDだけを渡し、宛先と文面はWorkerが決めます。
Workerに足すコードです。
async function handlePush(request, env) {
if (request.headers.get('x-push-key') !== env.PUSH_KEY) return new Response('forbidden', { status: 403 });
const { recordId } = await request.json();
const { record: r } = await kintone(env, 'GET',
`/k/v1/record.json?app=${env.KINTONE_APP_CUSTOMER}&id=${Number(recordId)}`, env.KINTONE_TOKEN_CUSTOMER);
if (r.status.value !== '入会' || r.join_sent_at.value || r.friend_state.value !== '友だち') {
return Response.json({ ok: false, reason: '入会でない、送信済み、またはブロック中です' });
}
const text = `${r.name.value || r.line_name.value}さん、ご入会ありがとうございます。初回はエプロンをお持ちください。`;
const res = await fetch('https://api.line.me/v2/bot/message/push', {
method: 'POST',
headers: { Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`,
'Content-Type': 'application/json', 'X-Line-Retry-Key': crypto.randomUUID() },
body: JSON.stringify({ to: r.line_user_id.value, messages: [{ type: 'text', text }] }),
});
if (!res.ok) return Response.json({ ok: false, reason: `LINE ${res.status}` });
await kintone(env, 'PUT', '/k/v1/record.json', env.KINTONE_TOKEN_CUSTOMER,
{ app: env.KINTONE_APP_CUSTOMER, id: r.$id.value, record: { join_sent_at: { value: iso(Date.now()) } } });
return Response.json({ ok: true });
}
LINE顧客アプリには、「JavaScript / CSSでカスタマイズ」のPC用に次のファイルを入れます。
kintone.events.on('app.record.detail.show', (event) => {
const space = kintone.app.record.getHeaderMenuSpaceElement();
const r = event.record;
if (!space || document.getElementById('push-join')) return event;
if (r.status.value !== '入会' || r.join_sent_at.value) return event;
const btn = document.createElement('button');
btn.id = 'push-join';
btn.textContent = '入会のお礼をLINEで送る';
btn.onclick = async () => {
if (!window.confirm('LINEで送ります')) return;
btn.disabled = true;
const [body] = await kintone.proxy('https://line-kintone.example.workers.dev/push', 'POST',
{ 'Content-Type': 'application/json', 'x-push-key': 'YOUR_PUSH_KEY' },
JSON.stringify({ recordId: event.recordId }));
const result = JSON.parse(body);
window.alert(result.ok ? '送りました' : result.reason);
window.location.reload();
};
space.appendChild(btn);
return event;
});
- 鍵はJavaScriptに書くのでアプリを開ける人には見える。Workerが「入会」「未送信」「友だち」を確かめて決まった文面しか送らないので、鍵でできるのは案内を1回送ることだけ
- 送ったら送信日時を書き込む。ボタンはその日時が空のときしか出ない
X-Line-Retry-Keyを付けると、やり直したときに2通届かない
プッシュメッセージはメッセージ通数に数えられます。2026年10月時点で、コミュニケーションプランは月200通、ライトプランは月5,000通です。チャットや応答メッセージは数えられません。


サンプル料理教室での一日の流れ
- 友だち追加されると、LINE顧客アプリにステータス「友だち」のレコードができる。
- リッチメニューの「体験レッスンの予約」から予約が届いたら、受付がお名前、電話番号、日にちを入れ、ステータスを「体験予約」にする。
- レッスン後、講師がステータスを「体験済み」にし、メモに様子を書く。
- 申込書を受け取ったら、受付がステータスを「入会」にして保存し、「入会のお礼をLINEで送る」を押す。
- 入会しなかった人は「見送り」にする。体験済みのまま2週間たった人で絞ると、声をかけ忘れた人が見つかる。
実際に運用していると、ステータスを変える人を決めておくことが一番効きます。受付と講師が別々に触ると、入会したのに「体験済み」に戻ります。

応用
ステータスを変えたら自動で送る
ボタンも省くなら、LINE顧客アプリの「Webhook」で通知を送信する条件を「レコードの編集」にし、UPDATE_RECORD の通知をWorkerで受けます。cybozu.com管理画面でWebhookの設定が許可されている必要があります。公式の通知内容にヘッダーや署名の記載はないので、URLの末尾に長いランダムな文字列を入れて守ります。編集のたびに来るので「入会」かつ「未送信」の条件は外せません。ドメイン全体で1分間に60回までです。
L Harnessを使っている場合
L HarnessはWebhookの受け口を自身が持つので、送信Webhookで出来事を外に出し、このWorkerのような受け口で受けてkintoneに書きます。タグとステップ配信はL Harness、顧客台帳はkintoneと分けます。
できないことと、その代わり
| できないこと | 代わりの手 |
|---|---|
| つなぐ前のトークをさかのぼって入れる | Webhookはその場で知らせる仕組みで、過去の分は届かない。大事な人の分だけ手で写す |
| 管理画面のチャットで返した文を自動で残す | Webhookで届くのは友だち側の出来事だけ。返信の要点をメモに書く |
| メールアドレスや電話番号をLINEから取る | Messaging APIでは取れない。予約フォームで本人に書いてもらう |
| 今いる友だち全員を一度に取り込む | ユーザーIDの一覧は認証済アカウントかプレミアムアカウントでしか取れない。他は次に話しかけてきたときに入る |
| 管理画面のステップ配信をkintoneのステータスで送り分ける | kintoneからのプッシュか、L Harnessなどのタグで組む |
| 画像の中身をkintoneに入れる | このコードは「(image)」と記録するだけ。画像は別のAPIで取りに行き、添付ファイルにする処理が要る |
よく詰まるところ
| 症状 | 原因 | 直し方 |
|---|---|---|
| 「検証」で成功にならない | URLの /line が抜けている。未デプロイ | URLを見直し、デプロイし直す |
| 署名が合わず401になる | チャネルシークレットが別のチャネルのもの、または再発行した | チャネル基本設定のシークレットを登録し直す |
| kintoneに入らずエラーが出る | APIトークンを作ったあと「アプリを更新」を押していない | アプリの設定で「アプリを更新」を押す |
| 同じトークが2件入る | イベントIDの重複禁止を付けていない | やりとりアプリのイベントIDに「値の重複を禁止する」を付ける |
| 表示名が空のまま | ブロックした人などはプロフィールが取れない | 友だちに戻れば次のトークで入る |
| 予約システムのユーザーIDと合わない | プロバイダーが違うチャネルで取ったID | 同じプロバイダーのチャネルにそろえる |
| 拡張ツールを入れたら入らなくなった | Webhook URLがツールのものに変わった | ツールから転送してもらう |
| ステータスをコードで書けない | プロセス管理のステータスを使っている | ステータスをドロップダウンにする |
| 関連レコード一覧に何も出ない | 条件のフィールドの種類が合っていない | 両方を文字列(1行)にそろえる |
| ボタンが出ない | 適用範囲が「アプリ管理者だけに適用」のまま、またはスマホで見ている | 「すべてのユーザーに適用」にし、PCで押す |
| 連携サービスが教室のLINEとつながらない | LINE WORKS向けを選んでいた | LINE公式アカウント向けか確かめる |