Skip to content
Kintone と HubSpot の並行運用 — Apps Script 増分同期の実装手順

Kintone と HubSpot の並行運用 — Apps Script 増分同期の実装手順

最終更新日: / 公開日:

最終更新日: / 公開日:

本記事のポイント

  • Kintone から HubSpot への完全移行は業務停止リスクが大きく、失敗事例も多い施策です。Kintone を主・HubSpot をマーケ / 分析として並行運用し、Apps Script で日次に差分だけ同期すれば、移行リスクをほぼゼロにできます
  • 過去 1.3 万件は Python で約 1 時間で一括 import、翌日から Apps Script が「前回同期以降の差分だけ」を自動同期します。API 呼び出しは全件同期比で約 1/100 に減ります
  • ISO 8601 / email 重複 / Private App scopes の 3 つのハマりを潰した実装コードと、全 STEP を Claude Code に丸ごと実行させる AI 実行キットを公開します

「HubSpot に移行したいが、営業は Kintone に慣れきっていて動かせない」。humbulls が CRM 導入を支援するなかで、最後に必ず出てくる本音です。つまずきの原因は営業現場の抵抗そのものではなく、「完全移行」しか選択肢に入れていないことにあります。既存 CRM を止めず、Kintone を主・HubSpot をマーケ / 分析用として並行運用し、差分だけを日次で同期すれば、移行リスクはほぼゼロにできます。本記事では、ある業務用美容機器メーカーの実装で使った Kintone × HubSpot 増分同期を、Python の一括 import から Apps Script の日次同期まで STEP 順に再現します。特別なスキルは不要で、型と巻末の AI 実行キットがあれば再現できます。

完成形 — 毎朝 Kintone の差分だけが HubSpot に流れる状態

最初に、これから作る状態を見せておきます。営業はこれまで通り Kintone に入力し、その差分が毎朝 1 回 Apps Script で HubSpot に自動同期される。マーケと経営は HubSpot 側のダッシュボードを見る。二重入力は発生しません。

Kintone ↔ HubSpot 並行運用フロー

所要時間: 過去履歴の一括 import に約 1 時間 + Apps Script 実装に半日〜1 日 追加料金: ゼロ(HubSpot Starter・Kintone・Google アカウントは既存利用が前提。Looker Studio と Apps Script は無料)

前提条件(着手前に揃えるもの):

前提 内容
HubSpot Starter Sales Hub Starter($50/月)契約済み
Kintone 契約済み + API Token を発行できる権限
Google アカウント Apps Script 実行用(無料)
Python 実行環境 初回の過去履歴 import でのみ使用(Python 3 系)

本記事の円換算はすべて 1 ドル = 150 円(2026 年 5 月時点)で計算しています。以降は STEP 1 から順に進めれば、上図の並行運用がそのまま組み上がります。

STEP 1. 並行運用の方針を固める — 真実は Kintone、同期は一方通行

「Kintone から HubSpot へ全部移すスケジュールを引いたが、営業が旧システムから動かず新システムが空回りしている」。CRM 移行の相談で最もよく聞く失敗です。

最初に決めるのは、コードではなく方針です。実務での選択肢は 3 つあります。Kintone を停止して HubSpot に完全移行する、Kintone を残して HubSpot は試験運用に留める、Kintone と HubSpot を並行運用してデータを自動同期する。それぞれの性質はこう整理できます。

観点 完全移行 並行運用
移行リスク 大(業務停止リスク) ほぼゼロ
営業現場の操作変更 必須(抵抗大) 不要(Kintone のまま)
HubSpot のメリット享受 完全 即時(マーケ / 分析側から)
ロールバック 困難 いつでも可
段階移行 不可(一発勝負)

並行運用を選んだら、次に決めるのはどちらを真実(source of truth)とするかです。この実装では「Kintone が真実、HubSpot はレプリカ」と定義し、同期は Kintone → HubSpot の一方通行に固定しました。双方向同期は更新衝突の解決が複雑になるため、最初から設計に入れないのが正解です。

つまずきやすいのは、並行運用が「いつ完全移行するか」を決めないまま走り出し、2 年経ってもデータが二重管理のまま固定化するパターンです。これを避けるため、humbulls では立ち上げ時に 3 段階 / 12 ヶ月のシフト計画を先に切っています。

