Back to Blog
Blazor.NETHangfireLegal TechAPI IntegrationClioZoom APIC#OAuth 2.0

Building a Legal Tech Automation System: Syncing Clio, Lawmatics, Zoom & Box Using .NET Hangfire

A deep dive into how we built a fully automated data sync pipeline for a law firm — connecting Clio, Lawmatics, Zoom, and Box using ASP.NET Core, Hangfire background jobs, and OAuth 2.0. Real architecture, real code, real problems solved.

28 April 202623 min read

Law firms run on data — contacts, matters, call recordings, transcripts, documents. The problem is that data lives in four different platforms that don't talk to each other. Clio and Lawmatics handle case management. Zoom handles calls. Box handles document storage. Every day, staff manually copy contacts, chase recordings, and create folders. That's exactly the workflow we automated.

This post documents the full architecture we built for a US-based law firm — North City Law — using ASP.NET Core, Hangfire, and the REST APIs of all four platforms. If you're building legal tech integrations or any multi-platform sync system, the patterns here apply directly.

The Problem — Four Platforms, Zero Sync

Here's what the firm's manual workflow looked like before automation:

1. A new client is added to Lawmatics (their CRM)
2. Staff manually add the same contact to Zoom's external contacts
3. A call happens — Zoom generates a recording, AI summary, and transcript
4. Staff download the transcript and paste it as a note in Clio
5. A new matter opens in Clio — staff manually create a Box folder and upload documents

This process consumed 2–3 hours of admin work daily and introduced errors at every manual step. Our goal: make all of it happen automatically, on a schedule, without human intervention.

Architecture Overview

The system is built on Blazor Server + ASP.NET Core + Hangfire, hosted on Azure App Service. Each integration is a Hangfire recurring job that runs on a configurable schedule. OAuth 2.0 tokens for each platform are stored encrypted in a SQL Server database and refreshed automatically.

The four core jobs are:

Job 1: Sync contacts from Clio/Lawmatics → Zoom External Contacts (every 6 hours)
Job 2: Pull Zoom call recordings, AI summaries, and transcripts → post as notes in Clio/Lawmatics (daily at 7 AM)
Job 3: Monitor new matters in Clio → create corresponding Box folder structure (triggered on schedule every 15 minutes)
Job 4: Upload matter documents from Clio → Box folder (daily at 6 AM)

Setting Up Hangfire with SQL Server

Hangfire is the backbone of the scheduling system. It stores job state in SQL Server, handles retries automatically, and provides a dashboard to monitor every job run.

// Program.cs
builder.Services.AddHangfire(config => config
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage(builder.Configuration.GetConnectionString("HangfireDb"),
        new SqlServerStorageOptions
        {
            CommandBatchMaxTimeout       = TimeSpan.FromMinutes(5),
            SlidingInvisibilityTimeout   = TimeSpan.FromMinutes(5),
            QueuePollInterval            = TimeSpan.Zero,
            UseRecommendedIsolationLevel = true,
            DisableGlobalLocks           = true
        }));

builder.Services.AddHangfireServer(options =>
{
    options.WorkerCount    = 5;
    options.Queues         = new[] { "critical", "default", "low" };
});

// Register recurring jobs after app builds
var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var recurringJobs = scope.ServiceProvider
        .GetRequiredService<IRecurringJobManager>();

    // Every 6 hours — sync contacts to Zoom
    recurringJobs.AddOrUpdate<ContactSyncJob>(
        "sync-contacts-to-zoom",
        job => job.ExecuteAsync(),
        "0 */6 * * *");

    // Daily at 7 AM UTC — sync Zoom recordings to Clio notes
    recurringJobs.AddOrUpdate<ZoomRecordingSyncJob>(
        "sync-zoom-recordings-to-clio",
        job => job.ExecuteAsync(),
        "0 7 * * *");

    // Every 15 minutes — check for new Clio matters
    recurringJobs.AddOrUpdate<NewMatterBoxJob>(
        "create-box-folders-for-new-matters",
        job => job.ExecuteAsync(),
        "*/15 * * * *");
}

Job 1: Syncing Contacts from Clio/Lawmatics to Zoom

The Clio API returns contacts with full contact details. We map these to Zoom's External Contact format and use the Zoom API to upsert them. The key challenge is matching contacts that already exist in Zoom — we match on phone number since names can differ between systems.

