GASのdoPostで
外部サービスのWebhookを受け取る方法
Webhook(イベント発生時に自動でHTTP送信される通知)をdoPost(e)で受け取り、スプレッドシートに記録してAIで自動分類するまでを、動くコードで解説します。サーバーは不要です。
Table of Contents
doPostとは何か|doGetとの違い
結論から言うと、外部サービスからの通知を受けたいならdoPost(e)を書きます。GASをWebアプリとして公開すると、決まった名前の関数が自動的にリクエストの受け口になります。
doGet(e)
ブラウザでURLを開いたときのGETリクエストを受ける。画面表示やJSONの返却に使う
doPost(e)
外部サービスがデータを送りつけるPOSTリクエストを受ける。Webhookの受け口はこちら
まずは最小構成です。受け取った内容をログに出し、ContentService(テキストやJSONを応答として返すGASのサービス)でJSONを返します。
// 外部サービスからのPOSTリクエストは doPost(e) が受け取る
function doPost(e) {
// ボディは文字列。JSONで届く場合はパースする
const raw = e && e.postData ? e.postData.contents : "";
console.log("受信ボディ: " + raw);
// 応答は必ず ContentService で返す(返さないとエラー扱いになる)
return ContentService
.createTextOutput(JSON.stringify({ ok: true }))
.setMimeType(ContentService.MimeType.JSON);
}ポイントは、必ず何かを返すことです。戻り値がないとGAS側がエラー応答を返し、送信元から見ると「失敗」になります。多くのサービスは失敗したWebhookを再送するため、返し忘れは二重処理の原因になります。
Webアプリとして公開する手順
コードを書いただけではURLは発行されません。Apps Scriptエディタ右上の「デプロイ」→「新しいデプロイ」から、種類に「ウェブアプリ」を選んで公開します。設定項目は2つだけです。
次のユーザーとして実行
「自分」を選びます。外部サービスはGoogleアカウントを持たないため、自分の権限でスプレッドシートやGmailを操作させる必要があります。
アクセスできるユーザー
「全員」を選びます。ここを社内限定にすると、外部サービスからのリクエストはログイン画面にリダイレクトされて届きません。そのぶんURLを知っている人は誰でもPOSTできる状態になるため、次章の合言葉が必要になります。
発行された/execで終わるURLを、送信元サービスのWebhook設定欄に登録します。コードを修正したあとは「デプロイを管理」から既存デプロイを編集して更新してください。「新しいデプロイ」を作るとURLが変わり、送信元の再設定が必要になります。
受信データを読み取ってシートに記録する
送られてきた中身は、引数eに入っています。形式によって読む場所が違うのが最初のつまずきポイントです。
e.postData.contents
リクエストボディの生の文字列。JSONで届く場合はここをJSON.parseする
e.postData.type
送られてきたContent-Type。application/json かどうかの判定に使う
e.parameter
クエリパラメータとフォーム形式(urlencoded)の項目。値は文字列
e.parameters
同じ名前のパラメータが複数ある場合の配列版
どちらの形式でも受けられるようにしておくと、送信元が増えても壊れません。生データもそのまま列に残しておくと、仕様の読み違いに後から気づけます。
function doPost(e) {
const body = parseBody_(e);
const sheet = SpreadsheetApp.getActive().getSheetByName("Webhookログ");
sheet.appendRow([
new Date(),
body.event || "", // イベント種別
body.id || "", // 送信元のイベントID
JSON.stringify(body), // 生データも残しておく
]);
return jsonResponse_({ ok: true });
}
// JSONでもフォーム形式でも受けられるようにする
function parseBody_(e) {
if (!e) return {};
const type = e.postData ? e.postData.type : "";
if (type && type.indexOf("application/json") === 0) {
try {
return JSON.parse(e.postData.contents);
} catch (err) {
console.error("JSONパース失敗: " + err);
return {};
}
}
// application/x-www-form-urlencoded は e.parameter に入る
return e.parameter || {};
}
function jsonResponse_(obj) {
return ContentService
.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}合言葉トークンでURLを守る
GASのWebアプリでは、受信リクエストのHTTPヘッダーを読み取れません。そのため、多くのサービスが用意している署名ヘッダーによる検証はそのままでは行えません。現実的な対策は、URLの末尾に自前の合言葉(トークン)を付け、doPostの冒頭で照合することです。
// 例: https://script.google.com/.../exec?token=xxxxx を送信元に登録しておく
function doPost(e) {
const expected = PropertiesService.getScriptProperties().getProperty("WEBHOOK_TOKEN");
const given = e && e.parameter ? e.parameter.token : "";
if (!expected || given !== expected) {
console.warn("不正なリクエストを拒否しました");
return jsonResponse_({ ok: false, error: "unauthorized" });
}
// ここから通常の処理
const body = parseBody_(e);
handleWebhook_(body);
return jsonResponse_({ ok: true });
}トークン自体はコードに直書きせず、PropertiesService(スクリプトに紐づく設定の保管場所)に入れます。URLごと秘密として扱う運用が前提になるため、送信元の設定画面以外にURLを貼らないでください。厳密な署名検証が要件なら、GASの前段にヘッダーを扱える実行環境を置く構成を検討します。
再送による二重処理を防ぐ
Webhookは「届いたかどうか怪しいときは再送する」のが基本動作です。応答が遅れただけでも同じ通知が2回届きます。イベントIDを控えておき、処理済みならスキップする形にします。IDが送られてこないサービスでは、ボディのハッシュ値を代わりのキーにできます。
function handleWebhook_(body) {
const eventId = body.id || Utilities.base64Encode(
Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
JSON.stringify(body),
Utilities.Charset.UTF_8
)
);
const lock = LockService.getScriptLock();
if (!lock.tryLock(10000)) {
console.warn("ロック取得に失敗。処理をスキップします");
return;
}
try {
const cache = CacheService.getScriptCache();
if (cache.get(eventId)) {
console.log("処理済みイベントのためスキップ: " + eventId);
return;
}
SpreadsheetApp.getActive()
.getSheetByName("Webhookログ")
.appendRow([new Date(), eventId, JSON.stringify(body), ""]);
cache.put(eventId, "done", 21600); // 6時間は再送を無視する
} finally {
lock.releaseLock();
}
}CacheServiceは最大6時間で消えるため、長期間の重複を厳密に防ぎたい場合は判定用の列やシートにIDを残してください。ほぼ同時に届いた再送にはLockServiceの排他ロックが効きます。
受信内容をAIで自動分類する
受信の仕組みができたら、内容の仕分けをAIに任せられます。ここで大事なのは受信とAI処理を分けることです。doPostの中でAIを呼ぶと応答に数秒かかり、送信元がタイムアウトして再送を始めます。doPostは記録して即返す、AI処理は時間主導トリガーで後追いする構成にします。
// 受信済みで未分類の行を、時間主導トリガーからまとめてAI処理する
function classifyPendingRows() {
const sheet = SpreadsheetApp.getActive().getSheetByName("Webhookログ");
const values = sheet.getDataRange().getValues();
for (let i = 1; i < values.length; i++) {
const bodyJson = values[i][2];
const classified = values[i][3];
if (!bodyJson || classified) continue; // 未受信 or 分類済みは飛ばす
const result = classifyWithClaude_(bodyJson);
sheet.getRange(i + 1, 4).setValue(result.category + " / " + result.summary);
}
}
function classifyWithClaude_(bodyJson) {
const apiKey = PropertiesService.getScriptProperties().getProperty("ANTHROPIC_API_KEY");
const payload = {
model: "claude-opus-5",
max_tokens: 1024,
system: "あなたは問い合わせ内容を分類する担当です。必ず指定のJSON形式だけを返します。",
messages: [
{
role: "user",
content: "次のWebhookデータを分類してください。\n" + bodyJson,
},
],
output_config: {
format: {
type: "json_schema",
schema: {
type: "object",
properties: {
category: { type: "string", enum: ["申込", "問い合わせ", "解約", "その他"] },
summary: { type: "string" },
},
required: ["category", "summary"],
additionalProperties: false,
},
},
},
};
const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", {
method: "post",
contentType: "application/json",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
payload: JSON.stringify(payload),
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) {
console.error("Claude APIエラー: " + res.getContentText());
return { category: "その他", summary: "分類に失敗しました" };
}
const json = JSON.parse(res.getContentText());
return JSON.parse(json.content[0].text);
}output_config.formatで受け取る形を指定しておくと、回答が必ずそのJSON構造になるためJSON.parseが失敗しません。分類先はenumで候補を固定しておくと、表記ゆれのない値だけが返ります。
あとは10分おきのトリガーを1つ作れば、受信から分類までが自動で回ります。
function createClassifyTrigger() {
ScriptApp.newTrigger("classifyPendingRows")
.timeBased()
.everyMinutes(10)
.create();
}実務での注意点
1. まず届いているかをログで確認する
動かないときは、Apps Scriptエディタの「実行数」画面でdoPostの実行履歴を見ます。履歴が無ければ、そもそもリクエストが届いていません。デプロイ設定か登録URLを疑います。
2. 例外を投げっぱなしにしない
処理中にエラーが出るとGASはエラー応答を返し、送信元は再送します。生データをシートに記録したあとの重い処理はtry-catchで囲み、失敗してもJSONの成功応答を返して後追いで直すほうが安全な場合があります。
3. リダイレクトを許可できない送信元がある
GASのWebアプリはリダイレクトを挟んで応答することがあります。送信元がリダイレクトを追わない仕様だと失敗扱いになるため、その場合はGASの前段に中継サーバーを置く構成を検討します。
4. 大量アクセスには向かない
GASには1日あたりの実行回数や実行時間の上限があります。1日数十〜数百件の業務連携なら十分ですが、秒間何十件も飛んでくる用途には向きません。件数が読めない場合は、受信ログの件数を毎日確認できるようにしておきます。
まとめ
GASのdoPostを使えば、サーバーを用意せずに外部サービスのWebhookを受け取れます。押さえるのは4点です。Webアプリとして「全員」に公開すること、e.postData.contentsとe.parameterを読み分けること、合言葉トークンで守ること、そして再送を前提に二重処理を防ぐことです。受信は軽く済ませてAI処理はトリガーに分ければ、通知を受けて仕分けるまでを丸ごと自動化できます。
よくある質問
doGet(e)はブラウザでURLを開いたときのようなGETリクエストを受ける関数、doPost(e)は外部サービスがデータを送りつけるPOSTリクエストを受ける関数です。Webhook(イベント発生時の自動通知)はほぼすべてPOSTで届くため、受け口はdoPostになります。1つのスクリプトに両方を書いても問題ありません。
リクエストボディはe.postData.contentsに文字列として入ります。JSONで送られてくる場合はJSON.parse(e.postData.contents)でオブジェクトに変換します。フォーム形式(application/x-www-form-urlencoded)で送られる場合はe.parameterに項目ごとに入るため、送信元の形式に合わせて読み分けます。
そのとおりです。GASのWebアプリを「全員(匿名ユーザーを含む)」でアクセス可能にすると、URLを知っている人は誰でもPOSTできます。URL自体を秘密として扱ったうえで、クエリパラメータに自前の合言葉(トークン)を付け、doPostの冒頭で照合するのが現実的な対策です。送信元がヘッダー認証しか使えない場合は、GASの前段に別のサーバーを置く構成も検討します。
多くのサービスは応答が遅い・エラーになった場合にWebhookを再送します。送信データに含まれるイベントID(またはボディのハッシュ値)を記録しておき、すでに処理済みならスキップする実装にしてください。加えて、LockServiceで排他ロックを取ると、ほぼ同時に届いた再送による二重登録も防げます。
件数が少なければ問題ありません。ただしAIの応答には数秒かかるため、送信元がタイムアウトすると再送が発生します。安全なのは、doPostでは受信内容をシートに記録して即座に応答を返し、AIでの分類や要約は時間主導トリガーで後からまとめて処理する分離型の構成です。
Apps Scriptエディタの「実行数」画面で、doPostの実行履歴とログを確認できます。表示されない場合はWebアプリのデプロイ設定(アクセスできるユーザー)か、送信元に登録したURLが古い可能性があります。コードを直したあとは、既存のデプロイを「デプロイを管理」から更新するとURLを変えずに反映できます。
関連するサービス・記事
外部サービス連携を
GASで実現する。
Webhook受信・API連携・受付フォーム・AIによる自動仕分けなど、サーバーを持たずに業務を自動化する開発をご相談いただけます。