
GAS初心者がChatGPTと作るツール開発記録② 【NotionのWebhook機能の活用】
このシリーズは、プログラミング初心者がGASを使ってツール開発を行う過程の試行錯誤の記録を綴ったものです。
今回は、特にNotionで2024年12月に実装されたWebhook機能についての試行錯誤を記載しますので、NotionのWebhook機能を使ってみたいと考えている方の参考になれば幸いです。
開発の背景と目的
私は普段、Notionを活用して売上管理を行っています。Notion内で請求書の作成状況や送付状況などを管理しているのですが、いざ請求書を作成する時には、スプレッドシートやExcelを開いて作業する必要があり、この二重管理が非効率だと感じていました。
この課題を解決するため、NotionのWebhook機能を活用して請求情報を自動的にスプレッドシートに転記して請求書を生成するツールの開発に取り組みました。
今回のロードマップ

・NotionのWebhook機能でGASを発火し、プロパティ情報をスプレッドシートに転記
・NotionAPIでデータベース情報を取得し、スプレッドシートに転記
・MATCH/INDEX関数を用いて各シートからデータを統合
・Query関数を活用して請求書雛形に情報を転記
Notionテンプレートの作成
まず最初にNotionのデータベースを作成します。
ポイントは、各情報についてそれぞれデータベースを作成し、リレーションで結びつけることです。

このようにすることで、各情報に対してNotion上で1つの固有IDが付与されます。Webhookを使って情報を抽出する際、この固有IDが鍵となり、必要な情報の検索できるという仕組みです。
「リレーション」はプロパティの設定でマルチセレクトとセレクトの切り替えが可能なので、使い勝手を損なうことなく使用することができます。
スプレッドシートの作成
次にスプレッドシートを作成します。
担当者、顧客、請求項目の3種類のデータベースの情報を各シートに転記し、IDで検索できるように必要なシートを作成しました。今回の請求内容を転記するために、Webhookで情報を送信された情報を出力できるシートも忘れずに準備します。

そこから、これらの情報をもとに統合データシートを作成します。請求内容のプロパティに記載されたIDを基に、MATCH/INDEX関数を使用して他のデータベースから必要な情報を検索します。
最後に、この統合データシートからQuery関数を使用して請求書に情報を反映させて、完成です。

Notionとの連携とWebhookの設定
ここまで準備できたら、GASのコード作成に入ります。
請求内容の情報は、Notionのプロパティに設置したボタンにWebhookを設定し、スプレッドシートにプロパティ情報を転記する仕組みを実装しました。GASの実行はこのボタンプロパティのクリックをトリガーとしており、処理の順序としては請求内容情報の転記を完了した後、担当者・顧客・請求内容データベースの内容を転記する流れとなっています。

