Claude CodeでGAS doPostを書く — Webhook受信とデプロイ設計
GASのWebアプリでdoPostを書き、外部サービスのWebhookを受ける手順です。トークンの置き場所、デプロイURLの更新、公開範囲の選び方を、Claude Codeへの指示とあわせて示します。
GASのWebアプリにdoPost(e)関数を置くと、外部サービスからのHTTP POSTを受け取れます。Webhookの受け口としては、サーバーを借りずにスプレッドシートへ書き込めるのが利点です。一方で、公開範囲の設定、デプロイURLの扱い、シークレットの置き場所を誤ると、誰でも書き込める受け口になります。この記事では、受信コードをClaude Codeに書かせる前提で、その3点を先に決めておく手順をまとめます。
claspでローカル編集してpushする基本はClaude CodeでGAS開発を進める手順にあります。ここではWebアプリ固有の部分に絞ります。
doPostが受け取るものと、受け取れないもの
POSTリクエストが届くと、Apps ScriptはdoPostを実行します。GETならdoGetです。どちらも引数eにリクエストの情報が入ります。Webアプリとして公開するには、この関数がHtmlOutputかTextOutputを返す必要があります。Webhookの受け口なら、ContentServiceでJSONやテキストを返す形になります。
eの主な項目は次のとおりです。
| 項目 | 中身 |
|---|---|
e.postData.contents | 中身POST本文の文字列 |
e.postData.type | 中身本文のMIMEタイプ(例: text/csv) |
e.parameter | 中身クエリ文字列のキーと値。同じキーが複数あれば最初の値だけ |
e.parameters | 中身同じキーの値を配列で持つもの |
e.pathInfo | 中身/execの後ろのパス |
この表で注目したいのは、リクエストヘッダの項目が無いことです。項目表にはクエリ、本文、MIMEタイプ、パスだけが載っています。つまりX-Signatureのようなヘッダで署名を送ってくる送信元は、ヘッダの値をdoPostで読む前提の設計にできません。署名をヘッダだけで渡す送信元を受けるなら、別の中継を挟む判断が要ります。送信元のURLにクエリを付けられるなら、そこへ共有トークンを載せる方式が現実的です。
もう一点、cとsidはシステム予約のパラメータ名です。URLのパラメータにもPOST本文にも使わないよう案内されており、使うとHTTP 405と「Sorry, the file you have requested does not exist.」が返ることがあります。送信元がたまたまsidというフィールドを持つ場合は、受け側で名前を変えられないか確かめてください。
シークレットはPropertiesServiceに置く
共有トークンはコードに書かず、スクリプトプロパティに置きます。PropertiesService.getScriptProperties()で取れる保管場所は、スクリプト、Webアプリ、アドオンの全ユーザーで共有されます。用途の例として、外部データベースのユーザー名とパスワードのような「アプリ全体の設定値」を挙げています。
値の入れ方は2通りあります。
- プロジェクトの設定ページから手で追加する(最大50件。それを超える分はコードで
setPropertiesを呼んで入れる) - コード内で
setPropertyを呼ぶ
トークンは前者のほうが、コードに値が残りません。コードでsetPropertyを呼ぶと、その呼び出しを書いた行にトークンの文字列が残ります。設定ページから入れる場合、変数を参照する書き方はできません。値はそのまま文字列で貼ります。
保存できるのは文字列だけで、文字列以外は自動で変換されます。1つの値は9KB、1つの保管場所の合計は500KBまでです。後で重複排除のためにイベントIDを溜めたくなっても、プロパティは長期の台帳には向きません。台帳はスプレッドシートに置きます。
受信コードの最小形
次のコードは、トークンを照合してから本文を読み、受信シートに1行追加します。WEBHOOK_TOKENは設定ページで入れておく前提です。
function doPost(e) {
const props = PropertiesService.getScriptProperties();
const expected = props.getProperty('WEBHOOK_TOKEN');
const given = e && e.parameter ? e.parameter.token : null;
if (!expected || given !== expected) {
return json_({ ok: false, error: 'unauthorized' });
}
let payload;
try {
payload = JSON.parse(e.postData.contents);
} catch (err) {
return json_({ ok: false, error: 'invalid json' });
}
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('inbox');
const ids = sheet.getRange('A:A').getValues().flat();
if (!ids.includes(payload.id)) {
sheet.appendRow([payload.id, new Date(), e.postData.contents]);
}
} finally {
lock.releaseLock();
}
return json_({ ok: true });
}
function json_(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}expectedが空のときも拒否している点が要です。プロパティの入れ忘れでトークン未設定のまま動くと、givenも未指定なら一致して素通りになる書き方は避けます。
LockServiceは、同時に届いた2つのリクエストが同じ領域を書き換えて衝突するのを防ぐ仕組みです。例では、採番のために読んで書くプロパティを守る形で使われています。ここでは「IDの存在確認から追記まで」を1つの区間にして、同じイベントが2回届いても1行しか残らないようにしました。重複配信の扱いを深く設計したい場合は、Stripe webhookの冪等受信の考え方がそのまま使えます。
デプロイとURL、公開範囲を決める
コードが書けても、受け口として使えるのは「デプロイ」したあとです。ここで3つの選択が出ます。
テスト用URLと本番用URL
/dev で終わるURL
編集権限を持つユーザーだけがアクセスできます。常に最後に保存したコードで動くため、開発中の確認専用です。外部サービスのWebhook送信先には登録できません。
/exec で終わるURL
バージョンに紐づいたデプロイのURLです。公開用には、このバージョン付きデプロイを使うよう案内されています。
公開範囲(access)と実行ユーザー(executeAs)
マニフェスト(appsscript.json)のwebappで、誰が呼べるかと誰の権限で動くかを決めます。
| 設定値 | 意味 |
|---|---|
access: MYSELF | 意味デプロイしたユーザーだけ |
access: DOMAIN | 意味同じドメインのユーザーだけ |
access: ANYONE | 意味ログイン済みの任意のユーザー |
access: ANYONE_ANONYMOUS | 意味ログインしていなくても誰でも |
executeAs: USER_DEPLOYING | 意味デプロイしたユーザーとして実行 |
executeAs: USER_ACCESSING | 意味アクセスしたユーザーとして実行 |
ログインしないで届く外部サービスのWebhookを受けるには、定義上ANYONE_ANONYMOUSが対象になります。そのうえでexecuteAsはUSER_DEPLOYINGにします。アクセス元は匿名なので、USER_ACCESSINGではスプレッドシートへ書く権限の持ち主がいません。
{
"timeZone": "Asia/Tokyo",
"runtimeVersion": "V8",
"webapp": {
"executeAs": "USER_DEPLOYING",
"access": "ANYONE_ANONYMOUS"
}
}この設定では、URLとトークンを知っている相手なら誰でも書き込めます。だからこそ、トークンの照合を省かないことと、前節の「トークン未設定は拒否」が効きます。なおUSER_DEPLOYINGで動かす場合は、OAuthトークンの扱いにも注意が要ります。ScriptApp.getOAuthTokenで得たトークンをクライアントへ返さないでください。
コードを直したあとにURLを変えない
送信元に登録したURLを、コードを直すたびに変えるのは避けたいところです。手順は次の流れです。
同じURLのままコードを更新する
- 1
新しいバージョンを作る
修正したコードから、新しいバージョンを作成します。バージョンは作成後に変更できないスナップショットです。
- 2
既存のデプロイを編集する
「デプロイ」から「デプロイを管理」を開き、有効なデプロイを選んで編集します。
- 3
参照するバージョンを新しいものに替える
デプロイ先を新しいバージョンに替えて、デプロイを押します。URLとデプロイIDは変わりません。
「新しいデプロイ」を押すと、別のURLのデプロイが増えます。古いURLを送信元に登録したままだと、修正前のコードが動き続けます。バージョン付きデプロイは削除できず、アーカイブだけできます。増えた古いデプロイは、不要になった時点でアーカイブします。
Claude Codeへの頼み方
ここまでの決定事項を、Claude Codeが毎回守れるようCLAUDE.mdに書いておきます。指針は、具体的に確かめられる書き方にすることです。「安全に書いて」ではなく、検証できる文にします。ファイルは200行以内を目安にします。
# GAS Webhook 受信プロジェクト
- doPost は先頭で PropertiesService の WEBHOOK_TOKEN と e.parameter.token を照合する。
トークン未設定のときも拒否する
- トークンやAPIキーの値をコード・コメント・テストに書かない。値は私が設定ページで入れる
- 共有領域を書き換える処理は LockService.getScriptLock() で囲む
- appsscript.json の webapp は executeAs: USER_DEPLOYING, access: ANYONE_ANONYMOUS を維持する
- e.postData.contents は JSON.parse を try/catch で囲む
- リクエストヘッダは doPost から読めない前提で書くトークンの実物をClaude Codeに見せない工夫も入れておきます。権限設定で.envやsecrets/の読み取りを拒否できます。ファイルツールの読み取りを止めるには、Readの拒否ルールを書きます。
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./secrets/**)"
]
}
}流れとしては、Claude Codeがコードを書き、あなたが設定ページにトークンを貼り、動作確認だけをターミナルで行います。トークンはシェルの環境変数から渡せば、curlのコマンド行にも本文にも値を書かずに済みます。環境変数を設定する側の行は履歴に残りうるので、設定方法は使っているシェルで確かめてください。
curl -L \
"https://script.google.com/macros/s/<デプロイID>/exec?token=$WEBHOOK_TOKEN" \
-H "Content-Type: application/json" \
-d '{"id":"evt_001","type":"test"}'/execは別のURLへ302でリダイレクトし、移動先が応答を返します。-Lはその移動を追う指定で、-dを付けているので最初のリクエストはPOSTになります。-X POSTを明示すると移動先にもPOSTが送られ、{"ok":true}でなくエラーのHTMLが返りやすくなるため、付けません。
{"ok":true}が返り、inboxシートに1行増えれば成功です。同じコマンドをもう一度実行して行が増えなければ、重複の排除も効いています。トークンを外すか書き換えて実行し、unauthorizedが返ることも確かめます。テストの手順をClaude Codeに任せるなら、実行するコマンドをCLAUDE.mdに1行書いておくと、毎回同じ確認が回ります。
よくあるつまずき
落とし穴
送信元が失敗扱いにして再送が止まらない
/execは302でリダイレクトします。送信元がリダイレクトを追わず、2xx以外を失敗とみなす実装だと、doPostが動いていても再送が続きます。再送されても、IDの照合で重複行は増えません。送信元の設定にリダイレクトを追う項目があるか、先に確かめます。
落とし穴
すべてのリクエストが unauthorized になる
WEBHOOK_TOKENをスクリプトプロパティに入れ忘れると、コードは期待値が空のため全件を拒否します。コードの不具合に見えるので、まずプロパティの名前と値の貼り間違い(前後の空白を含む)を見ます。
落とし穴
getActiveSpreadsheet が null で書き込めない
SpreadsheetApp.getActiveSpreadsheet()は、スプレッドシートに紐づいていない単独のスクリプトではnullを返します。受信シートを使うなら、シートから開いたスクリプトに置くか、SpreadsheetApp.openByIdでIDを指定します。
もう一つ、処理が長くなる場合は実行時間の上限に当たります。スクリプトの実行は1回6分まで、同時実行はユーザーあたり30件、スクリプトあたり1,000件が上限です。ただし上限値はお知らせなしに変更されることがあると明記されています。Webhookの受け口では、受信と記録だけをdoPostで済ませ、重い処理は別に切り出すのが無難です。6分を超える処理の分け方はClaudeでGASの6分制限を分割実行に直す依頼の出し方にあります。
受け取った内容を使ってClaudeに分類させたいときは、行の追加後にAPIを呼ぶ形になります。呼び出しの実装はGASからClaude APIをUrlFetchAppで呼ぶが詳しいです。ローカルのサーバーでWebhookを試したい場合は、ngrokプラグインで公開して受ける方法も選べます。
まとめ
受け口を作る前に、3つを決めます。トークンは設定ページからスクリプトプロパティへ、公開範囲はWebhookならANYONE_ANONYMOUSとUSER_DEPLOYING、更新は新バージョンを作ってデプロイを編集してURLを保つ。この3点をCLAUDE.mdに書いておけば、Claude Codeが書き直すたびに崩れることはありません。署名ヘッダを読めない点だけは、doPostの仕様として最初に受け入れておく必要があります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。
- developers.google.com/apps-script/guides/web
- developers.google.com/apps-script/guides/properties
- developers.google.com/apps-script/concepts/deployments
- developers.google.com/apps-script/manifest/web-app-api-executable
- developers.google.com/apps-script/guides/services/quotas
- developers.google.com/apps-script/reference/lock/lock
- code.claude.com/docs/en/memory
- code.claude.com/docs/en/permissions