LINEとkintoneをつなぐ:友だちとやりとりを顧客管理に入れる

公開 書いた人 大林 諒

LINE公式アカウントの友だちは、管理画面の中にいるだけでは顧客台帳になりません。誰が体験を予約し、誰が入会し、誰が何を問い合わせたかをスタッフ全員で見るには、kintoneのような顧客管理に入れます。この記事では、予約制の小さな料理教室「サンプル料理教室」を例に、友だち追加とトークの中身をkintoneに自動で入れ、入会したらkintoneのボタンからLINEで案内を送るところまで組みます。読み終わると、アプリを2つ作り、Webhookの受け口を動かせます。Messaging APIのチャネルと、kintoneのアプリを作れる権限が前提です。

この記事の内容
  1. 先に決めること
  2. つなぎ方の比較
  3. kintoneのアプリ設計
  4. 個人情報の扱い
  5. Webhookで受けてkintoneに入れる手順
  6. kintoneからLINEに送る
  7. サンプル料理教室での一日の流れ
  8. 応用
  9. できないことと、その代わり
  10. よく詰まるところ
  11. 次にやること

先に決めること

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側の準備

  1. LINE顧客アプリとLINEやりとりアプリを上の表のとおりに作る。
  2. それぞれのアプリIDを控える。URLの /k/ の後ろの数字です。
  3. LINE顧客アプリの「アプリの設定」から「設定」タブを開く。
  4. 「カスタマイズ/サービス連携」の「APIトークン」を開く。
  5. 「生成する」を押す。
  6. アクセス権でレコードの閲覧、追加、編集にチェックを入れる。
  7. 「保存」を押し、画面右上の「アプリを更新」を押す。
  8. LINEやりとりアプリでも同じようにトークンを作る。アクセス権はレコードの追加だけでよい。

最後の「アプリを更新」を押さないと、トークンは効きません。

Workerを作る

  1. PCに Node.js を入れておく。
  2. ターミナルで npm create cloudflare@latest line-kintone を実行し、「Hello World」の Worker を選ぶ。
  3. 次のシークレットを npx wrangler secret put 名前 で1つずつ登録する。
名前中身
LINE_CHANNEL_SECRETチャネル基本設定のチャネルシークレット
LINE_CHANNEL_ACCESS_TOKEN「Messaging API設定」タブで発行する長期のトークン
KINTONE_DOMAINexample.cybozu.com
KINTONE_APP_CUSTOMERLINE顧客アプリのID
KINTONE_TOKEN_CUSTOMERLINE顧客アプリのAPIトークン
KINTONE_APP_LOGLINEやりとりアプリのID
KINTONE_TOKEN_LOGLINEやりとりアプリのAPIトークン
PUSH_KEY長いランダムな文字列
  1. src/index.js を次のコードに置き換える。
  2. 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側の設定

  1. LINE Developersコンソールで、公式アカウントのチャネルの「Messaging API設定」タブを開く。
  2. 「Webhook URL」の「編集」を押し、https://line-kintone.example.workers.dev/line のように、控えたURLの末尾に /line を付けて入れ、「更新」を押す。「検証」と「Webhookの利用」は、URLを保存したあとに出る。
  3. 「検証」を押し、「成功」と出ることを確かめる。
  4. 「Webhookの利用」がオンになっているか確かめる。
  5. LINE Official Account Manager(管理画面)の「設定」>「応答設定」で、チャットがオンか確かめる。

Webhookとチャットは併用できるので、スタッフは今までどおり管理画面のチャットで返信できます。

LINE Developersコンソールの「Messaging API設定」タブ。Webhook URL、「検証」ボタン、「Webhookの利用」の並び
LINE Developersコンソールの「Messaging API設定」タブ。Webhook URL、「検証」ボタン、「Webhookの利用」の並び

動いたか確かめる

  1. スタッフのスマホで公式アカウントをブロックして解除する。
  2. LINE顧客アプリにレコードができ、友だちの状態が「友だち」になっているか見る。
  3. トークで「土曜の体験レッスンは空いていますか」と送る。
  4. 顧客のレコードの関連レコード一覧に、受信したトークが出るか見る。

入らないときは npx wrangler tail を開いたまま送り直すと、kintoneのエラーがそのまま出ます。

LINE顧客アプリのレコード詳細。関連レコード一覧に受信したトークが並んだ状態
LINE顧客アプリのレコード詳細。関連レコード一覧に受信したトークが並んだ状態

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で送る」ボタンが出た状態
LINE顧客アプリのレコード詳細の上部に「入会のお礼をLINEで送る」ボタンが出た状態
ボタンを押したあと、スタッフのスマホに届いたお礼のメッセージ
ボタンを押したあと、スタッフのスマホに届いたお礼のメッセージ

サンプル料理教室での一日の流れ

  1. 友だち追加されると、LINE顧客アプリにステータス「友だち」のレコードができる。
  2. リッチメニューの「体験レッスンの予約」から予約が届いたら、受付がお名前、電話番号、日にちを入れ、ステータスを「体験予約」にする。
  3. レッスン後、講師がステータスを「体験済み」にし、メモに様子を書く。
  4. 申込書を受け取ったら、受付がステータスを「入会」にして保存し、「入会のお礼をLINEで送る」を押す。
  5. 入会しなかった人は「見送り」にする。体験済みのまま2週間たった人で絞ると、声をかけ忘れた人が見つかる。

実際に運用していると、ステータスを変える人を決めておくことが一番効きます。受付と講師が別々に触ると、入会したのに「体験済み」に戻ります。

LINE顧客アプリの一覧。ステータスで絞り込み、体験済みの人が並んだ状態
LINE顧客アプリの一覧。ステータスで絞り込み、体験済みの人が並んだ状態

応用

ステータスを変えたら自動で送る

ボタンも省くなら、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公式アカウント向けか確かめる

次にやること

あわせて読む