humbulls post

HubSpot Stage History を Apps Script 30 行で日次記録する方法|humbulls

作成者: Kazuki Kurita|May 15, 2026 3:00:00 PM

本記事のポイント

  • Stage History (商談ステージ推移) は HubSpot Professional 専用機能で、Starter の API では 403 で弾かれます。過去の遡及はできませんが、今日から日次で記録すれば同等の推移データが手に入ります
  • 実装は Apps Script 30 行程度。全 Deal の現在ステージを毎朝 1 回 Google Sheets に追記 (append-only) するだけで、追加コストはゼロです
  • 3 ヶ月ぶんの記録が溜まれば Cohort 分析まで接続でき、Professional 移行 ($890/月) の判断を自社データで下せます

「Professional にしないと商談ステージの推移が見えないと言われた。でも Stage History のためだけに月 $890 は重い」。HubSpot Starter を使う組織の支援で、よく聞く悩みです。原因はプランの機能差ではなく、過去を遡って見ようとしていることにあります。Stage History のようなデータは、記録を始めた瞬間から未来に向かって溜まるものです。本記事では、Apps Script で毎朝ステージを記録し、Professional 相当の推移データを日次粒度で蓄積する手順を、コードと自動化設定つきで解説します。Apps Script の経験は不要で、型と巻末キットがあれば再現できます。

完成形 — _StageSnapshots シートに毎日 1 行ずつ商談が積み上がる

作るものは、毎朝自動で全 Deal の現在ステージを 1 行ずつ書き足していく記録シートです。snapshot_date (記録日) 列があるので、後から「5 月 15 日時点のパイプライン」のように任意の日付で切り出せます。これが Professional の Stage History の代わりになります。

項目 内容
所要時間 実装 30 分〜1 時間 (Apps Script 未経験でも Claude Code に書かせれば同等)
前提条件 HubSpot Starter の Private App / Google アカウント (Sheets + Apps Script)
追加コスト $0 (Apps Script・Looker Studio は無料枠、HubSpot は現行 Starter のまま)
再現できる範囲 日次粒度のステージ推移・滞留日数・月次 Cohort の基礎データ
再現できない範囲 分単位の履歴・ステージ移動のリアルタイム通知 (これらは Professional 必須)

ある業務用美容機器メーカーの実装では、この記録を過去 2 期分のデータと並走させ、約 1,300 件の Deal を毎朝 1 秒以内で書き込んでいます。以降の STEP 1〜6 をそのまま辿れば、同じ構成を再現できます。

なお本記事の円換算はすべて 1 ドル = 150 円 (2026 年 5 月時点) を基準にしています。

STEP 1. HubSpot の Private App を作り、Deal 読み取り権限を用意する

やること: HubSpot の Settings から Private App を新規作成し、アクセストークンを発行します。Deal を読むための Scope を付けるのがこの STEP の肝です。

Settings > Integrations > Private Apps > Create a private app から作成し、Scopes タブで最低限次の 2 つにチェックを入れます。

  • crm.objects.deals.read — Deal レコードの読み取り
  • crm.schemas.deals.read — Pipeline / ステージ定義 (ステージ ID → 日本語名の変換に使う) の読み取り

作成後に表示される Access Token を控えておきます。Apps Script 側では、このトークンをコードに直書きせず、Project Settings > Script PropertiesHUBSPOT_TOKEN として保存して呼び出します。

つまずきポイント: Scope を入れ忘れると、後の STEP でデータ取得時に 403 Forbidden が返ります。「Starter だから取れない」のか「Scope が足りないだけ」なのかは切り分けが必要です。Stage History のような property-history 系エンドポイントは Starter では本当に 403 ですが、Deal の現在値 (dealstage) は crm.objects.deals.read があれば Starter でも問題なく取得できます。この記事の記録方式が成立するのは、まさにこの差を突いているからです。

STEP 2. Google Sheets に 3 枚のシートを用意する

やること: 記録先の Google Sheets を 1 つ作り、以下の 3 シートを空で用意します。シート名は後のコードと一致させてください。

シート名 役割 書き込み方式
_StageSnapshots 日次の推移記録 (Cohort・滞留分析の元データ) append-only (追記のみ)
_DealCurrentState 現在の Deal 一覧 (Pipeline ファネル用) overwrite (毎日上書き)
pipelines_master ステージ ID → 日本語ステージ名の対応表 手動 1 回

pipelines_master には、A 列にステージ ID、B 列に日本語名を並べておきます。ステージ ID は HubSpot の Settings > Objects > Deals > Pipelines で各ステージの内部名として確認できます。この対応表を先に作っておくと、記録時に日本語名を一緒に書き込めるので、Looker Studio 側での変換 (VLOOKUP) が不要になります。

