diff --git a/db/docs/audit_events_group_external_streaming_destinations.yml b/db/docs/audit_events_group_external_streaming_destinations.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f7d3b4912b6ad5d5a7c7640586a73a0f10b2537b
--- /dev/null
+++ b/db/docs/audit_events_group_external_streaming_destinations.yml
@@ -0,0 +1,13 @@
+---
+table_name: audit_events_group_external_streaming_destinations
+classes:
+- AuditEvents::Group::ExternalStreamingDestination
+feature_categories:
+- audit_events
+description: Stores external audit event destinations configurations for top-level
+  groups.
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/141739
+milestone: '16.9'
+gitlab_schema: gitlab_main_cell
+sharding_key:
+  group_id: namespaces
diff --git a/db/migrate/20240112124030_create_audit_events_group_external_streaming_destinations.rb b/db/migrate/20240112124030_create_audit_events_group_external_streaming_destinations.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1ca225f9c71b5a31b0708ba5ab69b427704be32b
--- /dev/null
+++ b/db/migrate/20240112124030_create_audit_events_group_external_streaming_destinations.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class CreateAuditEventsGroupExternalStreamingDestinations < Gitlab::Database::Migration[2.2]
+  milestone '16.9'
+
+  NAMESPACE_INDEX_NAME = 'idx_audit_events_group_external_destinations_on_group_id'
+
+  def change
+    create_table :audit_events_group_external_streaming_destinations do |t|
+      t.timestamps_with_timezone null: false
+      t.references :group, null: false,
+        index: { name: NAMESPACE_INDEX_NAME },
+        foreign_key: { to_table: :namespaces, on_delete: :cascade }
+      t.integer :type, null: false, limit: 2
+      t.text :name, null: false, limit: 72
+      t.jsonb :config, null: false
+      t.binary :encrypted_secret_token, null: false
+      t.binary :encrypted_secret_token_iv, null: false
+    end
+  end
+end
diff --git a/db/schema_migrations/20240112124030 b/db/schema_migrations/20240112124030
new file mode 100644
index 0000000000000000000000000000000000000000..340f1ddb653c1bb1a7b4fb2ca87de9f72437b43a
--- /dev/null
+++ b/db/schema_migrations/20240112124030
@@ -0,0 +1 @@
+c4a2b947eae6ae3cda7a6bd6e4fc3e4674020a4929d6848f08d17733c5ef5817
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index a1abb5092b7cff3580381d94b2349463ba2169a2..bf51d637ab1697dd4c4d51ca6af923b39203f62f 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -13145,6 +13145,28 @@ CREATE SEQUENCE audit_events_google_cloud_logging_configurations_id_seq
 
 ALTER SEQUENCE audit_events_google_cloud_logging_configurations_id_seq OWNED BY audit_events_google_cloud_logging_configurations.id;
 
+CREATE TABLE audit_events_group_external_streaming_destinations (
+    id bigint NOT NULL,
+    created_at timestamp with time zone NOT NULL,
+    updated_at timestamp with time zone NOT NULL,
+    group_id bigint NOT NULL,
+    type smallint NOT NULL,
+    name text NOT NULL,
+    config jsonb NOT NULL,
+    encrypted_secret_token bytea NOT NULL,
+    encrypted_secret_token_iv bytea NOT NULL,
+    CONSTRAINT check_97d157fbd0 CHECK ((char_length(name) <= 72))
+);
+
+CREATE SEQUENCE audit_events_group_external_streaming_destinations_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+ALTER SEQUENCE audit_events_group_external_streaming_destinations_id_seq OWNED BY audit_events_group_external_streaming_destinations.id;
+
 CREATE SEQUENCE audit_events_id_seq
     START WITH 1
     INCREMENT BY 1
@@ -26993,6 +27015,8 @@ ALTER TABLE ONLY audit_events_external_audit_event_destinations ALTER COLUMN id
 
 ALTER TABLE ONLY audit_events_google_cloud_logging_configurations ALTER COLUMN id SET DEFAULT nextval('audit_events_google_cloud_logging_configurations_id_seq'::regclass);
 
+ALTER TABLE ONLY audit_events_group_external_streaming_destinations ALTER COLUMN id SET DEFAULT nextval('audit_events_group_external_streaming_destinations_id_seq'::regclass);
+
 ALTER TABLE ONLY audit_events_instance_amazon_s3_configurations ALTER COLUMN id SET DEFAULT nextval('audit_events_instance_amazon_s3_configurations_id_seq'::regclass);
 
 ALTER TABLE ONLY audit_events_instance_external_audit_event_destinations ALTER COLUMN id SET DEFAULT nextval('audit_events_instance_external_audit_event_destinations_id_seq'::regclass);
