diff --git a/Data/ScheduledEventData.cs b/Data/ScheduledEventData.cs
index 5bb1c10..661eed0 100644
--- a/Data/ScheduledEventData.cs
+++ b/Data/ScheduledEventData.cs
@@ -11,6 +11,7 @@ public class ScheduledEventData {
         Status = status;
     }
 
-    public DateTimeOffset?           ActualStartTime { get; set; }
-    public GuildScheduledEventStatus Status          { get; set; }
+    public bool                      EarlyNotificationSent { get; set; }
+    public DateTimeOffset?           ActualStartTime       { get; set; }
+    public GuildScheduledEventStatus Status                { get; set; }
 }
diff --git a/EventResponders.cs b/EventResponders.cs
index 22df360..2efe194 100644
--- a/EventResponders.cs
+++ b/EventResponders.cs
@@ -1,4 +1,3 @@
-using System.Text;
 using Boyfriend.Data;
 using Boyfriend.Services.Data;
 using DiffPlex;
@@ -7,14 +6,11 @@ using Microsoft.Extensions.Logging;
 using Remora.Discord.API.Abstractions.Gateway.Events;
 using Remora.Discord.API.Abstractions.Objects;
 using Remora.Discord.API.Abstractions.Rest;
-using Remora.Discord.API.Objects;
 using Remora.Discord.Caching;
 using Remora.Discord.Caching.Services;
 using Remora.Discord.Extensions.Embeds;
 using Remora.Discord.Extensions.Formatting;
 using Remora.Discord.Gateway.Responders;
-using Remora.Discord.Interactivity;
-using Remora.Rest.Core;
 using Remora.Results;
 
 // ReSharper disable UnusedType.Global
@@ -259,223 +255,6 @@ public class GuildMemberAddResponder : IResponder<IGuildMemberAdd> {
     }
 }
 