つまずきポイント: シート名の先頭アンダースコアや大文字小文字がコードと 1 文字でも違うと、getSheetByName()null を返して書き込みに失敗します。コピー & ペーストで揃えるのが安全です。

STEP 3. 全 Deal を取得するヘルパー関数を置く

やること: Apps Script エディタ (拡張機能 > Apps Script) を開き、HubSpot から全 Deal と全ステージ定義を取得する 2 つのヘルパー関数を用意します。

記録関数の前に、データ取得を担う fetchAllDeals() (全 Deal をページング取得) と fetchStagesMaster() (ステージ ID → 日本語名の辞書を返す) を置きます。この 2 つはシリーズ #2 の Apps Script で HubSpot ↔ Sheets 自動同期 で全文を公開しているので、同期をすでに組んでいる場合はそのまま流用できます。ポイントは、fetchAllDeals() の取得プロパティに dealname amount dealstage hubspot_owner_id createdate を含めることです。

つまずきポイント: HubSpot の Deal 取得はデフォルトで 100 件ずつのページングになります。件数が 100 を超える組織で paging の後続取得を実装し忘れると、記録が先頭 100 件だけになり、気づかないまま欠損したデータが溜まります。取得直後に件数をログ出力して、HubSpot 上の Deal 総数と一致するか毎回確認するのが安全です。

STEP 4. append-only の記録関数 recordStageSnapshot() を書く

やること: 取得した全 Deal を、_StageSnapshots シートの末尾に 1 日分まとめて追記する関数を書きます。これが本記事の中心となる 30 行です。

蓄積型データの鉄則は append-only です。一度書いた行は削除も更新もせず、新しいデータは常に末尾に足すだけにします。この原則を破って「その日の行を消してから書き直す」ような実装にすると、途中で失敗したときに過去データごと壊れる事故が起きます。

function recordStageSnapshot(deals, stagesMaster) {
  const ss = SpreadsheetApp.openById('YOUR_SHEET_ID');
  const sheet = ss.getSheetByName('_StageSnapshots');
  const today = Utilities.formatDate(new Date(), 'JST', 'yyyy-MM-dd');

  const rows = deals.map(d => [
    today,
    d.id,
    d.properties.dealname,
    Number(d.properties.amount) || 0,
    d.properties.dealstage,
    stagesMaster[d.properties.dealstage]?.label || 'unknown',
    d.properties.hubspot_owner_id,
    d.properties.createdate
  ]);

  if (rows.length > 0) {
    sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, 8).setValues(rows);
  }
  Logger.log(`Appended ${rows.length} rows on ${today}`);
}

設計のポイントは 3 つです。today を行ごとに付けるので、後から snapshot_date で日付絞り込みができます。stagesMaster で日本語ステージ名 (6 列目) を一緒に書き込むので、可視化側での変換が要りません。appendRow を 1 行ずつ呼ぶのではなく setValues で 1 日分をまとめて書くのは、そのほうが圧倒的に速いからです (1,300 件で 1 秒以内)。

つまずきポイント: _StageSnapshots は毎日行が増え続けます。1,300 件 × 365 日 = 約 47 万行は Sheets の上限 (1,000 万セル) に余裕で収まりますが、半年〜1 年でため込みすぎると Looker Studio の描画が重くなります。年に 1 回、古い年度分を別シート (別ファイル) に退避する運用を最初から想定しておくと安心です。

STEP 5. 現在状態シートと Orchestrator を足す

やること: 「現在の Deal 一覧だけ」を見たいユースケース用に _DealCurrentState (毎日上書き) を足し、2 つの記録を 1 回の API 取得でまかなう Orchestrator 関数でまとめます。

_StageSnapshots だけでも推移は取れますが、「今この瞬間のパイプライン」を Looker Studio で表示するたびに全履歴を走査すると描画が重くなります。そこで、現在値だけを毎日上書きする _DealCurrentState を分けて持ちます。追記先と上書き先を物理的に分離するのがこの STEP の型です。

function syncDealsOrchestrator() {
  const deals = fetchAllDeals(['dealname', 'amount', 'dealstage', 'hubspot_owner_id', 'createdate']);
  const stagesMaster = fetchStagesMaster();

  recordStageSnapshot(deals, stagesMaster);    // 追記 (append-only)
  recordDealCurrentState(deals, stagesMaster); // 上書き (overwrite)
}

