diff --git a/app/models/user.rb b/app/models/user.rb
index dae7ea75583381fc41c5949b9aff54c684bfbf95..856b90327a0be75a483b5e9725dfce45aebcb44a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1783,7 +1783,11 @@ def owns_runner?(runner)
 
   def notification_email_for(notification_group)
     # Return group-specific email address if present, otherwise return global notification email address
-    notification_group&.notification_email_for(self) || notification_email_or_default
+    group_email = if notification_group && notification_group.respond_to?(:notification_email_for)
+                    notification_group.notification_email_for(self)
+                  end
+
+    group_email || notification_email_or_default
   end
 
   def notification_settings_for(source, inherit: false)
diff --git a/ee/app/models/gitlab_subscription.rb b/ee/app/models/gitlab_subscription.rb
index 0a860aaba531cc40e827759cc658df6d3260fc55..aa9edc085bab3cbfeef4adfdc92884d11580cb60 100644
--- a/ee/app/models/gitlab_subscription.rb
+++ b/ee/app/models/gitlab_subscription.rb
@@ -36,6 +36,11 @@ class GitlabSubscription < ApplicationRecord
 
   scope :preload_for_refresh_seat, -> { preload([{ namespace: :route }, :hosted_plan]) }
 
+  scope :max_seats_used_changed_between, -> (from:, to:) do
+    where('max_seats_used_changed_at >= ?', from)
+      .where('max_seats_used_changed_at <= ?', to)
+  end
+
   DAYS_AFTER_EXPIRATION_BEFORE_REMOVING_FROM_INDEX = 30
 
   # We set a threshold for expiration before removing them from
diff --git a/ee/app/services/gitlab_subscriptions/notify_seats_exceeded_batch_service.rb b/ee/app/services/gitlab_subscriptions/notify_seats_exceeded_batch_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..7c152c3413aa9f6a85c86a8b412a9f446ddd0c8e
--- /dev/null
+++ b/ee/app/services/gitlab_subscriptions/notify_seats_exceeded_batch_service.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+module GitlabSubscriptions
+  class NotifySeatsExceededBatchService
+    class << self
+      def execute
+        iterator.each_batch(of: 100) do |records|
+          records = records.to_a
+          namespaces = Namespace.where(id: records.pluck(:namespace_id)) # rubocop:disable CodeReuse/ActiveRecord
+
+          next if namespaces.count == 0
+
+          payload = build_payload(namespaces)
+          Gitlab::SubscriptionPortal::Client.send_seat_overage_notification_batch(payload)
+        end
+
+        ServiceResponse.success( message: 'Overage notifications sent' )
+      end
+
+      private
+
+      def iterator
+        @iterator ||= begin
+          order = Gitlab::Pagination::Keyset::Order.build(
+            [
+              Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+                attribute_name: 'max_seats_used_changed_at',
+                order_expression: GitlabSubscription.arel_table[:max_seats_used_changed_at].asc,
+                distinct: false
+              ),
+              Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+                attribute_name: 'namespace_id',
+                order_expression: GitlabSubscription.arel_table[:namespace_id].asc,
+                nullable: :not_nullable,
+                distinct: true
+              )
+            ])
+
+          from = Time.current.beginning_of_day - 1.day
+          to = Time.current.beginning_of_day
+          scope = GitlabSubscription
+            .max_seats_used_changed_between(from: from, to: to)
+            .order(order) # rubocop:disable CodeReuse/ActiveRecord
+
+          Gitlab::Pagination::Keyset::Iterator.new(scope: scope)
+        end
+      end
+
+      def build_payload(namespaces)
+        namespaces.map { |namespace| build_namespace(namespace) }
+      end
+
+      def build_namespace(namespace)
+        {
+          glNamespaceId: namespace.id,
+          maxSeatsUsed: namespace.gitlab_subscription.max_seats_used,
+          groupOwners: build_owners(namespace)
+        }
+      end
+
+      def build_owners(namespace)
+        namespace.owners.map do |owner|
+          {
+            id: owner.id,
+            email: owner&.notification_email_for(namespace),
+            fullName: owner.name
+          }
+        end
+      end
+    end
+  end
+end
diff --git a/ee/lib/gitlab/subscription_portal/clients/graphql.rb b/ee/lib/gitlab/subscription_portal/clients/graphql.rb
index 729acb8dd03f5c27af4d5bdb4b2e0b8a43192e00..4df60e9f6322430f485218a3c29b3457ef5aaa6b 100644
--- a/ee/lib/gitlab/subscription_portal/clients/graphql.rb
+++ b/ee/lib/gitlab/subscription_portal/clients/graphql.rb
@@ -276,6 +276,29 @@ def send_seat_overage_notification(group:, max_seats_used:)
             error(CONNECTIVITY_ERROR)
           end
 
