diff --git a/API/ROLAC.API/Controllers/LineWebhookController.cs b/API/ROLAC.API/Controllers/LineWebhookController.cs
new file mode 100644
index 0000000..42e9a70
--- /dev/null
+++ b/API/ROLAC.API/Controllers/LineWebhookController.cs
@@ -0,0 +1,88 @@
+using System.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+using ROLAC.API.DTOs.Notifications;
+using ROLAC.API.Services.Notifications;
+
+namespace ROLAC.API.Controllers;
+
+///
+/// Anonymous Line webhook. Verifies the X-Line-Signature over the raw body, then dispatches
+/// follow/message/join/leave events. Always returns 200 for valid payloads so Line does not retry;
+/// returns 400 only on signature failure.
+///
+[ApiController]
+[Route("api/line")]
+[AllowAnonymous]
+public sealed class LineWebhookController : ControllerBase
+{
+ private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
+
+ private readonly ILineNotificationService _line;
+ private readonly IMessageChannel _channel;
+ private readonly LineOptions _options;
+
+ public LineWebhookController(
+ ILineNotificationService line, IMessageChannel channel, IOptions options)
+ {
+ _line = line;
+ _channel = channel;
+ _options = options.Value;
+ }
+
+ [HttpPost("webhook")]
+ public async Task Webhook(CancellationToken ct)
+ {
+ using var reader = new StreamReader(Request.Body, Encoding.UTF8);
+ var rawBody = await reader.ReadToEndAsync(ct);
+ var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
+
+ if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
+ return BadRequest();
+
+ var payload = JsonSerializer.Deserialize(rawBody, JsonOpts);
+ if (payload?.Events is not null)
+ foreach (var evt in payload.Events)
+ await DispatchAsync(evt, ct);
+
+ return Ok();
+ }
+
+ private async Task DispatchAsync(LineWebhookEvent evt, CancellationToken ct)
+ {
+ switch (evt.Type)
+ {
+ case "follow":
+ if (evt.ReplyToken is not null)
+ await _channel.ReplyAsync(evt.ReplyToken, "歡迎!請輸入您的綁定碼以連結教會帳號。", ct);
+ break;
+
+ case "message":
+ if (evt.Message?.Type == "text"
+ && evt.Source?.UserId is { } userId
+ && evt.Message.Text is { } text)
+ {
+ var result = await _line.TryBindMemberAsync(userId, text, ct);
+ if (evt.ReplyToken is not null)
+ await _channel.ReplyAsync(evt.ReplyToken, result.Message, ct);
+ }
+ break;
+
+ case "join":
+ if (evt.Source?.GroupId is { } joinGroupId)
+ {
+ await _line.RegisterGroupAsync(joinGroupId, ct);
+ if (evt.ReplyToken is not null)
+ await _channel.ReplyAsync(evt.ReplyToken, "已加入群組,請至後台命名此群組。", ct);
+ }
+ break;
+
+ case "leave":
+ if (evt.Source?.GroupId is { } leaveGroupId)
+ await _line.DeactivateGroupAsync(leaveGroupId, ct);
+ break;
+ }
+ }
+}
diff --git a/API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs b/API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs
new file mode 100644
index 0000000..6d9b441
--- /dev/null
+++ b/API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs
@@ -0,0 +1,28 @@
+namespace ROLAC.API.DTOs.Notifications;
+
+/// Top-level Line webhook payload (deserialized case-insensitively).
+public sealed class LineWebhookPayload
+{
+ public List? Events { get; set; }
+}
+
+public sealed class LineWebhookEvent
+{
+ public string? Type { get; set; } // follow | message | join | leave | ...
+ public string? ReplyToken { get; set; }
+ public LineWebhookSource? Source { get; set; }
+ public LineWebhookMessage? Message { get; set; }
+}
+
+public sealed class LineWebhookSource
+{
+ public string? Type { get; set; } // user | group | room
+ public string? UserId { get; set; }
+ public string? GroupId { get; set; }
+}
+
+public sealed class LineWebhookMessage
+{
+ public string? Type { get; set; } // text | image | ...
+ public string? Text { get; set; }
+}