Free & open source — no account required

v1.2.5-PRICING-19
Web & Frontend • Engineering Documentation

Stripe Webhook Mastery: Type-Safe Payment Handling

This technical guide provides an in-depth analysis of the json to stripe webhook type engine, best practices for implementation, and data security standards.

Stripe Webhook TypeScript Types: Handling Events with Full Type Safety

Stripe webhooks deliver JSON payloads for dozens of different event types — payment_intent.succeeded, customer.subscription.updated, invoice.payment_failed. Without types, you're accessing event.data.object as any and hoping the fields you need are there. TypeScript types let you handle each event with confidence, and a discriminated union makes narrowing between event types exhaustive and compile-time safe.

The Stripe Webhook Payload Structure

Every Stripe webhook event shares a common envelope. The data.object field changes shape based on type:

{
  "id": "evt_1NxK4B2eZvKYlo2C8qGh1234",
  "object": "event",
  "api_version": "2023-10-16",
  "created": 1709123456,
  "type": "payment_intent.succeeded",
  "livemode": false,
  "data": {
    "object": {
      "id": "pi_1NxK4A2eZvKYlo2Cabcdefgh",
      "object": "payment_intent",
      "amount": 2999,
      "currency": "usd",
      "status": "succeeded",
      "customer": "cus_OqTkXyz123",
      "metadata": { "order_id": "ord_789" }
    }
  }
}

Using the Official stripe-node Types

Before writing custom types, check if stripe npm package already types what you need. For most common events it does:

import Stripe from 'stripe';
import { headers } from 'next/headers';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// POST /api/webhooks/stripe
export async function POST(req: Request) {
  const body = await req.text();
  const sig  = headers().get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // event.type is typed as a string union of all Stripe event types
  switch (event.type) {
    case 'payment_intent.succeeded': {
      // event.data.object is typed as Stripe.PaymentIntent ✅
      const intent = event.data.object;
      await handlePaymentSucceeded(intent);
      break;
    }
    case 'customer.subscription.deleted': {
      // event.data.object is typed as Stripe.Subscription ✅
      const sub = event.data.object;
      await handleSubscriptionCancelled(sub);
      break;
    }
  }

  return new Response('ok');
}

Custom Types When stripe-node Isn't Available

In edge runtimes, Deno, or when you want to avoid the full stripe package, define the subset of event types you actually handle:

// The common envelope for all Stripe events
interface StripeEventBase {
  id:          string;
  object:      'event';
  api_version: string;
  created:     number;   // Unix timestamp
  livemode:    boolean;
  type:        string;
}

// PaymentIntent shape (fields you actually use)
interface StripePaymentIntent {
  id:       string;
  object:   'payment_intent';
  amount:   number;          // in smallest currency unit
  currency: string;          // lowercase ISO 4217
  status:   'requires_payment_method' | 'requires_confirmation' |
            'requires_action' | 'processing' | 'succeeded' |
            'canceled';
  customer: string | null;
  metadata: Record<string, string>;
}

// Subscription shape
interface StripeSubscription {
  id:                 string;
  object:             'subscription';
  customer:           string;
  status:             'active' | 'past_due' | 'canceled' | 'trialing' | 'incomplete';
  current_period_end: number;
  items: {
    data: Array<{
      id:    string;
      price: { id: string; product: string; unit_amount: number | null };
    }>;
  };
}

// Discriminated union — add only the event types you handle
type StripeWebhookEvent =
  | (StripeEventBase & { type: 'payment_intent.succeeded';        data: { object: StripePaymentIntent } })
  | (StripeEventBase & { type: 'payment_intent.payment_failed';   data: { object: StripePaymentIntent } })
  | (StripeEventBase & { type: 'customer.subscription.created';   data: { object: StripeSubscription } })
  | (StripeEventBase & { type: 'customer.subscription.updated';   data: { object: StripeSubscription } })
  | (StripeEventBase & { type: 'customer.subscription.deleted';   data: { object: StripeSubscription } });

