Clerk Webhook Implementation Plan¶
See also: Clerk Authentication Integration for the overall Clerk + event-sourced User aggregate design.
Overview¶
Implement a secure webhook handler to synchronize user data between Clerk and our system, creating user aggregates and preferences when users are created, updated, or deleted in Clerk.
Phase 1: Infrastructure Setup¶
1.1 Install Required NuGet Packages¶
1.2 Create Webhook Data Models¶
// src/Domain/LBS.Domain.Infrastructure/Authentication/Clerk/ClerkWebhookModels.cs
public sealed class ClerkWebhookEvent
{
public ClerkUserData Data { get; set; }
public string Object { get; set; } // "event"
public string Type { get; set; } // "user.created", "user.updated", "user.deleted"
}
public sealed class ClerkUserData
{
public string Id { get; set; } // Clerk user ID
public List<ClerkEmailAddress> EmailAddresses { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? Username { get; set; }
public string? ImageUrl { get; set; }
public bool? Banned { get; set; }
public long? CreatedAt { get; set; } // Unix timestamp
public long? UpdatedAt { get; set; }
public long? LastSignInAt { get; set; }
public bool? Deleted { get; set; }
}
public sealed class ClerkEmailAddress
{
public string Id { get; set; }
public string EmailAddress { get; set; }
public ClerkVerification Verification { get; set; }
}
public sealed class ClerkVerification
{
public string Status { get; set; } // "verified", "unverified"
}
Phase 2: Webhook Endpoint Implementation¶
2.1 Create ClerkWebhookEndpoint¶
// src/Domain/LBS.Domain.Infrastructure/Authentication/Clerk/ClerkWebhookEndpoint.cs
public class ClerkWebhookEndpoint : EndpointWithoutRequest<ClerkWebhookResponse>
{
private readonly ClerkWebhookImporter importer;
private readonly IConfiguration configuration;
private readonly ILogger<ClerkWebhookEndpoint> logger;
public override void Configure()
{
this.Post("/api/webhooks/clerk");
this.AllowAnonymous(); // Webhook uses signature verification instead
this.Description(d => d
.WithTags("Webhooks")
.WithSummary("Handle Clerk webhook events")
.ExcludeFromDescription()); // Don't expose in public API docs
}
public override async Task HandleAsync(CancellationToken ct)
{
// 1. Verify webhook signature
if (!await VerifyWebhookSignature(HttpContext))
{
await SendAsync(new ClerkWebhookResponse { Success = false }, 400, ct);
return;
}
// 2. Parse webhook event from raw body
var clerkEvent = await ParseWebhookEvent(HttpContext);
// 3. Run importer directly (no import executor needed)
var commands = await this.importer.ExecuteAsync(new ClerkWebhookImporterContract
{
Event = clerkEvent
});
// 4. Execute commands using existing infrastructure
foreach (var command in commands)
{
await commandExecutor.ExecuteAsync(command);
}
await SendOkAsync(new ClerkWebhookResponse { Success = true }, ct);
}
}
2.2 Implement Webhook Signature Verification¶
private async Task<bool> VerifyWebhookSignature(HttpContext context)
{
// Enable buffering to read body multiple times
context.Request.EnableBuffering();
// Read raw body for signature verification
using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
context.Request.Body.Position = 0; // Reset for FastEndpoints
// Get headers
var svixId = context.Request.Headers["svix-id"].FirstOrDefault();
var svixTimestamp = context.Request.Headers["svix-timestamp"].FirstOrDefault();
var svixSignature = context.Request.Headers["svix-signature"].FirstOrDefault();
if (string.IsNullOrEmpty(svixId) || string.IsNullOrEmpty(svixTimestamp) || string.IsNullOrEmpty(svixSignature))
{
return false;
}
// Get webhook secret from configuration
var webhookSecret = this.configuration["Clerk:WebhookSecret"];
// Verify using Svix
var webhook = new Webhook(webhookSecret);
try
{
webhook.Verify(rawBody, new WebhookHeaders
{
["svix-id"] = svixId,
["svix-timestamp"] = svixTimestamp,
["svix-signature"] = svixSignature
});
return true;
}
catch
{
return false;
}
}
Phase 3: Create ClerkWebhookImporter¶
3.0 Stable ID Generation Benefits¶
Using UserId.NewLuckBoxUserId(clerkUserId) provides several key advantages:
- Deterministic IDs: Same Clerk user always generates same UserId
- Idempotency: Duplicate webhooks won't create duplicate users
- No Database Lookups: Don't need to check if user exists before creating
- Reliable References: Other aggregates can reference users with stable IDs
- Cross-Service Consistency: Same user has same ID across all services
3.1 Define Import Contract¶
// src/Domain/LBS.Domain.Infrastructure/Authentication/Clerk/ClerkWebhookImporterContract.cs
public sealed class ClerkWebhookImporterContract
{
public ClerkWebhookEvent Event { get; set; }
}
3.2 Implement ClerkWebhookImporter¶
// src/Domain/LBS.Domain.Infrastructure/Authentication/Clerk/ClerkWebhookImporter.cs
public sealed class ClerkWebhookImporter : IImporter<ClerkWebhookImporterContract>
{
// Note: No longer need IUserLookupService since we use stable ID generation
public async Task<IEnumerable<IDomainCommand>> ExecuteAsync(ClerkWebhookImporterContract input)
{
var commands = new List<IDomainCommand>();
var clerkEvent = input.Event;
switch (clerkEvent.Type)
{
case "user.created":
commands.AddRange(await HandleUserCreated(clerkEvent.Data));
break;
case "user.updated":
commands.AddRange(await HandleUserUpdated(clerkEvent.Data));
break;
case "user.deleted":
commands.AddRange(await HandleUserDeleted(clerkEvent.Data));
break;
}
return commands;
}
private async Task<IEnumerable<IDomainCommand>> HandleUserCreated(ClerkUserData userData)
{
var commands = new List<IDomainCommand>();
// No need to check for existing user - stable ID generation ensures idempotency
// If webhook is delivered multiple times, the aggregate will handle it correctly
// Use stable ID generation from Clerk user ID
var userId = UserId.NewLuckBoxUserId(userData.Id);
var primaryEmail = userData.EmailAddresses?.FirstOrDefault()?.EmailAddress;
// Create user command
commands.Add(new CreateUserCommand
{
AggregateRootId = userId,
Email = primaryEmail ?? $"{userData.Id}@clerk.local",
FirstName = userData.FirstName,
LastName = userData.LastName,
UserName = userData.Username,
ClerkUserId = userData.Id,
EmailVerified = userData.EmailAddresses?.FirstOrDefault()?.Verification?.Status == "verified",
Status = DetermineUserStatus(userData),
Roles = [RoleDefinition.Member],
UserType = AccountType.Human,
CreatedAt = UnixToDateTime(userData.CreatedAt)
});
// TODO: Future work - Create user preferences aggregate
// This will be implemented in a separate card
return commands;
}
private async Task<IEnumerable<IDomainCommand>> HandleUserUpdated(ClerkUserData userData)
{
var commands = new List<IDomainCommand>();
// Use stable ID generation to find existing user
var userId = UserId.NewLuckBoxUserId(userData.Id);
var primaryEmail = userData.EmailAddresses?.FirstOrDefault()?.EmailAddress;
// Update user command
commands.Add(new UpdateUserCommand
{
AggregateRootId = userId,
Email = primaryEmail,
FirstName = userData.FirstName,
LastName = userData.LastName,
UserName = userData.Username,
EmailVerified = userData.EmailAddresses?.FirstOrDefault()?.Verification?.Status == "verified",
Status = DetermineUserStatus(userData),
LastSignInAt = UnixToDateTime(userData.LastSignInAt)
});
return commands;
}
private async Task<IEnumerable<IDomainCommand>> HandleUserDeleted(ClerkUserData userData)
{
var commands = new List<IDomainCommand>();
// Use stable ID generation to find user to soft delete
var userId = UserId.NewLuckBoxUserId(userData.Id);
// Soft delete user by updating status
commands.Add(new UpdateUserCommand
{
AggregateRootId = userId,
Status = UserStatus.Deleted
});
return commands;
}
}
4.1 Add Configuration¶
4.2 Register Services¶
// Program.cs or ServiceExtensions
// Register importer directly
services.AddScoped<ClerkWebhookImporter>();
5.1 Local Testing with Cloudflare Tunnels¶
# Install cloudflared
# Run API locally
dotnet run --project src/Apps/LBS.Api
# In another terminal - create tunnel
cloudflared tunnel --url http://localhost:5000
# Configure Clerk webhook endpoint to tunnel URL
# https://your-tunnel-url.trycloudflare.com/api/webhooks/clerk
Alternative: ngrok
5.2 Unit Tests¶
[Test]
public async Task ClerkWebhook_UserCreated_GeneratesCommands()
{
var importer = new ClerkWebhookImporter(mockUserLookupService);
var contract = new ClerkWebhookImporterContract
{
Event = new ClerkWebhookEvent
{
Type = "user.created",
Data = new ClerkUserData { /* test data */ }
}
};
var commands = await importer.ExecuteAsync(contract);
Assert.That(commands.Count(), Is.EqualTo(1)); // Just User for now
}
5.3 Integration Tests¶
- Test webhook signature verification
- Test duplicate event handling (idempotency)
- Test error scenarios
Phase 6: Deployment & Monitoring¶
6.1 Deployment Checklist¶
- [ ] Webhook secret configured in production
- [ ] Webhook endpoint URL configured in Clerk dashboard
- [ ] SSL/TLS enabled (webhooks require HTTPS)
- [ ] Logging configured for webhook events
- [ ] Error alerting configured
6.2 Monitoring¶
// Add structured logging
this.logger.LogInformation("Clerk webhook received",
new { EventType = req.Type, UserId = req.Data?.Id });
// Add metrics
_metrics.RecordWebhookReceived("clerk", req.Type);
Implementation Order¶
- Week 1:
- Set up webhook models and endpoint
- Implement signature verification
-
Basic webhook handler with logging
-
Week 2:
- Implement ClerkWebhookImporter
- Handle user.created events
-
Create UserPreferences aggregate
-
Week 3:
- Handle user.updated and user.deleted events
- Add comprehensive error handling
-
Implement idempotency checks
-
Week 4:
- Testing (unit, integration, end-to-end)
- Documentation
- Deployment preparation
Security Considerations¶
- Signature Verification: Always verify webhook signatures
- Idempotency: Handle duplicate webhook deliveries gracefully
- Rate Limiting: Implement rate limiting on webhook endpoint
- Error Handling: Don't expose internal errors in responses
- Logging: Log all webhook events for audit trail
- Secret Management: Use secure configuration for webhook secret
Error Handling Strategy¶
// Return 200 for successful processing
// Return 200 for duplicate events (already processed)
// Return 400 for invalid signature
// Return 400 for malformed payload
// Return 500 for internal errors (will trigger retry from Clerk)
Future Work Items (Separate Cards)¶
User Preferences Aggregate (High Priority)¶
Card: LBS-XXX - Create User Preferences Aggregate
- Create UserPreferencesAggregate with commands and events
- Create UserPreferencesId strongly-typed ID (use stable generation with UserId)
- Add CreateUserPreferencesCommand and related events
- Modify ClerkWebhookImporter to also create preferences when user is created
- Add read models for user preferences
- Add API endpoints for managing user preferences
- Use UserPreferencesId.NewLuckBoxUserPreferencesId(userId.Value) for stable preference IDs
Estimated Effort: 1-2 weeks
Enhanced User Profile Management (Medium Priority)¶
Card: LBS-XXX - Enhanced User Profile and Team Management - Add team selection and favorite team preferences - Add notification preferences (email, push, SMS) - Add theme and display preferences - Add privacy settings - Add cross-service preference sharing
Estimated Effort: 2-3 weeks
Advanced Webhook Features (Low Priority)¶
Card: LBS-XXX - Advanced Webhook Infrastructure - Webhook event store for replay capability - Dead letter queue for failed webhook processing - Webhook analytics dashboard - Organization-level event handling - Custom user attribute synchronization
Estimated Effort: 1-2 weeks
Current Scope (This Card)¶
What We're Building Now¶
- Secure Clerk webhook endpoint with Svix verification
- ClerkWebhookImporter that runs directly (no import executor)
- Handle user.created, user.updated, user.deleted events
- Create/update/soft-delete User aggregates only
- Proper error handling and logging
What We're NOT Building (Future Cards)¶
- User preferences aggregate (future card)
- Team selection preferences (future card)
- Cross-service preference sharing (future card)
- Advanced webhook features (future card)
Future Enhancements (Low Priority)¶
- Webhook Event Store: Store raw webhook events for replay capability
- Dead Letter Queue: Handle failed webhook processing
- Webhook Analytics: Dashboard for webhook health monitoring
- Bulk Operations: Handle organization-level events
- Custom Attributes: Sync custom user attributes from Clerk