0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ValibotのstrictObjectでWebhook検証、実payload余分キーで失敗、v.objectへ修正して許容

0
Last updated at Posted at 2026-09-02

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点で行いました。

  1. 配送元の接続テストを再送する (修正前は500、修正後は200)
  2. 後段の処理を手動で1件実行する (修正前は全件失敗、修正後は成功)

strictObjectとv.objectの使い分け

基準は「そのスキーマの鍵を誰が握っているか」です。

対象 使うもの 理由
自分たちが送出するpayload v.strictObject 鍵を握っているのは自分たち。余分キーは実装バグなので落ちて良い
外部サービスから受信するpayload v.object 仕様書にないキーが実payloadに増える。未知キーで落とすと実データが全滅する

自分たちの場合、送出側 (社内で定義したスキーマ) は今もstrictObjectのままです。未知キーが紛れ込んだら実装バグなので、落ちて検知したい。この事故で変えたのは受信側だけです。

LLMのJSON出力のように「送り主が指示に従わない前提」の入力には、許可リスト (v.picklist) で検証する別の設計が要ります。この記事では扱いません。

参考

この「実測で事故の正体を確定する」流れはLLM選びにもそのまま使える。LLMはベンチマークで選ばない — 自業務の実タスクで60分比較する手順で、同じ実測主義のモデル比較をまとめている。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?