@@ -28961,6 +28985,9 @@ ALTER TABLE ONLY audit_events_external_audit_event_destinations
 ALTER TABLE ONLY audit_events_google_cloud_logging_configurations
     ADD CONSTRAINT audit_events_google_cloud_logging_configurations_pkey PRIMARY KEY (id);
 
+ALTER TABLE ONLY audit_events_group_external_streaming_destinations
+    ADD CONSTRAINT audit_events_group_external_streaming_destinations_pkey PRIMARY KEY (id);
+
 ALTER TABLE ONLY audit_events_instance_amazon_s3_configurations
     ADD CONSTRAINT audit_events_instance_amazon_s3_configurations_pkey PRIMARY KEY (id);
 
@@ -32327,6 +32354,8 @@ CREATE INDEX idx_approval_project_rules_on_scan_result_policy_id ON approval_pro
 
 CREATE INDEX idx_approval_project_rules_on_sec_orchestration_config_id ON approval_project_rules USING btree (security_orchestration_policy_configuration_id);
 
+CREATE INDEX idx_audit_events_group_external_destinations_on_group_id ON audit_events_group_external_streaming_destinations USING btree (group_id);
+
 CREATE INDEX idx_audit_events_part_on_entity_id_desc_author_id_created_at ON ONLY audit_events USING btree (entity_id, entity_type, id DESC, author_id, created_at);
 
 CREATE INDEX idx_award_emoji_on_user_emoji_name_awardable_type_awardable_id ON award_emoji USING btree (user_id, name, awardable_type, awardable_id);
@@ -40325,6 +40354,9 @@ ALTER TABLE ONLY milestone_releases
 ALTER TABLE ONLY resource_state_events
     ADD CONSTRAINT fk_rails_7ddc5f7457 FOREIGN KEY (source_merge_request_id) REFERENCES merge_requests(id) ON DELETE SET NULL;
 
+ALTER TABLE ONLY audit_events_group_external_streaming_destinations
+    ADD CONSTRAINT fk_rails_7dffb88f29 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;
+
 ALTER TABLE ONLY clusters_kubernetes_namespaces
     ADD CONSTRAINT fk_rails_7e7688ecaf FOREIGN KEY (cluster_id) REFERENCES clusters(id) ON DELETE CASCADE;
 