function recordDealCurrentState(deals, stagesMaster) {
  const ss = SpreadsheetApp.openById('YOUR_SHEET_ID');
  const sheet = ss.getSheetByName('_DealCurrentState');
  const today = Utilities.formatDate(new Date(), 'JST', 'yyyy-MM-dd');

  const rows = deals.map(d => [
    today, d.id, d.properties.dealname,
    Number(d.properties.amount) || 0,
    d.properties.dealstage,
    stagesMaster[d.properties.dealstage]?.label || 'unknown',
    d.properties.hubspot_owner_id, d.properties.createdate
  ]);

  sheet.getRange(2, 1, Math.max(sheet.getLastRow() - 1, 1), 8).clearContent();
  if (rows.length > 0) {
    sheet.getRange(2, 1, rows.length, 8).setValues(rows);
  }
}

_DealCurrentState の「上書き」は、clearContent() で中身を消してから setValues() で書き直す実装です。clear() だと書式設定まで消えてしまうため、値だけを消す clearContent() を使います。API 取得は syncDealsOrchestrator() で 1 回だけ行い、その結果を 2 つの記録関数で共有するので、HubSpot への負荷も最小です。

つまずきポイント: Cohort 分析や滞留日数の分析を _DealCurrentState から組もうとすると失敗します。このシートには過去がなく、常に「今日」しか入っていないためです。推移を扱う分析は必ず _StageSnapshots を参照させてください。使い分けの詳細は 月次コホート × 経過日数 × 勝率 で解説しています。

STEP 6. 毎朝 6:00 に自動実行するトリガーを設定する

やること: syncDealsOrchestrator() を毎日 1 回、自動で走らせる時間主導型トリガーを登録します。手で実行し続ける必要はありません。

Apps Script エディタ左の時計アイコン (トリガー) から トリガーを追加 を開き、次のように設定します。

  • 実行する関数: syncDealsOrchestrator
  • イベントのソース: 時間主導型
  • 時間ベースのトリガー: 日付ベースのタイマー
  • 時刻: 午前 6 時〜7 時

つまずきポイント: 時刻を業務時間中や深夜 0 時付近にすると、他の夜間バッチや営業入力と重なってその日の値がぶれることがあります。夜間の更新が一通り終わった 午前 6〜7 時 に固定するのが安全です。また、初回のトリガー保存時に Google の承認画面が出ます。ここで承認しないとトリガーは動かないので、必ず「許可」まで進めてください。

動作確認 — 翌朝、行が「増えている」ことを確かめる

設定できたら、まず syncDealsOrchestrator() を手動で 1 回実行します。成功していれば、次の 3 つが確認できます。

  1. 実行ログAppended 1300 rows on 2026-05-15 のように件数と日付が出る (件数が HubSpot の Deal 総数と一致するか確認)
  2. _StageSnapshots に今日の snapshot_date で全 Deal ぶんの行が追加されている
  3. _DealCurrentState に現在の Deal 一覧が上書きされ、行数が Deal 総数と一致する

決め手は「翌朝また 1 日分増えている」ことです。トリガー設定の翌日にシートを開き、snapshot_date が 2 日分に分かれていれば、日次記録が回り始めた証拠です。この時点で Professional の Stage History と同じ「日ごとのステージ推移」が自前で溜まり始めています。

溜め始めた後にできること — 3 ヶ月で Cohort、半年で Professional 判断

蓄積型データは、始めた瞬間からしか価値が生まれません。今日始めれば、時間の経過とともに分析の解像度が上がっていきます。

経過 蓄積データ できる分析 Professional 移行判断
1 ヶ月後 約 30 日分 直近 30 日のステージ滞留 まだデータ不足
3 ヶ月後 約 90 日分 月次 Cohort × 経過日数 × 勝率 判断材料が揃う
6 ヶ月後 約 180 日分 四半期トレンド + 季節性 Professional なしでほぼ代替可
1 年後 約 365 日分 年次トレンド + 前年比 移行不要のケースが多い

Professional は月額 $890 (約 13.4 万円) なので、この記録で移行を 3 ヶ月先送りするだけでも $890 × 3 = $2,670 (約 40 万円) の差になります。実装コストは Apps Script 30 行 + トリガー設定の 30 分程度です。

最終的に Professional へ移る場合でも、溜めた記録は捨てる必要がありません。Professional の Stage History は「契約開始日以降」しか遡れないため、契約前の履歴は _StageSnapshots 側にしか残らないからです。実務では、移行後も半年ほどは記録を並走させ、Professional のデータとの齟齬チェックと契約前履歴の参照に使うことをおすすめします。移行そのものを検討すべきなのは、分単位の粒度やステージ移動のリアルタイム通知 (インサイドセールスの SLA 監視など) が業務に必須になったときです。