フェーズ 期間 内容
Phase 1: 並行運用立ち上げ 0-3 ヶ月 Kintone → HubSpot 増分同期を確立 / ダッシュボード稼働
Phase 2: 部分移行 3-6 ヶ月 マーケ業務を HubSpot に移行(MA / フォーム / メール配信)、営業は Kintone 継続
Phase 3: 完全移行判断 6-12 ヶ月 営業現場のフィードバック収集、操作トレーニング、Kintone 撤退 or 並行運用継続を判断

段階移行ロードマップ

営業現場が一番抵抗が大きいので、判断は最後に持ってきます。12 ヶ月後は「Kintone を完全撤退するか / 並行運用を継続するか」の 2 択で、実装事例では営業の日常を尊重して並行運用継続を選ぶ組織が多いです。方針が固まったら実装に入ります。

STEP 2. HubSpot Private App と Kintone API Token を発行する

「コードを書く前に、まず何のキーが要るのか分からない」。実装で最初に止まるのはここです。

同期に必要な認証情報は 2 つです。HubSpot 側の Private App アクセストークンと、Kintone 側の API Token。順に発行します。

HubSpot は Settings > Integrations > Private Apps > Create a private app から作成し、Scopes タブで以下 2 つに必ずチェックを入れます。

  • crm.objects.contacts.read(email 検索に必要)
  • crm.objects.contacts.write(post / patch に必要)

Kintone は対象アプリの 設定 > API トークン から生成し、「レコード閲覧」権限を付けます。生成したトークンは、後で Apps Script の PropertiesService か Python の環境変数に格納します。

ここで詰まりやすいのが HubSpot 側の scopes です。write のチェックを入れ忘れると、実装は一見正しいのに実行時に 403 Forbidden で止まります。403 はコードのバグと区別がつきにくく、原因特定に半日溶けるのが典型です。Token 発行の時点で read / write の両方が入っているかを必ず確認してください。

STEP 3. 過去履歴を Python で一括 import する — offset 10,000 制限を突破

「増分同期を組んだのに、過去のデータが HubSpot に 1 件も入っていない」。増分同期だけを組むと必ず起きる、想定通りの状態です。

増分同期は「これから更新されるレコード」を流す仕組みなので、過去データは別途 import する必要があります。Apps Script の 6 分実行制限内で 1.3 万件を一気に処理するのは不可能なので、過去履歴 import だけは Apps Script ではなく Python で動かします。Python なら 6 分制限がなく、1.3 万件を約 1 時間で投入できます。

Kintone REST API には offset の上限 10,000 という制約があり、素直にページングすると 1 万件目で取得が止まります。これを $id(レコード ID)の範囲分割クエリで突破します。

# fetch_kintone_full.py — Kintone 全件取得
import requests

def fetch_all_kintone_records(app_id, query=""):
    records = []
    offset = 0
    while True:
        # Kintone offset は 10,000 上限 → $id 範囲で分割が必要
        url = f"https://{SUBDOMAIN}.cybozu.com/k/v1/records.json"
        params = {
            'app': app_id,
            'query': f'{query} order by $id asc limit 500 offset {offset}',
        }
        r = requests.get(url, headers={'X-Cybozu-API-Token': KINTONE_TOKEN}, params=params)
        batch = r.json()['records']
        if not batch:
            break
        records.extend(batch)
        if len(batch) < 500:
            break
        offset += 500
        if offset >= 9500:  # offset 上限間際で $id 分割に切替
            last_id = batch[-1]['$id']['value']
            return records + fetch_all_kintone_records(app_id, f'$id > {last_id}')
    return records

1 回の取得は 500 件ずつ、offset が上限に近づいたら最後の $id を基点に範囲クエリへ切り替える。これで件数上限なく全件取得できます。取得したデータを HubSpot へ投入する import_full_history.py を並行して走らせれば、Kintone のフェッチと HubSpot 投入が非同期で進み、1.3 万件で約 1 時間で完了します。

ここでのつまずきは、投入前のバックアップを取り忘れることです。HubSpot は API 経由で一括削除もできるため、import 事故のときはバックアップから戻すことになります。着手前に Companies / Contacts / Deals を CSV エクスポートしておけば十分です。

STEP 4. Apps Script で増分同期を実装する — 失敗時に取りこぼさない設計

「毎朝全件同期で動かしていたら、途中でエラーが出てどこまで同期できたか分からなくなった」。全件同期の運用でほぼ必ず起きる事故です。