-/// <summary>
-///     Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventNotificationRole" /> if one is
-///     set,
-///     when a scheduled event is created
-///     in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
-/// </summary>
-public class GuildScheduledEventCreateResponder : IResponder<IGuildScheduledEventCreate> {
-    private readonly IDiscordRestChannelAPI _channelApi;
-    private readonly GuildDataService       _dataService;
-    private readonly IDiscordRestUserAPI    _userApi;
-
-    public GuildScheduledEventCreateResponder(
-        IDiscordRestChannelAPI channelApi, GuildDataService dataService,
-        IDiscordRestUserAPI    userApi) {
-        _channelApi = channelApi;
-        _dataService = dataService;
-        _userApi = userApi;
-    }
-
-    public async Task<Result> RespondAsync(IGuildScheduledEventCreate gatewayEvent, CancellationToken ct = default) {
-        var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct);
-        guildData.ScheduledEvents.Add(
-            gatewayEvent.ID.Value, new ScheduledEventData(GuildScheduledEventStatus.Scheduled));
-
-        if (guildData.Configuration.EventNotificationChannel is 0)
-            return Result.FromSuccess();
-
-        var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
-        if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
-
-        if (!gatewayEvent.CreatorID.IsDefined(out var creatorId))
-            return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.CreatorID)));
-        var creatorResult = await _userApi.GetUserAsync(creatorId.Value, ct);
-        if (!creatorResult.IsDefined(out var creator)) return Result.FromError(creatorResult);
-
-        Messages.Culture = guildData.Culture;
-
-        string embedDescription;
-        var eventDescription = gatewayEvent.Description is { HasValue: true, Value: not null }
-            ? gatewayEvent.Description.Value
-            : string.Empty;
-        switch (gatewayEvent.EntityType) {
-            case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice:
-                if (!gatewayEvent.ChannelID.AsOptional().IsDefined(out var channelId))
-                    return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ChannelID)));
-
-                embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote(
-                    string.Format(
-                        Messages.DescriptionLocalEventCreated,
-                        Markdown.Timestamp(gatewayEvent.ScheduledStartTime),
-                        Mention.Channel(channelId)
-                    ))}";
-                break;
-            case GuildScheduledEventEntityType.External:
-                if (!gatewayEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
-                    return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.EntityMetadata)));
-                if (!gatewayEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
-                    return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ScheduledEndTime)));
-                if (!metadata.Location.IsDefined(out var location))
-                    return Result.FromError(new ArgumentNullError(nameof(metadata.Location)));
-
-                embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote(
-                    string.Format(
-                        Messages.DescriptionExternalEventCreated,
-                        Markdown.Timestamp(gatewayEvent.ScheduledStartTime),
-                        Markdown.Timestamp(endTime),
-                        Markdown.InlineCode(location)
-                    ))}";
-                break;
-            default:
-                return Result.FromError(new ArgumentOutOfRangeError(nameof(gatewayEvent.EntityType)));
-        }
-
-        var embed = new EmbedBuilder()
-            .WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator)
-            .WithTitle(gatewayEvent.Name)
-            .WithDescription(embedDescription)
-            .WithEventCover(gatewayEvent.ID, gatewayEvent.Image)
-            .WithUserFooter(currentUser)
-            .WithCurrentTimestamp()
-            .WithColour(ColorsList.Default)
-            .Build();
-        if (!embed.IsDefined(out var built)) return Result.FromError(embed);
-
-        var roleMention = guildData.Configuration.EventNotificationRole is not 0
-            ? Mention.Role(guildData.Configuration.EventNotificationRole.ToDiscordSnowflake())
-            : string.Empty;
-
-        var button = new ButtonComponent(
-            ButtonComponentStyle.Primary,
-            Messages.EventDetailsButton,
-            new PartialEmoji(Name: "📋"),
-            CustomIDHelpers.CreateButtonIDWithState(
-                "scheduled-event-details", $"{gatewayEvent.GuildID}:{gatewayEvent.ID}")
-        );
-
-        return (Result)await _channelApi.CreateMessageAsync(
-            guildData.Configuration.EventNotificationChannel.ToDiscordSnowflake(), roleMention, embeds: new[] { built },
-            components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct);
-    }
-}
-
-/// <summary>
-///     Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventNotificationRole" /> if one is
-///     set,
-///     when a scheduled event has started or completed
-///     in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
-/// </summary>
-public class GuildScheduledEventUpdateResponder : IResponder<IGuildScheduledEventUpdate> {
-    private readonly IDiscordRestChannelAPI             _channelApi;
-    private readonly GuildDataService                   _dataService;
-    private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
-
-    public GuildScheduledEventUpdateResponder(
-        IDiscordRestChannelAPI channelApi, GuildDataService dataService, IDiscordRestGuildScheduledEventAPI eventApi) {
-        _channelApi = channelApi;
-        _dataService = dataService;
-        _eventApi = eventApi;
-    }
-
-    public async Task<Result> RespondAsync(IGuildScheduledEventUpdate gatewayEvent, CancellationToken ct = default) {
-        var guildData = await _dataService.GetData(gatewayEvent.GuildID, ct);
-        if (guildData.Configuration.EventNotificationChannel is 0)
-            return Result.FromSuccess();
-        if (!guildData.ScheduledEvents.TryGetValue(gatewayEvent.ID.Value, out var data)) {
-            guildData.ScheduledEvents.Add(gatewayEvent.ID.Value, new ScheduledEventData(gatewayEvent.Status));
-        } else {
-            if (gatewayEvent.Status == data.Status)
-                return Result.FromSuccess();
-
-            guildData.ScheduledEvents[gatewayEvent.ID.Value].Status = gatewayEvent.Status;
-        }
-
-        var embed = new EmbedBuilder();
-        StringBuilder? content = null;
-        switch (gatewayEvent.Status) {
-            case GuildScheduledEventStatus.Active:
-                guildData.ScheduledEvents[gatewayEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow;
-
-                string embedDescription;
-                switch (gatewayEvent.EntityType) {
-                    case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice:
-                        if (!gatewayEvent.ChannelID.AsOptional().IsDefined(out var channelId))
-                            return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ChannelID)));
-
-                        embedDescription = string.Format(
-                            Messages.DescriptionLocalEventStarted,
-                            Mention.Channel(channelId)
-                        );
-                        break;
-                    case GuildScheduledEventEntityType.External:
-                        if (!gatewayEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
-                            return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.EntityMetadata)));
-                        if (!gatewayEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
-                            return Result.FromError(new ArgumentNullError(nameof(gatewayEvent.ScheduledEndTime)));
-                        if (!metadata.Location.IsDefined(out var location))
-                            return Result.FromError(new ArgumentNullError(nameof(metadata.Location)));
-
-                        embedDescription = string.Format(
-                            Messages.DescriptionExternalEventStarted,
-                            Markdown.InlineCode(location),
-                            Markdown.Timestamp(endTime)
-                        );
-                        break;
-                    default:
-                        return Result.FromError(new ArgumentOutOfRangeError(nameof(gatewayEvent.EntityType)));
-                }
-
-                content = new StringBuilder();
-                var receivers = guildData.Configuration.EventStartedReceivers;
-                var role = guildData.Configuration.EventNotificationRole.ToDiscordSnowflake();
-                var usersResult = await _eventApi.GetGuildScheduledEventUsersAsync(
-                    gatewayEvent.GuildID, gatewayEvent.ID, withMember: true, ct: ct);
-                if (!usersResult.IsDefined(out var users)) return Result.FromError(usersResult);
-
-                if (receivers.Contains(GuildConfiguration.NotificationReceiver.Role) && role.Value is not 0)
-                    content.Append($"{Mention.Role(role)} ");
-                if (receivers.Contains(GuildConfiguration.NotificationReceiver.Interested))
-                    content = users.Where(
-                            user => {
-                                if (!user.GuildMember.IsDefined(out var member)) return true;
-                                return !member.Roles.Contains(role);
-                            })
-                        .Aggregate(content, (current, user) => current.Append($"{Mention.User(user.User)} "));
-
-                embed.WithTitle(string.Format(Messages.EventStarted, gatewayEvent.Name))
-                    .WithDescription(embedDescription)
-                    .WithCurrentTimestamp()
-                    .WithColour(ColorsList.Green);
-                break;
-            case GuildScheduledEventStatus.Completed:
-                embed.WithTitle(string.Format(Messages.EventCompleted, gatewayEvent.Name))
-                    .WithDescription(
-                        string.Format(
-                            Messages.EventDuration,
-                            DateTimeOffset.UtcNow.Subtract(
-                                guildData.ScheduledEvents[gatewayEvent.ID.Value].ActualStartTime
-                                ?? gatewayEvent.ScheduledStartTime).ToString()))
-                    .WithColour(ColorsList.Black);
-
-                guildData.ScheduledEvents.Remove(gatewayEvent.ID.Value);
-                break;
-            case GuildScheduledEventStatus.Canceled:
-            case GuildScheduledEventStatus.Scheduled:
-            default: return Result.FromError(new ArgumentOutOfRangeError(nameof(gatewayEvent.Status)));
-        }
-
-        var result = embed.WithCurrentTimestamp().Build();
-
-        if (!result.IsDefined(out var built)) return Result.FromError(result);
-
-        return (Result)await _channelApi.CreateMessageAsync(
-            guildData.Configuration.EventNotificationChannel.ToDiscordSnowflake(),
-            content?.ToString() ?? default(Optional<string>), embeds: new[] { built }, ct: ct);
-    }
-}
-
 /// <summary>
 ///     Handles sending a notification when a scheduled event has been cancelled
 ///     in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
