diff --git a/apps/docs-new/content/docs/schools/set-up-payments.mdx b/apps/docs-new/content/docs/schools/set-up-payments.mdx
index 8a46232d6..112756aab 100644
--- a/apps/docs-new/content/docs/schools/set-up-payments.mdx
+++ b/apps/docs-new/content/docs/schools/set-up-payments.mdx
@@ -34,7 +34,8 @@ CourseLit offers integrations with the following payment platforms:
- In the destination type, select `Webhook endpoint`.
- In the destination, enter your CourseLit school's webhook endpoint (listed in the same payment screen in your school).

-9. That's it! Your Stripe configuration is complete, and you are ready to receive payments.
+9. Copy the webhook signing secret from Stripe and paste it into `Stripe Webhook Secret` in your CourseLit payment settings.
+10. That's it! Your Stripe configuration is complete, and you are ready to receive payments.
## Razorpay setup
diff --git a/apps/docs/src/pages/en/schools/set-up-payments.md b/apps/docs/src/pages/en/schools/set-up-payments.md
index 1766a92ff..1119d0c3e 100644
--- a/apps/docs/src/pages/en/schools/set-up-payments.md
+++ b/apps/docs/src/pages/en/schools/set-up-payments.md
@@ -35,7 +35,8 @@ CourseLit offers integrations with the following payment platforms:
- In the destination type, select `Webhook endpoint`.
- In the destination, enter your CourseLit school's webhook endpoint (listed in the same payment screen in your school).

