Valibotのv.strictObjectがWebhookの実payloadの未知キーを拒否する原因と、v.objectでの解決が分かります。
どこで起きる話かというと、TypeScript + Valibotで外部サービスのWebhookやREST応答を受けている構成です。受信側のスキーマをどう直すか、後段だけ沈黙する症状をどう切り分けるか、そして両者をどう使い分けるかを記録しておきます。
発生した症状
接続テストが失敗する
Webhook受信を登録する際の配送元の接続テストが失敗しました。受信側のログに出ていたのは次の1行です。
ValiError: Invalid key: Expected never but received "type"
未知キー1つで検証が失敗し、受信は500になります。
受信は200なのに、後段の処理だけ全滅する
より気づきにくい方の症状です。受信 (200で受けてDBに記録) と、その後のREST応答を使った処理を別にしていたところ、REST応答にも同じ余分キーが含まれており、後段の処理が全件失敗していました。
受信と処理を2段構成にしているため、配送元のダッシュボードはgreenのまま、処理だけ沈黙します。外部サービス側の監視だけしていると発見が遅れます。
実行環境
- TypeScript 5.9.3 / valibot 1.4.2
- 実行環境: Cloudflare Workers (Next.jsアプリ)
- 外部SaaSのWebhook受信とREST応答を検証する構成
- 検証日: 2026-09-01〜2026-09-02 (本稿の実測値はすべてこの時点のもの)
strictObjectは未知キーを拒否し、実payloadは未知キーを含む
v.strictObject は、定義していないキーが1つでも含まれると検証を失敗させます (valibot 1.xのAPI仕様)。一方、外部サービスの実payloadは、ドキュメントのスキーマ図に載っていないキーを含むことがあります。今回の受信payloadには、エンベロープの定義にない type・app_id などが含まれていました。
なぜ受信側にstrictObjectを書いていたかというと、もともと自分たちで送出するpayloadの契約にはstrictObjectを使う方針を持っていて、それを受信側にもあてはめたためです。送り出すデータの鍵は自分たちが握っているので strict で正しいのですが、受信するデータの鍵は送り主が握っています。受信側では、ドキュメントどおりに厳しく検証するほど実データの未知キーで壊れやすくなります。
原因の切り分けとdeploy直後の罠
後段だけ全滅している状態では、まず「Webhookが届いていない」可能性を切る必要がありました。受信は200でDBにも受信記録が残っていたため、ここはすぐ切れました。残る疑いは後段の処理側で、合成イベントを1件注入して処理側のログをtailし、ValiErrorの一文を拾うのが一番早かったです。
もうひとつ、修正deploy直後に罠があります。Cloudflare Workersのデプロイ伝播にはラグがあり、修正後も数十秒は旧コードが応答することがありました (2026-09-01実測)。「直したはずなのに直らない」と誤診しないため、deploy後は待ってから再検証するようにしています。
受信側のスキーマをv.objectへ置き換える
修正は、外部payloadを受ける側のparseをstrictObjectから v.object に変えることだけです。v.objectは定義外のキーを破棄して通します (valibot 1.xのAPI仕様)。
修正後の受信エンベロープのスキーマです。
import * as v from "valibot";
// 実payloadは type / app_id 等の追加キーを含むため strict にしない。
// v.objectは未知キーを捨てて通す
export const webhookEnvelopeSchema = v.object({
topic: v.picklist(WEBHOOK_TOPICS),
id: v.pipe(v.string(), v.minLength(1)),
created_at: v.optional(v.number()),
data: v.unknown(), // 中身はtopicごとに別スキーマでparseする
});
WEBHOOK_TOPICS は受信側で購読を登録したtopic名の配列です。この記事の本筋から外れるため、定義は省略します。REST応答側のスキーマも、同じ方針でstrictObjectをv.objectに置き換えました。
v.objectが未知キーを黙って捨てるのは、監査や障害調査の観点では損です。そのため受信時に生payloadをそのままDBに記録し、後段の処理ではparse後の値を使う2段構成にしています。後から「捨てられたキーに何が入っていたか」を確認できます。
解決を確認した方法
修正が効いていることの確認は、次の2点で行いました。
- 配送元の接続テストを再送する (修正前は500、修正後は200)
- 後段の処理を手動で1件実行する (修正前は全件失敗、修正後は成功)
strictObjectとv.objectの使い分け
基準は「そのスキーマの鍵を誰が握っているか」です。
| 対象 | 使うもの | 理由 |
|---|---|---|
| 自分たちが送出するpayload | v.strictObject |
鍵を握っているのは自分たち。余分キーは実装バグなので落ちて良い |
| 外部サービスから受信するpayload | v.object |
仕様書にないキーが実payloadに増える。未知キーで落とすと実データが全滅する |
自分たちの場合、送出側 (社内で定義したスキーマ) は今もstrictObjectのままです。未知キーが紛れ込んだら実装バグなので、落ちて検知したい。この事故で変えたのは受信側だけです。
LLMのJSON出力のように「送り主が指示に従わない前提」の入力には、許可リスト (v.picklist) で検証する別の設計が要ります。この記事では扱いません。
参考
- v.object — Valibot API
- v.strictObject — Valibot API
- v.picklist — Valibot API
- Road to contributor of Valibot — ValibotにISBN validationを追加するまで (v.objectが未知キーを捨てる仕組みの内部解説)
- LLMのツール呼び出しを型で閉じ込める: Valibot許可リスト検証とstrictObjectの罠 (Zenn)
この「実測で事故の正体を確定する」流れはLLM選びにもそのまま使える。LLMはベンチマークで選ばない — 自業務の実タスクで60分比較する手順で、同じ実測主義のモデル比較をまとめている。