diff --git a/Services/Data/GuildDataService.cs b/Services/Data/GuildDataService.cs
index f1dcf82..4c4aa88 100644
--- a/Services/Data/GuildDataService.cs
+++ b/Services/Data/GuildDataService.cs
@@ -91,11 +91,11 @@ public class GuildDataService : IHostedService {
         return (await GetData(guildId, ct)).Configuration;
     }
 
-    public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) {
+    /*public async Task<MemberData> GetMemberData(Snowflake guildId, Snowflake userId, CancellationToken ct = default) {
         return (await GetData(guildId, ct)).GetMemberData(userId);
-    }
+    }*/
 
-    public List<Snowflake> GetGuildIds() {
-        return _datas.Keys.ToList();
+    public IEnumerable<Snowflake> GetGuildIds() {
+        return _datas.Keys;
     }
 }
diff --git a/Services/GuildUpdateService.cs b/Services/GuildUpdateService.cs
index ffe601f..8bb438c 100644
--- a/Services/GuildUpdateService.cs
+++ b/Services/GuildUpdateService.cs
@@ -1,17 +1,38 @@
+using Boyfriend.Data;
 using Boyfriend.Services.Data;
 using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Remora.Discord.API.Abstractions.Objects;
 using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.API.Objects;
+using Remora.Discord.Extensions.Embeds;
+using Remora.Discord.Extensions.Formatting;
+using Remora.Discord.Interactivity;
 using Remora.Rest.Core;
+using Remora.Results;
 
 namespace Boyfriend.Services;
 
 public class GuildUpdateService : BackgroundService {
-    private readonly GuildDataService     _dataService;
-    private readonly IDiscordRestGuildAPI _guildApi;
+    private readonly IDiscordRestChannelAPI             _channelApi;
+    private readonly GuildDataService                   _dataService;
+    private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
+    private readonly IDiscordRestGuildAPI               _guildApi;
+    private readonly ILogger<GuildUpdateService>        _logger;
+    private readonly IDiscordRestUserAPI                _userApi;
+    private readonly UtilityService                     _utility;
 
-    public GuildUpdateService(GuildDataService dataService, IDiscordRestGuildAPI guildApi) {
+    public GuildUpdateService(
+        IDiscordRestChannelAPI             channelApi, GuildDataService dataService, IDiscordRestGuildAPI guildApi,
+        IDiscordRestGuildScheduledEventAPI eventApi,   ILogger<GuildUpdateService> logger, IDiscordRestUserAPI userApi,
+        UtilityService                     utility) {
+        _channelApi = channelApi;
         _dataService = dataService;
         _guildApi = guildApi;
+        _eventApi = eventApi;
+        _logger = logger;
+        _userApi = userApi;
+        _utility = utility;
     }
 
     protected override async Task ExecuteAsync(CancellationToken ct) {
@@ -19,8 +40,7 @@ public class GuildUpdateService : BackgroundService {
         var tasks = new List<Task>();
 
         while (await timer.WaitForNextTickAsync(ct)) {
-            foreach (var id in _dataService.GetGuildIds())
-                tasks.Add(TickGuildAsync(id, ct));
+            tasks.AddRange(_dataService.GetGuildIds().Select(id => TickGuildAsync(id, ct)));
 
             await Task.WhenAll(tasks);
             tasks.Clear();
@@ -31,12 +51,219 @@ public class GuildUpdateService : BackgroundService {
         var data = await _dataService.GetData(guildId, ct);
         Messages.Culture = data.Culture;
 
+        // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator
         foreach (var memberData in data.MemberData.Values)
             if (DateTimeOffset.UtcNow > memberData.BannedUntil) {
                 var unbanResult = await _guildApi.RemoveGuildBanAsync(
                     guildId, memberData.Id.ToDiscordSnowflake(), Messages.PunishmentExpired.EncodeHeader(), ct);
                 if (unbanResult.IsSuccess)
                     memberData.BannedUntil = null;
+                else
+                    _logger.LogWarning("Error in member data update.\n{ErrorMessage}", unbanResult.Error.Message);
             }
+
+        var eventsResult = await _eventApi.ListScheduledEventsForGuildAsync(guildId, ct: ct);
+        if (!eventsResult.IsDefined(out var events)) return;
+
+        if (data.Configuration.EventNotificationChannel is 0) return;
+
+        foreach (var scheduledEvent in events) {
+            if (!data.ScheduledEvents.ContainsKey(scheduledEvent.ID.Value)) {
+                data.ScheduledEvents.Add(scheduledEvent.ID.Value, new ScheduledEventData(scheduledEvent.Status));
+            } else {
+                var storedEvent = data.ScheduledEvents[scheduledEvent.ID.Value];
+                if (storedEvent.Status == scheduledEvent.Status) {
+                    if (DateTimeOffset.UtcNow
+                        >= scheduledEvent.ScheduledStartTime - data.Configuration.EventEarlyNotificationOffset
+                        && !storedEvent.EarlyNotificationSent) {
+                        var earlyResult = await SendScheduledEventStartedMessage(scheduledEvent, data, true, ct);
+                        if (earlyResult.IsSuccess)
+                            storedEvent.EarlyNotificationSent = true;
+                        else
+                            _logger.LogWarning(
+                                "Error in scheduled event early notification sender.\n{ErrorMessage}",
+                                earlyResult.Error.Message);
+                    }
+
+                    continue;
+                }
+
+                storedEvent.Status = scheduledEvent.Status;
+            }
+
+            var result = scheduledEvent.Status switch {
+                GuildScheduledEventStatus.Scheduled =>
+                    await SendScheduledEventCreatedMessage(scheduledEvent, data.Configuration, ct),
+                GuildScheduledEventStatus.Active or GuildScheduledEventStatus.Completed =>
+                    await SendScheduledEventStartedMessage(scheduledEvent, data, false, ct),
+                _ => Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)))
+            };
+
+            if (!result.IsSuccess)
+                _logger.LogWarning("Error in guild update.\n{ErrorMessage}", result.Error.Message);
+        }
+    }
+
+    /// <summary>
+    ///     Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventNotificationRole" /> if one is
+    ///     set,
+    ///     when a scheduled event is created
+    ///     in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
+    /// </summary>
+    private async Task<Result> SendScheduledEventCreatedMessage(
+        IGuildScheduledEvent scheduledEvent, GuildConfiguration config, CancellationToken ct = default) {
+        var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
+        if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
+
+        if (!scheduledEvent.CreatorID.IsDefined(out var creatorId))
+            return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.CreatorID)));
+        var creatorResult = await _userApi.GetUserAsync(creatorId.Value, ct);
+        if (!creatorResult.IsDefined(out var creator)) return Result.FromError(creatorResult);
+
+        string embedDescription;
+        var eventDescription = scheduledEvent.Description is { HasValue: true, Value: not null }
+            ? scheduledEvent.Description.Value
+            : string.Empty;
+        switch (scheduledEvent.EntityType) {
+            case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice:
+                if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId))
+                    return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID)));
+
+                embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote(
+                    string.Format(
+                        Messages.DescriptionLocalEventCreated,
+                        Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
+                        Mention.Channel(channelId)
+                    ))}";
+                break;
+            case GuildScheduledEventEntityType.External:
+                if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
+                    return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)));
+                if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
+                    return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)));
+                if (!metadata.Location.IsDefined(out var location))
+                    return Result.FromError(new ArgumentNullError(nameof(metadata.Location)));
+
+                embedDescription = $"{eventDescription}\n\n{Markdown.BlockQuote(
+                    string.Format(
+                        Messages.DescriptionExternalEventCreated,
+                        Markdown.Timestamp(scheduledEvent.ScheduledStartTime),
+                        Markdown.Timestamp(endTime),
+                        Markdown.InlineCode(location)
+                    ))}";
+                break;
+            default:
+                return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)));
+        }
+
+        var embed = new EmbedBuilder()
+            .WithSmallTitle(string.Format(Messages.EventCreatedTitle, creator.GetTag()), creator)
+            .WithTitle(scheduledEvent.Name)
+            .WithDescription(embedDescription)
+            .WithEventCover(scheduledEvent.ID, scheduledEvent.Image)
+            .WithUserFooter(currentUser)
+            .WithCurrentTimestamp()
+            .WithColour(ColorsList.White)
+            .Build();
+        if (!embed.IsDefined(out var built)) return Result.FromError(embed);
+
+        var roleMention = config.EventNotificationRole is not 0
+            ? Mention.Role(config.EventNotificationRole.ToDiscordSnowflake())
+            : string.Empty;
+
+        var button = new ButtonComponent(
+            ButtonComponentStyle.Primary,
+            Messages.EventDetailsButton,
+            new PartialEmoji(Name: "📋"),
+            CustomIDHelpers.CreateButtonIDWithState(
+                "scheduled-event-details", $"{scheduledEvent.GuildID}:{scheduledEvent.ID}")
+        );
+
+        return (Result)await _channelApi.CreateMessageAsync(
+            config.EventNotificationChannel.ToDiscordSnowflake(), roleMention, embeds: new[] { built },
+            components: new[] { new ActionRowComponent(new[] { button }) }, ct: ct);
+    }
+
+    /// <summary>
+    ///     Handles sending a notification, mentioning the <see cref="GuildConfiguration.EventStartedReceivers" />s,
+    ///     when a scheduled event is about to start, has started or completed
+    ///     in a guild's <see cref="GuildConfiguration.EventNotificationChannel" /> if one is set.
+    /// </summary>
+    private async Task<Result> SendScheduledEventStartedMessage(
+        IGuildScheduledEvent scheduledEvent, GuildData data, bool early, CancellationToken ct = default) {
+        var currentUserResult = await _userApi.GetCurrentUserAsync(ct);
+        if (!currentUserResult.IsDefined(out var currentUser)) return Result.FromError(currentUserResult);
+
+        var embed = new EmbedBuilder();
+        string? content = null;
+        if (early)
+            embed.WithSmallTitle(string.Format(Messages.EventEarlyNotification, scheduledEvent.Name), currentUser)
+                .WithColour(ColorsList.Default);
+        else
+            switch (scheduledEvent.Status) {
+                case GuildScheduledEventStatus.Active:
+                    data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime = DateTimeOffset.UtcNow;
+
+                    string embedDescription;
+                    switch (scheduledEvent.EntityType) {
+                        case GuildScheduledEventEntityType.StageInstance or GuildScheduledEventEntityType.Voice:
+                            if (!scheduledEvent.ChannelID.AsOptional().IsDefined(out var channelId))
+                                return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ChannelID)));
+
+                            embedDescription = string.Format(
+                                Messages.DescriptionLocalEventStarted,
+                                Mention.Channel(channelId)
+                            );
+                            break;
+                        case GuildScheduledEventEntityType.External:
+                            if (!scheduledEvent.EntityMetadata.AsOptional().IsDefined(out var metadata))
+                                return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.EntityMetadata)));
+                            if (!scheduledEvent.ScheduledEndTime.AsOptional().IsDefined(out var endTime))
+                                return Result.FromError(new ArgumentNullError(nameof(scheduledEvent.ScheduledEndTime)));
+                            if (!metadata.Location.IsDefined(out var location))
+                                return Result.FromError(new ArgumentNullError(nameof(metadata.Location)));
+
+                            embedDescription = string.Format(
+                                Messages.DescriptionExternalEventStarted,
+                                Markdown.InlineCode(location),
+                                Markdown.Timestamp(endTime)
+                            );
+                            break;
+                        default:
+                            return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.EntityType)));
+                    }
+
+                    var contentResult = await _utility.GetEventNotificationMentions(data, scheduledEvent, ct);
+                    if (!contentResult.IsDefined(out content))
+                        return Result.FromError(contentResult);
+
+                    embed.WithTitle(string.Format(Messages.EventStarted, scheduledEvent.Name))
+                        .WithDescription(embedDescription)
+                        .WithColour(ColorsList.Green);
+                    break;
+                case GuildScheduledEventStatus.Completed:
+                    embed.WithTitle(string.Format(Messages.EventCompleted, scheduledEvent.Name))
+                        .WithDescription(
+                            string.Format(
+                                Messages.EventDuration,
+                                DateTimeOffset.UtcNow.Subtract(
+                                    data.ScheduledEvents[scheduledEvent.ID.Value].ActualStartTime
+                                    ?? scheduledEvent.ScheduledStartTime).ToString()))
+                        .WithColour(ColorsList.Black);
+
+                    data.ScheduledEvents.Remove(scheduledEvent.ID.Value);
+                    break;
+                case GuildScheduledEventStatus.Canceled:
+                case GuildScheduledEventStatus.Scheduled:
+                default: return Result.FromError(new ArgumentOutOfRangeError(nameof(scheduledEvent.Status)));
+            }
+
+        var result = embed.WithCurrentTimestamp().Build();
+
+        if (!result.IsDefined(out var built)) return Result.FromError(result);
+
+        return (Result)await _channelApi.CreateMessageAsync(
+            data.Configuration.EventNotificationChannel.ToDiscordSnowflake(),
+            content ?? default(Optional<string>), embeds: new[] { built }, ct: ct);
     }
 }
