diff --git a/src/EllieBot/Db/Models/DiscordUser.cs b/src/EllieBot/Db/Models/DiscordUser.cs
index f400694..b07da85 100644
--- a/src/EllieBot/Db/Models/DiscordUser.cs
+++ b/src/EllieBot/Db/Models/DiscordUser.cs
@@ -19,14 +19,12 @@ public class DiscordUser : DbEntity
 
     public long CurrencyAmount { get; set; }
 
-    public override bool Equals(object obj)
+    public override bool Equals(object? obj)
         => obj is DiscordUser du ? du.UserId == UserId : false;
 
     public override int GetHashCode()
         => UserId.GetHashCode();
 
     public override string ToString()
-    {
-        return Username;
-    }
+        => Username ?? DEFAULT_USERNAME;
 }
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Administration/Notify/INotifyModel.cs b/src/EllieBot/Modules/Administration/Notify/INotifyModel.cs
index 9fb1d80..bbfa70d 100644
--- a/src/EllieBot/Modules/Administration/Notify/INotifyModel.cs
+++ b/src/EllieBot/Modules/Administration/Notify/INotifyModel.cs
@@ -1,13 +1,13 @@
 using EllieBot.Db.Models;
-using System.Collections;
 
 namespace EllieBot.Modules.Administration;
 
-public interface INotifyModel
+public interface INotifyModel<T>
+    where T: struct, INotifyModel<T>
 {
     static abstract string KeyName { get; }
     static abstract NotifyType NotifyType { get; }
-    IReadOnlyDictionary<string, Func<SocketGuild, string>> GetReplacements();
+    static abstract IReadOnlyList<NotifyModelPlaceholderData<T>> GetReplacements();
 
     public virtual bool TryGetGuildId(out ulong guildId)
     {
@@ -20,4 +20,6 @@ public interface INotifyModel
         userId = 0;
         return false;
     }
-}
\ No newline at end of file
+}
+
+public readonly record struct NotifyModelPlaceholderData<T>(string Name, Func<T, SocketGuild, string> Func);
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Administration/Notify/INotifySubscriber.cs b/src/EllieBot/Modules/Administration/Notify/INotifySubscriber.cs
index f0a8731..23d4008 100644
--- a/src/EllieBot/Modules/Administration/Notify/INotifySubscriber.cs
+++ b/src/EllieBot/Modules/Administration/Notify/INotifySubscriber.cs
@@ -1,7 +1,16 @@
-namespace EllieBot.Modules.Administration;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Administration;
 
 public interface INotifySubscriber
 {
     Task NotifyAsync<T>(T data, bool isShardLocal = false)
-        where T : struct, INotifyModel;
-}
\ No newline at end of file
+        where T : struct, INotifyModel<T>;
+
+    void RegisterModel<T>()
+        where T : struct, INotifyModel<T>;
+
+    NotifyModelData GetRegisteredModel(NotifyType nType);
+}
+
+public readonly record struct NotifyModelData(NotifyType Type, IReadOnlyList<string> Replacements);
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Administration/Notify/Models/AddRoleRewardNotifyModel.cs b/src/EllieBot/Modules/Administration/Notify/Models/AddRoleRewardNotifyModel.cs
index d6f8d37..60e263c 100644
--- a/src/EllieBot/Modules/Administration/Notify/Models/AddRoleRewardNotifyModel.cs
+++ b/src/EllieBot/Modules/Administration/Notify/Models/AddRoleRewardNotifyModel.cs
@@ -3,7 +3,7 @@ using EllieBot.Modules.Administration;
 
 namespace EllieBot.Modules.Xp.Services;
 