-9. That's it! Your Stripe configuration is complete, and you are ready to receive payments.
+9. Copy the webhook signing secret from Stripe and paste it into `Stripe Webhook Secret` in your CourseLit payment settings.
+10. That's it! Your Stripe configuration is complete, and you are ready to receive payments.
## Razorpay setup
diff --git a/apps/web/app/api/payment/__tests__/stripe-payment.test.ts b/apps/web/app/api/payment/__tests__/stripe-payment.test.ts
new file mode 100644
index 000000000..ee0e1df7f
--- /dev/null
+++ b/apps/web/app/api/payment/__tests__/stripe-payment.test.ts
@@ -0,0 +1,86 @@
+/**
+ * @jest-environment node
+ */
+
+import StripePayment from "../../../../payments-new/stripe-payment";
+
+const siteInfo = {
+ currencyISOCode: "usd",
+ stripeKey: "pk_test_123",
+ stripeSecret: "sk_test_123",
+ stripeWebhookSecret: "whsec_test_123",
+};
+
+const checkoutSessionCompleted = {
+ id: "evt_test",
+ object: "event",
+ type: "checkout.session.completed",
+ data: {
+ object: {
+ id: "cs_test_123",
+ payment_status: "paid",
+ metadata: {
+ membershipId: "membership_123",
+ invoiceId: "invoice_123",
+ },
+ },
+ },
+};
+
+describe("StripePayment webhook verification", () => {
+ it("accepts Stripe events with a valid signature over the raw body", async () => {
+ const payment = (await new StripePayment(siteInfo).setup()) as any;
+ const rawBody = JSON.stringify(checkoutSessionCompleted);
+ const signature = payment.stripe.webhooks.generateTestHeaderString({
+ payload: rawBody,
+ secret: siteInfo.stripeWebhookSecret,
+ });
+
+ await expect(
+ payment.verify(checkoutSessionCompleted, {
+ rawBody,
+ signature,
+ }),
+ ).resolves.toBe(true);
+ });
+
+ it("rejects events when the Stripe signature is missing", async () => {
+ const payment = await new StripePayment(siteInfo).setup();
+ const rawBody = JSON.stringify(checkoutSessionCompleted);
+
+ await expect(
+ payment.verify(checkoutSessionCompleted, {
+ rawBody,
+ signature: null,
+ }),
+ ).resolves.toBe(false);
+ });
+
+ it("rejects events when the raw body has been changed", async () => {
+ const payment = (await new StripePayment(siteInfo).setup()) as any;
+ const rawBody = JSON.stringify(checkoutSessionCompleted);
+ const signature = payment.stripe.webhooks.generateTestHeaderString({
+ payload: rawBody,
+ secret: siteInfo.stripeWebhookSecret,
+ });
+
+ await expect(
+ payment.verify(checkoutSessionCompleted, {
+ rawBody: JSON.stringify({
+ ...checkoutSessionCompleted,
+ id: "evt_tampered",
+ }),
+ signature,
+ }),
+ ).resolves.toBe(false);
+ });
+
+ it("requires a Stripe webhook secret during setup", async () => {
+ await expect(
+ new StripePayment({
+ ...siteInfo,
+ stripeWebhookSecret: undefined,
+ }).setup(),
+ ).rejects.toThrow("stripe");
+ });
+});
diff --git a/apps/web/app/api/payment/webhook/route.ts b/apps/web/app/api/payment/webhook/route.ts
index 4d1285fc9..724b5596e 100644
--- a/apps/web/app/api/payment/webhook/route.ts
+++ b/apps/web/app/api/payment/webhook/route.ts
@@ -17,7 +17,8 @@ import { activateMembership } from "../helpers";
export async function POST(req: NextRequest) {
try {
- const body = await req.json();
+ const rawBody = await req.text();
+ const body = JSON.parse(rawBody);
const domainName = req.headers.get("domain");
const domain = await getDomain(domainName);
@@ -30,11 +31,22 @@ export async function POST(req: NextRequest) {
const paymentMethod = await getPaymentMethod(domain._id.toString());
if (!paymentMethod) {
- return Response.json({ message: "Payment method not found" });
+ return Response.json(
+ { message: "Payment method not found" },
+ { status: 404 },
+ );
}
- if (!(await paymentMethod.verify(body))) {
- return Response.json({ message: "Payment not verified" });
+ if (
+ !(await paymentMethod.verify(body, {
+ rawBody,
+ signature: req.headers.get("stripe-signature"),
+ }))
+ ) {
+ return Response.json(
+ { message: "Payment not verified" },
+ { status: 400 },
+ );
}
const metadata = paymentMethod.getMetadata(body);
@@ -42,7 +54,10 @@ export async function POST(req: NextRequest) {
const membership = await getMembership(domain._id, membershipId);
if (!membership) {
- return Response.json({ message: "Membership not found" });
+ return Response.json(
+ { message: "Membership not found" },
+ { status: 404 },
+ );
}
const paymentPlan = await getPaymentPlan(
diff --git a/apps/web/components/admin/settings/index.tsx b/apps/web/components/admin/settings/index.tsx
index c502a8559..246c6cacc 100644
--- a/apps/web/components/admin/settings/index.tsx
+++ b/apps/web/components/admin/settings/index.tsx
@@ -8,6 +8,7 @@ import {
SITE_SETTINGS_PAGE_HEADING,
SITE_SETTINGS_CURRENCY,
SITE_ADMIN_SETTINGS_STRIPE_SECRET,
+ SITE_ADMIN_SETTINGS_STRIPE_WEBHOOK_SECRET,
SITE_ADMIN_SETTINGS_RAZORPAY_SECRET,
SITE_ADMIN_SETTINGS_PAYPAL_SECRET,
SITE_ADMIN_SETTINGS_PAYTM_SECRET,
@@ -528,6 +529,7 @@ const Settings = (props: SettingsProps) => {
$paymentMethod: String,
$stripeKey: String,
$stripeSecret: String,
+ $stripeWebhookSecret: String,
$razorpayKey: String,
$razorpaySecret: String,
$razorpayWebhookSecret: String,
@@ -543,6 +545,7 @@ const Settings = (props: SettingsProps) => {
paymentMethod: $paymentMethod,
stripeKey: $stripeKey,
stripeSecret: $stripeSecret,
+ stripeWebhookSecret: $stripeWebhookSecret,
razorpayKey: $razorpayKey,
razorpaySecret: $razorpaySecret,
razorpayWebhookSecret: $razorpayWebhookSecret,
@@ -592,6 +595,7 @@ const Settings = (props: SettingsProps) => {
paymentMethod: newSettings.paymentMethod,
stripeKey: newSettings.stripeKey,
stripeSecret: newSettings.stripeSecret,
+ stripeWebhookSecret: newSettings.stripeWebhookSecret,
razorpayKey: newSettings.razorpayKey,
razorpaySecret: newSettings.razorpaySecret,
razorpayWebhookSecret:
@@ -694,6 +698,9 @@ const Settings = (props: SettingsProps) => {
stripeSecret: getNewSettings
? newSettings.stripeSecret
: settings.stripeSecret,
+ stripeWebhookSecret: getNewSettings
+ ? newSettings.stripeWebhookSecret
+ : settings.stripeWebhookSecret,
paypalSecret: getNewSettings
? newSettings.paypalSecret
: settings.paypalSecret,
@@ -1014,6 +1021,19 @@ const Settings = (props: SettingsProps) => {
sx={{ mb: 2 }}
autoComplete="off"
/>
+
>
)}
{newSettings.paymentMethod ===
diff --git a/apps/web/graphql/settings/helpers.ts b/apps/web/graphql/settings/helpers.ts
index 8f23c6a8f..a32754e12 100644
--- a/apps/web/graphql/settings/helpers.ts
+++ b/apps/web/graphql/settings/helpers.ts
@@ -74,7 +74,11 @@ export const checkForInvalidPaymentMethodSettings = (
if (
siteInfo.paymentMethod === UIConstants.PAYMENT_METHOD_STRIPE &&
- !(siteInfo.stripeSecret && siteInfo.stripeKey)
+ !(
+ siteInfo.stripeSecret &&
+ siteInfo.stripeKey &&
+ siteInfo.stripeWebhookSecret
+ )
) {
failedPaymentMethod = UIConstants.PAYMENT_METHOD_STRIPE;
}
diff --git a/apps/web/graphql/settings/logic.ts b/apps/web/graphql/settings/logic.ts
index aa39d58e0..6293cef9f 100644
--- a/apps/web/graphql/settings/logic.ts
+++ b/apps/web/graphql/settings/logic.ts
@@ -119,6 +119,7 @@ export const getSiteInfo = async (ctx: GQLContext) => {
deleted: 0,
customDomain: 0,
"settings.stripeSecret": 0,
+ "settings.stripeWebhookSecret": 0,
"settings.paytmSecret": 0,
"settings.paypalSecret": 0,
"settings.razorpaySecret": 0,
diff --git a/apps/web/payments-new/payment.ts b/apps/web/payments-new/payment.ts
index ba9d60e3d..ab5d8d43c 100644
--- a/apps/web/payments-new/payment.ts
+++ b/apps/web/payments-new/payment.ts
@@ -19,7 +19,10 @@ interface Metadata {
export default interface Payment {
setup: () => void;
initiate: (obj: InitiateProps) => void;
- verify: (event: any) => Promise;
+ verify: (
+ event: any,
+ context?: { rawBody?: string; signature?: string | null },
+ ) => Promise;
getPaymentIdentifier: (event: any) => unknown;
getMetadata: (event: any) => Record;
getName: () => string;
diff --git a/apps/web/payments-new/stripe-payment.ts b/apps/web/payments-new/stripe-payment.ts
index 9f8aa3112..0c1fd41ce 100644
--- a/apps/web/payments-new/stripe-payment.ts
+++ b/apps/web/payments-new/stripe-payment.ts
@@ -33,6 +33,10 @@ export default class StripePayment implements Payment {
throw new Error(`${this.name} ${paymentInvalidSettings}`);
}
+ if (!this.siteinfo.stripeWebhookSecret) {
+ throw new Error(`${this.name} ${paymentInvalidSettings}`);
+ }
+
this.stripe = new Stripe(this.siteinfo.stripeSecret, {
typescript: true,
});
@@ -77,7 +81,28 @@ export default class StripePayment implements Payment {
return this.siteinfo.currencyISOCode!;
}
- async verify(event: Stripe.Event) {
+ async verify(
+ event: Stripe.Event,
+ context?: { rawBody?: string; signature?: string | null },
+ ) {
+ if (
+ !context?.rawBody ||
+ !context.signature ||
+ !this.siteinfo.stripeWebhookSecret
+ ) {
+ return false;
+ }
+
+ try {
+ event = this.stripe.webhooks.constructEvent(
+ context.rawBody,
+ context.signature,
+ this.siteinfo.stripeWebhookSecret,
+ );
+ } catch {
+ return false;
+ }
+
if (!event) {
return false;
}
diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts
index cf88ffb6e..c4c7c6f01 100644
--- a/apps/web/ui-config/strings.ts
+++ b/apps/web/ui-config/strings.ts
@@ -100,6 +100,8 @@ export const HEADER_COURSELIT = "About CourseLit";
export const MEDIA_SELECTOR_UPLOAD_BTN_CAPTION = "Upload a picture";
export const MEDIA_SELECTOR_REMOVE_BTN_CAPTION = "Remove picture";
export const SITE_ADMIN_SETTINGS_STRIPE_SECRET = "Stripe Secret Key";
+export const SITE_ADMIN_SETTINGS_STRIPE_WEBHOOK_SECRET =
+ "Stripe Webhook Secret";
export const SITE_ADMIN_SETTINGS_RAZORPAY_SECRET = "Razorpay Secret Key";
export const SITE_ADMIN_SETTINGS_RAZORPAY_WEBHOOK_SECRET =
"Razorpay Webhook Secret";
diff --git a/packages/orm-models/src/models/site-info.ts b/packages/orm-models/src/models/site-info.ts
index 3430532a8..d88a40dd2 100644
--- a/packages/orm-models/src/models/site-info.ts
+++ b/packages/orm-models/src/models/site-info.ts
@@ -12,6 +12,7 @@ export const SettingsSchema = new mongoose.Schema({
codeInjectionHead: { type: String },
codeInjectionBody: { type: String },
stripeSecret: { type: String },
+ stripeWebhookSecret: { type: String },
paytmSecret: { type: String },
paypalSecret: { type: String },
mailingAddress: { type: String },