diff --git a/Services/UtilityService.cs b/Services/UtilityService.cs
index 7ce6da6..4c6da4d 100644
--- a/Services/UtilityService.cs
+++ b/Services/UtilityService.cs
@@ -1,5 +1,9 @@
+using System.Text;
+using Boyfriend.Data;
 using Microsoft.Extensions.Hosting;
+using Remora.Discord.API.Abstractions.Objects;
 using Remora.Discord.API.Abstractions.Rest;
+using Remora.Discord.Extensions.Formatting;
 using Remora.Rest.Core;
 using Remora.Results;
 
@@ -10,12 +14,15 @@ namespace Boyfriend.Services;
 ///     of some Discord APIs.
 /// </summary>
 public class UtilityService : IHostedService {
-    private readonly IDiscordRestGuildAPI _guildApi;
-    private readonly IDiscordRestUserAPI  _userApi;
+    private readonly IDiscordRestGuildScheduledEventAPI _eventApi;
+    private readonly IDiscordRestGuildAPI               _guildApi;
+    private readonly IDiscordRestUserAPI                _userApi;
 
-    public UtilityService(IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi) {
+    public UtilityService(
+        IDiscordRestGuildAPI guildApi, IDiscordRestUserAPI userApi, IDiscordRestGuildScheduledEventAPI eventApi) {
         _guildApi = guildApi;
         _userApi = userApi;
+        _eventApi = eventApi;
     }
 
     public Task StartAsync(CancellationToken ct) {
@@ -94,4 +101,25 @@ public class UtilityService : IHostedService {
 
         return Result<string?>.FromSuccess(null);
     }
+
+    public async Task<Result<string>> GetEventNotificationMentions(
+        GuildData data, IGuildScheduledEvent scheduledEvent, CancellationToken ct = default) {
+        var builder = new StringBuilder();
+        var receivers = data.Configuration.EventStartedReceivers;
+        var role = data.Configuration.EventNotificationRole.ToDiscordSnowflake();
+        var usersResult = await _eventApi.GetGuildScheduledEventUsersAsync(
+            scheduledEvent.GuildID, scheduledEvent.ID, withMember: true, ct: ct);
+        if (!usersResult.IsDefined(out var users)) return Result<string>.FromError(usersResult);
+
+        if (receivers.Contains(GuildConfiguration.NotificationReceiver.Role) && role.Value is not 0)
+            builder.Append($"{Mention.Role(role)} ");
+        if (receivers.Contains(GuildConfiguration.NotificationReceiver.Interested))
+            builder = users.Where(
+                    user => {
+                        if (!user.GuildMember.IsDefined(out var member)) return true;
+                        return !member.Roles.Contains(role);
+                    })
+                .Aggregate(builder, (current, user) => current.Append($"{Mention.User(user.User)} "));
+        return builder.ToString();
+    }
 }