diff --git a/ee/app/models/gitlab_subscription.rb b/ee/app/models/gitlab_subscription.rb
index 520dae7e7b7073cde5b820c4a7affa870500a95c..1db078011b5aa904fb99d69f59a563b11fc3717a 100644
--- a/ee/app/models/gitlab_subscription.rb
+++ b/ee/app/models/gitlab_subscription.rb
@@ -74,6 +74,10 @@ def calculate_seats_owed
     [0, max_seats_used - seats].max
   end
 
+  def seats_remaining
+    [0, seats - max_seats_used.to_i].max
+  end
+
   # Refresh seat related attribute (without persisting them)
   def refresh_seat_attributes!
     self.seats_in_use = calculate_seats_in_use
diff --git a/ee/app/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service.rb b/ee/app/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0ecefdf6ec053b133eafab69793056e2a5200217
--- /dev/null
+++ b/ee/app/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+#
+module GitlabSubscriptions
+  module Reconciliations
+    class CalculateSeatCountDataService
+      include Gitlab::Utils::StrongMemoize
+
+      # For smaller groups we want to alert when they have a set quantity of seats remaining.
+      # For larger groups we want to alert them when they have a percentage of seats remaining.
+      SEAT_COUNT_THRESHOLD_LIMITS = [
+        { range: (0..15), percentage: false, value: 1 },
+        { range: (16..25), percentage: false, value: 2 },
+        { range: (26..99), percentage: true, value: 10 },
+        { range: (100..999), percentage: true, value: 8 },
+        { range: (1000..nil), percentage: true, value: 5 }
+      ].freeze
+
+      attr_reader :namespace, :user
+
+      delegate :max_seats_used, :max_seats_used_changed_at, :seats, :seats_remaining, to: :current_subscription
+
+      def initialize(namespace:, user:)
+        @namespace = namespace
+        @user = user
+      end
+
+      def execute
+        return unless owner_of_paid_group? && seat_count_threshold_reached?
+        return if user_dismissed_alert?
+        return unless alert_user_overage?
+
+        {
+          namespace: namespace,
+          remaining_seat_count: seats_remaining,
+          seats_in_use: max_seats_used,
+          total_seat_count: seats
+        }
+      end
+
+      private
+
+      def owner_of_paid_group?
+        (::Gitlab::CurrentSettings.should_check_namespace_plan? &&
+          namespace.group_namespace? &&
+          user.can?(:admin_group, namespace) &&
+          current_subscription).present?
+      end
+
+      def adapted_remaining_user_count
+        return seats_remaining.fdiv(seats) * 100 if current_seat_count_threshold[:percentage]
+
+        seats_remaining
+      end
+
+      def alert_user_overage?
+        CheckSeatUsageAlertsEligibilityService.new(namespace: namespace).execute
+      end
+
+      def current_subscription
+        namespace.gitlab_subscription
+      end
+
+      def current_seat_count_threshold
+        strong_memoize(:current_seat_count_threshold) do
+          SEAT_COUNT_THRESHOLD_LIMITS.find do |threshold|
+            threshold[:range].cover?(seats)
+          end
+        end
+      end
+
+      def seat_count_threshold_reached?
+        max_seats_used &&
+          max_seats_used < seats &&
+          current_seat_count_threshold[:value] >= adapted_remaining_user_count
+      end
+
+      def user_dismissed_alert?
+        user.dismissed_callout_for_group?(
+          feature_name: Users::GroupCalloutsHelper::APPROACHING_SEAT_COUNT_THRESHOLD,
+          group: namespace,
+          ignore_dismissal_earlier_than: max_seats_used_changed_at
+        )
+      end
+    end
+  end
+end
diff --git a/ee/app/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service.rb b/ee/app/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d14a8a72ee5db7ac0056c9a74ac9963aa53d064d
--- /dev/null
+++ b/ee/app/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+# Service to determine if the given namespace meets the criteria to see
+# alert usage overage alerts when they cross the usage thresholds.
+# This service only determines eligibility from the CustomersDot application.
+#
+# If there is a problem querying CustomersDot, it assumes the status is false
+#
+# returns true, false
+module GitlabSubscriptions
+  module Reconciliations
+    class CheckSeatUsageAlertsEligibilityService
+      def initialize(namespace:)
+        @namespace = namespace
+      end
+
+      def execute
+        return false unless namespace.gitlab_subscription.present?
+
+        eligible_for_seat_usage_alerts
+      end
+
+      private
+
+      attr_reader :namespace
+
+      def client
+        Gitlab::SubscriptionPortal::Client
+      end
+
+      def eligible_for_seat_usage_alerts_request
+        response = client.subscription_seat_usage_alerts_eligibility(namespace.id)
+
+        return false unless response[:success]
+
+        response[:eligible_for_seat_usage_alerts]
+      end
+
+      def cache
+        Rails.cache
+      end
+
+      def cache_key
+        "subscription:eligible_for_seat_usage_alerts:namespace:#{namespace.gitlab_subscription.cache_key}"
+      end
+
+      def eligible_for_seat_usage_alerts
+        cache.fetch(cache_key, skip_nil: true, expires_in: 1.day) { eligible_for_seat_usage_alerts_request } || false
+      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 e87d9b7a807a6630af9e6616278e78fd07002a58..f4efa55b842c297a78b92f6878939bff39be7075 100644
--- a/ee/lib/gitlab/subscription_portal/clients/graphql.rb
+++ b/ee/lib/gitlab/subscription_portal/clients/graphql.rb
@@ -86,6 +86,30 @@ def plan_upgrade_offer(namespace_id)
             end
           end
 