過去データが入ったら、翌日からの差分を Apps Script で日次同期します。要は「更新日時 > 前回同期時刻」のレコードだけ取得する増分方式です。1.3 万件のフルデータがあっても日次の差分は数十件程度なので、API 呼び出しコストは全件同期比で約 1/100 に下がり、Kintone の 100 req/分・HubSpot の 100 req/10 秒という制限にも余裕で収まります。

設計の肝は、前回同期時刻を PropertiesServiceKINTONE_LAST_SYNC として保存し、全件成功したときだけ更新することです。失敗時に更新してしまうと、次回の差分取得から失敗分がまるごと漏れます。

function syncKintoneToHubSpot() {
  const props = PropertiesService.getScriptProperties();
  const lastSync = props.getProperty('KINTONE_LAST_SYNC') || '2020-01-01T00:00:00+0900';
  const now = Utilities.formatDate(new Date(), 'JST', "yyyy-MM-dd'T'HH:mm:ssXXX");

  try {
    const records = fetchKintoneIncremental(lastSync);
    Logger.log(`Fetched ${records.length} records since ${lastSync}`);

    const successes = upsertContactsToHubSpot(records);
    // 全件成功時のみ LAST_SYNC を更新 = 失敗時は次回再試行
    if (successes === records.length) {
      props.setProperty('KINTONE_LAST_SYNC', now);
    } else {
      Logger.log(`Partial success: ${successes}/${records.length}. Skipping LAST_SYNC update.`);
    }
  } catch (e) {
    Logger.log(`Sync failed: ${e.message}. Skipping LAST_SYNC update.`);
    throw e;
  }
}

初回起動時のフォールバック('2020-01-01T00:00:00+0900')を入れておくのもポイントです。初回だけは全件取得になるので、STEP 3 の過去履歴 import と組み合わせれば過不足なく回ります。

つまずきやすいのは PropertiesService の性質です。これはスクリプト単位で値を保持するため、別スクリプトからは KINTONE_LAST_SYNC を参照できません。複数スクリプトで同期時刻を共有したい場合は、共通の Google Sheets セルに書き込む設計へ切り替えてください。

STEP 5. 3 つのハマりを最初から潰す — ISO 8601 / email 重複 / scopes

「ドキュメント通りに書いたのに、400 が出る、409 が出る、403 が出る」。Kintone × HubSpot 連携では、公式ドキュメントに明記されていない実装ハマりが必ず 3 つ出ます。

これを事前に潰しておかないと、運用開始後に「なぜか同期が止まる」「なぜか重複が発生する」という症状で 1 つあたり半日、3 つで 1.5 日溶けます。中身と対策は次の通りです。

ハマり 症状 対策
Kintone は ISO 8601 datetime 必須 2026-05-01 00:00:00 で送ると 400 2026-05-01T00:00:00+0900 形式に整形(T 区切り + タイムゾーン明示)
HubSpot Contacts は email ユニーク 同じ email を post すると 409 Conflict email で Search → 存在すれば patch、無ければ post に切替
HubSpot Private App scopes 不足 post / patch で 403 Forbidden Private App 設定で crm.objects.contacts.write を追加(STEP 2)

ハマりポイント 3 つの修正コード

コードに落とすと、それぞれこう対応します。

// ハマり 1: ISO 8601 整形
function toIso8601(dateStr) {
  return Utilities.formatDate(new Date(dateStr), 'JST', "yyyy-MM-dd'T'HH:mm:ssXXX");
}

// ハマり 2: email upsert(存在すれば patch、無ければ post)
function upsertContactByEmail(record) {
  const searchRes = UrlFetchApp.fetch(`${HUBSPOT_BASE}/crm/v3/objects/contacts/search`, {
    method: 'post',
    headers: { Authorization: `Bearer ${HUBSPOT_TOKEN}` },
    contentType: 'application/json',
    payload: JSON.stringify({
      filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: record.email }] }]
    }),
    muteHttpExceptions: true
  });
  const existing = JSON.parse(searchRes.getContentText()).results[0];

  if (existing) {
    return patchContact(existing.id, record);
  } else {
    return createContact(record);
  }
}

// ハマり 3: Private App scopes
// HubSpot UI → Settings > Integrations > Private Apps > Edit > Scopes
// 必須スコープ: crm.objects.contacts.read / crm.objects.contacts.write