+          def send_seat_overage_notification_batch(namespaces)
+            query = <<~GQL
+              mutation($namespaces: [NamespaceSeatOverageInput!]) {
+                sendSeatOverageNotificationEmail(input: {
+                  namespaces: $namespaces
+                }) {
+                  errors
+                }
+              }
+            GQL
+
+            response = execute_graphql_query(
+              query: query,
+              variables: { namespaces: namespaces }
+            )
+
+            parse_errors(response, query_name: 'sendSeatOverageNotificationEmail').presence || { success: true }
+          rescue *RESCUABLE_HTTP_ERRORS => e
+            Gitlab::ErrorTracking.log_exception(e)
+
+            error(CONNECTIVITY_ERROR)
+          end
+
           private
 
           def execute_graphql_query(params)
diff --git a/ee/spec/lib/gitlab/subscription_portal/clients/graphql_spec.rb b/ee/spec/lib/gitlab/subscription_portal/clients/graphql_spec.rb
index 120c02e2bc60f3158feb4383d129d17b257075f1..d9f7ce43227a7672aa30682460b42ca9933daed5 100644
--- a/ee/spec/lib/gitlab/subscription_portal/clients/graphql_spec.rb
+++ b/ee/spec/lib/gitlab/subscription_portal/clients/graphql_spec.rb
@@ -713,4 +713,82 @@
       end
     end
   end
+
+  describe '#send_seat_overage_notification_batch' do
+    let(:query_params) do
+      {
+        variables: { namespaces: [] },
+        query: <<~GQL
+        mutation($namespaces: [NamespaceSeatOverageInput!]) {
+          sendSeatOverageNotificationEmail(input: {
+            namespaces: $namespaces
+          }) {
+            errors
+          }
+        }
+        GQL
+      }
+    end
+
+    context 'when the subscription portal response is successful' do
+      it 'returns successfully' do
+        portal_response = {
+          success: true,
+          data: {
+            "data" => {
+              "sendSeatOverageNotificationEmail" => {
+                "errors" => []
+              }
+            }
+          }
+        }
+
+        expect(client).to receive(:execute_graphql_query).with(query_params).and_return(portal_response)
+
+        request = client.send_seat_overage_notification_batch([])
+
+        expect(request).to eq({ success: true })
+      end
+    end
+
+    context 'when the subscription portal response is unsuccessful' do
+      it 'returns an error response' do
+        portal_response = {
+          success: true,
+          data: {
+            "errors" => [
+              {
+                "message" => "Argument 'maxSeatsUsed' on InputObject 'SendSeatOverageNotificationEmailInput' has an invalid value (null). Expected type 'Int!'.",
+                "locations" => [{ "line": 2, "column": 43 }],
+                "path" => %w[mutation sendSeatOverageNotificationEmail input maxSeatsUsed],
+                "extensions" => {
+                  "code" => "argumentLiteralsIncompatible",
+                  "typeName" => "InputObject",
+                  "argumentName" => "maxSeatsUsed"
+                }
+              }
+            ]
+          }
+        }
+
+        expect(client).to receive(:execute_graphql_query).with(query_params).and_return(portal_response)
+
+        request = client.send_seat_overage_notification_batch([])
+
+        expect(request[:success]).to be false
+        expect(request[:errors]).not_to be_empty
+      end
+    end
+
+    context 'when there is a network connectivity error' do
+      it 'returns an error response' do
+        allow(client).to receive(:execute_graphql_query).and_raise(HTTParty::Error)
+        expect(Gitlab::ErrorTracking).to receive(:log_exception).with(kind_of(HTTParty::Error))
+
+        request = client.send_seat_overage_notification_batch(group: build(:group), max_seats_used: nil)
+
+        expect(request).to eq({ success: false, errors: "CONNECTIVITY_ERROR" })
+      end
+    end
+  end
 end
diff --git a/ee/spec/models/gitlab_subscription_spec.rb b/ee/spec/models/gitlab_subscription_spec.rb
index bf1a016c4d7660573a5a739330f5596c504ec60e..90bbdf6885dea08742846ee74de62082dbd4484a 100644
--- a/ee/spec/models/gitlab_subscription_spec.rb
+++ b/ee/spec/models/gitlab_subscription_spec.rb
@@ -45,6 +45,24 @@
         expect(described_class.with_hosted_plan('bronze')).to be_empty
       end
     end
+
+    describe '.max_seats_used_changed_between', :timecop do
+      let(:from) { Time.current.beginning_of_day - 1.day }
+      let(:to) { Time.current.beginning_of_day }
+
+      let!(:in_bounds_subscription) do
+        create(:gitlab_subscription, max_seats_used_changed_at: to - 1.hour)
+      end
+
+      let!(:out_of_bounds_subscription) do
+        create(:gitlab_subscription, max_seats_used_changed_at: from - 1.hour)
+      end
+
+      it 'returns relevant subscriptions' do
+        expect(described_class.max_seats_used_changed_between(from: from, to: to))
+          .to contain_exactly(in_bounds_subscription)
+      end
+    end
   end
 
   describe '#calculate_seats_in_use' do
