Stripe changed a field name in their webhook payload. Your integration broke silently. You found out three days later when a customer complained. I built schema drift detection for HookScope so this never happens to you.
The Problem
Webhook providers change their payload structures. Sometimes they announce it, sometimes they do not. Either way, your code that does payload.data.object.amount breaks the moment that field moves or gets renamed.
The failure is usually silent. The webhook arrives, your handler throws an unhandled exception or produces a wrong result, and nothing tells you what changed. You find out from a bug report.
The Approach: Flatten and Diff
The schema scanner flattens every incoming JSON payload into a map of dot-notation paths to primitive types:
{
"data.object.id": "string",
"data.object.amount": "number",
"data.object.currency": "string",
"type": "string"
}Only the schema is stored — never the raw payload. This keeps storage light and avoids persisting sensitive customer data.
On the first request for a given event type, the flattened schema is saved as the baseline. On every subsequent request, a diff is computed across three dimensions:
- Added fields — new paths that did not exist before
- Removed fields — paths that disappeared
- Type changes — same path, different primitive type
Per Event Type Schemas
A single endpoint can receive many different event types from the same provider. payment_intent.succeeded and customer.subscription.deleted have completely different shapes — they cannot share a baseline schema.
Schemas are stored per event type using a nested structure:
Record<string, Record<string, string>>
// { "payment_intent.succeeded": { "data.amount": "number", ... } }When no event type is present in the payload, a __default__ key is used as fallback. The scanner detects the event type from the standard type or event fields that most providers include.
The Utility Functions
Three functions in schema.util.ts do the core work:
// flatten { data: { amount: 99 } }
// → { "data.amount": "number" }
function flattenSchema(obj: unknown, prefix = ''): Record<string, string>
// compute added, removed, and changed fields
function diffSchemas(
baseline: Record<string, string>,
incoming: Record<string, string>
): SchemaDiff
// true if no fields were added, removed, or changed
function isSchemaDiffEmpty(diff: SchemaDiff): booleanThese are pure functions with no side effects. They are easy to test and will be reused by the DTO generator I am planning to build next.
Alerts
When a drift is detected, the scanner emits an AlertDetectedEvent via the CQRS event bus. The alert carries strongly-typed metadata describing exactly what changed:
{
type: "schema_drift",
eventType: "payment_intent.succeeded",
added: ["data.object.payment_method_details"],
removed: [],
changed: [{ path: "data.object.amount", from: "string", to: "number" }]
}The scanner does not write to the database directly. It emits an event and moves on. A separate listener handles persistence and notification. This keeps the scanner fast and decoupled from everything downstream.
What I Learned
The per-event-type schema storage was not in the original design. I started with a single schema per endpoint and immediately hit the problem that different event types have different shapes. Adding the nesting was a ten-minute change but it required rethinking how the baseline is initialized on first request.
Storing only the flattened schema rather than the full payload was the right call from the start. It keeps the database small and means there is never a question about what data HookScope is retaining.
What's Next
The schema utility functions will feed into a DTO generator — given a baseline schema, automatically produce a TypeScript interface you can paste into your codebase. No more manually typing out webhook payload types.
Schema drift detection is live in HookScope. If a provider changes their payload structure, you will know before your users do.