W. Blanchard-Butter
Senior Systems Engineer
Case study 04 · Multi-tenant lead intake

Review-Request Intake with a 24-Hour Hold Queue

Multi-tenant lead intake pipeline routing ~5,000 contacts/month across 100 client accounts into a dialler CRM, with a reconciled 24-hour hold before outbound contact.

98
Client accounts
24h
Hold before contact
Hourly
Reconciliation sweep

The problem

The business sends review requests to homeowners after a job is completed, an appointment is attended, or a batch of historical customers is imported. Requests are dialled out by a call centre working from a CRM.

Contacting someone the moment the trigger fires is wrong. The upstream systems report an appointment as attended or a job as complete before that is reliably true — a visit gets rescheduled, an inspector closes a ticket early, a crew marks a job done and returns the next day. Calling a homeowner to ask about a job that has not actually happened yet is worse than not calling at all: it burns the contact, and the client hears about it.

A 24-hour hold absorbs that error window. Anything that was going to be corrected client-side has almost always been corrected within a day.

The build had to serve every client account from one pipeline. Per-client scenarios would have meant 98 near-identical flows to maintain, with configuration drift guaranteed.

Constraints

  • Single codebase across all tenants; per-client behaviour driven by configuration, not by duplicated scenarios.
  • Hold state must be visible to non-technical staff inside the CRM they already use.
  • Contacts already marked as do-not-call, wrong number, or already-reviewed must never be resurrected.
  • Failures must be observable rather than silent.

Architecture

Hold state lives in the CRM, not in the automation tool. A held contact sits in a CallTools stage named Pending 24h — {Home Visit | Bulk Import | Job Complete}. Anyone in the CRM can see that a contact is held and why. A Make data store keeps only timing metadata — held_at and target_stage, keyed on contact ID — which is what a scheduled release job needs and nothing more.

The alternative was the dialler platform's native wait step. Rejected because it puts pending state inside the automation tool, where it is invisible to CRM users and destroyed by any edit to the scenario. Keeping the system of record in the system that owns the contact also meant one set of logs to read instead of two.

Intake flow (webhook-triggered):

  1. Resolve tenant configuration and product status from MySQL. Inactive or offboarded accounts exit here.
  2. Apply the monthly review cap. Three branches: under cap, cap explicitly zero, cap unset. An unset cap defaults to unlimited rather than to zero, so a missing configuration row cannot silently stop a client's traffic.
  3. Campaign gate resolves the trigger to an effective campaign. A drop outcome is logged to Slack rather than discarded silently.
  4. Parse the contact number to E.164 and exclude the client's own business number, which otherwise appears in inbound payloads and would be created as a contact.
  5. Search CallTools by phone number. Existing contact → patch. No match → create.
  6. On the existing-contact path, a disposition guard blocks re-entry for contacts already dispositioned as do-not-call, wrong number, already reviewed, or negative review.
  7. Patch the contact into the appropriate Pending 24h stage and write the ledger row.

Reconciliation sweep (hourly):

Every ledger write is AddRecord with overwrite: false and errors ignored, which makes it an insert-if-absent. The sweep queries each Pending 24h stage, iterates the results, and attempts the same insert for every contact found.

This closes a partial-write race. If the stage patch succeeds and the ledger write then fails — or the execution dies between the two — the contact is held with no record of when the hold started and nothing to release it. The sweep back-fills the ledger entry within the hour. Because the insert is idempotent, running it against contacts already ledgered is a no-op, so the sweep is safe to run continuously with no coordination against the intake flow.

Selected decisions

Narrow use of an LLM. CRM rep-name fields arrive full of non-names — department names, lead sources, placeholders, call-centre labels. The model does one job: classify whether a string is a real human name, returning strict JSON against a fixed schema, instructed to return false on any doubt and never to infer or invent a name. On API error the branch breaks rather than proceeding on a guess. Every other decision in the pipeline is deterministic. The model is used where the alternative was an unmaintainable regex, and nowhere else.

Defaults chosen to fail open or closed deliberately. A missing cap value means unlimited; a missing product row means stop and alert. Each default was picked against the cost of getting it wrong in that specific direction.

Instrumentation before incident. Dead-letter queue enabled, error threshold set, and Slack alerts on both dropped-campaign and missing-product-row conditions. This is what makes the delivery figures below verifiable rather than assumed.

Results

  • ~5,000 contacts/month across 98 client accounts on a single scenario.
  • No failed deliveries observed in the first month of production, measured against dead-letter queue depth and Slack alert volume rather than assumed from absence of complaints.
  • Zero per-client scenarios. Behaviour for a new account is a configuration row.

What I would change

The disposition guard and the campaign gate encode business rules as filter conditions on the canvas, including hardcoded disposition IDs. That is legible to me and opaque to everyone else. Both belong in the configuration tables alongside the rest of the tenant config, where they can be changed without opening the scenario and reviewed by someone who does not use Make.

If contact volume grew by an order of magnitude, or the release logic needed anything more than a timer, I would move the hold-and-release mechanism to a scheduled Python service against the same ledger table. The intake side is a reasonable fit for a visual tool — it is mostly branching and API calls. Queue mechanics are not, and the sweep is already the shape of something that wants to be a cron job with tests.

← All work