diff --git a/ee/spec/services/gitlab_subscriptions/notify_seats_exceeded_batch_service_spec.rb b/ee/spec/services/gitlab_subscriptions/notify_seats_exceeded_batch_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d08e51002acf2eaf44dbeeadc05ba7c702a32743
--- /dev/null
+++ b/ee/spec/services/gitlab_subscriptions/notify_seats_exceeded_batch_service_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSubscriptions::NotifySeatsExceededBatchService, :timecop, :saas do
+  describe '.execute' do
+    context 'when subscriptions are present' do
+      let(:changed_at_time) { Time.current.beginning_of_day - 1.hour }
+      let(:user_namespace_owner) { user_namespace.owner }
+      let(:group_namespace_owner) { create(:user) }
+      let(:user_namespace) { create(:user_namespace) }
+
+      let(:group_namespace) do
+        group = create(:group)
+        group.add_owner(group_namespace_owner)
+
+        group
+      end
+
+      let!(:user_namespace_subscription) do
+        create(:gitlab_subscription, namespace: user_namespace, max_seats_used_changed_at: changed_at_time)
+      end
+
+      let!(:group_namespace_subscription) do
+        create(:gitlab_subscription, namespace: group_namespace, max_seats_used_changed_at: changed_at_time)
+      end
+
+      it 'sends notifications' do
+        expect(Gitlab::SubscriptionPortal::Client).to receive(:send_seat_overage_notification_batch).with(
+          a_collection_containing_exactly(
+            {
+              glNamespaceId: user_namespace.id,
+              groupOwners: [{
+                id: user_namespace_owner.id,
+                email: user_namespace_owner.notification_email_for(user_namespace),
+                fullName: user_namespace_owner.name
+              }],
+              maxSeatsUsed: user_namespace_subscription.max_seats_used
+            },
+            {
+              glNamespaceId: group_namespace.id,
+              groupOwners: [{
+                id: group_namespace_owner.id,
+                email: group_namespace_owner.notification_email_for(group_namespace),
+                fullName: group_namespace_owner.name
+              }],
+              maxSeatsUsed: group_namespace_subscription.max_seats_used
+            }
+          ))
+
+        result = described_class.execute
+
+        expect(result.success?).to be true
+        expect(result.message).to eq('Overage notifications sent')
+      end
+    end
+
+    context 'with no subscriptions' do
+      it 'does not send notifications' do
+        expect(Gitlab::SubscriptionPortal::Client).not_to receive(:send_seat_overage_notification_batch)
+
+        described_class.execute
+      end
+
+      it 'returns success' do
+        result = described_class.execute
+
+        expect(result.success?).to be true
+      end
+    end
+  end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 43541250904abe744d3a3e20e49f824350049a2f..8537e4b14386b93899889e677c70f026ee20cdfa 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -6163,33 +6163,44 @@ def access_levels(groups)
 
   describe '#notification_email_for' do
     let(:user) { create(:user) }
-    let(:group) { create(:group) }
 
-    subject { user.notification_email_for(group) }
+    subject { user.notification_email_for(namespace) }
 
-    context 'when group is nil' do
-      let(:group) { nil }
+    context 'when namespace is nil' do
+      let(:namespace) { nil }
 
       it 'returns global notification email' do
         is_expected.to eq(user.notification_email_or_default)
       end
     end
 
-    context 'when group has no notification email set' do
-      it 'returns global notification email' do
-        create(:notification_setting, user: user, source: group, notification_email: '')
+    context 'for group namespace' do
+      let(:namespace) { create(:group) }
 
-        is_expected.to eq(user.notification_email_or_default)
+      context 'when group has no notification email set' do
+        it 'returns global notification email' do
+          create(:notification_setting, user: user, source: namespace, notification_email: '')
+
+          is_expected.to eq(user.notification_email_or_default)
+        end
+      end
+
+      context 'when group has notification email set' do
+        it 'returns group notification email' do
+          group_notification_email = 'user+group@example.com'
+          create(:email, :confirmed, user: user, email: group_notification_email)
+          create(:notification_setting, user: user, source: namespace, notification_email: group_notification_email)
+
+          is_expected.to eq(group_notification_email)
+        end
       end
     end
 
-    context 'when group has notification email set' do
-      it 'returns group notification email' do
-        group_notification_email = 'user+group@example.com'
-        create(:email, :confirmed, user: user, email: group_notification_email)
-        create(:notification_setting, user: user, source: group, notification_email: group_notification_email)
+    context 'for user namespace' do
+      let(:namespace) { create(:user_namespace) }
 
-        is_expected.to eq(group_notification_email)
+      it 'returns global notification email' do
+        is_expected.to eq(user.notification_email_or_default)
       end
     end
   end