public class ContactSyncJob
{
    private readonly IClioService    _clio;
    private readonly IZoomService    _zoom;
    private readonly ILogger<ContactSyncJob> _logger;

    public ContactSyncJob(
        IClioService clio,
        IZoomService zoom,
        ILogger<ContactSyncJob> logger)
    {
        _clio   = clio;
        _zoom   = zoom;
        _logger = logger;
    }

    public async Task ExecuteAsync()
    {
        _logger.LogInformation("Starting contact sync at {Time}", DateTime.UtcNow);

        // Fetch all contacts from Clio (paginated)
        var clioContacts = await _clio.GetAllContactsAsync();

        // Fetch existing Zoom external contacts for deduplication
        var zoomContacts = await _zoom.GetExternalContactsAsync();
        var zoomByPhone  = zoomContacts
            .Where(z => !string.IsNullOrEmpty(z.Phone))
            .ToDictionary(z => z.Phone!.NormalizePhone(), z => z);

        int created = 0, updated = 0, skipped = 0;

        foreach (var contact in clioContacts)
        {
            try
            {
                var normalized = contact.PrimaryPhone?.NormalizePhone();

                if (string.IsNullOrEmpty(normalized))
                {
                    skipped++;
                    continue;
                }

                if (zoomByPhone.TryGetValue(normalized, out var existing))
                {
                    // Update if name changed
                    if (existing.DisplayName != contact.DisplayName)
                    {
                        await _zoom.UpdateExternalContactAsync(existing.Id, new ZoomContactRequest
                        {
                            DisplayName = contact.DisplayName,
                            Email       = contact.Email,
                            Phone       = contact.PrimaryPhone
                        });
                        updated++;
                    }
                }
                else
                {
                    await _zoom.CreateExternalContactAsync(new ZoomContactRequest
                    {
                        DisplayName = contact.DisplayName,
                        Email       = contact.Email,
                        Phone       = contact.PrimaryPhone
                    });
                    created++;
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Failed to sync contact {Name}", contact.DisplayName);
            }
        }

        _logger.LogInformation(
            "Contact sync complete. Created: {C}, Updated: {U}, Skipped: {S}",
            created, updated, skipped);
    }
}

Job 2: Syncing Zoom Recordings and AI Summaries to Clio Notes

This is the most valuable part of the automation. Zoom's API provides three assets per meeting: the recording file URL, an AI-generated summary, and a full transcript. We download all three and post them as a structured note in Clio against the matching contact.

public class ZoomRecordingSyncJob
{
    private readonly IZoomService       _zoom;
    private readonly IClioService       _clio;
    private readonly ISyncStateService  _state;
    private readonly ILogger<ZoomRecordingSyncJob> _logger;

    public async Task ExecuteAsync()
    {
        // Only fetch recordings since last successful sync
        var lastSync = await _state.GetLastSyncTimeAsync("zoom-recordings");
        var fromDate = lastSync ?? DateTime.UtcNow.AddDays(-1);

        _logger.LogInformation("Fetching Zoom recordings since {From}", fromDate);

        var meetings = await _zoom.GetCloudRecordingsAsync(
            from: fromDate,
            to:   DateTime.UtcNow);

        foreach (var meeting in meetings)
        {
            try
            {
                // Try to match the meeting host/participant to a Clio contact
                var clioContact = await _clio
                    .FindContactByEmailAsync(meeting.HostEmail);

                if (clioContact is null)
                {
                    _logger.LogWarning(
                        "No Clio contact found for {Email}",
                        meeting.HostEmail);
                    continue;
                }

                // Build a rich note from all three Zoom assets
                var noteBody = BuildNoteContent(meeting);

                await _clio.CreateNoteAsync(new ClioNoteRequest
                {
                    Subject  = $"Zoom Call — {meeting.Topic} ({meeting.StartTime:MMM dd, yyyy})",
                    Body     = noteBody,
                    ContactId = clioContact.Id,
                    Date     = meeting.StartTime
                });

                _logger.LogInformation(
                    "Posted note for meeting {Topic} to contact {Name}",
                    meeting.Topic, clioContact.DisplayName);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Failed to sync meeting {Id}", meeting.Id);
            }
        }

        await _state.SetLastSyncTimeAsync("zoom-recordings", DateTime.UtcNow);
    }

