Skip to content

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

<PackageReference Include="Svix" Version="1.14.0" />

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:

  1. Deterministic IDs: Same Clerk user always generates same UserId
  2. Idempotency: Duplicate webhooks won't create duplicate users
  3. No Database Lookups: Don't need to check if user exists before creating
  4. Reliable References: Other aggregates can reference users with stable IDs
  5. 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

// appsettings.json
{
  "Clerk": {
    "WebhookSecret": "whsec_..." // Store in secure 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

# Install ngrok
ngrok http 5000
# Use ngrok URL in Clerk dashboard

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

  1. Week 1:
  2. Set up webhook models and endpoint
  3. Implement signature verification
  4. Basic webhook handler with logging

  5. Week 2:

  6. Implement ClerkWebhookImporter
  7. Handle user.created events
  8. Create UserPreferences aggregate

  9. Week 3:

  10. Handle user.updated and user.deleted events
  11. Add comprehensive error handling
  12. Implement idempotency checks

  13. Week 4:

  14. Testing (unit, integration, end-to-end)
  15. Documentation
  16. Deployment preparation

Security Considerations

  1. Signature Verification: Always verify webhook signatures
  2. Idempotency: Handle duplicate webhook deliveries gracefully
  3. Rate Limiting: Implement rate limiting on webhook endpoint
  4. Error Handling: Don't expose internal errors in responses
  5. Logging: Log all webhook events for audit trail
  6. 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

  1. Secure Clerk webhook endpoint with Svix verification
  2. ClerkWebhookImporter that runs directly (no import executor)
  3. Handle user.created, user.updated, user.deleted events
  4. Create/update/soft-delete User aggregates only
  5. 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)

  1. Webhook Event Store: Store raw webhook events for replay capability
  2. Dead Letter Queue: Handle failed webhook processing
  3. Webhook Analytics: Dashboard for webhook health monitoring
  4. Bulk Operations: Handle organization-level events
  5. Custom Attributes: Sync custom user attributes from Clerk