DEV Community

Kuba
Kuba

Posted on

How Stripe Silently Broke My Production App (And How I Built a Fix)

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:

  1. Something breaks in production
  2. You check logs — no clear error
  3. You manually inspect the last few webhook payloads
  4. You realize a field changed, moved, or disappeared
  5. 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>);
}
Enter fullscreen mode Exit fullscreen mode

For example, this Stripe payload:

{
  "type": "payment_intent.succeeded",
  "data": {
    "object": {
      "id": "pi_123",
      "amount": 2000,
      "currency": "usd"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Becomes:

{
  "type": "string",
  "data.object.id": "string",
  "data.object.amount": "number",
  "data.object.currency": "string"
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)