Type-Safe Event Handler

async function handleStripeEvent(event: StripeWebhookEvent): Promise<void> {
  switch (event.type) {
    case 'payment_intent.succeeded': {
      const { amount, currency, customer, metadata } = event.data.object;
      // All fields are typed — TypeScript knows the shape of StripePaymentIntent
      await fulfillOrder({
        orderId:    metadata['order_id'],
        amountPaid: amount,
        currency,
        customerId: customer ?? 'guest',
      });
      break;
    }

    case 'customer.subscription.deleted': {
      const { customer, status } = event.data.object;
      // TypeScript knows this is StripeSubscription
      await revokeAccess(customer);
      break;
    }

    default: {
      // Narrow the union — if you add a new case without handling it,
      // TypeScript won't error here, but you can make it exhaustive:
      const _exhaustive: never = event; // ← only works if ALL union members are handled
      break;
    }
  }
}

Verifying Signatures in Different Runtimes

// Next.js App Router (Node.js)
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret);

// Hono on Cloudflare Workers (no Node.js crypto)
import { app } from 'hono';
app.post('/webhooks/stripe', async (c) => {
  const body = await c.req.text();
  const sig  = c.req.header('stripe-signature') ?? '';
  // Use the Stripe edge-compatible verifier:
  const event = await stripe.webhooks.constructEventAsync(body, sig, webhookSecret);
  // ...
});

// Raw HMAC verification (no stripe package)
async function verifyStripeSignature(
  payload: string,
  sigHeader: string,
  secret: string
): Promise<boolean> {
  const [, ts, , v1] = sigHeader.split(/[=,]/);
  const signedPayload = `${ts}.${payload}`;
  const key = await crypto.subtle.importKey(
    'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
  );
  const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(signedPayload));
  const expected = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2,'0')).join('');
  return expected === v1;
}

Common Pitfalls

  • Reading req.body instead of raw bytes: Stripe signature verification requires the raw request body as a string — before any JSON parsing. Parsing it first corrupts the signature check. Always read the raw body first, then parse.
  • Not handling idempotency: Stripe may deliver the same event more than once. Store processed event IDs and skip duplicates — your type system can't protect against this.
  • Assuming event.data.object is the current state: For *.updated events, data.object is the state after the update, and data.previous_attributes contains what changed. Don't fetch the resource again from the API to check — use what's in the payload.
  • Missing event types in the union: If Stripe sends an event type not in your discriminated union, TypeScript sees it as never in the switch. Add an explicit default case to log and ignore unknown events gracefully.

FAQ

Q: Should I use the official stripe-node types or define my own?
A: Use the official types from the stripe npm package when running on Node.js — they're comprehensive and maintained. Define custom types only for edge runtimes where you can't import the full package.

Q: How do I access the previous value of a field in an update event?
A: event.data.previous_attributes contains only the changed fields. Its TypeScript type is Partial<DataObject>. Cast it or use optional chaining to access specific fields.

Q: My webhook handler needs to be fast — should I process synchronously?
A: Return 200 to Stripe immediately, then process asynchronously. If your handler takes longer than 30 seconds, Stripe marks the delivery as failed and retries. Queue the event (Redis, a database, a background job) and return success right away.

Q: How do I test webhook handlers locally without exposing a public URL?
A: Use the Stripe CLI: stripe listen --forward-to localhost:3000/api/webhooks/stripe. It proxies real Stripe events to your local server and handles signature generation automatically.

Developer FAQ

Is the processing local-only?

Absolutely. TypeMorph operates entirely within your browser's sandbox. We use Web Workers for high-performance computation without ever transmitting your JSON, SQL, or API data to a remote server.

Can I use this for enterprise projects?

Yes. The tool is designed for professional software engineers who require GDPR compliance and data privacy. It is trusted by developers at top-tier startups and financial institutions.