    private static string BuildNoteContent(ZoomMeeting meeting)
    {
        var sb = new StringBuilder();

        sb.AppendLine($"**Meeting:** {meeting.Topic}");
        sb.AppendLine($"**Date:** {meeting.StartTime:f}");
        sb.AppendLine($"**Duration:** {meeting.Duration} minutes");
        sb.AppendLine($"**Host:** {meeting.HostEmail}");
        sb.AppendLine();

        if (!string.IsNullOrEmpty(meeting.AiSummary))
        {
            sb.AppendLine("## AI Summary");
            sb.AppendLine(meeting.AiSummary);
            sb.AppendLine();
        }

        if (!string.IsNullOrEmpty(meeting.Transcript))
        {
            sb.AppendLine("## Call Transcript");
            // Truncate to 5000 chars — Clio notes have limits
            var transcript = meeting.Transcript.Length > 5000
                ? meeting.Transcript[..5000] + "\n\n[Transcript truncated — see Zoom for full version]"
                : meeting.Transcript;
            sb.AppendLine(transcript);
        }

        if (!string.IsNullOrEmpty(meeting.RecordingUrl))
        {
            sb.AppendLine();
            sb.AppendLine($"[View Full Recording]({meeting.RecordingUrl})");
        }

        return sb.ToString();
    }
}

Job 3: Auto-Creating Box Folders When a New Matter Opens

When a lawyer opens a new matter in Clio, the firm needs a Box folder created immediately with the correct naming convention and subfolder structure. We poll Clio's matters API every 15 minutes looking for matters created since the last check, then create the Box folder hierarchy.

public class NewMatterBoxJob
{
    private readonly IClioService      _clio;
    private readonly IBoxService       _box;
    private readonly ISyncStateService _state;
    private readonly ILogger<NewMatterBoxJob> _logger;

    // The root Box folder ID where all matter folders live
    private const string ROOT_FOLDER_ID = "your-root-box-folder-id";

    public async Task ExecuteAsync()
    {
        var lastCheck = await _state.GetLastSyncTimeAsync("new-matters")
                        ?? DateTime.UtcNow.AddMinutes(-15);

        var newMatters = await _clio.GetMattersCreatedAfterAsync(lastCheck);

        foreach (var matter in newMatters)
        {
            try
            {
                // Check if folder already exists (idempotency guard)
                var folderName  = BuildFolderName(matter);
                var existingId  = await _box.FindFolderByNameAsync(
                    ROOT_FOLDER_ID, folderName);

                if (existingId is not null)
                {
                    _logger.LogInformation(
                        "Box folder already exists for matter {Id}", matter.Id);
                    continue;
                }

                // Create parent matter folder
                var matterFolderId = await _box.CreateFolderAsync(
                    parentId: ROOT_FOLDER_ID,
                    name:     folderName);

                // Create standard subfolder structure
                var subfolders = new[]
                {
                    "Correspondence",
                    "Pleadings",
                    "Discovery",
                    "Contracts",
                    "Research",
                    "Billing"
                };

                foreach (var sub in subfolders)
                {
                    await _box.CreateFolderAsync(
                        parentId: matterFolderId,
                        name:     sub);
                }

                // Store Box folder ID back in Clio custom field
                await _clio.UpdateMatterCustomFieldAsync(
                    matterId:    matter.Id,
                    fieldName:   "box_folder_id",
                    fieldValue:  matterFolderId);

                _logger.LogInformation(
                    "Created Box folder structure for matter: {Name}",
                    matter.DisplayNumber);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Failed to create Box folder for matter {Id}", matter.Id);
            }
        }

        await _state.SetLastSyncTimeAsync("new-matters", DateTime.UtcNow);
    }

    private static string BuildFolderName(ClioMatter matter)
    {
        // Format: "2025-001 — Smith v Jones"
        var sanitized = Regex.Replace(
            matter.DisplayNumber + " — " + matter.Description,
            @"[\\/:*?""<>|]", "_");
        return sanitized[..Math.Min(sanitized.Length, 100)];
    }
}

OAuth 2.0 Token Management — The Part Everyone Gets Wrong

All three APIs (Clio, Lawmatics, Zoom) use OAuth 2.0. Tokens expire. If your Hangfire job runs at 3 AM and the access token expired at 2 AM, the job fails silently. The solution is a dedicated token service that automatically refreshes before every API call.

public class TokenService : ITokenService
{
    private readonly AppDbContext _db;
    private readonly IHttpClientFactory _http;