そして、Notion APIを使って担当者、顧客、請求内容の各データベースの内容を全てシートに転記するコードを作成します。
これらの情報が正しく転記されると、設定したシートで情報が整理され、請求書が自動的に雛形に生成されます。
開発過程での課題
今回の開発で最も時間を要したのは、NotionのWebhook機能の理解と実装でした。特に、「デプロイ」の理解不足でデバッグが難航し、Webhookの実装に3日という時間を要しました。
今回は、デプロイを伴う開発が初めてであったことを考えても、まず最初にしっかり内容を確認すべきだったと感じます。今後も新たな工程を伴う開発をする際は、前提となる知識を学んでから開発に取り組む必要性を実感しました。
一方で、前回の反省を踏まえ、機能を細分化して段階的に実装を進める方法を採用したことによって、問題の特定と解決をより効率的に行えるようになったことは良かった点として挙げられます。
今後の課題と学び
本開発を通じて、デバッグしやすさを意識したコーディングの重要性を痛感しました。具体的には取得したログを出力し、受け渡されたデータがどのような内容かを逐一チェックしていくことで、デバッグの時間が短縮されるのではないかと考えています。
これらの学びを今後の開発に活かし、より効率的なツール開発を目指していきます。
おまけ
参考までに、今回生成した行動を以下に記載します。
NotionデータベースのボタンプロパティにWebhookを設定した場合、自身を含むそのページのプロパティ情報をbodyに格納して情報を送信することができるようです。
function doPost(e) {
// スプレッドシートを取得
const ss = SpreadsheetApp.openById('スプレッドシートID');
const seikyusheet = ss.getSheetByName('請求内容');
// スプレッドシートの内容をクリア
seikyusheet.clearContents();
// POSTデータを取得
const data = JSON.parse(e.postData.contents).data;
// properties の値を配列に格納
const properties = data.properties;
const rowData = [
new Date(), // 日時
data.id, // ページID
properties['請求合計(税抜)'].rollup.number,
properties['最終更新者'].last_edited_by.name,
properties['顧客'].relation[0].id,
properties['担当者'].relation[0].id,
properties['備考'].rich_text[0].plain_text,
properties['このページのID'].formula.string,
properties['支払期限'].date.start,
properties['請求内容'].relation.map(r => r.id).join(', '), // 複数IDをカンマ区切りで結合
properties['最終更新日時'].last_edited_time,
properties['消費税を含める'].checkbox,
properties['発行日'].date.start,
properties['名前'].title[0].plain_text,
];
// スプレッドシートにデータを追加
seikyusheet.appendRow(rowData);
// notionToSpreadsheet() を実行
notionToSpreadsheet();
}
function notionToSpreadsheet() {
// Notion APIキーを設定
const notionApiKey = 'APIキー';
// データベースIDとシート名を対応付けるオブジェクト
const databaseSheetMapping = {
'データベースID1': { sheetName: '担当者DB', type: 'database' }, // 担当者DB
'データベースID2': { sheetName: '顧客DB', type: 'database' }, // 顧客DB
'データベースID3': { sheetName: '請求項目DB', type: 'database' }, // 請求内容DB
};
// スプレッドシートIDを設定
const spreadsheetId = 'スプレッドシートID';
// スプレッドシートを取得
const ss = SpreadsheetApp.openById(spreadsheetId);
// 各データベースとシートに対して処理を実行
for (const databaseId in databaseSheetMapping) {
const { sheetName, type } = databaseSheetMapping[databaseId];
const sheet = ss.getSheetByName(sheetName);
sheet.clearContents(); // シート全体の値を消去
if (type === 'database') {
// データベースの場合の処理
// Notion APIのエンドポイント
const url = `https://api.notion.com/v1/databases/${databaseId}/query`;
// リクエストオプション
const options = {
'method': 'post',
'headers': {
'Authorization': `Bearer ${notionApiKey}`,
'Content-Type': 'application/json',
'Notion-Version': '2022-06-28'
},
'payload': JSON.stringify({})
};
// Notion APIを呼び出す
const response = UrlFetchApp.fetch(url, options);
const data = JSON.parse(response.getContentText());
// resultsプロパティが存在するかチェック
if (!data.results || data.results.length === 0) {
Logger.log(`シート ${sheetName} : データが見つかりませんでした。`);
Logger.log(JSON.stringify(data));
continue;
}
// ヘッダー行を作成
let headerRow = ["ページID"];
if (data.results.length > 0) {
for (const propertyName in data.results[0].properties) {
if (propertyName !== "請求書情報") { // 「請求書情報」プロパティを除外
headerRow.push(propertyName);
}
}
sheet.appendRow(headerRow);
}
// データをスプレッドシートに書き出す
data.results.forEach(page => {
let rowData = [];
// ページIDをA列に追加
rowData.push(page.id);
// 各プロパティを取得
for (const propertyName in page.properties) {
if (propertyName !== "請求書情報") { // 「請求書情報」プロパティを除外
const property = page.properties[propertyName];
let cellValue = "";
switch (property.type) {
case "title":
cellValue = property.title.map(text => text.plain_text).join("");
break;
case "rich_text":
cellValue = property.rich_text.map(text => text.plain_text).join("");
break;
case "number":
cellValue = property.number;
break;
case "select":
cellValue = property.select ? property.select.name : "";
break;
case "multi_select":
cellValue = property.multi_select.map(select => select.name).join(",");
break;
case "date":
cellValue = property.date ? property.date.start : "";
break;
case "url":
cellValue = property.url ? property.url : "";
break;
case "checkbox":
cellValue = property.checkbox;
break;
case "files":
cellValue = property.files.map(file => file.name).join(",");
break;
case "email":
cellValue = property.email;
break;
case "relation":
if (property.relation && property.relation.length > 0) {
cellValue = property.relation.map(relation => relation.id).join(",");
}
break;
case "rollup":
// ロールアップのプロパティの場合
if (property.rollup.type === "array") {
// ロールアップの結果が配列の場合
// 配列の最初の要素をオリジナルの値として取得
const originalValue = property.rollup.array[0];
// オリジナルの値の型によって処理を分岐
switch (originalValue.type) {
case "number":
cellValue = originalValue.number;
break;
case "rich_text":
cellValue = originalValue.rich_text.map(text => text.plain_text).join("");
break;
default:
cellValue = JSON.stringify(originalValue);
break;
}
} else {
cellValue = "";
}
break;
case "formula":
// 計算プロパティの場合
switch (property.formula.type) {
case "number":
cellValue = property.formula.number;
break;
case "string":
cellValue = property.formula.string;
break;
case "boolean":
cellValue = property.formula.boolean;
break;
default:
cellValue = JSON.stringify(property.formula);
break;
}
break;
default:
cellValue = JSON.stringify(property);
break;
}
rowData.push(cellValue);
}
}
sheet.appendRow(rowData);
});
} else if (type === 'page') {
// ページの場合の処理
// Notion APIへのリクエスト
const options = {
'method': 'get',
'headers': {
'Authorization': 'Bearer ' + notionApiKey,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json'
}
};
const response = UrlFetchApp.fetch('https://api.notion.com/v1/pages/' + databaseId, options);
const data = JSON.parse(response.getContentText());
Logger.log(JSON.stringify(data));
if (data.properties) {
let rowIndex = 1;
for (const propertyName in data.properties) {
const property = data.properties[propertyName];
let propertyValue = '';
let columnIndex = 2; // B列から開始
sheet.getRange(rowIndex, 1).setValue(propertyName); // A列にプロパティ名を書き込み
switch (property.type) {
case 'relation':
if (property.relation && property.relation.length > 0) {
property.relation.forEach(relation => {
sheet.getRange(rowIndex, columnIndex).setValue(relation.id);
columnIndex++;
});
} else {
sheet.getRange(rowIndex, columnIndex).setValue("");
}
rowIndex++;
continue;
case 'title':
propertyValue = property.title.map(text => text.plain_text).join('');
break;
case 'rich_text':
propertyValue = property.rich_text.map(text => text.plain_text).join('');
break;
case 'number':
propertyValue = property.number;
break;
case 'select':
propertyValue = property.select ? property.select.name : '';
break;
case 'multi_select':
propertyValue = property.multi_select.map(select => select.name).join(', ');
break;
case 'date':
propertyValue = property.date ? property.date.start : '';
break;
case 'checkbox':
propertyValue = property.checkbox;
break;
case 'url':
propertyValue = property.url;
break;
case 'email':
propertyValue = property.email;
break;
case 'phone_number':
propertyValue = property.phone_number;
break;
case "formula":
// 計算プロパティの場合
switch (property.formula.type) {
case "number":
propertyValue = property.formula.number;
break;
case "string":
propertyValue = property.formula.string;
break;
case "boolean":
propertyValue = property.formula.boolean;
break;
default:
propertyValue = JSON.stringify(property.formula);
break;
}
break;
case "rollup":
// ロールアップのプロパティの場合
if (property.rollup) {
if (property.rollup.type === "array" && property.rollup.array.length > 0) {
// ロールアップの結果が配列で、かつ要素が存在する場合
let sum = 0;
property.rollup.array.forEach(item => {
sum += item.number; // 各要素の値を sum に加算
});
propertyValue = sum; // propertyValue に sum を代入
} else if (property.rollup.type === "number") {
// ロールアップの結果が数値の場合
propertyValue = property.rollup.number; // propertyValue に number を代入
} else {
// その他の場合は空文字列
propertyValue = "";
}
} else {
// property.rollup が存在しない場合は空文字列
propertyValue = "";
}
break;
default:
propertyValue = JSON.stringify(property);
break;
}
sheet.getRange(rowIndex, 2).setValue(propertyValue); // B列に値を書き込み
rowIndex++;
}
} else {
Logger.log('プロパティが見つかりませんでした。');
Logger.log(JSON.stringify(data)); // エラー内容をログに出力
}
}
}
}