muteHttpExceptions: true は必ず付けてください。これを忘れると 4xx エラーで Apps Script が例外を投げてスクリプトごと止まり、後段の upsert が動かなくなります。scopes 不足の 403 は実行時にしか気付けないので、運用に乗せる前にテスト同期で必ず検証します。

STEP 6. 毎朝 1 回のトリガーで自動化する

「手動実行では動くが、毎日誰かが実行するのは現実的でない」。ここまで来たら最後は無人化です。

Apps Script エディタの時計アイコン(トリガー)から、syncKintoneToHubSpot を「時間主導型 → 日タイマー → 午前 6〜7 時」で登録します。これで毎朝、前回同期以降の差分だけが自動で HubSpot に流れます。

失敗に気付く仕組みも同時に入れておきます。STEP 4 の Logger.log は Apps Script 内に留まるので、同期失敗時は Slack Webhook に通知を飛ばすようにしておくと、差分が膨らむ前に対処できます。翌日まで失敗に気付かないと、その分の差分が翌々日にまとめて流れ、原因追跡が難しくなります。通知には対象の kintone_id を含めておくと、再同期がしやすくなります。

動作確認 — 何が見えれば成功か

無人運用に入る前に、以下が確認できれば実装は完了です。

  • HubSpot Contacts の件数が過去履歴 import 後に想定件数(例: 1.3 万件)に達している
  • Kintone 側でテストレコードを 1 件更新 → 翌朝のトリガー実行後、HubSpot 側の該当 Contact が更新されている
  • 同じ email のレコードを 2 回同期しても、Contacts が重複せず patch で上書きされている
  • Apps Script の実行ログに Fetched N records since ... が出て、KINTONE_LAST_SYNC が実行のたびに前進している
  • 意図的に 1 件失敗させたとき、KINTONE_LAST_SYNC が更新されず、次回に取りこぼしなく再試行される

最後の 2 つが確認できていれば、失敗時の取りこぼしが起きない設計になっています。

応用 — 同じパターンを Salesforce / Notion / AirTable に横展開する

ここまでの設計は「既存 CRM の更新日時クエリで増分取得 → HubSpot に upsert」というシンプルなパターンです。骨格は Kintone 限定ではなく、多くの SaaS で流用できます。変更が必要なのは取得側だけで、HubSpot 側の upsert ロジックは共通です。