まとめ — 過去は諦め、未来を今日から取りに行く

Stage History が Starter で取れないのは事実ですが、それは「過去を遡る」機能が取れないだけです。未来のステージ推移は、Apps Script 30 行で今日から日次記録できます。データは Starter の API で取れる、記録は自前で溜める、可視化は外でやる。この型があれば、Professional 移行の判断を「機能一覧の比較」ではなく「自社の 3 ヶ月分のデータ」で下せます。まずは STEP 1 の Private App 作成から始めてみてください。

本記事はシリーズ #4 です。全体設計は Pillar 記事 HubSpot Starter で Pro 級ダッシュボードを再現する、データ同期の土台は #2 Apps Script で HubSpot ↔ Sheets 自動同期、溜めた記録の分析は #5 月次コホート × 経過日数 × 勝率 で解説しています。

humbulls では、こうした設計から実装・運用まで一気通貫で伴走する Growth Partner サービス を提供しています。実装テンプレと判断マトリクスをまとめた BtoB マーケ AI 活用ガイド もご活用ください。

🤖 AI 実行キット

本記事の STEP 1〜6 を、Claude Code にまとめて実装させるためのキットです。Apps Script のコードを 1 行も書かずに、動く記録スクリプトまで到達できます。

キット① Stage Snapshot の記録スクリプトを一括生成する — 30 分

種別: 実装キット 使うもの: Claude Code (HubSpot MCP があれば Deal 総数の照合まで自動化できます。なければ HubSpot 画面の Deal 件数を手で伝えても動きます) 事前に用意するもの: 対象の Google Sheets の ID (URL の /d//edit の間の文字列) / HubSpot Private App のアクセストークン (STEP 1 で発行)

プロンプト:

HubSpot Starter の全 Deal の現在ステージを、毎朝 Google Sheets に記録する
Apps Script 一式を書いてください。Stage History (Professional 専用) の
日次スナップショット代替が目的です。

【私の環境】
- Google Sheets ID: YOUR_SHEET_ID(記入例。URL の /d/ と /edit の間)
- HubSpot トークン: Script Properties の HUBSPOT_TOKEN から読む前提
- Deal 総数: 約 1300 件(記入例。ページング実装の要否判断に使う)

【必ず従う設計ルール】
1. シートは 3 枚。名前は完全一致させること:
   - `_StageSnapshots` … append-only(既存行は絶対に消さない・末尾に追記のみ)
   - `_DealCurrentState` … overwrite(clearContent → setValues で値だけ上書き)
   - `pipelines_master` … ステージ ID → 日本語名の対応表(読み取りのみ)
2. カラム順(両シート共通・8 列):
   snapshot_date / deal_id / dealname / amount / stage_id / stage_jp /
   owner_id / create_date
3. API 取得は 1 回だけ。その結果を 2 つの記録関数で共有する
   Orchestrator 関数を作る
4. Deal が 100 件を超える場合、paging の後続取得を必ず実装する
5. 書き込みは appendRow ではなく setValues で 1 日分まとめて行う
6. 実行後に「Appended N rows on YYYY-MM-DD」をログ出力する

【出力】
- syncDealsOrchestrator() / recordStageSnapshot() /
  recordDealCurrentState() / fetchAllDeals() / fetchStagesMaster() の全関数
- 毎朝 6〜7 時に実行する時間主導型トリガーの設定手順
- 動作確認手順(何がログ・シートに出れば成功か)

出力の確認ポイント:

  • recordStageSnapshot() が既存行を消していないか (clearContentdeleteRow_StageSnapshots 側に混ざっていないか) を必ず目視する。ここが append-only 違反だと過去データが壊れます
  • 生成コードを手動実行し、ログの件数が HubSpot の Deal 総数と一致するかを確認する。一致しなければページング未実装のサインです
  • 2 シートのカラム順が 8 列で揃っているか (Cohort 記事 #5 と接続するため列ズレは厳禁)

うまくいかないとき:

  • 取得時に 403 Forbidden → Private App の Scope 不足。crm.objects.deals.readcrm.schemas.deals.read が付いているか確認 (property-history 系エンドポイントを叩いていないかも確認。現在値 dealstage は Starter でも取れます)
  • getSheetByName(...) is null → シート名の綴り・大文字小文字・先頭アンダースコアがコードと不一致。コピー & ペーストで揃える
  • ステージ名が全部 unknownpipelines_master のステージ ID が HubSpot 内部名と一致していない。Settings > Objects > Deals > Pipelines で内部名を再確認する

参考文献