+          def subscription_seat_usage_alerts_eligibility(namespace_id)
+            return error('Must provide a namespace ID') unless namespace_id
+
+            query = <<~GQL
+              query($namespaceId: ID!) {
+                subscription(namespaceId: $namespaceId) {
+                  eligibleForSeatUsageAlerts
+                }
+              }
+            GQL
+
+            response = execute_graphql_query({ query: query, variables: { namespaceId: namespace_id } })
+
+            if response[:success]
+              {
+                success: true,
+                eligible_for_seat_usage_alerts: response.dig(:data, 'data', 'subscription',
+                                                             'eligibleForSeatUsageAlerts')
+              }
+            else
+              error(response.dig(:data, :errors))
+            end
+          end
+
           def subscription_last_term(namespace_id)
             return error('Must provide a namespace ID') unless namespace_id
 
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 e3a9a3eed7873a7f7893c084cda716d26589b9d6..e337d05016c7d24e0689b9d1a91b7b21ba73ed8c 100644
--- a/ee/spec/lib/gitlab/subscription_portal/clients/graphql_spec.rb
+++ b/ee/spec/lib/gitlab/subscription_portal/clients/graphql_spec.rb
@@ -204,6 +204,69 @@
     end
   end
 
+  describe '#subscription_seat_usage_alerts_eligibility' do
+    let(:query) do
+      <<~GQL
+        query($namespaceId: ID!) {
+          subscription(namespaceId: $namespaceId) {
+            eligibleForSeatUsageAlerts
+          }
+        }
+      GQL
+    end
+
+    it 'returns success when a valid license key is specified' do
+      expected_args = {
+        query: query,
+        variables: {
+          namespaceId: 'namespace-id'
+        }
+      }
+
+      expected_response = {
+        success: true,
+        data: {
+          "data" => {
+            "subscription" => {
+              "eligibleForSeatUsageAlerts" => true
+            }
+          }
+        }
+      }
+
+      expect(client).to receive(:execute_graphql_query).with(expected_args).and_return(expected_response)
+
+      result = client.subscription_seat_usage_alerts_eligibility('namespace-id')
+
+      expect(result).to eq({ success: true, eligible_for_seat_usage_alerts: true })
+    end
+
+    it 'returns failure when license_key is invalid' do
+      error = "some error"
+      expect(client).to receive(:execute_graphql_query).and_return(
+        {
+          success: false,
+          data: {
+            errors: error
+          }
+        }
+      )
+
+      result = client.subscription_seat_usage_alerts_eligibility('failing-namespace-id')
+
+      expect(result).to eq({ success: false, errors: error })
+    end
+
+    context 'with no namespace_id' do
+      it 'returns failure' do
+        expect(client).not_to receive(:execute_graphql_query)
+
+        expect(client.subscription_seat_usage_alerts_eligibility(nil))
+          .to eq({ success: false, errors: 'Must provide a namespace ID' })
+      end
+    end
+  end
+
   describe '#get_plans' do
     subject { client.get_plans(tags: ['test-plan-id']) }
 
