-
-
Notifications
You must be signed in to change notification settings - Fork 1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adds Zod validation for webhook payloads #377
base: main
Are you sure you want to change the base?
Changes from all commits
a8f52f2
20ee20a
ad41d60
b7de406
ef496af
a969544
77ad7a4
e40c1e4
40afc5e
e628733
98047b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
export class UnhandledWebhookEventError extends Error { | ||
constructor(eventType: string) { | ||
super(`Unhandled event type: ${eventType}`); | ||
this.name = 'UnhandledWebhookEventError'; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,60 +4,75 @@ import { type PrismaClient } from '@prisma/client'; | |
import express from 'express'; | ||
import { paymentPlans, PaymentPlanId, SubscriptionStatus } from '../plans'; | ||
import { updateUserLemonSqueezyPaymentDetails } from './paymentDetails'; | ||
import { type Order, type Subscription, getCustomer } from '@lemonsqueezy/lemonsqueezy.js'; | ||
import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js'; | ||
import crypto from 'crypto'; | ||
import { requireNodeEnvVar } from '../../server/utils'; | ||
import { parseWebhookPayload, type OrderData, type SubscriptionData } from './webhookPayload'; | ||
import { assertUnreachable } from '../../shared/utils'; | ||
import { UnhandledWebhookEventError } from '../errors'; | ||
|
||
export const lemonSqueezyWebhook: PaymentsWebhook = async (request, response, context) => { | ||
try { | ||
const rawBody = request.body.toString('utf8'); | ||
const signature = request.get('X-Signature'); | ||
if (!signature) { | ||
throw new HttpError(400, 'Lemon Squeezy Webhook Signature Not Provided'); | ||
} | ||
|
||
const secret = requireNodeEnvVar('LEMONSQUEEZY_WEBHOOK_SECRET'); | ||
const hmac = crypto.createHmac('sha256', secret); | ||
const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8'); | ||
|
||
if (!crypto.timingSafeEqual(Buffer.from(signature, 'utf8'), digest)) { | ||
throw new HttpError(400, 'Invalid signature'); | ||
} | ||
const rawRequestBody = parseRequestBody(request); | ||
|
||
const event = JSON.parse(rawBody); | ||
const userId = event.meta.custom_data.user_id; | ||
const { eventName, meta, data } = await parseWebhookPayload(rawRequestBody); | ||
const userId = meta.custom_data.user_id; | ||
const prismaUserDelegate = context.entities.User; | ||
switch (event.meta.event_name) { | ||
|
||
switch (eventName) { | ||
case 'order_created': | ||
await handleOrderCreated(event as Order, userId, prismaUserDelegate); | ||
await handleOrderCreated(data, userId, prismaUserDelegate); | ||
break; | ||
case 'subscription_created': | ||
await handleSubscriptionCreated(event as Subscription, userId, prismaUserDelegate); | ||
await handleSubscriptionCreated(data, userId, prismaUserDelegate); | ||
break; | ||
case 'subscription_updated': | ||
await handleSubscriptionUpdated(event as Subscription, userId, prismaUserDelegate); | ||
await handleSubscriptionUpdated(data, userId, prismaUserDelegate); | ||
break; | ||
case 'subscription_cancelled': | ||
await handleSubscriptionCancelled(event as Subscription, userId, prismaUserDelegate); | ||
await handleSubscriptionCancelled(data, userId, prismaUserDelegate); | ||
break; | ||
case 'subscription_expired': | ||
await handleSubscriptionExpired(event as Subscription, userId, prismaUserDelegate); | ||
await handleSubscriptionExpired(data, userId, prismaUserDelegate); | ||
break; | ||
default: | ||
console.error('Unhandled event type: ', event.meta.event_name); | ||
// If you'd like to handle more events, you can add more cases above. | ||
assertUnreachable(eventName); | ||
} | ||
|
||
response.status(200).json({ received: true }); | ||
return response.status(200).json({ received: true }); | ||
} catch (err) { | ||
if (err instanceof UnhandledWebhookEventError) { | ||
return response.status(200).json({ received: true }); | ||
} | ||
|
||
console.error('Webhook error:', err); | ||
if (err instanceof HttpError) { | ||
response.status(err.statusCode).json({ error: err.message }); | ||
return response.status(err.statusCode).json({ error: err.message }); | ||
} else { | ||
response.status(400).json({ error: 'Error Processing Lemon Squeezy Webhook Event' }); | ||
return response.status(400).json({ error: 'Error Processing Lemon Squeezy Webhook Event' }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How did this work without There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Express sends a response as soon as you call |
||
} | ||
} | ||
}; | ||
|
||
function parseRequestBody(request: express.Request): string { | ||
const requestBody = request.body.toString('utf8'); | ||
const signature = request.get('X-Signature'); | ||
if (!signature) { | ||
throw new HttpError(400, 'Lemon Squeezy webhook signature not provided'); | ||
} | ||
|
||
const secret = requireNodeEnvVar('LEMONSQUEEZY_WEBHOOK_SECRET'); | ||
const hmac = crypto.createHmac('sha256', secret); | ||
const digest = Buffer.from(hmac.update(requestBody).digest('hex'), 'utf8'); | ||
|
||
if (!crypto.timingSafeEqual(Buffer.from(signature, 'utf8'), digest)) { | ||
throw new HttpError(400, 'Invalid signature'); | ||
} | ||
|
||
return requestBody; | ||
} | ||
|
||
export const lemonSqueezyMiddlewareConfigFn: MiddlewareConfigFn = (middlewareConfig) => { | ||
// We need to delete the default 'express.json' middleware and replace it with 'express.raw' middleware | ||
// because webhook data in the body of the request as raw JSON, not as JSON in the body of the request. | ||
|
@@ -69,8 +84,8 @@ export const lemonSqueezyMiddlewareConfigFn: MiddlewareConfigFn = (middlewareCon | |
// This will fire for one-time payment orders AND subscriptions. But subscriptions will ALSO send a follow-up | ||
// event of 'subscription_created'. So we use this handler mainly to process one-time, credit-based orders, | ||
// as well as to save the customer portal URL and customer id for the user. | ||
async function handleOrderCreated(data: Order, userId: string, prismaUserDelegate: PrismaClient['user']) { | ||
const { customer_id, status, first_order_item, order_number } = data.data.attributes; | ||
async function handleOrderCreated(data: OrderData, userId: string, prismaUserDelegate: PrismaClient['user']) { | ||
const { customer_id, status, first_order_item, order_number } = data.attributes; | ||
const lemonSqueezyId = customer_id.toString(); | ||
|
||
const planId = getPlanIdByVariantId(first_order_item.variant_id.toString()); | ||
|
@@ -94,11 +109,11 @@ async function handleOrderCreated(data: Order, userId: string, prismaUserDelegat | |
} | ||
|
||
async function handleSubscriptionCreated( | ||
data: Subscription, | ||
data: SubscriptionData, | ||
userId: string, | ||
prismaUserDelegate: PrismaClient['user'] | ||
) { | ||
const { customer_id, status, variant_id } = data.data.attributes; | ||
const { customer_id, status, variant_id } = data.attributes; | ||
const lemonSqueezyId = customer_id.toString(); | ||
|
||
const planId = getPlanIdByVariantId(variant_id.toString()); | ||
|
@@ -123,11 +138,11 @@ async function handleSubscriptionCreated( | |
|
||
// NOTE: LemonSqueezy's 'subscription_updated' event is sent as a catch-all and fires even after 'subscription_created' & 'order_created'. | ||
async function handleSubscriptionUpdated( | ||
data: Subscription, | ||
data: SubscriptionData, | ||
userId: string, | ||
prismaUserDelegate: PrismaClient['user'] | ||
) { | ||
const { customer_id, status, variant_id } = data.data.attributes; | ||
const { customer_id, status, variant_id } = data.attributes; | ||
const lemonSqueezyId = customer_id.toString(); | ||
|
||
const planId = getPlanIdByVariantId(variant_id.toString()); | ||
|
@@ -153,11 +168,11 @@ async function handleSubscriptionUpdated( | |
} | ||
|
||
async function handleSubscriptionCancelled( | ||
data: Subscription, | ||
data: SubscriptionData, | ||
userId: string, | ||
prismaUserDelegate: PrismaClient['user'] | ||
) { | ||
const { customer_id } = data.data.attributes; | ||
const { customer_id } = data.attributes; | ||
const lemonSqueezyId = customer_id.toString(); | ||
|
||
await updateUserLemonSqueezyPaymentDetails( | ||
|
@@ -174,11 +189,11 @@ async function handleSubscriptionCancelled( | |
} | ||
|
||
async function handleSubscriptionExpired( | ||
data: Subscription, | ||
data: SubscriptionData, | ||
userId: string, | ||
prismaUserDelegate: PrismaClient['user'] | ||
) { | ||
const { customer_id } = data.data.attributes; | ||
const { customer_id } = data.attributes; | ||
const lemonSqueezyId = customer_id.toString(); | ||
|
||
await updateUserLemonSqueezyPaymentDetails( | ||
|
@@ -217,4 +232,3 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId { | |
} | ||
return planId; | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import * as z from 'zod'; | ||
import { UnhandledWebhookEventError } from '../errors'; | ||
import { HttpError } from 'wasp/server'; | ||
|
||
export async function parseWebhookPayload(rawPayload: string) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one's missing a return type and is a module boundary. Btw, it's a complicated type. Should we maybe name it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
try { | ||
const rawEvent: unknown = JSON.parse(rawPayload); | ||
const event = await genericEventSchema.parseAsync(rawEvent); | ||
switch (event.meta.event_name) { | ||
case 'order_created': | ||
const orderData = await orderDataSchema.parseAsync(event.data); | ||
return { eventName: event.meta.event_name, meta: event.meta, data: orderData }; | ||
case 'subscription_created': | ||
case 'subscription_updated': | ||
case 'subscription_cancelled': | ||
case 'subscription_expired': | ||
const subscriptionData = await subscriptionDataSchema.parseAsync(event.data); | ||
return { eventName: event.meta.event_name, meta: event.meta, data: subscriptionData }; | ||
default: | ||
// If you'd like to handle more events, you can add more cases above. | ||
throw new UnhandledWebhookEventError(event.meta.event_name); | ||
} | ||
} catch (e: unknown) { | ||
if (e instanceof UnhandledWebhookEventError) { | ||
throw e; | ||
} else { | ||
console.error(e); | ||
throw new HttpError(400, 'Error parsing Lemon Squeezy webhook payload'); | ||
} | ||
} | ||
} | ||
|
||
export type SubscriptionData = z.infer<typeof subscriptionDataSchema>; | ||
|
||
export type OrderData = z.infer<typeof orderDataSchema>; | ||
|
||
const genericEventSchema = z.object({ | ||
meta: z.object({ | ||
event_name: z.string(), | ||
custom_data: z.object({ | ||
user_id: z.string(), | ||
}), | ||
}), | ||
data: z.unknown(), | ||
}); | ||
|
||
// This is a subtype of Order type from "@lemonsqueezy/lemonsqueezy.js" | ||
// specifically Order['data'] | ||
const orderDataSchema = z.object({ | ||
attributes: z.object({ | ||
customer_id: z.number(), | ||
status: z.string(), | ||
first_order_item: z.object({ | ||
variant_id: z.number(), | ||
}), | ||
order_number: z.number(), | ||
}), | ||
}); | ||
|
||
// This is a subtype of Subscription type from "@lemonsqueezy/lemonsqueezy.js" | ||
// specifically Subscription['data'] | ||
const subscriptionDataSchema = z.object({ | ||
attributes: z.object({ | ||
customer_id: z.number(), | ||
status: z.string(), | ||
variant_id: z.number(), | ||
}), | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't sure if we wanted to return something other than
200
if we receive a request for a webhook event we don't handle.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess we could, but we shouldn't be receiving any webhooks we don't explicitly request from the Stripe dashboard settings. Maybe the console.error is enough?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I understand but I kept seeing errors for some of the hooks in the e2e tests so I implemented this bit - this way we are just "ignoring" the extra webhook calls.