diff --git a/ee/app/models/audit_events/group/external_streaming_destination.rb b/ee/app/models/audit_events/group/external_streaming_destination.rb
new file mode 100644
index 0000000000000000000000000000000000000000..017d5f434b9bba850aabac6af07110712f8761ba
--- /dev/null
+++ b/ee/app/models/audit_events/group/external_streaming_destination.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module AuditEvents
+  module Group
+    class ExternalStreamingDestination < ApplicationRecord
+      include Limitable
+      include ExternallyStreamable
+
+      self.limit_name = 'external_audit_event_destinations'
+      self.limit_scope = :group
+      self.table_name = 'audit_events_group_external_streaming_destinations'
+
+      belongs_to :group, class_name: '::Group', inverse_of: :audit_events
+      validate :top_level_group?
+      validates :name, uniqueness: { scope: :group_id }
+
+      def top_level_group?
+        errors.add(:group, 'must not be a subgroup. Use a top-level group.') if group.subgroup?
+      end
+    end
+  end
+end
diff --git a/ee/app/models/concerns/audit_events/externally_streamable.rb b/ee/app/models/concerns/audit_events/externally_streamable.rb
new file mode 100644
index 0000000000000000000000000000000000000000..58426f537a2f74d1d6ded5d317263c6e07993f95
--- /dev/null
+++ b/ee/app/models/concerns/audit_events/externally_streamable.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module AuditEvents
+  module ExternallyStreamable
+    extend ActiveSupport::Concern
+
+    included do
+      before_validation :assign_default_name
+
+      enum type: {
+        http: 0,
+        gcp: 1,
+        aws: 2
+      }
+
+      validates :name, length: { maximum: 72 }
+      validates :type, presence: true
+
+      validates :config, presence: true, json_schema: { filename: 'external_streaming_destination_config' }
+      validates :secret_token, presence: true
+
+      attr_encrypted :secret_token,
+        mode: :per_attribute_iv,
+        key: Settings.attr_encrypted_db_key_base_32,
+        algorithm: 'aes-256-gcm',
+        encode: false,
+        encode_iv: false
+
+      private
+
+      def assign_default_name
+        self.name ||= "Destination_#{SecureRandom.uuid}"
+      end
+    end
+  end
+end
diff --git a/ee/app/validators/json_schemas/external_streaming_destination_config.json b/ee/app/validators/json_schemas/external_streaming_destination_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..308c3fddfbd0366f8a0491d6d1d00f853f154efd
--- /dev/null
+++ b/ee/app/validators/json_schemas/external_streaming_destination_config.json
@@ -0,0 +1,7 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "description": "Config for external audit event streaming destinations",
+  "type": "object",
+  "properties": {
+  }
+}
diff --git a/ee/spec/factories/audit_events/audit_events_group_external_streaming_destination.rb b/ee/spec/factories/audit_events/audit_events_group_external_streaming_destination.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d0ee93fd29d9ebcd76bd914c97299d617f82614f
--- /dev/null
+++ b/ee/spec/factories/audit_events/audit_events_group_external_streaming_destination.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory :audit_events_group_external_streaming_destination,
+    class: 'AuditEvents::Group::ExternalStreamingDestination' do
+    group
+    type { 'http' }
+    config { { url: 'https://www.example.com' } }
+    secret_token { 'hello' }
+  end
+end
diff --git a/ee/spec/models/audit_events/group/external_streaming_destination_spec.rb b/ee/spec/models/audit_events/group/external_streaming_destination_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d495fc2dbdd905b6a9f02fdc8cf94d76877d6ed6
--- /dev/null
+++ b/ee/spec/models/audit_events/group/external_streaming_destination_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AuditEvents::Group::ExternalStreamingDestination, feature_category: :audit_events do
+  subject(:destination) { build(:audit_events_group_external_streaming_destination) }
+
+  describe 'Associations' do
+    it 'belongs to a group' do
+      expect(destination.group).not_to be_nil
+    end
+  end
+
+  describe 'Validations' do
+    let_it_be(:group) { create(:group) }
+
+    it 'validates uniqueness of name scoped to namespace' do
+      create(:audit_events_group_external_streaming_destination, name: 'Test Destination', group: group)
+      destination = build(:audit_events_group_external_streaming_destination, name: 'Test Destination', group: group)
+
+      expect(destination).not_to be_valid
+      expect(destination.errors.full_messages).to include('Name has already been taken')
+    end
+
+    context 'when group' do
+      it 'is a subgroup' do
+        destination.group = build(:group, :nested)
+
+        expect(destination).to be_invalid
+        expect(destination.errors.full_messages).to include('Group must not be a subgroup. Use a top-level group.')
+      end
+    end
+  end
+
+  it_behaves_like 'includes Limitable concern' do
+    subject { build(:audit_events_group_external_streaming_destination) }
+  end
+
+  it_behaves_like 'includes ExternallyStreamable concern' do
+    subject { build(:audit_events_group_external_streaming_destination) }
+
+    let(:model_factory_name) { :audit_events_group_external_streaming_destination }
+  end
+end
diff --git a/ee/spec/support/shared_examples/models/concerns/externally_streamable_shared_examples.rb b/ee/spec/support/shared_examples/models/concerns/externally_streamable_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..165c13accb52eeb73cab048265063e8751f67bc5
--- /dev/null
+++ b/ee/spec/support/shared_examples/models/concerns/externally_streamable_shared_examples.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'includes ExternallyStreamable concern' do
+  describe 'validations' do
+    it { is_expected.to validate_presence_of(:config) }
+    it { is_expected.to validate_presence_of(:secret_token) }
+    it { is_expected.to validate_presence_of(:type) }
+    it { is_expected.to be_a(AuditEvents::ExternallyStreamable) }
+    it { is_expected.to validate_length_of(:name).is_at_most(72) }
+
+    context 'when type' do
+      it 'is valid' do
+        expect(destination).to be_valid
+      end
+
+      it 'is nil' do
+        destination.type = nil
+
+        expect(destination).not_to be_valid
+        expect(destination.errors.full_messages)
+          .to match_array(["Type can't be blank"])
+      end
+
+      it 'is invalid' do
+        expect { destination.type = 'invalid' }.to raise_error(ArgumentError)
+      end
+    end
+
+    it_behaves_like 'having unique enum values'
+
+    context 'when config' do
+      it 'is invalid' do
+        destination.config = 'hello'
+
+        expect(destination).not_to be_valid
+        expect(destination.errors.full_messages).to include('Config must be a valid json schema')
+      end
+    end
+
+    context 'when creating without a name' do
+      before do
+        allow(SecureRandom).to receive(:uuid).and_return('12345678')
+      end
+
+      it 'assigns a default name' do
+        destination = build(model_factory_name, name: nil)
+
+        expect(destination).to be_valid
+        expect(destination.name).to eq('Destination_12345678')
+      end
+    end
+  end
+end