Repository: saleor/apps
Pull request #1912: Add app id to webhook url
Author: login: lkostrowski
Labels: name: skip changeset
description: Attach this label to PRs which does not need changes description for the release notes.
name: App Payment: Stripe
Created: 2025-05-09T05:25:26Z | Merged: 2025-05-09T07:21:45Z

## Scope of the PR

<!-- Describe briefly changed made in this PR -->

## Related issues

<!-- If any, mention issues that are connected with this PR -->

## Checklist

- [ ] I added changesets and [read good practices](/.changeset/README.md).



Diff:
diff --git a/apps/stripe/src/__tests__/mocks/constants.ts b/apps/stripe/src/__tests__/mocks/constants.ts
index 0aeecac40..972a1d841 100644
--- a/apps/stripe/src/__tests__/mocks/constants.ts
+++ b/apps/stripe/src/__tests__/mocks/constants.ts
@@ -11,6 +11,7 @@ export const mockAppUrlBase = "https://my-app.saleor.app";
 export const mockAdyenWebhookUrl = `${mockAppUrlBase}?${new URLSearchParams({
   [WebhookParams.saleorApiUrlSearchParam]: mockedSaleorApiUrl,
   [WebhookParams.configurationIdIdSearchParam]: mockedConfigurationId,
+  [WebhookParams.appIdSearchParam]: mockedSaleorAppId,
 }).toString()}`;
 /**
  *  @deprecated - use `mockedSaleorTransactionIdBranded` instead
diff --git a/apps/stripe/src/app/api/stripe/webhook/route.ts b/apps/stripe/src/app/api/stripe/webhook/route.ts
index 2803d122d..f347799eb 100644
--- a/apps/stripe/src/app/api/stripe/webhook/route.ts
+++ b/apps/stripe/src/app/api/stripe/webhook/route.ts
@@ -15,6 +15,7 @@ import { loggerContext, withLoggerContext } from "@/lib/logger-context";
 import { saleorApp } from "@/lib/saleor-app";
 import { appConfigRepoImpl } from "@/modules/app-config/repositories/app-config-repo-impl";
 import { TransactionEventReporter } from "@/modules/saleor/transaction-event-reporter";
+import { StripeWebhookManager } from "@/modules/stripe/stripe-webhook-manager";
 import { StripeWebhookSignatureValidator } from "@/modules/stripe/stripe-webhook-signature-validator";
 import { transactionRecorder } from "@/modules/transactions-recording/repositories/transaction-recorder-impl";
 
@@ -29,6 +30,7 @@ const useCase = new StripeWebhookUseCase({
       graphqlClient: createInstrumentedGraphqlClient(authData),
     });
   },
+  webhookManager: new StripeWebhookManager(),
 });
 
 const logger = createLogger("StripeWebhookHandler");
diff --git a/apps/stripe/src/app/api/stripe/webhook/use-case-error.test.ts b/apps/stripe/src/app/api/stripe/webhook/use-case-error.test.ts
index cac44486b..0bada593c 100644
--- a/apps/stripe/src/app/api/stripe/webhook/use-case-error.test.ts
+++ b/apps/stripe/src/app/api/stripe/webhook/use-case-error.test.ts
@@ -18,6 +18,7 @@ import {
   ITransactionEventReporter,
   TransactionEventReportResultResult,
 } from "@/modules/saleor/transaction-event-reporter";
+import { StripeWebhookManager } from "@/modules/stripe/stripe-webhook-manager";
 import { IStripeEventVerify, StripeEventParsingError } from "@/modules/stripe/types";
 import { RecordedTransaction } from "@/modules/transactions-recording/domain/recorded-transaction";
 
@@ -57,6 +58,7 @@ describe("StripeWebhookUseCase - Error cases", () => {
         return mockEventReporter;
       },
       transactionRecorder: mockTransactionRecorder,
+      webhookManager: new StripeWebhookManager(),
     });
   });
 
diff --git a/apps/stripe/src/app/api/stripe/webhook/use-case-legacy-event.test.ts b/apps/stripe/src/app/api/stripe/webhook/use-case-legacy-event.test.ts
new file mode 100644
index 000000000..3211db89e
--- /dev/null
+++ b/apps/stripe/src/app/api/stripe/webhook/use-case-legacy-event.test.ts
@@ -0,0 +1,162 @@
+import { APL } from "@saleor/app-sdk/APL";
+import { err, ok } from "neverthrow";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { mockedAppConfigRepo } from "@/__tests__/mocks/app-config-repo";
+import { mockAdyenWebhookUrl } from "@/__tests__/mocks/constants";
+import { mockAuthData } from "@/__tests__/mocks/mock-auth-data";
+import { mockedStripeConfig } from "@/__tests__/mocks/mock-stripe-config";
+import { MockedTransactionRecorder } from "@/__tests__/mocks/mocked-transaction-recorder";
+import { StripeWebhookUseCase } from "@/app/api/stripe/webhook/use-case";
+import { WebhookParams } from "@/app/api/stripe/webhook/webhook-params";
+import { BaseError } from "@/lib/errors";
+import { ITransactionEventReporter } from "@/modules/saleor/transaction-event-reporter";
+import { StripeWebhookManager } from "@/modules/stripe/stripe-webhook-manager";
+import { IStripeEventVerify } from "@/modules/stripe/types";
+
+describe("StripeWebhookUseCase - Legacy event cases", () => {
+  const rawEventBody = JSON.stringify({ id: 1 });
+
+  const mockApl = {
+    get: vi.fn(),
+    set: vi.fn(),
+    delete: vi.fn(),
+    getAll: vi.fn(),
+  } satisfies APL;
+
+  let instance: StripeWebhookUseCase;
+
+  const eventVerify = {
+    verifyEvent: vi.fn(),
+  } satisfies IStripeEventVerify;
+
+  const webhookParams = WebhookParams.createFromWebhookUrl(mockAdyenWebhookUrl)._unsafeUnwrap();
+
+  const mockEventReporter = {
+    reportTransactionEvent: vi.fn(),
+  } satisfies ITransactionEventReporter;
+
+  const mockTransactionRecorder = new MockedTransactionRecorder();
+
+  const webhookManager = new StripeWebhookManager();
+
+  beforeEach(() => {
+    vi.spyOn(webhookManager, "removeWebhook");
+
+    mockApl.get.mockImplementation(async () => {
+      return { ...mockAuthData, appId: "different-app-id" };
+    });
+
+    mockTransactionRecorder.reset();
+
+    instance = new StripeWebhookUseCase({
+      apl: mockApl,
+      appConfigRepo: mockedAppConfigRepo,
+      webhookEventVerifyFactory: () => eventVerify,
+      transactionEventReporterFactory() {
+        return mockEventReporter;
+      },
+      transactionRecorder: mockTransactionRecorder,
+      webhookManager,
+    });
+  });
+
+  describe("Once event received comes from different app ID than currently resolved from AuthData", () => {
+    it("Will remove webhook and return 200 to Stripe", async () => {
+      vi.mocked(webhookManager.removeWebhook).mockImplementationOnce(async () => ok(null));
+
+      const result = await instance.execute({
+        rawBody: rawEventBody,
+        webhookParams: webhookParams,
+        signatureHeader: "",
+      });
+
+      expect(webhookManager.removeWebhook).toHaveBeenCalledExactlyOnceWith({
+        restrictedKey: mockedStripeConfig.restrictedKey,
+        webhookId: mockedStripeConfig.webhookId,
+      });
+
+      expect(result.isOk()).toBe(true);
+
+      expect(mockedAppConfigRepo.getStripeConfig).toHaveBeenCalledExactlyOnceWith(
+        expect.objectContaining({
+          appId: webhookParams.appId,
+        }),
+      );
+    });
+
+    it("Will return 500 to Stripe if legacy config matching legacy event not found", async () => {
+      vi.mocked(mockedAppConfigRepo.getStripeConfig).mockImplementationOnce(async () => ok(null));
+
+      const result = await instance.execute({
+        rawBody: rawEventBody,
+        webhookParams: webhookParams,
+        signatureHeader: "",
+      });
+
+      expect(result.isErr()).toBe(true);
+
+      expect(result._unsafeUnwrapErr()).toMatchInlineSnapshot(`
+        StripeWebhookErrorResponse {
+          "error": [BaseError: Legacy config is empty
+        Received legacy webhook but failed to handle removing it],
+          "responseStatusCode": 500,
+        }
+      `);
+
+      expect(mockedAppConfigRepo.getStripeConfig).toHaveBeenCalledOnce();
+    });
+
+    it("Will return 500 to Stripe if can't access DB to fetch config", async () => {
+      vi.mocked(mockedAppConfigRepo.getStripeConfig).mockImplementationOnce(async () =>
+        err(new BaseError("test err")),
+      );
+
+      const result = await instance.execute({
+        rawBody: rawEventBody,
+        webhookParams: webhookParams,
+        signatureHeader: "",
+      });
+
+      expect(result.isErr()).toBe(true);
+
+      expect(result._unsafeUnwrapErr()).toMatchInlineSnapshot(`
+        StripeWebhookErrorResponse {
+          "error": [BaseError: test err
+        Failed to fetch config attached to legacy Webhook, this requires manual cleanup
+        Failed to fetch legacy config
+        Received legacy webhook but failed to handle removing it],
+          "responseStatusCode": 500,
+        }
+      `);
+
+      expect(mockedAppConfigRepo.getStripeConfig).toHaveBeenCalledOnce();
+    });
+
+    it("Will return 500 to Stripe if can't remove webhook for some reason", async () => {
+      vi.mocked(webhookManager.removeWebhook).mockImplementationOnce(async () =>
+        err(new BaseError("error removing webhook")),
+      );
+
+      const result = await instance.execute({
+        rawBody: rawEventBody,
+        webhookParams: webhookParams,
+        signatureHeader: "",
+      });
+
+      expect(result.isErr()).toBe(true);
+
+      expect(result._unsafeUnwrapErr()).toMatchInlineSnapshot(`
+        StripeWebhookErrorResponse {
+          "error": [BaseError: error removing webhook
+        Failed to remove webhook
+        Failed to remove legacy webhook
+        Received legacy webhook but failed to handle removing it],
+          "responseStatusCode": 500,
+        }
+      `);
+
+      expect(mockedAppConfigRepo.getStripeConfig).toHaveBeenCalledOnce();
+    });
+  });
+});
diff --git a/apps/stripe/src/app/api/stripe/webhook/use-case-success.test.ts b/apps/stripe/src/app/api/stripe/webhook/use-case-success.test.ts
index 0caef6517..0e374678a 100644
--- a/apps/stripe/src/app/api/stripe/webhook/use-case-success.test.ts
+++ b/apps/stripe/src/app/api/stripe/webhook/use-case-success.test.ts
@@ -21,6 +21,7 @@ import {
   TransactionEventReportResultResult,
 } from "@/modules/saleor/transaction-event-reporter";
 import { createStripePaymentIntentId } from "@/modules/stripe/stripe-payment-intent-id";
+import { StripeWebhookManager } from "@/modules/stripe/stripe-webhook-manager";
 import { IStripeEventVerify } from "@/modules/stripe/types";
 import { RecordedTransaction } from "@/modules/transactions-recording/domain/recorded-transaction";
 
@@ -61,6 +62,7 @@ describe("StripeWebhookUseCase - handling payment_intent.success event", () => {
         return mockEventReporter;
       },
       transactionRecorder: mockTransactionRecorder,
+      webhookManager: new StripeWebhookManager(),
     });
   });
 
@@ -210,6 +212,7 @@ describe("StripeWebhookUseCase - handling payment_intent.amount_capturable_updat
         return mockEventReporter;
       },
       transactionRecorder: mockTransactionRecorder,
+      webhookManager: new StripeWebhookManager(),
     });
   });
 
@@ -295,6 +298,7 @@ describe("StripeWebhookUseCase - handling payment_intent.payment_failed event",
         return mockEventReporter;
       },
       transactionRecorder: mockTransactionRecorder,
+      webhookManager: new StripeWebhookManager(),
     });
   });
 
@@ -444,6 +448,7 @@ describe("StripeWebhookUseCase - handling payment_intent.processing event", () =
         return mockEventReporter;
       },
       transactionRecorder: mockTransactionRecorder,
+      webhookManager: new StripeWebhookManager(),
     });
   });
 
@@ -593,6 +598,7 @@ describe("StripeWebhookUseCase - handling payment_intent.requires_action event",
         return mockEventReporter;
       },
       transactionRecorder: mockTransactionRecorder,
+      webhookManager: new StripeWebhookManager(),
     });
   });
 
@@ -742,6 +748,7 @@ describe("StripeWebhookUseCase - handling payment_intent.canceled event", () =>
         return mockEventReporter;
       },
       transactionRecorder: mockTransactionRecorder,
+      webhookManager: new StripeWebhookManager(),
     });
   });
 
@@ -887,6 +894,7 @@ describe("StripeWebhookUseCase - handling charge.refund.updated event", () => {
         return mockEventReporter;
       },
       transactionRecorder: mockTransactionRecorder,
+      webhookManager: new StripeWebhookManager(),
     });
   });
 
diff --git a/apps/stripe/src/app/api/stripe/webhook/use-case.ts b/apps/stripe/src/app/api/stripe/webhook/use-case.ts
index 96df54081..b6f6df979 100644
--- a/apps/stripe/src/app/api/stripe/webhook/use-case.ts
+++ b/apps/stripe/src/app/api/stripe/webhook/use-case.ts
@@ -18,6 +18,8 @@ import {
 } from "@/modules/saleor/transaction-event-reporter";
 import { StripeClient } from "@/modules/stripe/stripe-client";
 import { StripeEnv } from "@/modules/stripe/stripe-env";
+import { StripeRestrictedKey } from "@/modules/stripe/stripe-restricted-key";
+import { StripeWebhookManager } from "@/modules/stripe/stripe-webhook-manager";
 import { IStripeEventVerify } from "@/modules/stripe/types";
 import { TransactionRecorderRepo } from "@/modules/transactions-recording/repositories/transaction-recorder-repo";
 
@@ -39,6 +41,7 @@ export class StripeWebhookUseCase {
   private logger = createLogger("StripeWebhookUseCase");
   private transactionRecorder: TransactionRecorderRepo;
   private transactionEventReporterFactory: SaleorTransactionEventReporterFactory;
+  private webhookManager: StripeWebhookManager;
 
   constructor(deps: {
     appConfigRepo: AppConfigRepo;
@@ -46,12 +49,34 @@ export class StripeWebhookUseCase {
     apl: APL;
     transactionRecorder: TransactionRecorderRepo;
     transactionEventReporterFactory: SaleorTransactionEventReporterFactory;
+    webhookManager: StripeWebhookManager;
   }) {
     this.appConfigRepo = deps.appConfigRepo;
     this.webhookEventVerifyFactory = deps.webhookEventVerifyFactory;
     this.apl = deps.apl;
     this.transactionRecorder = deps.transactionRecorder;
     this.transactionEventReporterFactory = deps.transactionEventReporterFactory;
+    this.webhookManager = deps.webhookManager;
+  }
+
+  private async removeStripeWebhook({
+    webhookId,
+    restrictedKey,
+  }: {
+    webhookId: string;
+    restrictedKey: StripeRestrictedKey;
+  }) {
+    const result = await this.webhookManager.removeWebhook({ webhookId, restrictedKey });
+
+    if (result.isErr()) {
+      this.logger.error(`Failed to remove webhook ${webhookId}`, result.error);
+
+      return err(new BaseError("Failed to remove webhook", { cause: result.error }));
+    }
+
+    this.logger.info(`Webhook ${webhookId} removed successfully`);
+
+    return ok(null);
   }
 
   private async processEvent({
@@ -96,6 +121,59 @@ export class StripeWebhookUseCase {
     }
   }
 
+  /**
+   * It handles case when
+   * 1. App was installed and configured. Webhook exists in Stripe
+   * 2. App is removed - webhook is not
+   * 3. App is reinstalled and configured again
+   * 4. There are now 2 webhooks - old and new. Old one will always fail.
+   *
+   * At this point we detect an old webhook because it has different appId in URL (from previous installation).
+   * Now we can use that to fetch old config from DB and remove the webhook.
+   */
+  private async processLegacyWebhook(webhookParams: WebhookParams) {
+    const legacyConfig = await this.appConfigRepo.getStripeConfig({
+      configId: webhookParams.configurationId,
+      // Use app ID from webhook, not AuthData, so we have it frozen in time
+      appId: webhookParams.appId,
+      saleorApiUrl: webhookParams.saleorApiUrl,
+    });
+
+    if (legacyConfig.isErr()) {
+      captureException(
+        new BaseError(
+          "Failed to fetch config attached to legacy Webhook, this requires manual cleanup",
+          {
+            cause: legacyConfig.error,
+          },
+        ),
+      );
+
+      return err(
+        new BaseError("Failed to fetch legacy config", {
+          cause: legacyConfig.error,
+        }),
+      );
+    }
+
+    if (!legacyConfig.value) {
+      this.logger.error("Legacy config is empty, this requires manual cleanup");
+
+      return err(new BaseError("Legacy config is empty"));
+    }
+
+    const removalResult = await this.removeStripeWebhook({
+      webhookId: legacyConfig.value.webhookId,
+      restrictedKey: legacyConfig.value.restrictedKey,
+    });
+
+    if (removalResult.isErr()) {
+      return err(new BaseError("Failed to remove legacy webhook", { cause: removalResult.error }));
+    }
+
+    return ok(null);
+  }
+
   async execute({
     rawBody,
     signatureHeader,
@@ -130,6 +208,26 @@ export class StripeWebhookUseCase {
       );
     }
 
+    if (authData.appId !== webhookParams.appId) {
+      this.logger.error(
+        "Received webhook with different appId than expected. There may be old webhook from uninstalled app. Will try to remove it now.",
+      );
+
+      const processingResult = await this.processLegacyWebhook(webhookParams);
+
+      if (processingResult.isErr()) {
+        return err(
+          new StripeWebhookErrorResponse(
+            new BaseError("Received legacy webhook but failed to handle removing it", {
+              cause: processingResult.error,
+            }),
+          ),
+        );
+      } else {
+        return ok(new StripeWebhookSuccessResponse());
+      }
+    }
+
     const transactionEventReporter = this.transactionEventReporterFactory(authData);
 
     const config = await this.appConfigRepo.getStripeConfig({
diff --git a/apps/stripe/src/app/api/stripe/webhook/webhook-params.test.ts b/apps/stripe/src/app/api/stripe/webhook/webhook-params.test.ts
index 5ba3f4fc4..65af69798 100644
--- a/apps/stripe/src/app/api/stripe/webhook/webhook-params.test.ts
+++ b/apps/stripe/src/app/api/stripe/webhook/webhook-params.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from "vitest";
 
-import { mockedConfigurationId } from "@/__tests__/mocks/constants";
+import { mockedConfigurationId, mockedSaleorAppId } from "@/__tests__/mocks/constants";
 import { mockedSaleorApiUrl } from "@/__tests__/mocks/saleor-api-url";
 import { WebhookParams } from "@/app/api/stripe/webhook/webhook-params";
 
@@ -8,6 +8,7 @@ describe("WebhookParams", () => {
   const validSearchParams = new URLSearchParams({
     [WebhookParams.saleorApiUrlSearchParam]: mockedSaleorApiUrl,
     [WebhookParams.configurationIdIdSearchParam]: mockedConfigurationId,
+    [WebhookParams.appIdSearchParam]: mockedSaleorAppId,
   });
 
   const validUrl = new URL(
@@ -17,7 +18,7 @@ describe("WebhookParams", () => {
   // Ensure testing entities are valid from human perspective
   it("Valid url is valid", () => {
     expect(validUrl).toMatchInlineSnapshot(
-      `"https://test-deployment.com/?saleorApiUrl=https%3A%2F%2Ffoo.bar.saleor.cloud%2Fgraphql%2F&configurationId=81f323bd-91e2-4838-ab6e-5affd81ffc3b"`,
+      `"https://test-deployment.com/?saleorApiUrl=https%3A%2F%2Ffoo.bar.saleor.cloud%2Fgraphql%2F&configurationId=81f323bd-91e2-4838-ab6e-5affd81ffc3b&appId=saleor-app-id"`,
     );
   });
 
@@ -116,4 +117,51 @@ describe("WebhookParams", () => {
       `);
     });
   });
+
+  describe("working on appId", () => {
+    it("Parses appId to the field", () => {
+      const result = WebhookParams.createFromWebhookUrl(validUrl);
+
+      const vo = result._unsafeUnwrap();
+
+      expect(vo.appId).toStrictEqual(mockedSaleorAppId);
+    });
+
+    it("Throws if appId is missing", () => {
+      const params = new URLSearchParams({
+        [WebhookParams.saleorApiUrlSearchParam]: mockedSaleorApiUrl,
+        [WebhookParams.configurationIdIdSearchParam]: mockedConfigurationId,
+      });
+
+      const result = WebhookParams.createFromWebhookUrl(
+        new URL("https://test-deployment.com?" + params.toString()).toString(),
+      );
+
+      const error = result._unsafeUnwrapErr();
+
+      expect(error).toMatchInlineSnapshot(`
+        [ParsingError: appId URL param is invalid
+        Cant parse Stripe incoming webhook URL]
+      `);
+    });
+
+    it("Throws if appId is malformed", () => {
+      const params = new URLSearchParams({
+        [WebhookParams.configurationIdIdSearchParam]: mockedConfigurationId,
+        [WebhookParams.saleorApiUrlSearchParam]: mockedSaleorApiUrl,
+        [WebhookParams.appIdSearchParam]: "",
+      });
+
+      const result = WebhookParams.createFromWebhookUrl(
+        new URL(`https://test-deployment.com?${params.toString()}`).toString(),
+      );
+
+      const error = result._unsafeUnwrapErr();
+
+      expect(error).toMatchInlineSnapshot(`
+        [ParsingError: appId URL param is invalid
+        Cant parse Stripe incoming webhook URL]
+      `);
+    });
+  });
 });
diff --git a/apps/stripe/src/app/api/stripe/webhook/webhook-params.ts b/apps/stripe/src/app/api/stripe/webhook/webhook-params.ts
index aa1d9f1a8..be80c64bb 100644
--- a/apps/stripe/src/app/api/stripe/webhook/webhook-params.ts
+++ b/apps/stripe/src/app/api/stripe/webhook/webhook-params.ts
@@ -5,11 +5,12 @@ import { BaseError } from "@/lib/errors";
 import { createSaleorApiUrl, SaleorApiUrl } from "@/modules/saleor/saleor-api-url";
 
 /**
- * Stores attributes that Stripe webhooks returns to us
+ * Stores attributes that Stripe webhook returns to us
  */
 export class WebhookParams {
   static saleorApiUrlSearchParam = "saleorApiUrl";
   static configurationIdIdSearchParam = "configurationId";
+  static appIdSearchParam = "appId";
 
   static ParsingError = BaseError.subclass("ParsingError", {
     props: {
@@ -19,10 +20,25 @@ export class WebhookParams {
 
   readonly saleorApiUrl: SaleorApiUrl;
   readonly configurationId: string;
-
-  private constructor(props: { saleorApiUrl: SaleorApiUrl; configurationId: string }) {
+  /**
+   * Require app ID to store a full DynamoDB access path in the webhook URL.
+   *
+   * If the app is removed, and we still receive the webhook (it's not removed automatically) we will have to somehow disable it.
+   * To do that, we need to fetch config from DB. And in DB the path requires saleorApiUrl, appID and configurationId.
+   * We must have ALL of them in the webhook to make such a query.
+   *
+   * We will be also able to recognize if the current app installation is conflicting with the previous one.
+   */
+  readonly appId: string;
+
+  private constructor(props: {
+    saleorApiUrl: SaleorApiUrl;
+    configurationId: string;
+    appId: string;
+  }) {
     this.saleorApiUrl = props.saleorApiUrl;
     this.configurationId = props.configurationId;
+    this.appId = props.appId;
   }
 
   private static getSaleorApiUrlOrThrow(searchParams: URLSearchParams): SaleorApiUrl {
@@ -52,6 +68,17 @@ export class WebhookParams {
     throw new BaseError(`${WebhookParams.configurationIdIdSearchParam} URL param is invalid`);
   }
 
+  private static getAppIdOrThrow(searchParams: URLSearchParams): string {
+    const configurationId = searchParams.get(this.appIdSearchParam);
+    const parsedAppId = z.string().min(3).safeParse(configurationId);
+
+    if (parsedAppId.success) {
+      return parsedAppId.data;
+    }
+
+    throw new BaseError(`${WebhookParams.appIdSearchParam} URL param is invalid`);
+  }
+
   static createFromWebhookUrl(
     url: string,
   ): Result<WebhookParams, InstanceType<typeof WebhookParams.ParsingError>> {
@@ -61,11 +88,13 @@ export class WebhookParams {
       // Inner error will be caught by catch and remapped
       const saleorApiUrlVo = this.getSaleorApiUrlOrThrow(searchParams);
       const channelId = this.getConfigurationIdOrThrow(searchParams);
+      const appId = this.getAppIdOrThrow(searchParams);
 
       return ok(
         new WebhookParams({
           saleorApiUrl: saleorApiUrlVo,
           configurationId: channelId,
+          appId,
         }),
       );
     } catch (error) {
@@ -78,10 +107,15 @@ export class WebhookParams {
     }
   }
 
-  static createFromParams(params: { saleorApiUrl: SaleorApiUrl; configurationId: string }) {
+  static createFromParams(params: {
+    saleorApiUrl: SaleorApiUrl;
+    configurationId: string;
+    appId: string;
+  }) {
     return new WebhookParams({
       saleorApiUrl: params.saleorApiUrl,
       configurationId: params.configurationId,
+      appId: params.appId,
     });
   }
 }
diff --git a/apps/stripe/src/modules/app-config/trpc-handlers/new-stripe-config-trpc-handler.ts b/apps/stripe/src/modules/app-config/trpc-handlers/new-stripe-config-trpc-handler.ts
index 4f2edd57c..25ec6fe15 100644
--- a/apps/stripe/src/modules/app-config/trpc-handlers/new-stripe-config-trpc-handler.ts
+++ b/apps/stripe/src/modules/app-config/trpc-handlers/new-stripe-config-trpc-handler.ts
@@ -103,6 +103,7 @@ export class NewStripeConfigTrpcHandler {
         {
           saleorApiUrl: saleorApiUrl.value,
           appUrl: ctx.appUrl,
+          appId: ctx.appId,
         },
       );
 
diff --git a/apps/stripe/src/modules/stripe/stripe-webhook-manager.test.ts b/apps/stripe/src/modules/stripe/stripe-webhook-manager.test.ts
index 3310d372a..5392b3843 100644
--- a/apps/stripe/src/modules/stripe/stripe-webhook-manager.test.ts
+++ b/apps/stripe/src/modules/stripe/stripe-webhook-manager.test.ts
@@ -1,7 +1,7 @@
 import Stripe from "stripe";
 import { beforeEach, describe, expect, it, vi } from "vitest";
 
-import { mockedConfigurationId } from "@/__tests__/mocks/constants";
+import { mockedConfigurationId, mockedSaleorAppId } from "@/__tests__/mocks/constants";
 import { mockedStripePublishableKey } from "@/__tests__/mocks/mocked-stripe-publishable-key";
 import { mockedStripeRestrictedKey } from "@/__tests__/mocks/mocked-stripe-restricted-key";
 import { mockedSaleorApiUrl } from "@/__tests__/mocks/saleor-api-url";
@@ -42,6 +42,7 @@ describe("StripeWebhookManager", () => {
         {
           appUrl: "not url",
           saleorApiUrl: mockedSaleorApiUrl,
+          appId: mockedSaleorAppId,
         },
       );
 
@@ -69,6 +70,7 @@ describe("StripeWebhookManager", () => {
         {
           appUrl: "http://localhost:3000",
           saleorApiUrl: mockedSaleorApiUrl,
+          appId: mockedSaleorAppId,
         },
       );
 
@@ -92,6 +94,7 @@ describe("StripeWebhookManager", () => {
         {
           appUrl: "http://localhost:3000",
           saleorApiUrl: mockedSaleorApiUrl,
+          appId: mockedSaleorAppId,
         },
       );
 
@@ -138,6 +141,7 @@ describe("StripeWebhookManager", () => {
       {
         appUrl: "http://localhost:3000",
         saleorApiUrl: mockedSaleorApiUrl,
+        appId: mockedSaleorAppId,
       },
     );
 
@@ -165,7 +169,7 @@ describe("StripeWebhookManager", () => {
           "metadata": {
             "saleorAppConfigurationId": "81f323bd-91e2-4838-ab6e-5affd81ffc3b",
           },
-          "url": "http://localhost:3000/api/stripe/webhook?configurationId=81f323bd-91e2-4838-ab6e-5affd81ffc3b&saleorApiUrl=https%3A%2F%2Ffoo.bar.saleor.cloud%2Fgraphql%2F",
+          "url": "http://localhost:3000/api/stripe/webhook?configurationId=81f323bd-91e2-4838-ab6e-5affd81ffc3b&saleorApiUrl=https%3A%2F%2Ffoo.bar.saleor.cloud%2Fgraphql%2F&appId=saleor-app-id",
         }
       `);
   });
diff --git a/apps/stripe/src/modules/stripe/stripe-webhook-manager.ts b/apps/stripe/src/modules/stripe/stripe-webhook-manager.ts
index 28f12b1ff..869a54d8d 100644
--- a/apps/stripe/src/modules/stripe/stripe-webhook-manager.ts
+++ b/apps/stripe/src/modules/stripe/stripe-webhook-manager.ts
@@ -100,7 +100,7 @@ export class StripeWebhookManager {
     config: NewStripeConfigInput & {
       configurationId: string;
     },
-    { appUrl, saleorApiUrl }: { appUrl: string; saleorApiUrl: SaleorApiUrl },
+    { appUrl, saleorApiUrl, appId }: { appUrl: string; saleorApiUrl: SaleorApiUrl; appId: string },
   ): Promise<
     Result<
       {
@@ -121,6 +121,7 @@ export class StripeWebhookManager {
       webhookParams: WebhookParams.createFromParams({
         saleorApiUrl: saleorApiUrl,
         configurationId: config.configurationId,
+        appId,
       }),
     });
 
diff --git a/apps/stripe/src/modules/stripe/stripe-webhook-url-builder.test.ts b/apps/stripe/src/modules/stripe/stripe-webhook-url-builder.test.ts
index ecc1da4e7..703a2b951 100644
--- a/apps/stripe/src/modules/stripe/stripe-webhook-url-builder.test.ts
+++ b/apps/stripe/src/modules/stripe/stripe-webhook-url-builder.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from "vitest";
 
-import { mockedConfigurationId } from "@/__tests__/mocks/constants";
+import { mockedConfigurationId, mockedSaleorAppId } from "@/__tests__/mocks/constants";
 import { mockedSaleorApiUrl } from "@/__tests__/mocks/saleor-api-url";
 import { WebhookParams } from "@/app/api/stripe/webhook/webhook-params";
 import { StripeWebhookUrlBuilder } from "@/modules/stripe/stripe-webhook-url-builder";
@@ -11,6 +11,7 @@ describe("StripeWebhookUrlBuilder", () => {
       const webhookParams = WebhookParams.createFromParams({
         saleorApiUrl: mockedSaleorApiUrl,
         configurationId: mockedConfigurationId,
+        appId: mockedSaleorAppId,
       });
 
       const urlBuilder = new StripeWebhookUrlBuilder();
@@ -21,7 +22,7 @@ describe("StripeWebhookUrlBuilder", () => {
       });
 
       expect(result._unsafeUnwrap()).toMatchInlineSnapshot(
-        `"http://localhost:3000/api/stripe/webhook?configurationId=81f323bd-91e2-4838-ab6e-5affd81ffc3b&saleorApiUrl=https%3A%2F%2Ffoo.bar.saleor.cloud%2Fgraphql%2F"`,
+        `"http://localhost:3000/api/stripe/webhook?configurationId=81f323bd-91e2-4838-ab6e-5affd81ffc3b&saleorApiUrl=https%3A%2F%2Ffoo.bar.saleor.cloud%2Fgraphql%2F&appId=saleor-app-id"`,
       );
 
       /**
diff --git a/apps/stripe/src/modules/stripe/stripe-webhook-url-builder.ts b/apps/stripe/src/modules/stripe/stripe-webhook-url-builder.ts
index 238ab81e4..3d38fd445 100644
--- a/apps/stripe/src/modules/stripe/stripe-webhook-url-builder.ts
+++ b/apps/stripe/src/modules/stripe/stripe-webhook-url-builder.ts
@@ -21,6 +21,7 @@ export class StripeWebhookUrlBuilder {
         WebhookParams.saleorApiUrlSearchParam,
         webhookParams.saleorApiUrl,
       );
+      webhookUrl.searchParams.set(WebhookParams.appIdSearchParam, webhookParams.appId);
 
       return ok(webhookUrl.toString());
     } catch (e) {


Reviews:
user: login: lkostrowski
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T05:25:50Z
user: login: lkostrowski
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T05:36:03Z
user: login: kzuraw
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T06:59:22Z
user: login: kzuraw
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T07:04:07Z

Review comments:
diff_hunk: @@ -130,6 +130,19 @@ export class StripeWebhookUseCase {
       );
     }
 
+    if (authData.appId !== webhookParams.appId) {
+      this.logger.error(
+        "Received webhook with different appId than expected. There may be old webhook from uninstalled app",
+      );
+
+      /*
+       * todo that next?
+       * - return 200?
+       * - return 500?
+       * - remove webhook and return 200?
+       */
path: apps/stripe/src/app/api/stripe/webhook/use-case.ts
user: login: lkostrowski
body: @krzysztofzuraw wdyt? I guess removing is not a big deal here
created_at: 2025-05-09T05:25:50Z
author_association: MEMBER
diff_hunk: @@ -130,6 +130,19 @@ export class StripeWebhookUseCase {
       );
     }
 
+    if (authData.appId !== webhookParams.appId) {
+      this.logger.error(
+        "Received webhook with different appId than expected. There may be old webhook from uninstalled app",
+      );
+
+      /*
+       * todo that next?
+       * - return 200?
+       * - return 500?
+       * - remove webhook and return 200?
+       */
path: apps/stripe/src/app/api/stripe/webhook/use-case.ts
user: login: lkostrowski
body: Nevermind, I think we should just remove it, adding
created_at: 2025-05-09T05:36:03Z
author_association: MEMBER

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-09T05:25:30Z
body: ###  ⚠️  No Changeset found

Latest commit: daa35c924e1a7e1f8b57bd00bac4a0bc98f49ab2

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/saleor/apps/new/EXT-2247?filename=.changeset/hip-eggs-draw.md&value=---%0A%22saleor-app-payment-stripe%22%3A%20patch%0A---%0A%0AAdd%20app%20id%20to%20webhook%20url%0A)


author_association: NONE
user: login: vercel[bot]
created_at: 2025-05-09T05:25:31Z
body: [vc]: #jKqPF83weLhzqG/hpR/+9Wg1L27wm/8mREf7Mn6xApI=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJzYWxlb3ItYXBwLXNlYXJjaCIsInJvb3REaXJlY3RvcnkiOiJhcHBzL3NlYXJjaCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3NhbGVvcmNvbW1lcmNlL3NhbGVvci1hcHAtc2VhcmNoLzZ5ZkVqQXhKdVFMOUFvNk00ZnBBeFFhMWZVWlYiLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQifSx7Im5hbWUiOiJzYWxlb3ItYXBwLWtsYXZpeW8iLCJyb290RGlyZWN0b3J5IjoiYXBwcy9rbGF2aXlvIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2FsZW9yY29tbWVyY2Uvc2FsZW9yLWFwcC1rbGF2aXlvLzRrdzNyR1FnUnA1QVp4c0hyY1lYejR0REppVTYiLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQifSx7Im5hbWUiOiJzYWxlb3ItYXBwLXByb2R1Y3RzLWZlZWQiLCJyb290RGlyZWN0b3J5IjoiYXBwcy9wcm9kdWN0cy1mZWVkIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2FsZW9yY29tbWVyY2Uvc2FsZW9yLWFwcC1wcm9kdWN0cy1mZWVkLzhta2FYQkZrVjViNk5ReVZ1OEhGcVVLZUhHN2siLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQifSx7Im5hbWUiOiJzYWxlb3ItYXBwLWNtcyIsInJvb3REaXJlY3RvcnkiOiJhcHBzL2NtcyIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3NhbGVvcmNvbW1lcmNlL3NhbGVvci1hcHAtY21zLzc5UTFIQmFBOUhLVnp5M1pTS3VVSGJhVFNadjgiLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQifSx7Im5hbWUiOiJzYWxlb3ItYXBwLWF2YXRheCIsInJvb3REaXJlY3RvcnkiOiJhcHBzL2F2YXRheCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3NhbGVvcmNvbW1lcmNlL3NhbGVvci1hcHAtYXZhdGF4LzNUUHRyQVh0SGV5NHBRU0w1Q0Y2eEhIQTZIcG8iLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQifSx7Im5hbWUiOiJzYWxlb3ItYXBwLXNtdHAiLCJyb290RGlyZWN0b3J5IjoiYXBwcy9zbXRwIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2FsZW9yY29tbWVyY2Uvc2FsZW9yLWFwcC1zbXRwL0ROM3hSZkNkMms0c0pDZFFIeEg3NEQ1ZTZDbXciLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQifSx7Im5hbWUiOiJzYWxlb3ItYXBwLXNlZ21lbnQiLCJyb290RGlyZWN0b3J5IjoiYXBwcy9zZWdtZW50IiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6IiJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vc2FsZW9yY29tbWVyY2Uvc2FsZW9yLWFwcC1zZWdtZW50Lzl4dVZYUmdicDRLdmoxWGpqdmtXRHlpMmNaa3AiLCJwcmV2aWV3VXJsIjoiIiwibmV4dENvbW1pdFN0YXR1cyI6IlNLSVBQRUQifSx7Im5hbWUiOiJzYWxlb3ItYXBwLXBheW1lbnRzLXN0cmlwZS12MiIsInJvb3REaXJlY3RvcnkiOiJhcHBzL3N0cmlwZSIsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9zYWxlb3Jjb21tZXJjZS9zYWxlb3ItYXBwLXBheW1lbnRzLXN0cmlwZS12Mi80VVNieFlyeWFBQjZtQ24zMVdWN1dBWUh2aWJ3IiwicHJldmlld1VybCI6InNhbGVvci1hcHAtcGF5bWVudHMtc3RyaXBlLXYyLWdpdC1leHQtMjI0Ny1zYWxlb3Jjb21tZXJjZS52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6InNhbGVvci1hcHAtcGF5bWVudHMtc3RyaXBlLXYyLWdpdC1leHQtMjI0Ny1zYWxlb3Jjb21tZXJjZS52ZXJjZWwuYXBwIn19XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **saleor-app-payments-stripe-v2** | ✅ Ready ([Inspect](https://vercel.com/saleorcommerce/saleor-app-payments-stripe-v2/4USbxYryaAB6mCn31WV7WAYHvibw)) | [Visit Preview](https://saleor-app-payments-stripe-v2-git-ext-2247-saleorcommerce.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/saleor-app-payments-stripe-v2-git-ext-2247-saleorcommerce.vercel.app?via=pr-comment-feedback-link) | May 9, 2025 7:06am |

<details><summary>7 Skipped Deployments</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **saleor-app-avatax** | ⬜️ Skipped ([Inspect](https://vercel.com/saleorcommerce/saleor-app-avatax/3TPtrAXtHey4pQSL5CF6xHHA6Hpo)) |  |  | May 9, 2025 7:06am |
| **saleor-app-cms** | ⬜️ Skipped ([Inspect](https://vercel.com/saleorcommerce/saleor-app-cms/79Q1HBaA9HKVzy3ZSKuUHbaTSZv8)) |  |  | May 9, 2025 7:06am |
| **saleor-app-klaviyo** | ⬜️ Skipped ([Inspect](https://vercel.com/saleorcommerce/saleor-app-klaviyo/4kw3rGQgRp5AZxsHrcYXz4tDJiU6)) |  |  | May 9, 2025 7:06am |
| **saleor-app-products-feed** | ⬜️ Skipped ([Inspect](https://vercel.com/saleorcommerce/saleor-app-products-feed/8mkaXBFkV5b6NQyVu8HFqUKeHG7k)) |  |  | May 9, 2025 7:06am |
| **saleor-app-search** | ⬜️ Skipped ([Inspect](https://vercel.com/saleorcommerce/saleor-app-search/6yfEjAxJuQL9Ao6M4fpAxQa1fUZV)) |  |  | May 9, 2025 7:06am |
| **saleor-app-segment** | ⬜️ Skipped ([Inspect](https://vercel.com/saleorcommerce/saleor-app-segment/9xuVXRgbp4Kvj1XjjvkWDyi2cZkp)) |  |  | May 9, 2025 7:06am |
| **saleor-app-smtp** | ⬜️ Skipped ([Inspect](https://vercel.com/saleorcommerce/saleor-app-smtp/DN3xRfCd2k4sJCdQHxH74D5e6Cmw)) |  |  | May 9, 2025 7:06am |
</details>









author_association: NONE
user: login: codecov[bot]
created_at: 2025-05-09T05:27:28Z
body: ## [Codecov](https://app.codecov.io/gh/saleor/apps/pull/1912?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=saleor) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 24.38%. Comparing base [(`841a7e3`)](https://app.codecov.io/gh/saleor/apps/commit/841a7e3f0a1113fb1bde02e9dc02e2fd152fa56b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=saleor) to head [(`daa35c9`)](https://app.codecov.io/gh/saleor/apps/commit/daa35c924e1a7e1f8b57bd00bac4a0bc98f49ab2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=saleor).
> Report is 2 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##             main    #1912   +/-   ##
=======================================
  Coverage   24.38%   24.38%           
=======================================
  Files         694      694           
  Lines       51563    51563           
  Branches     1636     1636           
=======================================
  Hits        12576    12576           
  Misses      38652    38652           
  Partials      335      335           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/saleor/apps/pull/1912?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=saleor).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=saleor).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE