Stop typing the same client into three systems
Most firms don't have a software problem — they have a between-software problem. Clio holds the matters, Lawmatics holds the intake, Zoom holds the calls, Box holds the documents, and a person holds it all together by hand.
I build the layer that connects them: ASP.NET Core services with OAuth 2.0, Hangfire scheduling, and write logic designed so a retry never creates a second copy of your client. The most recent pipeline I built along these lines removed more than ten hours a week of manual data entry from a firm's admin workload.
What actually goes wrong
None of these are dramatic failures. That's what makes them expensive — they show up as a slow tax on admin time rather than an outage anyone escalates.
The same client typed into three systems
A new matter gets entered in Lawmatics during intake, again in Clio when it becomes a matter, and again in the billing sheet. Every re-entry is a chance for the phone number to drift.
Zoom recordings that never reach the matter file
The consultation happened, the recording exists, and nobody can find it six weeks later because it lives in a Zoom cloud folder instead of attached to the matter it belongs to.
Zapier that works until it quietly doesn't
No-code connectors handle the happy path. They don't handle rate limits, partial failures, or a token expiring on a Friday night — and they rarely tell you they stopped.
Reporting that requires a human with a spreadsheet
Intake conversion, matter aging, source attribution — the data exists across Clio and Lawmatics, but nothing joins it, so someone exports CSVs once a month.
How the sync layer sits between your tools
Neither platform becomes the other's dependency. A separate service owns the mapping, the schedule, and the failure handling — so either vendor can change without the other one breaking.
Typical Practice Management Sync Architecture
Clio
Matters, contacts
Sync Engine
ASP.NET Core
Hangfire
Schedule + retry
Lawmatics
Intake, pipelines
Clio
Matters, contacts
Sync Engine
ASP.NET Core
Hangfire
Schedule + retry
Lawmatics
Intake, pipelines
Credentials stay yours
OAuth tokens live encrypted in your database, in your infrastructure. No third-party processor sits in the path.
Schedules you can inspect
Hangfire's dashboard shows the last run, the next run, and every failure with its stack trace.
Safe to re-run
Every write keys off a stable external id, enforced by a unique index. Running the job twice changes nothing.
The three things that decide whether it survives production
Integrations rarely fail at the API call. They fail at token expiry, at restart, and at the second write. Here's how each is handled.
1. Refresh tokens before expiry, not after a 401
Reactive refresh means every token expiry costs you a failed batch. Refreshing ahead of the window costs nothing and never surprises anyone.
// ClioTokenStore.cs — refresh before expiry, never on 401
// Clio access tokens are short-lived; the refresh token is the asset.
// Persist it encrypted, and treat a failed refresh as an alert, not a retry.
public class ClioTokenStore(IDbContextFactory<AppDb> dbFactory, IHttpClientFactory http)
{
private static readonly TimeSpan Skew = TimeSpan.FromMinutes(5);
public async Task<string> GetAccessTokenAsync(int firmId, CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var creds = await db.OAuthCredentials
.SingleAsync(c => c.FirmId == firmId && c.Provider == "clio", ct);
if (DateTimeOffset.UtcNow < creds.ExpiresAt - Skew)
return creds.AccessToken;
var client = http.CreateClient("clio");
var res = await client.PostAsync("/oauth/token", new FormUrlEncodedContent(new
Dictionary<string, string>
{
["grant_type"] = "refresh_token",
["refresh_token"] = creds.RefreshToken,
["client_id"] = _opts.ClientId,
["client_secret"] = _opts.ClientSecret,
}), ct);
// A dead refresh token means the firm must re-authorise — surface it,
// don't silently swallow it into a retry loop.
if (!res.IsSuccessStatusCode)
throw new ReauthorisationRequiredException(firmId, "clio");
var token = await res.Content.ReadFromJsonAsync<TokenResponse>(ct);
creds.AccessToken = token!.AccessToken;
creds.RefreshToken = token.RefreshToken ?? creds.RefreshToken;
creds.ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(token.ExpiresIn);
await db.SaveChangesAsync(ct);
return creds.AccessToken;
}
}2. Scheduled jobs with cursors, so a restart resumes
Per-firm recurring jobs with per-record cursor advancement. A crash halfway through a page picks up at the next record instead of replaying the batch.
// Program.cs — one recurring job per firm, per direction.
// Separate jobs mean one firm's rate limit never stalls another's sync.
RecurringJob.AddOrUpdate<ContactSyncJob>(
recurringJobId: $"clio-to-lawmatics-{firm.Id}",
job: j => j.RunAsync(firm.Id, CancellationToken.None),
cronExpression: Cron.Hourly,
new RecurringJobOptions { TimeZone = firm.TimeZone });
// ContactSyncJob.cs — cursor-based, so a restart resumes instead of replaying
[DisableConcurrentExecution(timeoutInSeconds: 300)]
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [60, 300, 900])]
public class ContactSyncJob(ClioClient clio, LawmaticsClient lawmatics, ISyncCursor cursor)
{
public async Task RunAsync(int firmId, CancellationToken ct)
{
var since = await cursor.GetAsync(firmId, "contacts", ct);
var page = await clio.GetContactsUpdatedSinceAsync(firmId, since, ct);
foreach (var contact in page.Items)
{
await lawmatics.UpsertContactAsync(firmId, contact, ct);
// Advance per record, not per page — a mid-page failure resumes
// exactly where it stopped rather than reprocessing the page.
await cursor.SetAsync(firmId, "contacts", contact.UpdatedAt, ct);
}
}
}3. Idempotent writes — the duplicate-record fix
The single most common defect in legal integrations is duplicated client records. The fix is an external-key mapping table with a unique index, not a search-before-create.
// LawmaticsClient.cs — the rule that prevents duplicate client records.
// Never "create if search returns nothing" — two overlapping runs both search,
// both find nothing, and the firm ends up with two of every contact.
// Key on a stable external id instead.
public async Task UpsertContactAsync(int firmId, ClioContact src, CancellationToken ct)
{
var externalKey = $"clio:{src.Id}";
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var map = await db.ContactMappings
.SingleOrDefaultAsync(m => m.FirmId == firmId && m.ExternalKey == externalKey, ct);
if (map is not null)
{
await PatchAsync(map.LawmaticsId, src, ct);
return;
}
var created = await CreateAsync(src, ct);
// Unique index on (FirmId, ExternalKey) is what actually enforces this —
// the check above is an optimisation, the constraint is the guarantee.
db.ContactMappings.Add(new ContactMapping
{
FirmId = firmId,
ExternalKey = externalKey,
LawmaticsId = created.Id,
});
await db.SaveChangesAsync(ct);
}What I've connected
Clio Manage
Matters, contacts, activities, custom fields, documents. OAuth 2.0 with refresh-token rotation and per-firm rate limits.
Lawmatics
Intake forms, pipelines, contacts, events. Webhook-driven where available, polled with cursors where it isn't.
Zoom
Cloud recordings, transcripts, and meeting metadata pulled down and filed against the correct matter automatically.
Box
Document storage with folder structures derived from matter data, so the file tree matches how the firm actually thinks.
ASP.NET Core
The service that owns the sync. Testable, observable, and deployed wherever the firm's IT policy allows.
Hangfire
Scheduling, retries with backoff, and a dashboard the firm can look at to confirm last night's run actually ran.
Not on the list? Most practice management platforms expose a REST API with OAuth 2.0 — the patterns above transfer. MyCase, PracticePanther, Smokeball and Filevine all fit the same shape.
Three shapes this work usually takes
The variable that drives cost isn't the number of platforms — it's the number of fields that need mapping and how many of them disagree.
Single-direction sync
One platform to another, one object type — for example Lawmatics intake contacts pushed into Clio as matters.
Typical duration
2–3 weeks
The usual starting point. Proves the integration works on real firm data before scope grows.
Two-way sync with conflict rules
Both systems can write. Requires deciding, per field, which system wins when both changed since the last run.
Typical duration
4–8 weeks
The conflict rules are a business decision, not a technical one — that conversation is part of the work.
Full automation pipeline
Multiple platforms, documents and recordings included, plus a reporting layer that joins data across systems.
Typical duration
8–16 weeks
This is the shape of the pipeline I built connecting Clio, Lawmatics, Zoom and Box for a firm's admin workflow.
Durations are indicative starting points for a scoping conversation, not quotes. A fixed price follows a short discovery call where we walk the actual field mappings — that call is free and there's no obligation attached to it.
Questions firms actually ask
Do you work with Clio Manage and Clio Grow both?+
Yes. They're separate APIs with different data models, and a firm running both usually has the worst duplication problem — the same person exists as a Grow lead and a Manage contact with no link between them. Reconciling those two is often the first piece of work worth doing.
We already use Zapier. Why would we pay for custom work?+
If Zapier is working, keep it. Custom work earns its cost when you hit one of three walls: volume that makes per-task pricing painful, logic that Zapier can't express (conditional field mapping, deduplication against existing records, multi-step rollback), or a compliance requirement that client data cannot transit a third-party processor. Below those walls, a no-code connector is the cheaper answer and I'll tell you so.
Where does client data actually go?+
Wherever your policy requires. The sync service is a standard ASP.NET Core application — it can run in your Azure tenant, your AWS account, or on-premise. It holds OAuth credentials and a mapping table; it does not need to warehouse matter content unless you specifically want a reporting store.
What happens when an API changes or a token expires?+
Token refresh is handled ahead of expiry rather than reactively on a 401, and a genuinely dead refresh token raises an alert to a human instead of retrying forever. For API changes, the integration layer is isolated behind an interface per platform, so a breaking change touches one adapter rather than the whole pipeline.
Can you take over an integration someone else built?+
Often, yes. The first step is a short paid review — reading the code, the job schedule, and the error history — before committing to a rebuild-or-repair recommendation. Sometimes the honest answer is that repairing costs more than rebuilding, and it's better to find that out in week one.
How do you handle firms outside India?+
All of this work is remote. I've delivered on US and UK business hours with overlap for standups and weekly demos, and I work in fixed scopes with defined deliverables so timezone gaps don't turn into status anxiety.
Tell me what your stack looks like
Which platforms, which records get double-entered, and roughly how many hours a week it costs. That's enough for me to tell you whether this is a two-week fix, a two-month project, or something Zapier already solves.