Skip to content

Adds Zod validation for webhook payloads #377

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

Merged
merged 17 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions template/app/src/payment/errors.ts
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';
}
}
89 changes: 52 additions & 37 deletions template/app/src/payment/lemonSqueezy/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,76 @@ 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) {
Copy link
Collaborator Author

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.

Copy link
Collaborator

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?

Copy link
Collaborator Author

@infomiho infomiho Feb 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't be receiving any webhooks we don't explicitly request from the Stripe

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.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should probably return an error code, right? This way, we explicitly tell Stripe (hey, we couldn't handle this). It probably makes things easier for people requesting refunds etc.

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.

I didn't get this part. Why would they be sending events we didn't request? If that's the case, all the more reason to return 400 or something similar (e.g., 422 - unprocessable content).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've went with explicitly returning a 422 for the unhandled events. I don't have much experience with Stripe or Lemon Squeezy web hook events so I thought they might not like a non-200 response.

I'll see what happens in the e2e tests in the CI and post a screenshot just to be extra clear on what I meant earlier.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2025-04-16 at 13 44 19

In the CI, we are getting the 422 error meaning there are some unhandled events.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we log them and find out? I'll leave it to @vincanger

Copy link
Collaborator Author

@infomiho infomiho Apr 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on latest CI run: https://github.com/wasp-lang/open-saas/actions/runs/14514923688/job/40721696044

The unhandled events are:

  • customer.created
  • charge.succeeded
  • payment_method.attached
  • customer.updated
  • customer.subscription.created
  • payment_intent.created
  • invoice.created
  • invoice.finalized
  • invoice.updated
  • invoice.payment_succeeded

I think that's fine that we don't handle all the events Stripe send to us. If it's okay to have the 422 status code with Stripe, then I think we are okay with the current state of things. @vincanger

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is totally fine according to Stripe. I've read through the docs and am making sure to handle the most important ones for most SaaS apps (plus a couple more, I think).

console.error(err.message);
return response.status(422).json({ error: err.message });
}

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' });
}
}
};

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.
Expand All @@ -69,8 +85,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());
Expand All @@ -94,11 +110,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());
Expand All @@ -123,11 +139,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());
Expand All @@ -153,11 +169,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(
Expand All @@ -174,11 +190,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(
Expand Down Expand Up @@ -217,4 +233,3 @@ function getPlanIdByVariantId(variantId: string): PaymentPlanId {
}
return planId;
}

77 changes: 77 additions & 0 deletions template/app/src/payment/lemonSqueezy/webhookPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as z from 'zod';
import { UnhandledWebhookEventError } from '../errors';
import { HttpError } from 'wasp/server';

export async function parseWebhookPayload(rawPayload: string) {
try {
const rawEvent: unknown = JSON.parse(rawPayload);
const { meta, data } = await genericEventSchema.parseAsync(rawEvent);
switch (meta.event_name) {
case 'order_created':
const orderData = await orderDataSchema.parseAsync(data);
return { eventName: meta.event_name, meta, data: orderData };
case 'subscription_created':
case 'subscription_updated':
case 'subscription_cancelled':
case 'subscription_expired':
const subscriptionData = await subscriptionDataSchema.parseAsync(data);
return { eventName: meta.event_name, meta, data: subscriptionData };
default:
// If you'd like to handle more events, you can add more cases above.
throw new UnhandledWebhookEventError(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>;

/**
* This schema is based on LemonSqueezyResponse type
*/
const genericEventSchema = z.object({
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we document where this type comes from as well?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we link the type like we did for the others?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type is not exported unfortunately, so I get a Typescript error when I try to link to the type.

meta: z.object({
event_name: z.string(),
custom_data: z.object({
user_id: z.string(),
}),
}),
data: z.unknown(),
});

/**
* This schema is based on
* @type import('@lemonsqueezy/lemonsqueezy.js').Order
* 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 schema is based on
* @type import('@lemonsqueezy/lemonsqueezy.js').Subscription
* specifically Subscription['data'].
*/
const subscriptionDataSchema = z.object({
attributes: z.object({
customer_id: z.number(),
status: z.string(),
variant_id: z.number(),
}),
});
Loading