-public record struct AddRoleRewardNotifyModel(ulong GuildId, ulong RoleId, ulong UserId, long Level) : INotifyModel
+public record struct AddRoleRewardNotifyModel(ulong GuildId, ulong RoleId, ulong UserId, long Level) : INotifyModel<AddRoleRewardNotifyModel>
 {
     public static string KeyName
         => "notify.reward.addrole";
@@ -11,16 +11,17 @@ public record struct AddRoleRewardNotifyModel(ulong GuildId, ulong RoleId, ulong
     public static NotifyType NotifyType
         => NotifyType.AddRoleReward;
 
-    public IReadOnlyDictionary<string, Func<SocketGuild, string>> GetReplacements()
-    {
-        var model = this;
-        return new Dictionary<string, Func<SocketGuild, string>>()
-        {
-            { "%event.user%", g => g.GetUser(model.UserId)?.ToString() ?? model.UserId.ToString() },
-            { "%event.role%", g => g.GetRole(model.RoleId)?.ToString() ?? model.RoleId.ToString() },
-            { "%event.level%", g => model.Level.ToString() }
-        };
-    }
+    public const string PH_LEVEL = "level";
+    public const string PH_USER = "user";
+    public const string PH_ROLE = "role";
+
+    public static IReadOnlyList<NotifyModelPlaceholderData<AddRoleRewardNotifyModel>> GetReplacements()
+        =>
+        [
+            new(PH_LEVEL, static (data, g) => data.Level.ToString() ),
+            new(PH_USER, static (data, g) => g.GetUser(data.UserId)?.ToString() ?? data.UserId.ToString() ),
+            new(PH_ROLE, static (data, g) => g.GetRole(data.RoleId)?.ToString() ?? data.RoleId.ToString() )
+        ];
 
     public bool TryGetUserId(out ulong userId)
     {
@@ -33,4 +34,4 @@ public record struct AddRoleRewardNotifyModel(ulong GuildId, ulong RoleId, ulong
         guildId = GuildId;
         return true;
     }
-}
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Administration/Notify/Models/LevelUpNotifyModel.cs b/src/EllieBot/Modules/Administration/Notify/Models/LevelUpNotifyModel.cs
index dc85d3e..2643e22 100644
--- a/src/EllieBot/Modules/Administration/Notify/Models/LevelUpNotifyModel.cs
+++ b/src/EllieBot/Modules/Administration/Notify/Models/LevelUpNotifyModel.cs
@@ -2,11 +2,11 @@ using EllieBot.Db.Models;
 
 namespace EllieBot.Modules.Administration;
 
-public record struct LevelUpNotifyModel(
+public readonly record struct LevelUpNotifyModel(
     ulong GuildId,
     ulong ChannelId,
     ulong UserId,
-    long Level) : INotifyModel
+    long Level) : INotifyModel<LevelUpNotifyModel>
 {
     public static string KeyName
         => "notify.levelup";
@@ -14,23 +14,25 @@ public record struct LevelUpNotifyModel(
     public static NotifyType NotifyType
         => NotifyType.LevelUp;
 
-    public IReadOnlyDictionary<string, Func<SocketGuild, string>> GetReplacements()
+    public const string PH_USER = "user";
+    public const string PH_LEVEL = "level";
+
+    public static IReadOnlyList<NotifyModelPlaceholderData<LevelUpNotifyModel>> GetReplacements()
     {
-        var data = this;
-        return new Dictionary<string, Func<SocketGuild, string>>()
-        {
-            { "%event.level%", g => data.Level.ToString() },
-            { "%event.user%", g => g.GetUser(data.UserId)?.ToString() ?? data.UserId.ToString() },
-        };
+        return
+        [
+            new(PH_LEVEL, static (data, g) => data.Level.ToString() ),
+            new(PH_USER, static (data, g) => g.GetUser(data.UserId)?.ToString() ?? data.UserId.ToString() )
+        ];
     }
 
-    public bool TryGetGuildId(out ulong guildId)
+    public readonly bool TryGetGuildId(out ulong guildId)
     {
         guildId = GuildId;
         return true;
     }
 
-    public bool TryGetUserId(out ulong userId)
+    public readonly bool TryGetUserId(out ulong userId)
     {
         userId = UserId;
         return true;
diff --git a/src/EllieBot/Modules/Administration/Notify/Models/ProtectionNotifyModel.cs b/src/EllieBot/Modules/Administration/Notify/Models/ProtectionNotifyModel.cs
index bc23531..d27307f 100644
--- a/src/EllieBot/Modules/Administration/Notify/Models/ProtectionNotifyModel.cs
+++ b/src/EllieBot/Modules/Administration/Notify/Models/ProtectionNotifyModel.cs
@@ -3,7 +3,7 @@ using EllieBot.Db.Models;
 
 namespace EllieBot.Modules.Administration.Services;
 
-public record struct ProtectionNotifyModel(ulong GuildId, ProtectionType ProtType, ulong UserId) : INotifyModel
+public record struct ProtectionNotifyModel(ulong GuildId, ProtectionType ProtType, ulong UserId) : INotifyModel<ProtectionNotifyModel>
 {
     public static string KeyName
         => "notify.protection";
@@ -11,13 +11,13 @@ public record struct ProtectionNotifyModel(ulong GuildId, ProtectionType ProtTyp
     public static NotifyType NotifyType
         => NotifyType.Protection;
 
-    public IReadOnlyDictionary<string, Func<SocketGuild, string>> GetReplacements()
+    public const string PH_TYPE = "type";
+
+    public static IReadOnlyList<NotifyModelPlaceholderData<ProtectionNotifyModel>> GetReplacements()
     {
-        var data = this;
-        return new Dictionary<string, Func<SocketGuild, string>>()
-        {
-            { "%event.type%", g => data.ProtType.ToString() },
-        };
+        return [
+            new(PH_TYPE, static (data, g) => data.ProtType.ToString() )
+        ];
     }
 
     public bool TryGetUserId(out ulong userId)
diff --git a/src/EllieBot/Modules/Administration/Notify/Models/RemoveRoleRewardNotifyModel.cs b/src/EllieBot/Modules/Administration/Notify/Models/RemoveRoleRewardNotifyModel.cs
index f078925..5997692 100644
--- a/src/EllieBot/Modules/Administration/Notify/Models/RemoveRoleRewardNotifyModel.cs
+++ b/src/EllieBot/Modules/Administration/Notify/Models/RemoveRoleRewardNotifyModel.cs
@@ -3,7 +3,7 @@ using EllieBot.Modules.Administration;
 
 namespace EllieBot.Modules.Xp.Services;
 
-public record struct RemoveRoleRewardNotifyModel(ulong GuildId, ulong RoleId, ulong UserId, long Level) : INotifyModel
+public record struct RemoveRoleRewardNotifyModel(ulong GuildId, ulong RoleId, ulong UserId, long Level) : INotifyModel<RemoveRoleRewardNotifyModel>
 {
     public static string KeyName
         => "notify.reward.removerole";
@@ -11,17 +11,6 @@ public record struct RemoveRoleRewardNotifyModel(ulong GuildId, ulong RoleId, ul
     public static NotifyType NotifyType
         => NotifyType.RemoveRoleReward;
 
-    public IReadOnlyDictionary<string, Func<SocketGuild, string>> GetReplacements()
-    {
-        var model = this;
-        return new Dictionary<string, Func<SocketGuild, string>>()
-        {
-            { "%event.user%", g => g.GetUser(model.UserId)?.ToString() ?? model.UserId.ToString() },
-            { "%event.role%", g => g.GetRole(model.RoleId)?.ToString() ?? model.RoleId.ToString() },
-            { "%event.level%", g => model.Level.ToString() },
-        };
-    }
-
     public bool TryGetUserId(out ulong userId)
     {
         userId = UserId;
@@ -33,4 +22,17 @@ public record struct RemoveRoleRewardNotifyModel(ulong GuildId, ulong RoleId, ul
         guildId = GuildId;
         return true;
     }
-}
+
+    public const string PH_USER = "user";
+    public const string PH_ROLE = "role";
+    public const string PH_LEVEL = "level";
+
+    public static IReadOnlyList<NotifyModelPlaceholderData<RemoveRoleRewardNotifyModel>> GetReplacements()
+    {
+        return [
+            new(PH_USER, static (data, g) => g.GetUser(data.UserId)?.ToString() ?? data.UserId.ToString() ),
+            new(PH_ROLE, static (data, g) => g.GetRole(data.RoleId)?.ToString() ?? data.RoleId.ToString() ),
+            new(PH_LEVEL, static (data, g) => data.Level.ToString() ),
+        ];
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Administration/Notify/NotifyCommands.cs b/src/EllieBot/Modules/Administration/Notify/NotifyCommands.cs
index 3b75f24..2dd39b7 100644
--- a/src/EllieBot/Modules/Administration/Notify/NotifyCommands.cs
+++ b/src/EllieBot/Modules/Administration/Notify/NotifyCommands.cs
@@ -13,7 +13,7 @@ public partial class Administration
         {
             await Response()
                   .Paginated()
-                  .Items(Enum.GetValues<NotifyType>())
+                  .Items(Enum.GetValues<NotifyType>().DistinctBy(x => (int)x).ToList())
                   .PageSize(5)
                   .Page((items, page) =>
                   {
@@ -75,6 +75,22 @@ public partial class Administration
             await Response().Confirm(strs.notify_on($"<#{ctx.Channel.Id}>", Format.Bold(nType.ToString()))).SendAsync();
         }
 
+        [Cmd]
+        [UserPerm(GuildPerm.Administrator)]
+        public async Task NotifyPlaceholders(NotifyType nType)
+        {
+            var data = _service.GetRegisteredModel(nType);
+
+            var eb = CreateEmbed()
+                     .WithOkColor()
+                     .WithTitle(GetText(strs.notify_placeholders(nType.ToString().ToLower())));
+
+            eb.WithDescription(data.Replacements.Join("\n---\n", x => $"`%event.{x}%`"));
+
+            await Response().Embed(eb).SendAsync();
+
+        }
+
         [Cmd]
         [UserPerm(GuildPerm.Administrator)]
         public async Task NotifyList(int page = 1)
diff --git a/src/EllieBot/Modules/Administration/Notify/NotifyModelExtensions.cs b/src/EllieBot/Modules/Administration/Notify/NotifyModelExtensions.cs
index 9aca824..6f02324 100644
--- a/src/EllieBot/Modules/Administration/Notify/NotifyModelExtensions.cs
+++ b/src/EllieBot/Modules/Administration/Notify/NotifyModelExtensions.cs
@@ -3,6 +3,6 @@
 public static class NotifyModelExtensions
 {
     public static TypedKey<T> GetTypedKey<T>(this T model)
-        where T : struct, INotifyModel
+        where T : struct, INotifyModel<T>
         => new(T.KeyName);
 }
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Administration/Notify/NotifyService.cs b/src/EllieBot/Modules/Administration/Notify/NotifyService.cs
index 4509552..9fc7ea7 100644
--- a/src/EllieBot/Modules/Administration/Notify/NotifyService.cs
+++ b/src/EllieBot/Modules/Administration/Notify/NotifyService.cs
@@ -3,6 +3,8 @@ using LinqToDB.EntityFrameworkCore;
 using EllieBot.Common.ModuleBehaviors;
 using EllieBot.Db.Models;
 using EllieBot.Generators;
+using EllieBot.Modules.Administration.Services;
+using EllieBot.Modules.Xp.Services;
 
 namespace EllieBot.Modules.Administration;
 
@@ -32,30 +34,42 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
         _pubSub = pubSub;
     }
 
+    private void RegisterModels()
+    {
+
+        RegisterModel<LevelUpNotifyModel>();
+        RegisterModel<ProtectionNotifyModel>();
+        RegisterModel<AddRoleRewardNotifyModel>();
+        RegisterModel<RemoveRoleRewardNotifyModel>();
+
+    }
+
     public async Task OnReadyAsync()
     {
+        RegisterModels();
+
         await using var uow = _db.GetDbContext();
         _events = (await uow.GetTable<Notify>()
-                            .Where(x => Queries.GuildOnShard(x.GuildId,
-                                _creds.TotalShards,
-                                _client.ShardId))
-                            .ToListAsyncLinqToDB())
-                  .GroupBy(x => x.Type)
-                  .ToDictionary(x => x.Key, x => x.ToDictionary(x => x.GuildId).ToConcurrent())
-                  .ToConcurrent();
+                .Where(x => Queries.GuildOnShard(x.GuildId,
+                    _creds.TotalShards,
+                    _client.ShardId))
+                .ToListAsyncLinqToDB())
+            .GroupBy(x => x.Type)
+            .ToDictionary(x => x.Key, x => x.ToDictionary(x => x.GuildId).ToConcurrent())
+            .ToConcurrent();
 
 
         await SubscribeToEvent<LevelUpNotifyModel>();
     }
 
     private async Task SubscribeToEvent<T>()
-        where T : struct, INotifyModel
+        where T : struct, INotifyModel<T>
     {
         await _pubSub.Sub(new TypedKey<T>(T.KeyName), async (model) => await OnEvent(model));
     }
 
     public async Task NotifyAsync<T>(T data, bool isShardLocal = false)
-        where T : struct, INotifyModel
+        where T : struct, INotifyModel<T>
     {
         try
         {
@@ -77,7 +91,7 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
     }
 
     private async Task OnEvent<T>(T model)
-        where T : struct, INotifyModel
+        where T : struct, INotifyModel<T>
     {
         if (_events.TryGetValue(T.NotifyType, out var subs))
         {
@@ -113,7 +127,8 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
         }
     }
 
-    private async Task HandleNotifyEvent(Notify conf, INotifyModel model)
+    private async Task HandleNotifyEvent<T>(Notify conf, T model)
+        where T : struct, INotifyModel<T>
     {
         var guild = _client.GetGuild(conf.GuildId);
         var channel = guild?.GetTextChannel(conf.ChannelId);
@@ -130,26 +145,28 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
         var rctx = new ReplacementContext(guild: guild, channel: channel, user: user);
 
         var st = SmartText.CreateFrom(conf.Message);
-        foreach (var modelRep in model.GetReplacements())
+        foreach (var modelRep in T.GetReplacements())
         {
-            rctx.WithOverride(modelRep.Key, () => modelRep.Value(guild));
+            rctx.WithOverride(GetPhToken(modelRep.Name), () => modelRep.Func(model, guild));
         }
 
         st = await _repSvc.ReplaceAsync(st, rctx);
         if (st is SmartPlainText spt)
         {
             await _mss.Response(channel)
-                      .Confirm(spt.Text)
-                      .SendAsync();
+                .Confirm(spt.Text)
+                .SendAsync();
             return;
         }
 
         await _mss.Response(channel)
-                  .Text(st)
-                  .Sanitize(false)
-                  .SendAsync();
+            .Text(st)
+            .Sanitize(false)
+            .SendAsync();
     }
 
+    private static string GetPhToken(string name) => $"%event.{name}%";
+
     public async Task EnableAsync(
         ulong guildId,
         ulong channelId,
@@ -158,23 +175,23 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
     {
         await using var uow = _db.GetDbContext();
         await uow.GetTable<Notify>()
-                 .InsertOrUpdateAsync(() => new()
-                 {
-                     GuildId = guildId,
-                     ChannelId = channelId,
-                     Type = nType,
-                     Message = message,
-                 },
-                     (_) => new()
-                     {
-                         Message = message,
-                         ChannelId = channelId
-                     },
-                     () => new()
-                     {
-                         GuildId = guildId,
-                         Type = nType
-                     });
+            .InsertOrUpdateAsync(() => new()
+            {
+                GuildId = guildId,
+                ChannelId = channelId,
+                Type = nType,
+                Message = message,
+            },
+                (_) => new()
+                {
+                    Message = message,
+                    ChannelId = channelId
+                },
+                () => new()
+                {
+                    GuildId = guildId,
+                    Type = nType
+                });
 
         var eventDict = _events.GetOrAdd(nType, _ => new());
         eventDict[guildId] = new()
@@ -190,8 +207,8 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
     {
         await using var uow = _db.GetDbContext();
         var deleted = await uow.GetTable<Notify>()
-                               .Where(x => x.GuildId == guildId && x.Type == nType)
-                               .DeleteAsync();
+            .Where(x => x.GuildId == guildId && x.Type == nType)
+            .DeleteAsync();
 
         if (deleted == 0)
             return;
@@ -208,11 +225,11 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
 
         await using var ctx = _db.GetDbContext();
         var list = await ctx.GetTable<Notify>()
-                            .Where(x => x.GuildId == guildId)
-                            .OrderBy(x => x.Type)
-                            .Skip(page * 10)
-                            .Take(10)
-                            .ToListAsyncLinqToDB();
+            .Where(x => x.GuildId == guildId)
+            .OrderBy(x => x.Type)
+            .Skip(page * 10)
+            .Take(10)
+            .ToListAsyncLinqToDB();
 
         return list;
     }
@@ -221,7 +238,17 @@ public sealed class NotifyService : IReadyExecutor, INotifySubscriber, IEService
     {
         await using var ctx = _db.GetDbContext();
         return await ctx.GetTable<Notify>()
-                        .Where(x => x.GuildId == guildId && x.Type == nType)
-                        .FirstOrDefaultAsyncLinqToDB();
+            .Where(x => x.GuildId == guildId && x.Type == nType)
+            .FirstOrDefaultAsyncLinqToDB();
     }
-}
\ No newline at end of file
+
+    // messed up big time, it was supposed to be fully extensible, but it's stored as an enum in the database already...
+    private readonly ConcurrentDictionary<NotifyType, NotifyModelData> _models = new();
+    public void RegisterModel<T>() where T : struct, INotifyModel<T>
+    {
+        var data = new NotifyModelData(T.NotifyType, T.GetReplacements().Map(x => x.Name));
+        _models[T.NotifyType] = data;
+    }
+
+    public NotifyModelData GetRegisteredModel(NotifyType nType) => _models[nType];
+}
diff --git a/src/EllieBot/strings/aliases.yml b/src/EllieBot/strings/aliases.yml
index 969bfe0..77e41e6 100644
--- a/src/EllieBot/strings/aliases.yml
+++ b/src/EllieBot/strings/aliases.yml
@@ -1562,6 +1562,10 @@ notifyclear:
   - notifyremove
   - notifyrm
   - notifclr
+notifyphs:
+  - notifyphs
+  - notifyph
+  - notifyplaceholders
 winlb:
   - winlb
   - wins
diff --git a/src/EllieBot/strings/commands/commands.en-US.yml b/src/EllieBot/strings/commands/commands.en-US.yml
index 849bf15..a390f53 100644
--- a/src/EllieBot/strings/commands/commands.en-US.yml
+++ b/src/EllieBot/strings/commands/commands.en-US.yml
@@ -4911,6 +4911,14 @@ notifyclear:
   params:
     - event:
         desc: "The notify event to clear."
+notifyphs:
+  desc: |-
+    Lists the placeholders for a given notify event type
+  ex:
+    - 'levelup'
+  params:
+    - event:
+        desc: "The notify event to list placeholders for."
 winlb:
   desc: |-
     Shows the biggest wins leaderboard
diff --git a/src/EllieBot/strings/responses/responses.en-US.json b/src/EllieBot/strings/responses/responses.en-US.json
index de8f197..13c177e 100644
--- a/src/EllieBot/strings/responses/responses.en-US.json
+++ b/src/EllieBot/strings/responses/responses.en-US.json
@@ -1158,6 +1158,7 @@
   "notify_desc_addrolerew": "Triggers when a user gets a role as a reward for reaching a level (xprew).",
   "notify_desc_removerolerew": "Triggers when a user loses a role as a reward for reaching a level (xprew).",
   "notify_desc_not_found": "No description found for this notify event. Please report this.",
+  "notify_placeholders": "Placeholders for '{0}' notify event",
   "winlb": "Biggest Wins Leaderboard",
   "no_banner": "No banner set.",
   "fish_nothing": "You caught nothing, try again.",