diff --git a/ee/spec/models/gitlab_subscription_spec.rb b/ee/spec/models/gitlab_subscription_spec.rb
index 3c446690c68e048932dc5149c2308f74ecfc7a06..ee97077bbabda781705645a210a75d92069ea728 100644
--- a/ee/spec/models/gitlab_subscription_spec.rb
+++ b/ee/spec/models/gitlab_subscription_spec.rb
@@ -169,6 +169,40 @@
     end
   end
 
+  describe '#seats_remaining' do
+    context 'when there are more seats used than available in the subscription' do
+      it 'returns zero' do
+        subscription = build(:gitlab_subscription, seats: 10, max_seats_used: 15)
+
+        expect(subscription.seats_remaining).to eq 0
+      end
+    end
+
+    context 'when seats used equals seats in subscription' do
+      it 'returns zero' do
+        subscription = build(:gitlab_subscription, seats: 10, max_seats_used: 10)
+
+        expect(subscription.seats_remaining).to eq 0
+      end
+    end
+
+    context 'when there are seats left in the subscription' do
+      it 'returns the seat count remaining from the max seats used' do
+        subscription = build(:gitlab_subscription, seats: 10, max_seats_used: 5)
+
+        expect(subscription.seats_remaining).to eq 5
+      end
+    end
+
+    context 'when max seat data has not yet been generated for the subscription' do
+      it 'returns the seat count of the subscription' do
+        subscription = build(:gitlab_subscription, seats: 10, max_seats_used: nil)
+
+        expect(subscription.seats_remaining).to eq 10
+      end
+    end
+  end
+
   describe '#refresh_seat_attributes!' do
     subject(:gitlab_subscription) { create(:gitlab_subscription, seats: 3, max_seats_used: 2) }
 
