Free & open source — no account required
Free & open source — no account required
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 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.
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" }
}
}
}
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');
}
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 } });
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;
}
}
}
// 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;
}
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.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.never in the switch. Add an explicit default case to log and ignore unknown events gracefully.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.
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.