Skip to content

Missing customer.deleted webhook handler — PII retained after Stripe customer deletion (GDPR compliance blocker) #38

@mafifi

Description

@mafifi

Summary

When Stripe fires a customer.deleted webhook event, the component silently drops it (falls through to the default case in processEvent()). There is no handleCustomerDeleted private mutation, so the customer record — including PII (email, name, metadata) — persists indefinitely in the component's customers table.

This prevents downstream applications from fulfilling GDPR Article 17 (Right to Erasure) and CCPA/CPRA deletion obligations.

Current behavior

  1. Customer is deleted in Stripe (via dashboard, API, or data subject request)
  2. Stripe sends customer.deleted webhook to the registered endpoint
  3. processEvent() in src/client/index.ts hits the default case:
    default:
      console.log(`ℹ️  Unhandled event type: ${event.type}`);
    
  4. The customer row in the component's customers table is never removed or anonymized
  5. Associated records in subscriptions, payments, and invoices tables also retain PII (stripeCustomerId, metadata with potential user
    identifiers)

Expected behavior

A customer.deleted event should:

  1. Remove or anonymize the customer record in the customers table
  2. Optionally cascade to associated records (subscriptions, payments, invoices) — at minimum clearing PII fields while retaining financial records
    needed for accounting

Why this is urgent

Under GDPR Article 17, data controllers must erase personal data "without undue delay" (within 30 days) when requested. Because the customers table lives inside the component's namespace, consuming applications cannot directly delete these records — they depend on the component exposing a private mutation for this.

The workaround of adding a custom event handler via registerRoutes({ events: { 'customer.deleted': ... } }) is insufficient because the handler cannot access the component's internal tables to delete the customer row.

Proposed fix

1. Add handleCustomerDeleted to src/component/private.ts

export const handleCustomerDeleted = mutation({
  args: {
    stripeCustomerId: v.string(),
  },
  returns: v.null(),
  handler: async (ctx, args) => {
    const customer = await ctx.db
      .query("customers")
      .withIndex("by_stripe_customer_id", (q) =>
        q.eq("stripeCustomerId", args.stripeCustomerId),
      )
      .unique();

    if (customer) {
      await ctx.db.delete(customer._id);
    }

    return null;
  },
});

2. Add the case to processEvent() in src/client/index.ts

case "customer.deleted": {
  const customer = event.data.object as StripeSDK.Customer;
  await ctx.runMutation(component.private.handleCustomerDeleted, {
    stripeCustomerId: customer.id,
  });
  break;
}

Environment

  • @convex-dev/stripe version: 0.1.3
  • stripe SDK: ^20.0.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions