Skip to content
Case studies

One reporting layer across two platforms and every client account

Daily collection from two sending platforms with incompatible vocabularies, normalised at the edge into a single schema — which is what made any of the other numbers knowable.

Architecture— why, in the margin
  1. Daily schedule fires
  2. Client list and per-client credentials read from the data layerMulti-tenancy as data — no account is hardcoded anywhereOnboarding a client to reporting is adding a row. Nobody edits a workflow, nobody redeploys, and no copy exists to fall behind.
  3. Report requested from the asynchronous platformReturns a job hash, not dataThe endpoint does not answer with the report. It answers with a promise of one, which changes how the rest of the step has to be written.
  4. Status polled until ready, then looped back if notA fixed sleep would silently record zeros on a slow dayThis is the part most implementations get wrong. A sleep long enough to usually work fails silently, and nobody notices for a month.
  5. Fields normalised at the point of collectionTwo vocabularies mapped to one schema before anything is storedNormalising at the edge rather than in analysis means every later question is asked of one consistent dataset.
  6. Written to a single table, tagged by sourceOne table with a source field is what turns two vendors with different vocabularies into a single queryable history.
Async platformRequest, poll, aggregate campaign rows to one daily client record
Sync platformDirect query, derived fields, same schema

On failureNumeric parsing is defensive throughout, coercing missing or malformed values to zero so one bad field degrades a row rather than aborting the day's collection.

An outbound operation running across two sending platforms reports badly, and the reason is not effort.

Each platform reports metrics under its own field names, with its own definitions and its own idea of what a delivery is. Each client account has to be queried separately with its own credentials. And neither platform will tell you what happened yesterday across the whole book of business, which is the only question anyone actually asks.

The practical result was monthly reporting that meant logging into two tools, exporting per account, reconciling column names by hand in a spreadsheet, and hoping the arithmetic held. It took hours, it happened once a month at best, and it produced no history. You could see the current state and never the trend.

What was built

A daily collection layer that pulls campaign metrics from both platforms across every client account and normalises them into a single schema in one table.

The decision that makes it work is the single unified table with a source field as the discriminator. Both platforms’ data land in the same table, in the same columns, tagged by origin. Two vendors with different vocabularies become one dataset you can filter, group and chart — by client, by date, by platform, or across all of them.

Multi-tenancy as data. The collector has no account hardcoded anywhere. It reads the client list and credentials at runtime and iterates. Onboarding a client to reporting is adding a row. Nobody edits a workflow, nobody redeploys, and there is no per-client copy of the same automation drifting out of sync.

Handling an asynchronous API properly. One platform’s reporting endpoint does not return data — it returns a job hash and generates the report in the background. The workflow requests, waits, polls the status, and loops back if it is not ready. This is the part most implementations get wrong: a fixed sleep long enough to usually work will silently record zeros on the day the report takes longer, and nobody notices for a month.

Normalisation at the edge. Field names are mapped to the shared schema at the point of collection, not later in analysis. Where one platform does not report a metric at all, it is derived rather than left null.

Why it is worth more than the reports

The obvious value is that client reporting stopped being manual. The larger value is that the operation acquired a history it did not have.

Every day of every campaign for every client, in one queryable table, in consistent units. That makes previously unanswerable questions answerable: which platform performs better for this profile of client, what a healthy reply rate looks like across the whole book, whether an account’s response rate is declining or last week was an anomaly.

None of that was knowable before. Not because the data did not exist, but because it was scattered across two vendors and a dozen accounts in incompatible shapes, and nobody had time to reconcile it more than monthly.

This is also the measurement layer the other two builds would have drawn their numbers from. That it exists and those numbers are still not published is the honest state of things.

Failure modes

The error logging was wired to the success output. It fired on every clean run and never once on a failure. On the canvas it looked as though observability had been taken seriously; in practice the log was present, populated and structurally incapable of recording the thing it existed to record. A log that is present and empty answers the question before anyone thinks to ask it. No volume or outcome figures are claimed — none were recorded for publication.

Sources and methodology

Scope
A production reporting architecture with client and vendor identifiers removed. It documents data movement and an observability defect; no campaign volume, performance or outcome figures are claimed.
How this was produced
Reconstructed from the deployed workflow structure and retained implementation record. Normalisation, asynchronous polling and the miswired error log are described as built, without retrospective correction.

Read the editorial standards, corrections policy and AI-use disclosure.

This is what a build looks like. The service does it inside your matter lifecycle.

AI Systems for Law Firms