CRM 差分取得クエリ upsert キー 主な差分
Kintone 更新日時 > LAST_SYNC $id offset 10,000 上限 → $id 分割
Salesforce WHERE LastModifiedDate > LAST_SYNC(SOQL) Id Bulk API でレート制限を緩和
Notion filter: { last_edited_time: { after: LAST_SYNC } } id(UUID) ページネーション(next_cursor
AirTable filterByFormula: {Last Modified} > LAST_SYNC recordId レート制限 5 req/秒で要注意

置き換えの順序は「LAST_SYNC 保存ロジック → 差分取得クエリ → upsert ロジック」です。各 CRM の API レート制限はバラバラなので、横展開の前に公式ドキュメントで確認してください。特に AirTable の 5 req/秒、Notion の 3 req/秒は実運用では厳しく、バッチサイズと sleep の調整が必須になります。Salesforce は API 制限が組織契約に紐づくため(Enterprise 以上で日次 15,000 req 目安)、事前の容量確認をおすすめします。

まとめ — 移行ではなく並行運用から始める

Kintone から HubSpot への移行は、完全移行を前提にすると失敗率が跳ね上がります。私たち humbulls が現場で採る現実解は、Kintone を主・HubSpot をマーケ / 分析として並行運用し、差分だけを日次で同期する構成です。過去履歴は Python で約 1 時間、日次同期は Apps Script、ハマりは 3 つとも潰し済み。まずは STEP 1 の方針決めと 12 ヶ月シフト計画から始めてみてください。

本記事はシリーズ #6(既存 CRM 統合)です。データ層から分析まで、自社に必要な 1 本から読むのが効率的です。

# タイトル 役割
#1(Pillar) HubSpot Starter で Pro 級ダッシュボードを再現 全体設計
#2 Apps Script で HubSpot ↔ Google Sheets を自動同期 データ層
#3 Looker Studio で HubSpot ダッシュボードを 30 分で組む 可視化層
#4 Stage History を Apps Script 30 行で日次 Snapshot Professional 専用機能の代替
#5 月次コホート × 経過日数 × 勝率を AI で 1 時間設計 分析フレームワーク
#6(本記事) Kintone と HubSpot の並行運用 — 増分同期の実装手順 既存 CRM 統合

humbulls では、Kintone を含む既存 CRM との並行運用設計から段階移行支援まで伴走する Growth Partner サービス を提供しています。並行運用テンプレと段階移行ロードマップを一括で入手したい方は、BtoB マーケ AI 活用ガイド もご活用ください。

🤖 AI 実行キット

本記事の全 STEP を、そのまま AI で実行するためのキットです。Claude Code に環境を接続したうえで、以下のプロンプトを渡せば同期スクリプト一式が生成できます。

キット① Kintone × HubSpot 増分同期を丸ごと組む — 半日

種別: 実装キット 使うもの: Claude Code(Python + Apps Script のコード生成に使用) 事前に用意するもの: STEP 2 で発行した HubSpot Private App トークンと Kintone API Token、対象 Kintone アプリのフィールド一覧(フィールド名は日本語のままで構いません)

プロンプト:

Kintone から HubSpot への並行運用(増分同期)の実装一式を生成してください。
一方通行(Kintone → HubSpot)、真実は Kintone とします。

【私の環境】
- Kintone: サブドメイン = your-company(記入例。実際の値に置換)
  対象アプリ = 商談アプリ(app_id は数値)
  同期したいフィールド = 会社名 / 担当者名 / email / 電話 / 更新日時
- HubSpot: Sales Hub Starter。同期先は Contacts。
  upsert キー = email、Kintone のレコード ID は custom property client_kintone_id に格納
- 過去データ件数 = 約 13,000 件(記入例。実件数に置換)

【必ず守る設計基準】
1. 過去履歴の一括 import は Python で書く(Apps Script の 6 分制限を避ける)。
   Kintone の offset 上限 10,000 は $id 範囲分割で突破する。1 回 500 件。
2. 日次の増分同期は Apps Script で書く。前回同期時刻を
   PropertiesService に KINTONE_LAST_SYNC(ISO 8601 + タイムゾーン付き)で保存。
   全件成功時のみ LAST_SYNC を更新し、失敗時は更新せず次回再試行する。
3. 以下 3 つのハマりを最初からコードに織り込む:
   - Kintone datetime は "yyyy-MM-dd'T'HH:mm:ssXXX" 形式に整形
   - HubSpot Contacts は email ユニーク → Search してから patch / post を切替
   - UrlFetchApp は muteHttpExceptions: true を付け、4xx で止めない
4. HubSpot Private App の必須スコープを README に明記する
   (crm.objects.contacts.read / crm.objects.contacts.write)。

【出力】
- fetch_kintone_full.py(Kintone 全件 fetch、tqdm で進捗表示)
- import_full_history.py(HubSpot への一括投入)
- Code.gs(syncKintoneToHubSpot / fetchKintoneIncremental /
  upsertContactByEmail / toIso8601)
- requirements.txt と、必須スコープ・トリガー設定手順を書いた README.md

出力の確認ポイント:

  • KINTONE_LAST_SYNC の更新が「全件成功時のみ」になっているか。無条件更新になっていたら失敗分を取りこぼすので修正させる
  • upsertContactByEmail が email 未設定レコードを post しようとしていないか(email 空はスキップが正しい)
  • Python 側に $id 範囲分割のフォールバック(offset 9,500 付近で切替)が入っているか

うまくいかないとき:

  • 実行時に 403 が出る → HubSpot Private App の scopes に write が入っていない。STEP 2 に戻って付与し直す
  • 生成コードが全件同期になっている → 「更新日時 > KINTONE_LAST_SYNC の差分だけ取得」と明示して再生成させる
  • Salesforce / Notion / AirTable へ横展開したい → 上記プロンプトの「Kintone」節を対象 CRM の差分取得クエリ(応用の表参照)に置き換えて再実行する

トラブルシュート早見表:

症状 原因 対処
post / patch で 403 Private App scopes 不足 crm.objects.contacts.write を追加
更新日時クエリで 400 datetime が ISO 8601 でない toIso8601 で T 区切り + +0900 に整形
同じ人が二重登録される email 検索前に post している Search → patch / post 切替に修正
過去データが 1 万件で止まる Kintone offset 10,000 上限 $id 範囲分割クエリに切替
差分が翌々日にまとめて流れる 失敗に気付いていない Slack Webhook 通知を追加

参考文献

こんな記事も読まれています