diff --git a/ee/spec/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service_spec.rb b/ee/spec/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1d2e47f1dcc0867a748712a4859fe711ae23b0da
--- /dev/null
+++ b/ee/spec/services/gitlab_subscriptions/reconciliations/calculate_seat_count_data_service_spec.rb
@@ -0,0 +1,165 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSubscriptions::Reconciliations::CalculateSeatCountDataService, :saas do
+  using RSpec::Parameterized::TableSyntax
+
+  describe '#execute' do
+    let_it_be(:user) { create(:user) }
+
+    let(:should_check_namespace_plan) { true }
+
+    before do
+      allow_next_instance_of(GitlabSubscriptions::Reconciliations::CheckSeatUsageAlertsEligibilityService) do |service|
+        expect(service).to receive(:execute).and_return(alert_user_overage)
+      end
+
+      allow(::Gitlab::CurrentSettings).to receive(:should_check_namespace_plan?).and_return(should_check_namespace_plan)
+    end
+
+    subject(:execute_service) { described_class.new(namespace: root_ancestor, user: user).execute }
+
+    context 'with no subscription' do
+      let(:root_ancestor) { create(:group) }
+      let(:alert_user_overage) { true }
+
+      before do
+        root_ancestor.add_owner(user)
+      end
+
+      it { is_expected.to be nil }
+    end
+
+    context 'when conditions are not met' do
+      let(:max_seats_used) { 9 }
+
+      before do
+        create(
+          :gitlab_subscription,
+          namespace: root_ancestor,
+          plan_code: Plan::ULTIMATE,
+          seats: 10,
+          max_seats_used: max_seats_used
+        )
+      end
+
+      context 'when it is not SaaS' do
+        let(:alert_user_overage) { true }
+        let(:root_ancestor) { create(:group) }
+        let(:should_check_namespace_plan) { false }
+
+        before do
+          root_ancestor.add_owner(user)
+        end
+
+        it { is_expected.to be nil }
+      end
+
+      context 'when namespace is not a group' do
+        let(:alert_user_overage) { true }
+        let(:root_ancestor) { create(:namespace, :with_namespace_settings) }
+
+        it { is_expected.to be nil }
+      end
+
+      context 'when the alert was dismissed' do
+        let(:alert_user_overage) { true }
+        let(:root_ancestor) { create(:group) }
+
+        before do
+          allow(user).to receive(:dismissed_callout_for_group?).and_return(true)
+          root_ancestor.add_owner(user)
+        end
+
+        it { is_expected.to be nil }
+      end
+
+      context 'when the user does not have admin rights to the group' do
+        let(:alert_user_overage) { true }
+        let(:root_ancestor) { create(:group) }
+
+        before do
+          root_ancestor.add_developer(user)
+        end
+
+        it { is_expected.to be nil }
+      end
+
+      context 'when the subscription is not eligible for usage alerts' do
+        let(:alert_user_overage) { false }
+        let(:root_ancestor) { create(:group) }
+
+        before do
+          root_ancestor.add_owner(user)
+        end
+
+        it { is_expected.to be nil }
+      end
+
+      context 'when max seats used are more than the subscription seats' do
+        let(:alert_user_overage) { true }
+        let(:max_seats_used) { 11 }
+        let(:root_ancestor) { create(:group) }
+
+        before do
+          root_ancestor.add_owner(user)
+        end
+
+        it { is_expected.to be nil }
+      end
+    end
+
+    context 'with threshold limits' do
+      let_it_be(:alert_user_overage) { true }
+      let_it_be(:root_ancestor) { create(:group) }
+
+      before do
+        create(
+          :gitlab_subscription,
+          namespace: root_ancestor,
+          plan_code: Plan::ULTIMATE,
+          seats: seats,
+          max_seats_used: max_seats_used
+        )
+
+        root_ancestor.add_owner(user)
+      end
+
+      context 'when limits are not met' do
+        where(:seats, :max_seats_used) do
+          15    | 13
+          24    | 20
+          35    | 29
+          100   | 90
+          1000  | 949
+        end
+
+        with_them do
+          it { is_expected.to be nil }
+        end
+      end
+
+      context 'when limits are met' do
+        where(:seats, :max_seats_used) do
+          15    | 14
+          24    | 22
+          35    | 32
+          100   | 93
+          1000  | 950
+        end
+
+        with_them do
+          it {
+            is_expected.to eq({
+              namespace: root_ancestor,
+              remaining_seat_count: [seats - max_seats_used, 0].max,
+              seats_in_use: max_seats_used,
+              total_seat_count: seats
+            })
+          }
+        end
+      end
+    end
+  end
+end
diff --git a/ee/spec/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service_spec.rb b/ee/spec/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..851b9e462256c38e6a0531db8151df257597c89d
--- /dev/null
+++ b/ee/spec/services/gitlab_subscriptions/reconciliations/check_seat_usage_alerts_eligibility_service_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSubscriptions::Reconciliations::CheckSeatUsageAlertsEligibilityService,
+               :use_clean_rails_memory_store_caching, :saas do
+  using RSpec::Parameterized::TableSyntax
+
+  describe '#execute' do
+    let_it_be(:namespace) { create(:namespace_with_plan) }
+
+    let(:cache_key) do
+      "subscription:eligible_for_seat_usage_alerts:namespace:#{namespace.gitlab_subscription.cache_key}"
+    end
+
+    subject(:execute_service) { described_class.new(namespace: namespace).execute }
+
+    where(:eligible_for_seat_usage_alerts, :expected_response) do
+      true  | true
+      false | false
+    end
+
+    with_them do
+      let(:response) { { success: true, eligible_for_seat_usage_alerts: eligible_for_seat_usage_alerts } }
+
+      before do
+        allow(Gitlab::SubscriptionPortal::Client)
+          .to receive(:subscription_seat_usage_alerts_eligibility)
+          .and_return(response)
+      end
+
+      it 'returns the correct value' do
+        expect(execute_service).to eq expected_response
+      end
+
+      it 'caches the query response' do
+        expect(Rails.cache).to receive(:fetch).with(cache_key, skip_nil: true, expires_in: 1.day).and_call_original
+
+        execute_service
+      end
+    end
+
+    context 'with an unsuccessful CustomersDot query' do
+      it 'assumes no future renewal' do
+        allow(Gitlab::SubscriptionPortal::Client).to receive(:subscription_seat_usage_alerts_eligibility).and_return({
+          success: false
+        })
+
+        expect(execute_service).to be false
+      end
+    end
+
+    context 'when called with a group' do
+      let(:namespace) { create(:group_with_plan) }
+
+      it 'uses the namespace id' do
+        expect(Gitlab::SubscriptionPortal::Client)
+          .to receive(:subscription_seat_usage_alerts_eligibility)
+          .with(namespace.id)
+          .and_return({})
+
+        execute_service
+      end
+    end
+
+    context 'when the namespace has no plan' do
+      let(:namespace) { build(:group) }
+
+      it { is_expected.to be false }
+    end
+  end
+end