How Stripe Silently Broke My Production App (And How I Built a Fix)
It was a Tuesday afternoon. A client called — orders weren't going through.
I checked the logs. Stripe webhooks were coming in, the handler was running, but something was off. After an hour of debugging I found it: Stripe had quietly changed the structure of their payment_intent.succeeded payload. A field I was relying on had moved. No announcement. No deprecation warning. Just a silent change that crashed my production handler.
I fixed it in 20 minutes. But I lost 3 hours total — debugging, deploying, explaining to the client.
That's when I started thinking: why doesn't anyone alert me when a webhook payload changes?
The Problem with Webhooks
Webhooks are deceptively simple. Provider sends a POST request, you handle it. Easy.
Until it isn't.
The real problem is that you don't control the schema. Stripe, GitHub, Shopify — they can change their payload structure at any time. Sometimes they announce it. Often they don't. And you only find out when your handler crashes.
Here's what a typical debugging session looks like:
- Something breaks in production
- You check logs — no clear error
- You manually inspect the last few webhook payloads
- You realize a field changed, moved, or disappeared
- You fix it, deploy, explain to stakeholders
This happens more than you'd think. Stripe changed their payload structure multiple times last year.
The Solution: Schema Drift Detection
The idea is simple: store a flattened schema for every webhook endpoint, and compare it against every new incoming request.
If the schema changes — alert the developer immediately, before it crashes production.
Step 1: Flatten the payload
Instead of storing the raw JSON, we flatten it into a map of dot-notation paths to primitive types:
function flattenSchema(
obj: Record<string, unknown>,
prefix = ''
): Record<string, string> {
return Object.entries(obj).reduce((acc, [key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (Array.isArray(value)) {
acc[fullKey] = 'array';
if (value.length > 0 && typeof value[0] === 'object' && value[0] !== null) {
Object.assign(acc, flattenSchema(value[0] as Record<string, unknown>, `${fullKey}[]`));
}
} else if (value !== null && typeof value === 'object') {
Object.assign(acc, flattenSchema(value as Record<string, unknown>, fullKey));
} else {
acc[fullKey] = value === null ? 'null' : typeof value;
}
return acc;
}, {} as Record<string, string>);
}
For example, this Stripe payload:
{
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_123",
"amount": 2000,
"currency": "usd"
}
}
}
Becomes:
{
"type": "string",
"data.object.id": "string",
"data.object.amount": "number",
"data.object.currency": "string"
}
This is our baseline schema. Lightweight, easy to store, easy to compare.
Step 2: Diff the schemas
When a new webhook arrives, we flatten its payload and compare it against the stored baseline:
interface SchemaDiff {
added: string[];
removed: string[];
typeChanged: { field: string; from: string; to: string }[];
}
function diffSchemas(
previous: Record<string, string>,
current: Record<string, string>
): SchemaDiff {
const added = Object.keys(current).filter(k => !(k in previous));
const removed = Object.keys(previous).filter(k => !(k in current));
const typeChanged = Object.keys(current)
.filter(k => k in previous && previous[k] !== current[k])
.map(k => ({ field: k, from: previous[k], to: current[k] }));
return { added, removed, typeChanged };
}
function isSchemaDiffEmpty(diff: SchemaDiff): boolean {
return (
diff.added.length === 0 &&
diff.removed.length === 0 &&
diff.typeChanged.length === 0
);
}
Step 3: Handle multiple event types
Here's a detail that matters: most providers send different event types to the same endpoint.
Stripe sends payment_intent.succeeded, customer.created, invoice.paid — all to the same webhook URL. You can't compare schemas across different event types or you'll get false positives on every request.
The solution: store schemas per endpoint AND per event type.
// schemas stored as: Record<eventType, Record<fieldPath, type>>
{
"payment_intent.succeeded": {
"data.object.amount": "number",
"data.object.currency": "string"
},
"customer.created": {
"data.object.email": "string",
"data.object.name": "string"
},
"__default__": {
// for webhooks without an event type field
}
}
Step 4: Wire it up
In NestJS, this lives in a scanner service that runs as a BullMQ background job after every incoming webhook:
@Injectable()
export class SchemaDriftScanner implements ScanServicePort {
async scan(context: ScanContext): Promise<void> {
if (!context.payload) return;
const flattenedPayload = flattenSchema(context.payload);
const endpoint = await this.endpointRepository.findById(context.endpointId);
if (!endpoint) return;
const eventTypeKey = context.eventType ?? DEFAULT_EVENT_TYPE_KEY;
const targetSchema = endpoint.schemas?.[eventTypeKey];
if (!targetSchema) {
// First request — save as baseline, no alert
endpoint.saveSchema(flattenedPayload);
await this.endpointRepository.save(endpoint);
return;
}
const diff = diffSchemas(targetSchema, flattenedPayload);
if (!isSchemaDiffEmpty(diff)) {
// Schema changed — emit alert event
this.eventBus.publish(new AlertDetectedEvent({
type: 'schema_drift',
endpointId: context.endpointId,
userId: context.userId,
eventType: context.eventType,
metadata: AlertMetadata.schemaDrift(diff),
}));
// Update baseline
endpoint.saveSchema(flattenedPayload);
await this.endpointRepository.save(endpoint);
}
}
}
The full pipeline looks like this:
POST /webhooks/:endpointId
→ verify signature
→ save request to DB
→ emit WebSocket event (live inspector)
→ enqueue ScanWebhookJob
ScanWebhookJob (BullMQ)
→ SchemaDriftScanner.scan()
→ DuplicateScanner.scan()
AlertDetectedEvent
→ save to DB
→ send email/Slack/Discord notification
Taking It Further: Auto-Generated Types
Detecting drift is useful. But what if the tool could also fix it for you?
When a schema drift alert fires, we use the Claude API to automatically generate updated TypeScript, Zod, Go, and Python types from the new schema:
// Input: flattened schema
{
"data.object.amount": "number",
"data.object.currency": "string",
"data.object.total": "number" // new field
}
// Output: updated NestJS DTO
export class DataObjectDto {
@IsNumber()
amount: number;
@IsString()
currency: string;
@IsNumber()
total: number; // automatically added
}
No manual work. Schema changes, types update automatically.
The Result
After building this into HookScope, I tested it against my own Stripe integration. Within minutes of changing a test payload structure, I got an alert with the exact diff — and an auto-generated updated DTO ready to copy into my codebase.
That 3-hour debugging session? It would have been a 5-minute fix.
Key Takeaways
- Flatten your schemas — dot-notation paths make diffing trivial
- Store per event type — or you'll get false positives on every request
- Run async — never block the webhook response for scanning
- Fail safe — if scanning fails, the webhook should still be processed
-
Default key for unknown event types —
__default__as a fallback
The full implementation is live at app.hookscope.dev if you want to see it in action.
If you've ever been burned by a silent webhook schema change, I'd love to hear about it in the comments.
Top comments (0)