diff --git a/API/ROLAC.API/Controllers/NotificationsController.cs b/API/ROLAC.API/Controllers/NotificationsController.cs
new file mode 100644
index 0000000..972d686
--- /dev/null
+++ b/API/ROLAC.API/Controllers/NotificationsController.cs
@@ -0,0 +1,95 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using ROLAC.API.Data;
+using ROLAC.API.DTOs.Notifications;
+using ROLAC.API.Services.Logging;
+using ROLAC.API.Services.Notifications;
+
+namespace ROLAC.API.Controllers;
+
+///
+/// Admin endpoints for the notification module (API-only phase). Binding-code generation, group
+/// management, send history, and manual send — the manual send endpoints are the only way to fire
+/// a message before a UI exists; programmatic callers use the services directly.
+///
+[ApiController]
+[Route("api/notifications")]
+[Authorize]
+public sealed class NotificationsController : ControllerBase
+{
+ private readonly IEmailService _email;
+ private readonly ILineNotificationService _line;
+ private readonly AppDbContext _db;
+ private readonly CurrentUserAccessor _currentUser;
+
+ public NotificationsController(
+ IEmailService email, ILineNotificationService line,
+ AppDbContext db, CurrentUserAccessor currentUser)
+ {
+ _email = email;
+ _line = line;
+ _db = db;
+ _currentUser = currentUser;
+ }
+
+ [HttpPost("members/{id:int}/line-binding-code")]
+ public async Task GenerateBindingCode(int id, CancellationToken ct)
+ => Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) });
+
+ [HttpGet("groups")]
+ public async Task Groups(CancellationToken ct)
+ => Ok(await _db.MessagingGroups
+ .OrderBy(g => g.Id)
+ .Select(g => new { g.Id, g.Name, g.IsActive, g.RegisteredAt })
+ .ToListAsync(ct));
+
+ [HttpPut("groups/{id:int}")]
+ public async Task UpdateGroup(int id, [FromBody] UpdateGroupRequest request, CancellationToken ct)
+ {
+ var group = await _db.MessagingGroups.FirstOrDefaultAsync(g => g.Id == id, ct);
+ if (group is null) return NotFound();
+
+ group.Name = request.Name;
+ group.IsActive = request.IsActive;
+ await _db.SaveChangesAsync(ct);
+ return NoContent();
+ }
+
+ [HttpGet("history")]
+ public async Task History(
+ [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
+ {
+ var size = Math.Clamp(pageSize, 1, 200);
+ var skip = (Math.Max(page, 1) - 1) * size;
+
+ var query = _db.NotificationLogs.OrderByDescending(l => l.SentAt);
+ var total = await query.CountAsync(ct);
+ var items = await query
+ .Skip(skip).Take(size)
+ .Select(l => new
+ {
+ l.Id, l.Channel, l.TargetType, l.TargetExternalId, l.Subject,
+ l.Status, l.Error, l.SentByUserId, l.SentAt,
+ })
+ .ToListAsync(ct);
+
+ return Ok(new { total, items });
+ }
+
+ [HttpPost("send-line")]
+ public async Task SendLine([FromBody] SendLineRequest request, CancellationToken ct)
+ => Ok(await _line.SendLineAsync(
+ request.Body, request.MemberIds ?? [], request.GroupIds ?? [],
+ _currentUser.UserIdOrSystem, ct));
+
+ [HttpPost("send-email")]
+ public async Task SendEmail([FromBody] SendEmailRequest request, CancellationToken ct)
+ => Ok(await _email.SendAsync(new EmailMessage(
+ MemberIds: request.MemberIds ?? [],
+ Addresses: request.Addresses ?? [],
+ Subject: request.Subject,
+ HtmlBody: request.HtmlBody,
+ Attachments: null,
+ SentByUserId: _currentUser.UserIdOrSystem), ct));
+}
diff --git a/API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs b/API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs
new file mode 100644
index 0000000..903954f
--- /dev/null
+++ b/API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs
@@ -0,0 +1,7 @@
+namespace ROLAC.API.DTOs.Notifications;
+
+public sealed record UpdateGroupRequest(string? Name, bool IsActive);
+
+public sealed record SendLineRequest(string Body, int[]? MemberIds, int[]? GroupIds);
+
+public sealed record SendEmailRequest(string Subject, string HtmlBody, int[]? MemberIds, string[]? Addresses);