    public async Task<string> GetValidTokenAsync(string platform)
    {
        var stored = await _db.OAuthTokens
            .FirstOrDefaultAsync(t => t.Platform == platform)
            ?? throw new InvalidOperationException(
                $"No token found for {platform}. Re-authenticate.");

        // Refresh if token expires within the next 5 minutes
        if (stored.ExpiresAt <= DateTime.UtcNow.AddMinutes(5))
        {
            stored = await RefreshTokenAsync(stored);
        }

        return Decrypt(stored.AccessTokenEncrypted);
    }

    private async Task<OAuthToken> RefreshTokenAsync(OAuthToken token)
    {
        var client   = _http.CreateClient();
        var platform = GetPlatformConfig(token.Platform);

        var response = await client.PostAsync(
            platform.TokenEndpoint,
            new FormUrlEncodedContent(new Dictionary<string, string>
            {
                ["grant_type"]    = "refresh_token",
                ["refresh_token"] = Decrypt(token.RefreshTokenEncrypted),
                ["client_id"]     = platform.ClientId,
                ["client_secret"] = platform.ClientSecret
            }));

        response.EnsureSuccessStatusCode();

        var result = await response.Content
            .ReadFromJsonAsync<OAuthTokenResponse>();

        token.AccessTokenEncrypted  = Encrypt(result!.AccessToken);
        token.ExpiresAt             = DateTime.UtcNow
                                        .AddSeconds(result.ExpiresIn - 60);

        if (!string.IsNullOrEmpty(result.RefreshToken))
            token.RefreshTokenEncrypted = Encrypt(result.RefreshToken);

        _db.OAuthTokens.Update(token);
        await _db.SaveChangesAsync();

        return token;
    }

    // AES-256 encryption for stored tokens
    private string Encrypt(string plainText) { /* AES-256-CBC impl */ }
    private string Decrypt(string cipherText) { /* AES-256-CBC impl */ }
}

Error Handling and Retry Strategy

Hangfire has built-in retry with exponential backoff. We configure it per job based on criticality. For contact sync, 3 retries over a few minutes is fine. For matter folder creation, we retry 10 times because missing a Box folder has real business impact.

// Apply per job class using attributes
[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 60, 300, 900 })]
public class ContactSyncJob { ... }

[AutomaticRetry(Attempts = 10, DelaysInSeconds = new[] { 30, 60, 120, 300, 600 })]
public class NewMatterBoxJob { ... }

// Global filter to log all failures to the database
public class JobFailureFilter : JobFilterAttribute, IElectStateFilter
{
    public void OnStateElection(ElectStateContext context)
    {
        if (context.CandidateState is FailedState failed)
        {
            var logger = context.GetJobParameter<ILogger>("logger");
            logger?.LogError(
                failed.Exception,
                "Job {JobId} ({JobType}) failed permanently",
                context.BackgroundJob.Id,
                context.BackgroundJob.Job.Type.Name);

            // Optionally: send email alert or post to Slack
        }
    }
}

Results and What This Saved

After deploying this system for North City Law, the results were immediate. The firm eliminated approximately 2–3 hours of daily admin work, removed all manual copy-paste errors between systems, and ensured every Zoom call automatically appeared as a structured note in Clio within hours — without anyone touching a keyboard.

The Hangfire dashboard gave the firm's operations lead full visibility into every job run, every failure, and every retry — so when Zoom's API had a 20-minute outage one morning, the jobs simply queued and completed automatically when the service came back online.

Key Takeaways

If you're building similar multi-platform integrations in .NET, these are the principles that saved us the most time:

1. Store sync state — always record the last successful sync timestamp. Never re-process everything from the beginning.
2. Match on stable identifiers — phone numbers and emails are more reliable than names across systems.
3. Make jobs idempotent — running a job twice should produce the same result as running it once.
4. Encrypt tokens at rest — OAuth tokens are credentials. Treat them like passwords.
5. Use Hangfire's dashboard — give your client visibility into the automation. It builds trust and surfaces issues before they become problems.

Building legal tech integrations, automation pipelines, or multi-platform sync systems in .NET? Get in touch — this is exactly the kind of project I specialise in.

Found this useful?

Share it with your network — it helps others find this too.

https://kathanpatel.vercel.app/blog/legal-tech-automation-clio-lawmatics-zoom-box-hangfire-dotnet