Grimoire 006 · Automations 101 · Free · No gate · 2026 Edition
25 patterns · 11 chapter sections · 6 appendices · source-complete
Grimoire 006 · Automation Pattern Reference · May 2026

Automations 101: The Absolute Guide

Before you open n8n, Zapier, or any agent framework — read this first. The tool is not the system. The architecture is. Twenty-five production patterns built from real client work: the idempotency failures, the 3am webhook fires, the systems that actually recovered. Most automation guides teach you what buttons to click. This one teaches you what to design before you click anything.

25 patterns
19 pipeline stages
11 chapter sections
6 appendices
Source complete
Free · no gate
Publication readiness pass
37
Markdown source files represented
25
Pattern sections rebuilt from source
A-F
Appendices split and linked
003
Visual system matched to GHL 101

This page follows the Grimoire 003 structure: sticky publication wrapper, compact sidebar, editorial hero, module sections, polished tables, dark code frames, and source-corpus tracking.

CH00
The Automation Pattern Reference Model
How to read the reference, use the 19-stage pipeline, and apply the repeatable pattern template.

A Pipeline‑Driven Reference for Straightforward vs Production‑Grade Automations Across Industries

Foreword

Every business runs on processes. Most of those processes have at least one step where something slows down, breaks, or quietly stops happening — and nobody notices for two weeks. That is where automation belongs.

But automation is not a magic wand. AI is not the engine. AI is a cog – one piece in a larger machine of idempotent workflows, handoff validation, logging, and human judgment.

This book is not a collection of “cool automations.” It is a reference thesaurus organized by the glues that hold your business pipeline together. For each automation pattern, you will find:

  • The straightforward way (fast, fragile, good for prototypes)
  • The production‑grade way (logged, idempotent, recoverable)
  • Industry variants (medical, construction, real estate, home improvement, SaaS, payments, executive work)
  • Human handoff decisions (when to keep a person in the loop)
  • Override behavior (what happens when a human steps in)

This book is for both beginners and tenured practitioners. Beginners will find sidebars explaining key concepts (idempotency, webhooks, idempotency keys). Tenured professionals will use the pattern reference to avoid reinventing wheels.

The pipeline that structures this book comes directly from real businesses – not a theoretical ideal. It has 19 stages, from LEAD to REACTIVATE. But the book is not organized by stages. It is organized by automation patterns – the glue that connects stages. A matrix in Appendix A maps every pattern to the stages it touches.

How to use this book:

  • If you know what automation you need (e.g., “capture leads”), go directly to the pattern in Part II.
  • If you are designing a new process, read Part I to understand the principles.
  • If you encounter a failure, consult the runbooks in Appendix E.

Let’s build automations that don’t break silently, that respect human judgment, and that actually make your business run better.

Table of Contents

Part 0 – How to Use This Book

Chapter 0 – The Automation Pattern Reference Model

Part I – Foundation: Principles That Apply to Every Automation

Chapter 1 – Why AI Is Just a Cog Chapter 2 – Straightforward vs Production‑Grade Chapter 3 – Failure Families & Observability Chapter 4 – The Human Handoff Taxonomy

Part II – Automation Patterns (The Glue)

Group 1 – Capture & Routing (Patterns 1–4)

Pattern 1 – Capture inbound lead from any channel Pattern 2 – Deduplicate and merge duplicate records Pattern 3 – Round‑robin / territory assignment Pattern 4 – AI classification of intent / BANT

Group 2 – Scheduling & Reminders (Patterns 5–7)

Pattern 5 – Self‑service appointment booking with confirmation Pattern 6 – Multi‑channel reminders with confirmation Pattern 7 – No‑show detection and auto‑reschedule

Group 3 – Document & Estimate (Patterns 8–11)

Pattern 8 – Document generation from template + dynamic data Pattern 9 – Open/click tracking with webhooks Pattern 10 – Timer‑based follow‑up sequence Pattern 11 – Objection detection and auto‑response

Group 4 – Approval & E‑Signature (Patterns 12–13)

Pattern 12 – Multi‑stage approval workflow Pattern 13 – E‑signature collection with audit trail

Group 5 – Payment & Financing (Patterns 14–16)

Pattern 14 – Payment processing with idempotent webhooks Pattern 15 – Dunning management for recurring payments Pattern 16 – Financing application & approval workflow

Group 6 – Supply Chain & Delivery Monitoring (Patterns 17–18)

Pattern 17 – Purchase order generation and vendor routing Pattern 18 – Shipment tracking with delay alerts

Group 7 – Job / Work Scheduling (Patterns 19–20)

Pattern 19 – Resource‑aware scheduling (crews, rooms, equipment) Pattern 20 – Multi‑phase milestone tracking & notifications

Group 8 – Fulfillment & Sign‑Off (Patterns 21–22)

Pattern 21 – Digital sign‑off on completion Pattern 22 – Warranty registration and document delivery

Group 9 – Retention & Reactivation (Patterns 23–25)

Pattern 23 – Review request with reputation monitoring Pattern 24 – Referral program with unique links & reward tracking Pattern 25 – Reactivation sequence (lost leads / dormant customers)

Part III – Cross‑Cutting Concerns

Chapter 26 – Compliance Wrappers (HIPAA, TCPA, SOC2, SOX) Chapter 27 – Tool Roles & 2026 Norms (Execution, Orchestration, Logging, Intelligence, Reporting) Chapter 28 – Idempotency, Retries, and Dead‑Letter Queues Chapter 29 – Testing Automations (Unit, Integration, Chaos, Synthetic Health Checks) Chapter 30 – Documentation and Runbooks for Each Pattern Chapter 31 – Handoff Decision Guide (Flowcharts & Industry Rules)

Part IV – Appendices

Appendix A – Pipeline Stage Matrix (Maps 19 stages to patterns) Appendix B – Pseudocode & Real‑Code Library (Idempotency Key, Webhook with DLQ, Timer Escalation) Appendix C – Tool Comparison Tables (2026) – By Role, Not Endorsement Appendix D – Decision Guides (One‑Page Checklist: Straightforward vs Prod‑Grade + Handoff) Appendix E – Sample Runbooks for Failure Scenarios Appendix F – Human Handoff Cheat Sheet (Pattern, Handoff Type, Why, Override Tags)

Part 0 – How to Use This Book

Chapter 0 – The Automation Pattern Reference Model

Reading time: 10 minutes Audience: All readers

🔧
This is the design layer. Once you have the pattern, Grimoire 004 (n8n) shows you how to build it, Grimoire 005 (Zapier) covers the managed path, and Grimoire 007 (Agentic AI) picks up when the workflow needs to reason.

This book is a reference. You are not expected to read it cover to cover. Instead, you will:

  1. Identify the automation pattern you need (e.g., “capture leads”, “send reminders”, “approve contract”).
  1. Turn to that pattern in Part II.
  1. Read the pattern – it will give you:
  • A Mermaid diagram of the production‑grade flow.
  • Straightforward implementation (fast, cheap, fragile).
  • Production‑grade implementation (logged, idempotent, recoverable) with real code.
  • Industry variants (how medical, construction, etc. differ).
  • Human handoff (optional, necessary, or questionable).
  • Override behavior (tags, stage changes, logging).
  • A decision guide (when to use, when not to).

If you are new to automation concepts (idempotency, webhooks, idempotency keys), look for the “For beginners” sidebars. If you are tenured, skip them.

The Pattern Template (Explained)

Every pattern in Part II follows this exact structure:

markdown template
## Pattern X – [Name]
**Glue:** Which stages this connects (from the 19‑stage pipeline)
**Mermaid Diagram** – Production‑grade flow
**Straightforward Implementation** (fast & fragile)  
- Description  
- Code snippet (real)
**Production‑Grade Implementation** (logged, idempotent, recoverable)  
- Step‑by‑step with real code (JavaScript/Python)  
- Includes: deduplication, idempotency, logging, retries, escalation
**Industry Variants** (table)
**Human Handoff**  
- Optional: when override is allowed  
- Necessary: when human must act  
- Questionable: when automation is possible but not recommended
**Override Behavior**  
- Tags added  
- Stage change (if any)  
- Downstream automations canceled or altered  
- Logging
**Decision Guide** (table or bullet list)

Example sidebars (for beginners):

For beginners

** Idempotency means doing the same action twice produces the same result. If a webhook fires twice, an idempotent system will not send two welcome SMS messages. We achieve this by checking a tag (auto:welcome_sent) before sending.

Now that you understand the template, we move to the foundation principles.

CH01
Why AI Is Just a Cog
AI augments deterministic systems; it does not replace workflow design.
Reading time: 12 minutesAudience: All readers

1.1 The 2026 Hype and the Reality

Walk into any business conference in 2026, and you will hear: “AI replaces everything.” Salespeople promise “fully autonomous” lead qualification, “AI‑powered” contract review, and “intelligent” customer service that never sleeps.

This is nonsense.

AI does not replace workflows. It does not replace state machines. It does not replace human judgment for compliance, high‑stakes decisions, or relationship building.

What AI actually does:

  • It classifies ambiguous inputs (e.g., “Is this message a price objection or a trust objection?”).
  • It extracts structured data from free text (e.g., “Pull budget, timeline, and authority from a lead reply”).
  • It generates drafts (e.g., “Write a polite follow‑up SMS based on estimate amount”).
  • It scores based on patterns (e.g., “Score lead 0–100 from form fields and past behavior”).

But AI is a cog – one gear in a larger deterministic machine. It does not drive the whole system.

For beginners

** A cog is a gear that turns when another gear turns. It amplifies or redirects motion, but it does not create the motion itself. In automation, deterministic logic (if‑this‑then‑that) provides the motion; AI provides the tuning.

1.2 The Cog Principle: AI Augments, Never Decides Alone

The core principle of this book: No automation should rely on AI as the only decision maker. Every AI output must be:

  1. Logged – so you can audit and retrain.
  2. Confidence‑scored – so you know when to trust it.
  3. Fallback‑ready – if AI times out or returns low confidence, a deterministic rule (or human) takes over.
  4. Override‑able – a human can always correct the AI’s output, and that correction becomes training data.

Example – Lead qualification (Pattern 4):

  • AI extracts budget, timeline, authority from a lead’s SMS reply.
  • The automation checks confidence. If >0.7, it auto‑updates the CRM and moves the lead to QUALIFIED.
  • If confidence is 0.4–0.7, it sends a clarifying question.
  • If <0.4, it creates a task for a rep to manually review.

The AI does not change the CRM stage directly – the deterministic workflow does, based on the AI’s output and confidence threshold.

1.3 Where AI Belongs (The Three Safe Zones)

ZoneWhat AI doesExampleWhy it works
ClassificationMap an input to one of a fixed set of categories“Intent = price / trust / timing”Output is bounded; fallback rule exists
ExtractionPull specific fields from unstructured text“Budget = $15k, Timeline = 30 days”Fields are well‑defined; missing values default to “unknown”
GenerationCreate draft content from a template“Your estimate of $15k can be financed from $250/month”Human reviews before sending (or after, with override)
For beginners

** Why bounded? If you ask AI “what is the customer’s intent?” and it can only answer one of five pre‑defined intents, the rest of your automation can handle each case. If you let AI invent new intents, your automation breaks.

Where AI does NOT belong:

  • Deterministic logic – “If phone number missing, send email.” That is a simple conditional, not AI. Using AI here adds cost, latency, and unpredictability.
  • State changes – AI should never directly move a lead from ESTIMATE_SENT to CLOSED_WON. It should suggest “accept”, and a deterministic workflow executes the state change after checking guards.
  • Final approvals – In medical, legal, or high‑stakes business (e.g., approving a $100k contract), a human must be the last cog.

1.4 Why This Matters for Your Automations

When you treat AI as a cog, you gain:

  • Swap‑ability – You can replace OpenAI with Claude, Llama, or a fine‑tuned local model without rewriting your entire workflow. The interface (input → output → confidence score) stays the same.
  • Auditability – Every AI decision is logged (input, output, confidence, timestamp). You can prove to a regulator or a client what the AI recommended and what the automation did.
  • Fallback resilience – If the AI service is down (API timeout, rate limit), your automation still works with rule‑based fallbacks.
  • Continuous improvement – When a human overrides an AI decision, that override is logged. You can periodically retrain the AI on those corrections.

Example – Override loop (Pattern 1): A lead comes in with source = “facebook”. AI classifies intent as “low”. A rep manually changes it to “high”. The automation logs manual:override_intent. Monthly, you export all overrides and fine‑tune the AI model to reduce future misclassifications.

1.5 The Three‑Cog Machine (AI + Deterministic + Human)

A healthy automation has three cogs working together:

Production-grade flow
graph LR
    A[Trigger Event] --> B[Deterministic Logic]
    B --> C{AI Needed?}
    C -->|Yes| D[AI Cog: classify, extract, generate]
    D --> E{Confidence > threshold?}
    E -->|Yes| B
    E -->|No| F[Human Cog: review, correct, approve]
    F --> B
    B --> G[Execute Action]
    G --> H[Log Outcome]
  • Deterministic cog – the state machine, idempotency checks, timers, and fallbacks. This cog never fails unexpectedly (if written correctly).
  • AI cog – handles ambiguity. It can fail (low confidence, timeout), but the deterministic cog catches that failure.
  • Human cog – the final authority for high‑stakes, compliance, or edge cases. The system never assumes a human is always available; it escalates after an SLA.

1.6 Industry Examples of AI as a Cog

IndustryAI cog useDeterministic wrapperHuman handoff
MedicalExtract ICD‑10 codes from doctor’s notesIf confidence <0.9, flag for manual codingRequired for final coding (compliance)
ConstructionClassify RFI (Request for Information) urgencyIf urgency = “critical”, notify project manager immediatelyOptional – PM can override urgency
Real EstateGenerate listing description from property featuresDraft saved to CMS; human must approve before publishingNecessary (legal liability)
Home ImprovementScore lead intent from form fieldsIf score >70, auto‑book appointment; if <30, send nurture sequenceOptional – rep can override score
SaaSClassify support ticket severityIf severity = “P1”, page on‑call engineer immediatelyQuestionable – some teams auto‑page, others require human triage
Payment ProcessingDetect potential fraud from transaction patternIf fraud score >0.9, block transaction; if 0.5–0.9, queue for manual reviewNecessary for high‑risk transactions
Executive AssistantParse email into calendar invite (date, time, location)If all fields extracted with high confidence, auto‑add to calendar; else, create draft for assistant to reviewNecessary – CEO’s calendar cannot have errors

1.7 The Cost of Not Treating AI as a Cog

If you embed AI as the sole decision maker:

  • No audit trail – When an AI misclassifies a price objection as “accept”, the system auto‑closes a deal with no contract. You cannot explain why.
  • No fallback – When OpenAI API has a 30‑second outage, your lead qualification stops completely. Leads go unanswered.
  • No human override – When a rep knows the AI is wrong, they have no way to correct it without breaking the automation.
  • No improvement – You never collect correction data, so the AI never gets better.

Real example from a home improvement company: They used AI to auto‑send financing offers when a lead said “too expensive”. The AI worked well for six months, then started misclassifying “I need to think about it” as price objection. The system sent financing offers to leads who were actually hesitant about trust. No override was possible. They lost deals. The fix was to add a confidence threshold and escalate low‑confidence cases to a human rep – treating AI as a cog.

1.8 Chapter Summary

ConceptKey takeaway
AI is a cogIt augments deterministic logic, does not replace it.
Three cogsDeterministic + AI + Human. Each has a role.
Safe zonesClassification, extraction, generation – all bounded and fallback‑ready.
No‑go zonesState changes, final approvals, deterministic logic.
Override loopHuman corrections become training data for AI.

One sentence takeaway:

AI is not the engine of your automations – it is a replaceable, auditable, fallible cog. Design every automation so that if the AI fails, the system still works (degraded) and a human can step in.

End of Chapter 1

CH02
Straightforward vs Production-Grade
The risk-based decision between quick automation and durable systems.
Reading time: 15 minutesAudience: All readers

2.1 The Two Paths

Every automation can be built in two ways:

  • Straightforward – fast to implement, cheap, but fragile. No logging, no retries, no idempotency. Works fine for internal tools, prototypes, or very low volume.
  • Production‑grade – slower to build, more expensive, but robust. Includes idempotency, logging, retries, fallbacks, and escalation. Required for customer‑facing, high‑volume, or compliance‑sensitive automations.
For beginners

** Think of straightforward as a bicycle – cheap, quick to ride, but you wouldn't use it to deliver 1,000 packages across a city. Production‑grade is a delivery truck – more expensive, takes longer to build, but reliable at scale.

The mistake most people make: They build straightforward automations, deploy them to production, and then wonder why leads are lost, estimates never get follow‑ups, and no one can debug failures.

2.2 Straightforward (Fast & Fragile)

Characteristics:

AspectDescription
IdempotencyNone – duplicate events cause duplicate actions
LoggingNone or minimal (e.g., console log)
RetriesNone – if an API call fails, the automation stops
FallbacksNone – if a service is down, the process fails
EscalationNone – failures are silent
Audit trailNone – you cannot replay what happened
Human overrideNot supported – automation makes final decision
TestingManual only

When to use straightforward:

  • Internal tools used by 1–5 people.
  • Prototypes or proof‑of‑concept.
  • Very low volume (<50 executions per day).
  • No compliance requirements (no TCPA, HIPAA, SOX).
  • Failures are tolerable (e.g., a daily summary email that can be resent manually).

Example (from Pattern 1 – Capture lead): A Google Form submits to a Google Sheet. Zapier sends an email to the sales team. No deduplication, no SMS, no task creation. If a lead submits twice, the sales team gets two emails. If Zapier fails, leads are lost. This is straightforward – acceptable for a small business with 5 leads per day, but not for a high‑volume roofing company.

2.3 Production‑Grade (Logged, Idempotent, Recoverable)

Characteristics:

AspectDescription
IdempotencyExplicit check (tag, database, or idempotency key) before every action
LoggingEvery event written to immutable log (Airtable, BigQuery, or custom DB)
RetriesExponential backoff for transient failures (API timeouts, rate limits)
FallbacksIf primary service fails, use secondary (e.g., rule‑based fallback when AI times out)
EscalationIf fallback fails, create a task for a human and send alert
Audit trailComplete history of every event, decision, and override
Human overrideExplicit override mechanism with logging
TestingAutomated unit, integration, and health checks

When to use production‑grade:

  • Customer‑facing automations (SMS, email, payments).
  • High volume (>100 executions per day).
  • Compliance required (TCPA consent, HIPAA, financial regulations).
  • Failure is expensive (lost revenue, legal liability, damaged reputation).

Example (from Pattern 1 – Capture lead): Webhook receives lead, checks for duplicate via phone/email, creates CRM contact, sends SMS only if auto:welcome_sent tag missing, creates task with SLA, logs every step to Airtable, retries failed API calls, escalates to ops if webhook fails. This is production‑grade.

2.4 The Cost Comparison

ActivityStraightforward (hours)Production‑grade (hours)
Initial build1–48–40
Testing0.54–8
Documentation02–4
Maintenance per month0–1 (when broken)1–2 (proactive)
debugging a failureHours (no logs)Minutes (rich logs)
Total first year~50–100 hours~100–200 hours

But the hidden cost of straightforward is failure expense:

Failure typeStraightforward costProduction‑grade cost
Duplicate welcome SMSAnnoyed customer, possible opt‑out$0 (idempotency prevents)
Lead lost due to webhook failure$500–$5,000 lost revenue$0 (retry + DLQ + manual reprocess)
AI misclassification without fallbackWrong follow‑up, lost deal$0 (confidence threshold escalates to human)
No audit trail for complianceRegulatory fine ($10k–$1M)$0 (full logs)

Real example: A home improvement company used a straightforward lead capture (form → email). A form plugin update broke the webhook for three days. 120 leads were lost. At $15k average ticket and 15% close rate, that’s $270k lost revenue. The production‑grade version would have alerted the ops team within 15 minutes and queued the lost leads for manual import.

2.5 The Decision Matrix

Use this table to choose which path:

ConditionRecommendation
Volume < 50 executions/day AND internal users onlyStraightforward
Volume > 100 executions/day OR customer‑facingProduction‑grade
No compliance requirementsStraightforward possible
Any TCPA, HIPAA, SOC2, SOX, or financial regulationProduction‑grade (mandatory)
Failure leads to lost revenue > $1,000 per incidentProduction‑grade
Team has time to build and maintainProduction‑grade
Prototype or proof‑of‑conceptStraightforward (but plan to rebuild)

The “hybrid” approach: Build straightforward first to validate the logic, then rebuild as production‑grade before releasing to customers. Never run straightforward in production for customer‑facing automations.

2.6 The “Production‑Grade Minimum” Checklist

Even a simple automation must have these to be called production‑grade:

  • [ ] Idempotency – Duplicate events do not cause duplicate actions.
  • [ ] Logging – Every event (success, failure, retry) is written to an immutable store.
  • [ ] Retries – Transient failures are retried with exponential backoff (at least 3 attempts).
  • [ ] Fallback – If a primary service fails, a degraded mode exists (e.g., rule‑based classification if AI is down).
  • [ ] Escalation – If fallback fails, a human task is created and an alert is sent (Slack, SMS).
  • [ ] Human override – A human can correct the automation’s decision, and that override is logged.
  • [ ] Health check – A synthetic test runs regularly (e.g., every 15 minutes) to verify the automation works end‑to‑end.
  • [ ] Documentation – A one‑page runbook describes what the automation does, its failure modes, and how to recover.
For beginners

** You do not need all of these for a script that runs once a week on your laptop. You do need them for any automation that touches customers, money, or compliance.

2.7 When Straightforward Is Actually Better

There are legitimate use cases for straightforward:

  1. Internal analytics pipeline – A daily script that aggregates data and emails a CSV. If it fails, someone can re‑run it manually.
  2. One‑time data migration – Moving historical data from an old system. After migration, the automation is discarded.
  3. Personal assistant automation – A script that organizes your own files. If it duplicates a file, only you are affected.
  4. Prototype for client approval – Show the concept quickly, then rebuild properly for production.

Rule of thumb: If the automation’s failure would not be noticed for a week, and the damage is low, straightforward may be fine. If you would notice within an hour, or if damage is high, go production‑grade.

2.8 The Opt‑Up Pattern

You can start straightforward and opt‑up to production‑grade without rewriting everything. The pattern:

  1. Phase 1 – Straightforward – Build the happy path. No error handling. Test manually.
  2. Phase 2 – Add logging – Wrap each action in try/catch and write to a log (even just a file).
  3. Phase 3 – Add idempotency – Add a tag or key check before each action.
  4. Phase 4 – Add retries – Wrap API calls in a retry loop with backoff.
  5. Phase 5 – Add fallback – If a service fails, use a simpler alternative.
  6. Phase 6 – Add escalation – If fallback fails, create a task and alert.

Example (Pattern 1 – Lead capture): Phase 1: Form → email. Phase 2: Log each submission to Google Sheets. Phase 3: Check for existing lead by phone before creating. Phase 4: If GHL API returns 5xx, retry after 2s, 5s, 15s. Phase 5: If GHL down, write lead to a “dead letter” Airtable. Phase 6: If dead letter has 5 entries, create ops task and send Slack alert.

This way, you get value early and improve incrementally.

2.9 Industry Nuances

IndustryWhat “production‑grade” adds beyond the checklist
MedicalAudit trail must be immutable and tamper‑evident (e.g., write to blockchain or append‑only database).
ConstructionEscalation must include text messages to project managers (email may not be read on site).
Real EstateLogging must retain client consent for communications (TCPA).
Home ImprovementFallbacks must include in‑house financing if lender API fails.
SaaSIdempotency is critical for billing – duplicate charges are catastrophic.
Payment ProcessingRetries must respect card network rules (e.g., no retries after decline for certain reason codes).
Executive AssistantHuman override is not optional – every calendar invite must be approved by the executive or their assistant.

2.10 Chapter Summary

AspectStraightforwardProduction‑Grade
Speed to buildFast (hours to days)Slow (days to weeks)
Failure toleranceLow – failures are silentHigh – failures are logged, retried, escalated
CostLow upfront, high long‑term (failure expense)Higher upfront, low long‑term
Best forInternal tools, prototypes, low volume (>50/day)Customer‑facing, high volume, compliance
IdempotencyNoYes
LoggingNone or minimalComplete audit trail
EscalationNoneTask + alert
Human overrideNoYes (logged)

One sentence takeaway:

Straightforward automations are for prototypes and internal tools. Production‑grade automations are for anything that touches customers, money, or compliance – and they are not optional in 2026.

End of Chapter 2

CH03
Failure Families & Observability
Data, logic, human, and tech failures with observability patterns.
Reading time: 14 minutesAudience: All readers

3.1 The Four Failure Families

No matter how carefully you design an automation, failures will happen. The key is not to prevent all failures – that is impossible – but to categorize them, detect them, and recover systematically.

Every automation failure falls into one of four families:

FamilyRoot causeExample
DataIncorrect, incomplete, or malformed inputMissing phone number, invalid email, duplicate submission
LogicThe automation does the wrong thing, but executed as programmedWrong follow‑up timing, misconfigured threshold, missing transition
HumanA person fails to perform an expected actionRep ignores task, manager doesn’t approve, customer doesn’t reply
TechSoftware or network component failsAPI timeout, webhook 404, database down, rate limit exceeded
For beginners

** If you classify every failure correctly, you will know whether to fix a form (data), change a workflow (logic), retrain a team (human), or add a retry (tech). Mixing them up leads to wasted effort.

3.2 Data Failures – The Silent Revenue Leak

Symptoms:

  • Workflows run but produce wrong outcomes (e.g., lead tagged source:unknown).
  • No error logs – everything appears successful.
  • Reporting shows anomalies (e.g., 40% of leads missing phone numbers).

Common examples in automations:

ScenarioFailureConsequence
Lead capture form does not require phonephone field emptyNo SMS can be sent → lower contact rate
UTM parameters lost between pagessource = directAttribution broken → wrong ad spend decisions
Customer enters “six thousand” instead of “6000”Budget parsing failsLead score defaults to medium → misrouting

Detection:

  • Weekly data completeness audit (percentage of leads with required fields).
  • Validation at point of entry (form validation, API schema checks).
  • Automated anomaly detection (e.g., if source:unknown spikes to >20%, alert).

Recovery:

  • Immediate: Fallback to default values (e.g., timeline = “unknown”).
  • Systemic: Add AI enrichment (e.g., infer source from IP address).
  • Manual: Create a task for ops to review leads with missing critical fields.

Prevention:

  • Make required fields mandatory in forms.
  • Use dropdowns instead of free text for budget, timeline, service type.
  • Store UTMs in localStorage across multi‑page forms.
  • Implement duplicate detection before creating new contacts.

3.3 Logic Failures – The Automation Does the Wrong Thing

Symptoms:

  • Workflows execute without errors, but outcomes are incorrect.
  • Leads move to wrong stages.
  • Follow‑ups send at wrong times or wrong messages.
  • AI classifies with high confidence but wrong intent.

Common examples:

ScenarioFailureConsequence
Decision timer set to 72h, but optimal is 24hLeads receive follow‑up too lateConversion drops
Guard requires estimate_opened = true, but tracking brokenLeads never move to DECISION_PENDINGStalled deals
AI classifies price objection as trustSends testimonials instead of financingLost deal

Detection:

  • Compare expected vs actual state transitions (event log analysis).
  • Track rep override rate – if >10%, AI is misclassifying.
  • A/B test logic changes (e.g., follow‑up timing) against a control group.

Recovery:

  • Immediate: Add missing rule or correct guard.
  • Systemic: Move parameters to a decision table (Airtable) so ops can change without code.
  • Manual: Reprocess affected leads (e.g., re‑trigger follow‑up sequences).

Prevention:

  • Document every transition rule (state machine).
  • Use decision tables (Airtable) for timing, thresholds, and routing.
  • Implement idempotency keys to prevent duplicate actions.
  • Test edge cases in a sandbox before deploying.
For beginners

** Logic failures are the hardest to detect because the automation looks successful – green checkmarks everywhere. Always monitor downstream metrics (close rate, show rate) for unexplained drops; that is often a logic failure.

3.4 Human Failures – The System Worked, the Person Didn’t

Symptoms:

  • Tasks are created but not completed.
  • CRM stages not updated after manual actions (e.g., appointment completed).
  • Overdue SLAs without automation errors.

Common examples:

ScenarioFailureConsequence
Rep forgets to mark appointment as completedEstimate workflow never triggersLost deal
Rep closes a lost deal without selecting loss reasonReactivation not scheduledNo win‑back opportunity
Customer no‑shows without cancelingWasted rep time, no rescheduleLost revenue

Detection:

  • Task completion rate (tasks completed within SLA / total created).
  • Handoff validation logs (e.g., handoff_log shows pending > SLA).
  • No‑show rate dashboard.

Recovery:

  • Immediate: Auto‑detect likely completion (e.g., if appointment end time passed + 30 min, send rep a nudge).
  • Systemic: Automate the manual step (e.g., infer appointment completion from calendar).
  • Escalation: Reassign ignored tasks to manager after SLA breach.

Prevention:

  • Automate wherever possible – reduce human steps.
  • Make required fields mandatory before stage change.
  • Send reminders and escalations for overdue tasks.
  • Use multi‑channel notifications (SMS, Slack, push) for critical tasks.
For beginners

** Human failures are not character flaws – they are system design flaws. If a task is ignored, the system did not make it urgent enough. If a field is missing, the system did not enforce it. Fix the system, not the person.

3.5 Tech Failures – The Stack Breaks

Symptoms:

  • Error logs (401, 500, 429).
  • Workflows stop executing.
  • Health checks fail.
  • Missing events in logs.

Common examples:

ScenarioFailureConsequence
OpenAI API returns 503 (Service Unavailable)No classification → fallback to rule‑basedLower accuracy
Webhook from form to CRM times outLead never createdLost lead
Airtable rate limit (5 req/sec) exceededSome events not loggedGaps in audit trail

Detection:

  • Synthetic health checks (e.g., submit a test lead every 15 min).
  • API uptime monitoring (UptimeRobot, Pingdom).
  • Error logs (Make, GHL, custom code).
  • Dead‑letter queue (events that failed after retries).

Recovery:

  • Immediate: Retry with exponential backoff (2s, 5s, 15s, 45s).
  • Fallback: Use secondary service (e.g., rule‑based if AI down, email if SMS fails).
  • Escalation: If retries and fallback fail, write to dead‑letter queue, create ops task, and send Slack alert.
  • Manual: Reprocess dead‑letter queue via scheduled job or manual trigger.

Prevention:

  • Implement idempotent operations so retries are safe.
  • Use multiple API keys for rate‑limited services.
  • Batch writes to avoid rate limits.
  • Circuit breaker: after 5 failures in 1 minute, stop calling API for 30 seconds.

3.6 The Failure Handling Pattern – Retry → Fallback → Escalate

For any automation, design this three‑stage response:

Production-grade flow
flowchart TD
    A[Action fails] --> B{Retry < 5?}
    B -->|Yes| C[Wait exponential backoff]
    C --> D[Retry action]
    D --> A
    B -->|No| E{Fallback available?}
    E -->|Yes| F[Execute fallback (degraded mode)]
    F --> G{Fallback succeeds?}
    G -->|Yes| H[Log success with fallback flag]
    G -->|No| I[Escalate to human]
    E -->|No| I
    I --> J[Create task, send Slack/SMS alert]
    J --> K[Log escalation]

Example – Lead capture webhook fails:

StageAction
RetryRetry 3 times with 2s, 5s, 15s delay
FallbackWrite payload to Google Sheets fallback
EscalateAfter fallback fails, create ops task: “Manually import leads from Google Sheets” and send Slack alert

Time to recover:

  • Retry: <1 minute
  • Fallback: <5 minutes
  • Escalate: <15 minutes (human action may take hours, but the system is not losing data)

3.7 Observability – The Three Pillars

To detect failures before customers do, build observability into every automation:

PillarWhat it isImplementationPurpose
LogsImmutable, timestamped records of every eventAirtable event_log, BigQuery, or custom DBDebugging, auditing, replay
MetricsAggregated numerical data (counts, rates, latencies)Looker Studio, Prometheus, or custom dashboardsTrend analysis, SLA reporting
TracesEnd‑to‑end view of a single request across servicesOpenTelemetry, Datadog, or custom correlation IDsFinding bottlenecks and failures across multiple systems

Minimum for production‑grade automation:

  • Logs: every event (trigger, action, success, failure, retry, escalation).
  • Metrics: success rate, latency (p95), retry count, fallback usage.
  • Traces: correlation ID (e.g., lead_12345) across all logs.
For beginners

** Without logs, you cannot debug. Without metrics, you cannot see trends (e.g., “failures are increasing every Tuesday”). Without traces, you cannot follow a single lead across 10 different services. Start with logs, then add metrics, then add traces.

3.8 Health Checks (Synthetic Monitoring)

A health check is an automated test that simulates a real user action and verifies the entire automation works.

Example – Lead capture health check (every 15 minutes):

  1. Make scenario submits a test lead with a unique email (e.g., test_{timestamp}@example.com).
  2. Wait 1 minute.
  3. Query CRM for contact with that email.
  4. If not found → escalate (Slack alert, create ops task).
  5. If found, verify auto:welcome_sent tag exists. If missing → escalate.
  6. (Optional) Delete the test lead to avoid clutter.

Critical health checks for common automations:

AutomationHealth checkFrequency
Lead captureSubmit form → verify CRM record → verify SMS sent15 min
AI classificationSend test message → verify intent classification1 hour
Estimate trackingCreate estimate → simulate open → verify log1 hour
Payment webhookSimulate payment success → verify order status updated1 hour

If a health check fails twice in a row, escalate to critical (SMS ops manager).

3.9 The Failure Dashboard

Monitor these metrics in a single dashboard (Looker Studio or similar):

MetricTargetAlert if
Success rate (automation)>99%<95%
Data completeness (phone, source)>98%<95%
Rep task completion (on time)>95%<85%
AI confidence (average)>0.85<0.75
Handoff latency (time between stages)<SLA>1.5x SLA
Health check pass rate (last hour)100%<100% (critical)

Example dashboard layout:

text
┌─────────────────────────────────────────────────────────────┐
│ AUTOMATION HEALTH – LAST 24 HOURS                           │
├─────────────────────────────────────────────────────────────┤
│ Lead capture success: 99.4% ✅                              │
│ AI classification confidence: 0.88 (high) ✅               │
│ Rep task completion: 82% ❌ → investigate overdue tasks     │
│ Health checks: Form passing, SMS failing (2x) 🚨           │
│ Dead letter queue: 3 pending leads → reprocess             │
└─────────────────────────────────────────────────────────────┘

3.10 Industry‑Specific Failure Patterns

IndustryMost common failure familyWhyMitigation
MedicalData (missing insurance info, wrong ICD codes)Manual entry errorsValidation rules, AI extraction with high threshold
ConstructionHuman (site delays, no‑shows, missed approvals)Weather, subcontractor issuesAuto‑reschedule, escalation to PM
Real EstateLogic (commission calculation errors, disclosure mismatches)Complex rulesDecision tables, audit logs
Home ImprovementTech (estimate tracking pixels blocked, financing webhook down)Email privacy, lender API stabilityFallback SMS, in‑house plan
SaaSTech (webhook from Stripe delayed, subscription sync lag)Third‑party reliabilityRetry with backoff, idempotent billing
Payment ProcessingTech + Data (fraud detection false positives)Model driftHuman review queue for borderline scores
Executive AssistantHuman (executive ignores automated calendar invites)TrustAlways require human confirmation before adding to calendar

3.11 Chapter Summary

Failure familyRoot causeDetectionRecoveryPrevention
DataBad inputCompleteness auditFallback defaults, enrichmentValidation, dropdowns, dedupe
LogicWrong rulesEvent log comparison, rep override rateFix rule, add missing transitionDecision tables, simulation tests
HumanPerson fails to actTask completion rate, handoff logsAuto‑detect, reassign, escalateAutomate, enforce, remind
TechSoftware/network downHealth checks, error logsRetry, fallback, dead‑letter queueMulti‑key, batching, circuit breaker

One sentence takeaway:

Classify every failure into Data, Logic, Human, or Tech – then apply the three‑stage response (retry → fallback → escalate) and monitor with logs, metrics, and health checks. Silent failures are the only unacceptable failures.

End of Chapter 3

CH04
The Human Handoff Taxonomy
Optional, necessary, and questionable handoffs across industries.
Reading time: 12 minutesAudience: All readers

4.1 Why Human Handoff Cannot Be an Afterthought

Most automation books pretend that the goal is to eliminate humans entirely. That is a fantasy – and a dangerous one.

In real businesses, humans are not the enemy of automation. They are the final authority, the escalation path, and the source of training data for AI. A system that cannot hand off to a human is a system that will eventually fail catastrophically.

This chapter defines when, why, and how to hand off from automation to a human – and what happens when that handoff occurs.

For beginners

** A “handoff” is any point where the automation stops and a person must take an action. It could be approving a contract, overriding a classification, or simply calling a lead who didn’t reply to SMS.

4.2 The Three Handoff Types

Every automation pattern in this book will specify one of three handoff types:

TypeDefinitionExampleAutomation’s role
OptionalAutomation works 95%+, but human can override if they wishAI classifies intent as “price”; rep can change it to “trust” before follow‑up sendsSuggest, not decide
NecessaryLegal, compliance, trust, or high‑stakes judgment requires human actionApproving a contract over $50k, signing off on a medical procedure, accepting a counter‑offerPrepare, notify, escalate – but not decide
QuestionableAutomation could be built, but cost/effort > benefit, or human touch adds disproportionate valuePersonal follow‑up call for a high‑value lead ($100k+), reviewing a complex construction change orderKeep manual until volume justifies automation

The 80/20 rule for handoffs:

  • 80% of decisions can be fully automated (high confidence, low risk).
  • 15% require optional human override (low confidence, medium risk).
  • 5% require necessary human approval (high risk, compliance, or judgment).

The mistake most automations make is treating the 5% as if it were 80% – either ignoring necessary handoffs (dangerous) or requiring human approval for everything (inefficient).

4.3 Optional Handoff – Automation Suggests, Human Disposes

When to use:

  • AI confidence is moderate (e.g., 0.6–0.8).
  • The cost of a wrong automation decision is medium (e.g., sending the wrong follow‑up message, not a lost deal).
  • Human override is easy (one click, one reply).

How it works:

Production-grade flow
flowchart LR
    A[Automation executes] --> B{Confidence > threshold?}
    B -->|No| C[Create task for human]
    C --> D[Human reviews, overrides if needed]
    D --> E[Human action logged]
    E --> F[Automation continues with override value]
    B -->|Yes| G[Automation decides]

Example – AI classification of objection (Pattern 11):

  • AI detects “price” objection with confidence 0.75.
  • Automation sends financing offer (auto‑response).
  • But it also creates a low‑priority task for the rep: “Review AI classification – lead said ‘too expensive’.”
  • Rep reviews and agrees (no action) or disagrees and corrects to “trust”.
  • The correction is logged and fed back to retrain the AI.

Override mechanics (from Chapter 3):

ElementBehavior
Tags addedmanual:override_intent, override_reason:trust
Stage changeNone (remains in OBJECTION_HANDLING)
Automation stoppedThe auto‑response already sent; but the second follow‑up is suppressed if override occurs before it
Loggingevent_type = manual_override, metadata includes original AI classification and human correction

Decision guide – Optional handoff:

  • Build it if: volume justifies automation, but mistakes are tolerable.
  • Do not build if: human override would be needed >20% of the time (then raise confidence threshold or retrain AI).

4.4 Necessary Handoff – Human Must Decide

When to use:

  • Legal or compliance requirement (e.g., HIPAA sign‑off, SOX approval).
  • High financial risk (e.g., discount over 20%, contract over a threshold).
  • Trust‑sensitive (e.g., final walkthrough sign‑off, medical diagnosis).
  • The automation cannot reasonably learn the judgment (e.g., “Is this change order reasonable?”).

How it works:

Production-grade flow
flowchart LR
    A[Automation prepares data] --> B[Creates notification + task]
    B --> C[Human reviews and decides]
    C --> D{Approved?}
    D -->|Yes| E[Automation executes next step]
    D -->|No| F[Automation stops, logs rejection]
    E --> G[Log decision]
    F --> G

Example – Multi‑stage approval workflow (Pattern 12):

  • A contract over $50k is submitted.
  • Automation checks: is approver available? No SLA yet; creates task for VP of Sales.
  • Sends Slack and email notification.
  • If no response in 24h, escalates to VP of Operations.
  • If no response in 48h, escalates to CEO.
  • Only after human approval does the automation send the contract for signature.

Override mechanics:

ElementBehavior
Tags addedmanual:approved, manual:rejected, approver:vp_sales
Stage changeMoves from PENDING_APPROVAL to APPROVED or REJECTED
Automation stoppedIf rejected, no further actions; if approved, continues
LoggingFull audit trail: who approved, when, from which device, any comments

Decision guide – Necessary handoff:

  • Always build it – compliance is not optional.
  • Always include escalation (multiple approvers, time‑based).
  • Never allow automation to bypass necessary handoff, even if confidence is 100%.
For beginners

** Necessary handoff does not mean “slow.” A good design will notify the human immediately, give them a one‑click approve/reject button on their phone, and escalate if they are unavailable. The goal is to make the human’s job as fast as possible, not to eliminate them.

4.5 Questionable Automation – When Not to Automate

When to consider automation questionable:

  • Volume is very low (<10 executions per month).
  • The human touch adds significant relationship value (e.g., a CEO calling a high‑value lead personally).
  • The cost to build, test, and maintain automation exceeds the expected benefit.
  • The decision requires nuanced understanding that AI cannot replicate (e.g., “Is this subcontractor reliable?”).

How to handle questionable automations:

  • Do not automate – keep manual, but document the process.
  • Build a lightweight helper – e.g., a script that pulls data together but leaves the final action to a human.
  • Re‑evaluate quarterly – if volume grows, reassess.

Example – Executive assistant calendar management (Pattern adapted for executive work):

  • An assistant receives an email: “Can we move the 2 PM to 3 PM?”
  • Automation could parse the email, check calendar availability, send a proposed new time, and update the calendar.
  • But many executives prefer that a human assistant handles calendar changes because:
  • They trust the assistant’s judgment about priority.
  • The assistant can negotiate with the other party.
  • Mistakes (double booking, wrong timezone) are high cost.

Decision: Questionable automation. Instead, build a helper that extracts the proposed time and highlights conflicts, but leaves the actual update to the assistant.

Decision guide – Questionable:

  • If you cannot clearly articulate the ROI of automation (time saved × hourly rate > build cost), question it.
  • If the human’s judgment adds value beyond efficiency, question it.
  • If compliance or trust would suffer, question it.

4.6 Handoff Decision Tree

Use this flowchart to determine which handoff type applies to your automation:

Production-grade flow
graph TD
    A[Is human input legally required?] -->|Yes| B[Necessary Handoff]
    A -->|No| C{Can AI make decision with >95% accuracy?}
    C -->|Yes| D{Fully automatable (no handoff)}
    C -->|No| E{Is cost of wrong decision > $1,000?}
    E -->|Yes| B
    E -->|No| F{Does human add trust or relationship value?}
    F -->|Yes| G[Optional Handoff - allow override]
    F -->|No| H{Volume > 100/month?}
    H -->|Yes| I[Build automation with fallback to human]
    H -->|No| J[Questionable - keep manual or build helper]

4.7 Designing Handoffs That Don’t Fail

A handoff is itself an automation – and it can fail. Common handoff failures:

FailureExampleFix
Human ignores notificationTask sits in CRM for daysEscalate after SLA, re‑assign, send SMS
No escalation pathOne approver is on vacationDefine backup approver, time‑based escalation
Handoff not loggedNo record of who approved whatMandatory logging before automation proceeds
Human overrides incorrectlyApprover accepts a fraudulent transactionRequire two‑factor approval for high‑risk actions
Automation assumes human is always availableTask created at 2 AM, no alertTime‑aware escalation: during off‑hours, escalate to on‑call

The “human SLA” rule: Every handoff must have an SLA (e.g., “approve within 4 hours”). If the human does not meet the SLA, the system must escalate (to another human or to a fallback decision).

4.8 Industry‑Specific Handoff Rules

IndustryNecessary handoff examplesOptional handoff examplesQuestionable examples
MedicalDiagnosis sign‑off, prescription approval, insurance pre‑authAppointment rescheduling, medication refill remindersPatient triage for non‑emergency (risk too high)
ConstructionChange order approval, permit sign‑off, safety incident reportSubcontractor scheduling, material order confirmationRFI prioritization (usually fine, but some require PM judgment)
Real EstateContract signing, counter‑offer acceptance, disclosure reviewShowing confirmation, document upload remindersLead scoring (realtors often prefer to judge)
Home ImprovementFinal sign‑off, warranty claim approvalEstimate objection handling (price → financing)Follow‑up call for high‑value leads (human touch wins)
SaaSDiscount approval (>30%), contract terms deviationSupport ticket triage, trial extensionNew feature request prioritization (product manager wants to see)
Payment ProcessingFraud investigation, high‑risk transaction reviewRecurring payment retry managementDispute resolution (some automate, some keep human)
Executive AssistantCalendar changes (always necessary for some execs)Email filtering, expense report categorizationMeeting scheduling (questionable – many execs trust AI, others don’t)

4.9 The Override Log – Your Most Valuable Training Data

Every optional or necessary human handoff produces override data. This is gold.

What to log for every override:

  • Automation pattern ID
  • Contact ID (if applicable)
  • Timestamp
  • Human who performed the override
  • Original automation decision (e.g., AI intent = “price”)
  • Human correction (e.g., “trust”)
  • Reason (free text or dropdown)

How to use override logs:

  1. Retrain AI models – Export overrides, label them as ground truth, fine‑tune the model monthly.
  2. Detect drift – If override rate spikes, the AI or process has drifted.
  3. Improve deterministic logic – If overrides always change the same field (e.g., timeline), change the default or add a clarifying question.

Example – Override log in Airtable:

timestamppatterncontact_idoriginalcorrectedreason
2026-05-13 10:32Pattern 4 (intent)lead_12345pricetrustLead said “I like your work but price is high” – AI missed trust signal

After 100 such overrides, you update the prompt: “If message contains both price and trust signals, classify as trust (trust objections override price).”

4.10 Chapter Summary

Handoff typeWhen to useAutomation’s roleEscalation if human fails
OptionalAI confidence moderate, cost of error mediumSuggest, allow overrideRe‑assign to another human
NecessaryLegal, compliance, high risk, judgmentPrepare, notify, escalateEscalate to next level (manager, CEO)
QuestionableLow volume, human touch adds valueDo not automate; build helperN/A (manual)

One sentence takeaway:

Design every automation with explicit handoff rules – optional, necessary, or questionable – and log every override to retrain your AI and improve deterministic logic. A system that cannot hand off to a human is a system that will eventually fail.

End of Chapter 4

P01
Capture Inbound Lead from Any Channel
Group 1 · Capture & Routing · Connects **LEAD** → **QUALIFY** stages (stages 1 and 2 of the 19‑stage pipeline). This is the entry point for all revenue‑related automations.

Group 1 – Capture & Routing

Pattern 1 – Capture Inbound Lead from Any Channel

Glue: Connects LEADQUALIFY stages (stages 1 and 2 of the 19‑stage pipeline). This is the entry point for all revenue‑related automations.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Inbound event: form, call, chat, email, referral] --> B[Normalize to standard event schema]
    B --> C{Check for duplicate? phone/email}
    C -->|Duplicate found| D[Merge: update source, preserve oldest created_at]
    C -->|No duplicate| E[Create contact in CRM (GHL)]
    E --> F[Apply source tag and UTM attribution]
    F --> G{SMS consent given?}
    G -->|Yes| H[Send welcome SMS (idempotent)]
    G -->|No| I[Send welcome email only]
    H --> J[Create task for rep with SLA]
    I --> J
    J --> K[Change stage to CONTACTED]
    K --> L[Log event to Airtable]
    L --> M{High intent? (source=google or budget=high)}
    M -->|Yes| N[Slack alert to sales channel]
    M -->|No| O[End]
    D --> L

Straightforward Implementation (Fast & Fragile)

Use case: Low volume (<50 leads/day), internal tool, no compliance needs.

Steps:

  1. Form submits to a Google Sheet via webhook (no deduplication).
  2. Zapier (or Make) sends a raw email notification to sales.
  3. Rep manually copies lead into CRM (or uses a spreadsheet).

Code (JavaScript webhook – Google Apps Script):

javascript
function doPost(e) {
  const data = JSON.parse(e.postData.contents);
  const sheet = SpreadsheetApp.openById('SHEET_ID').getSheetByName('Leads');
  sheet.appendRow([data.name, data.phone, data.email, data.service, new Date()]);
  MailApp.sendEmail('rep@company.com', 'New Lead', `Name: ${data.name} Phone: ${data.phone}`);
  return ContentService.createTextOutput('OK');
}

Downsides:

  • No deduplication → duplicate leads waste reps’ time.
  • No attribution → cannot measure ROAS by channel.
  • No SLA monitoring → leads may sit for hours.
  • No recovery on failure → leads lost if script errors.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Use case: >100 leads/day, requires attribution, SLA, full audit trail.

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Orchestration: Make (Integromat)
  • Logging: Airtable
  • AI (optional): OpenAI for spam detection
  • Communication: GHL SMS (Twilio backend)

Step‑by‑step with real code:

1. Normalize inbound event (Make webhook → Node.js)

javascript
// In Make's "Custom webhook" module
const rawPayload = req.body;
const normalized = {
  event_type: rawPayload.event || 'lead_submitted',
  contact: {
    name: rawPayload.name || rawPayload.contact_name || '',
    phone: rawPayload.phone || rawPayload.caller_number || '',
    email: rawPayload.email || '',
    service_type: rawPayload.service_type || rawPayload.project_type || '',
    budget_range: rawPayload.budget_range || null,
    timeline: rawPayload.timeline || null,
  },
  source: rawPayload.utm_source || rawPayload.source || 'direct',
  campaign: rawPayload.utm_campaign,
  gclid: rawPayload.gclid,
  timestamp: new Date().toISOString(),
  idempotency_key: `${rawPayload.phone || rawPayload.email}_${Date.now()}`
};
return normalized;

2. Deduplication (GHL API call from Make)

javascript
// Search for existing contact by phone (primary) or email (fallback)
const searchUrl = `https://rest.gohighlevel.com/v1/contacts/lookup?phone=${encodeURIComponent(contact.phone)}`;
const existing = await fetch(searchUrl, {
  headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY' }
}).then(res => res.json());

if (existing.contacts && existing.contacts.length > 0) {
  // Merge: add new source tag, update custom fields, preserve oldest timestamp
  const mergePayload = {
    tags: [`source:${contact.source}`],
    customField: {
      source: contact.source,
      campaign: contact.campaign,
      last_contact: new Date().toISOString()
    }
  };
  await fetch(`https://rest.gohighlevel.com/v1/contacts/${existing.contacts[0].id}`, {
    method: 'PUT',
    headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY', 'Content-Type': 'application/json' },
    body: JSON.stringify(mergePayload)
  });
  await logToAirtable({ event_type: 'duplicate_merged', contact_id: existing.contacts[0].id });
  return; // Stop, do not create new contact
}

3. Create contact in GHL

javascript
const createPayload = {
  name: contact.name,
  phone: contact.phone,
  email: contact.email,
  customField: {
    source: contact.source,
    campaign: contact.campaign,
    service_type: contact.service_type,
    budget_range: contact.budget_range,
    timeline: contact.timeline,
    lead_score: 0,
  },
  tags: [`source:${contact.source}`, 'lead:new']
};
const newContact = await fetch('https://rest.gohighlevel.com/v1/contacts/', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY', 'Content-Type': 'application/json' },
  body: JSON.stringify(createPayload)
}).then(res => res.json());

4. Send welcome SMS (idempotent – check tag first)

javascript
// Idempotency: check if auto:welcome_sent tag exists
const tagsUrl = `https://rest.gohighlevel.com/v1/contacts/${newContact.id}/tags`;
const existingTags = await fetch(tagsUrl, {
  headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY' }
}).then(res => res.json());

if (!existingTags.tags.includes('auto:welcome_sent') && contact.sms_consent !== false) {
  const smsPayload = {
    message: `Hi ${contact.name}, thanks for reaching out! A specialist will contact you within 5 minutes.`,
    from: 'YOUR_GHL_NUMBER'
  };
  await fetch(`https://rest.gohighlevel.com/v1/contacts/${newContact.id}/sms`, {
    method: 'POST',
    headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY', 'Content-Type': 'application/json' },
    body: JSON.stringify(smsPayload)
  });
  // Add idempotency tag
  await fetch(`https://rest.gohighlevel.com/v1/contacts/${newContact.id}/tags`, {
    method: 'POST',
    headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY' },
    body: JSON.stringify({ tags: ['auto:welcome_sent'] })
  });
} else if (!contact.sms_consent) {
  // Send email instead
  await sendWelcomeEmail(contact);
}

5. Create sales task with SLA

javascript
const dueDate = new Date(Date.now() + 5 * 60000).toISOString(); // 5 minutes
const taskPayload = {
  title: `Call lead ${contact.name} – ${contact.service_type || 'inquiry'}`,
  assignedTo: 'round_robin', // GHL will assign based on territory or workload
  dueDate: dueDate,
  priority: (contact.source === 'google' || contact.budget_range === 'high') ? 'high' : 'normal',
  description: `Phone: ${contact.phone} | Email: ${contact.email} | Source: ${contact.source} | Campaign: ${contact.campaign}`
};
await fetch('https://rest.gohighlevel.com/v1/tasks', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY' },
  body: JSON.stringify(taskPayload)
});

6. Change pipeline stage to CONTACTED

javascript
await fetch(`https://rest.gohighlevel.com/v1/contacts/${newContact.id}/stage`, {
  method: 'PUT',
  headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY' },
  body: JSON.stringify({ stageId: 'YOUR_CONTACTED_STAGE_ID' })
});

7. Log event to Airtable

javascript
async function logToAirtable(event) {
  const airtableUrl = 'https://api.airtable.com/v0/YOUR_BASE_ID/event_log';
  await fetch(airtableUrl, {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_AIRTABLE_API_KEY',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      fields: {
        event_type: event.event_type,
        contact_id: event.contact_id || newContact.id,
        timestamp: new Date().toISOString(),
        source: contact.source,
        outcome: 'success',
        metadata: JSON.stringify({ pattern: 'Pattern 1', attempt: 1 })
      }
    })
  });
}

8. High‑intent Slack alert

javascript
if (contact.source === 'google' || contact.budget_range === 'high') {
  await fetch('YOUR_SLACK_WEBHOOK', {
    method: 'POST',
    body: JSON.stringify({
      text: `🔥 Hot lead from ${contact.source}: ${contact.name} – ${contact.service_type || 'inquiry'}\nCRM: https://gohighlevel.com/contact/${newContact.id}`
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalLead capture must include insurance verification and HIPAA acknowledgement checkbox. Auto‑response cannot contain PHI. No SMS without explicit consent (separate checkbox). Store consent proof. SMS must include opt‑out.Compliance (HIPAA, TCPA)
ConstructionLead capture includes project size (sq ft), permit status, and GC license number. Routing by project location and project value. SMS includes link to upload site photos.Project complexity, legal requirements
Real EstateLead capture includes MLS ID, property address, buyer/renter flag, and preferred contact time. Must integrate with IDX feed. Auto‑response includes link to schedule a showing.Industry‑specific data
Home ImprovementLead capture includes budget range, timeline, and picture upload (optional). Financing offer included in auto‑response. SMS includes “Reply HELP for options.”High‑ticket, financing critical
SaaSLead capture includes company size, user count, and current software. Auto‑response includes trial link, not SMS (email only) unless explicit SMS consent.Low‑touch sales model
Payment ProcessingLead capture includes monthly volume, average transaction, industry type. Compliance: must include legal disclosures. Auto‑response includes link to compliance doc.Risk and compliance
Executive AssistantLead capture is an email to a dedicated inbox (e.g., assistant@ceo.com). Automation parses email, extracts meeting requests and travel details, and adds to CEO’s calendar only after assistant approves. No SMS; email + calendar API only. SMS would be intrusive.Direct access to CEO’s time; low volume, high trust

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can override the auto‑assigned lead owner or priority before the task is created.If AI confidence in source classification <0.7, system creates task but also sends a Slack “review” request. Rep can change assignment within 2 minutes.
NecessaryFor leads with missing phone number or invalid email – system cannot send auto‑SMS or email. Creates a high‑priority manual task for rep to contact via alternative channel (e.g., LinkedIn, physical mail).Validation fails (phone missing or invalid format, email syntax error).
QuestionableSending welcome SMS for executive assistant leads – most executives prefer email or calendar invite only. Automation is possible but may be perceived as intrusive.Industry variant (executive assistant) shows alternative (email only). For now, keep manual.

Override Behavior

If a human rep manually creates a lead (instead of waiting for automation):

ElementBehavior
Tags addedmanual:lead_creation, auto:welcome_sent (to prevent duplicate welcome SMS)
Stage changeSet directly to CONTACTED (skip NEW_LEAD)
Automation stoppedThe normal lead intake workflow will check for auto:welcome_sent tag and exit early.
Loggingevent_type = manual_lead_creation, trigger = rep_override, metadata includes rep ID and reason (optional)
DownstreamNo welcome SMS, but task creation still occurs? Rule: if manual override, do not create duplicate task (rep already handling).

Code for override detection (at the start of the intake workflow):

javascript
const tags = await getContactTags(contactId);
if (tags.includes('auto:welcome_sent') || tags.includes('manual:lead_creation')) {
  console.log('Lead already processed or manually created; skipping intake automation.');
  return;
}

Override if rep changes source tag after automation:

  • Tags added: manual:override_source, original_source:facebook, corrected_source:google
  • Stage change: None
  • Automation stopped: None – but downstream attribution reports will use corrected source (or both for audit)
  • Logging: event_type = manual_override_source, old value, new value, rep name

Decision Guide

When to use this automationWhen NOT to use
Volume >20 leads/monthVolume very low (<5 leads/month) – manual is fine
Need attribution (source tracking)No need to know where leads come from
Multiple channels (form, call, chat, email)Only one channel, e.g., just a phone number
Must comply with TCPA / consent lawsNo consent required (e.g., internal leads)
Executive assistant scenario: automate email parsing, but skip SMSExecutive assistant prefers fully manual screening

Cost‑benefit estimate (for production‑grade version):

Volume (leads/month)Build hours (one‑time)Monthly maintenance (hours)Estimated ROI (first year)
50201Low – may not break even
200202Positive (saves ~10 rep hours/month)
1000304High (saves ~50 rep hours/month, reduces lead loss)

Decision: Build production‑grade for >100 leads/month. For 20–100 leads/month, use straightforward with manual fallback. For executive assistant, keep manual or build a lightweight email parser only.

End of Pattern 1

P02
Deduplicate and Merge Duplicate Records
Group 1 · Capture & Routing · This automation operates within the **LEAD** stage (stage 1 of the 19‑stage pipeline). It prevents duplicate records from being created when the same person submits multiple inquiries through different channels or at different times. It also merges existing duplicates found through audits.
Glue: This automation operates within the LEAD stage (stage 1 of the 19‑stage pipeline). It prevents duplicate records from being created when the same person submits multiple inquiries through different channels or at different times. It also merges existing duplicates found through audits.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Incoming lead data] --> B{Search existing contacts by phone and email}
    B -->|No match| C[Create new contact]
    B -->|Match found| D{Compare confidence: exact phone? exact email?}
    D -->|High confidence| E[Merge: update existing contact]
    D -->|Low confidence| F[Queue for manual review]
    
    E --> G[Preserve oldest created_at timestamp]
    E --> H[Append new source tag to existing source list]
    E --> I[Update last_contacted timestamp]
    E --> J[If existing stage is earlier than qualified, keep qualified stage]
    E --> K[Log merge event to Airtable]
    E --> L[Suppress duplicate welcome SMS]
    
    F --> M[Create task for ops: review potential duplicate]
    M --> N[Send Slack alert to #duplicate-review]
    
    C --> O[Continue to Pattern 1 intake flow]

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, no CRM, spreadsheets only.

Steps:

  1. In Google Sheets, use a formula to check for duplicate phone numbers: =COUNTIF(A:A, A2)>1.
  2. Manually review highlighted rows, delete or merge rows by copy‑pasting data.
  3. No automation – purely manual.

Code (Google Sheets script):

javascript
function findDuplicates() {
  var sheet = SpreadsheetApp.getActiveSheet();
  var data = sheet.getDataRange().getValues();
  var phones = data.map(row => row[1]); // assuming phone in column B
  for (var i = 1; i < data.length; i++) {
    var phone = data[i][1];
    var count = phones.filter(p => p === phone).length;
    if (count > 1) sheet.getRange(i+1, 1, 1, data[0].length).setBackground('#FFCCCC');
  }
}

Downsides:

  • No automatic merging.
  • No prevention at creation time – duplicates already exist.
  • No audit trail.
  • No handling of different email addresses for same phone.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Orchestration: Make (Integromat)
  • Logging: Airtable
  • Data store: Redis (via Make’s Data Store) for caching recent duplicates

Step‑by‑step with real code:

1. Search for existing contacts by phone and email (GHL API)

javascript
// Called on every lead creation attempt
async function findExistingContact(contact) {
  // Primary: search by phone
  let phoneUrl = `https://rest.gohighlevel.com/v1/contacts/lookup?phone=${encodeURIComponent(contact.phone)}`;
  let phoneRes = await fetch(phoneUrl, {
    headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY' }
  });
  let phoneData = await phoneRes.json();
  if (phoneData.contacts && phoneData.contacts.length > 0) {
    return { match: true, contact: phoneData.contacts[0], method: 'phone' };
  }
  
  // Fallback: search by email
  let emailUrl = `https://rest.gohighlevel.com/v1/contacts/lookup?email=${encodeURIComponent(contact.email)}`;
  let emailRes = await fetch(emailUrl, {
    headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY' }
  });
  let emailData = await emailRes.json();
  if (emailData.contacts && emailData.contacts.length > 0) {
    return { match: true, contact: emailData.contacts[0], method: 'email' };
  }
  
  return { match: false };
}

2. Deterministic merge rules

When a duplicate is found, apply these rules:

FieldMerge rule
created_atKeep the oldest (preserve original entry timestamp)
sourceAppend new source to a list (e.g., ["facebook", "google"])
last_contactedUpdate to most recent timestamp
stageKeep the most advanced stage (e.g., if existing is QUALIFIED and new is NEW_LEAD, keep QUALIFIED)
tagsMerge unique tags; do not duplicate
custom fieldsIf new field has value and old does not, update; if both have values and they differ, keep both in a JSON array or flag for review

Code for merge logic:

javascript
async function mergeContact(existing, newContactData) {
  // 1. Preserve oldest created_at
  const existingCreated = new Date(existing.createdAt);
  const newCreated = new Date(newContactData.createdAt || Date.now());
  const finalCreated = existingCreated < newCreated ? existing.createdAt : newContactData.createdAt;
  
  // 2. Merge source tags
  let existingSources = existing.customField?.source ? existing.customField.source.split(',') : [];
  if (!existingSources.includes(newContactData.source)) {
    existingSources.push(newContactData.source);
  }
  const mergedSources = existingSources.join(',');
  
  // 3. Determine most advanced stage
  const stageOrder = ['NEW_LEAD', 'CONTACTED', 'QUALIFIED', 'APPOINTMENT_BOOKED', 'APPOINTMENT_COMPLETED', 'ESTIMATE_SENT', 'DECISION_PENDING', 'CLOSED_WON', 'CLOSED_LOST'];
  const existingStageIndex = stageOrder.indexOf(existing.stage);
  const newStageIndex = stageOrder.indexOf(newContactData.stage || 'NEW_LEAD');
  const finalStage = existingStageIndex >= newStageIndex ? existing.stage : newContactData.stage;
  
  // 4. Merge tags (unique)
  const existingTags = existing.tags || [];
  const newTags = newContactData.tags || [];
  const mergedTags = [...new Set([...existingTags, ...newTags])];
  
  // 5. Update CRM
  const updatePayload = {
    id: existing.id,
    customField: {
      ...existing.customField,
      source: mergedSources,
      last_contacted: new Date().toISOString()
    },
    stage: finalStage,
    tags: mergedTags
  };
  // Only update created_at if we need to preserve the oldest (GHL may not allow overwrite; instead store in custom field)
  updatePayload.customField.original_created_at = finalCreated;
  
  await fetch(`https://rest.gohighlevel.com/v1/contacts/${existing.id}`, {
    method: 'PUT',
    headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY', 'Content-Type': 'application/json' },
    body: JSON.stringify(updatePayload)
  });
  
  // 6. Suppress duplicate welcome SMS: add tag auto:welcome_sent if the new lead would have triggered it
  if (newContactData.sms_consent && !existingTags.includes('auto:welcome_sent')) {
    await fetch(`https://rest.gohighlevel.com/v1/contacts/${existing.id}/tags`, {
      method: 'POST',
      headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY' },
      body: JSON.stringify({ tags: ['auto:welcome_sent'] })
    });
  }
  
  return { merged: true, contactId: existing.id };
}

3. Low‑confidence duplicate queue (manual review)

If search returns multiple possible matches (e.g., same name but different phone, or same phone but different name), do not auto‑merge.

javascript
// If phone and email searches return different contacts
if (phoneData.contacts && emailData.contacts && phoneData.contacts[0].id !== emailData.contacts[0].id) {
  // Queue for manual review
  const reviewTask = {
    title: `Potential duplicate: phone contact ${phoneData.contacts[0].id} vs email contact ${emailData.contacts[0].id}`,
    assignedTo: 'ops_queue',
    dueDate: new Date(Date.now() + 24 * 60 * 60000).toISOString(),
    description: `Phone: ${contact.phone}, Email: ${contact.email}. Please review and merge manually.`
  };
  await fetch('https://rest.gohighlevel.com/v1/tasks', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY' },
    body: JSON.stringify(reviewTask)
  });
  await logToAirtable({ event_type: 'duplicate_low_confidence', contact_data: contact });
}

4. Periodic duplicate audit (weekly cron)

Run a scheduled Make scenario every Sunday at 2 AM to find and merge duplicates that were not caught at creation time.

javascript
// Get all contacts updated in last 7 days
const allContacts = await fetch('https://rest.gohighlevel.com/v1/contacts/?limit=100&updatedAt=last_7_days', {
  headers: { 'Authorization': 'Bearer YOUR_GHL_API_KEY' }
}).then(res => res.json());

// Group by phone and email
const phoneMap = new Map();
allContacts.contacts.forEach(contact => {
  if (contact.phone) {
    if (!phoneMap.has(contact.phone)) phoneMap.set(contact.phone, []);
    phoneMap.get(contact.phone).push(contact);
  }
});

// For each group with >1 contact, apply merge logic
for (let [phone, contacts] of phoneMap.entries()) {
  if (contacts.length > 1) {
    // Sort by created_at, oldest first
    contacts.sort((a,b) => new Date(a.createdAt) - new Date(b.createdAt));
    const primary = contacts[0];
    const duplicates = contacts.slice(1);
    for (let dup of duplicates) {
      await mergeContact(primary, dup);
      // Optionally delete duplicate contact after merge
    }
  }
}

5. Logging to Airtable

javascript
async function logMerge(primaryId, duplicateId, method) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/event_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer YOUR_AIRTABLE_API_KEY', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      fields: {
        event_type: 'duplicate_merged',
        contact_id: primaryId,
        secondary_contact_id: duplicateId,
        merge_method: method, // 'phone_exact', 'email_exact', 'manual', 'auto_audit'
        timestamp: new Date().toISOString(),
        outcome: 'success'
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalMerge only after verifying patient ID (MRN), not just phone/email. Preserve insurance information and consent forms. Add audit log for HIPAA compliance.Patient safety, legal requirements
ConstructionMerge based on project address + contractor license number, not just person. Preserve change order history.Unique project identifiers
Real EstateMerge based on property address + client name. Preserve showing history and offer details.Multiple buyers for same property
Home ImprovementMerge based on phone and address. If duplicate leads have different estimate amounts, flag for review (possible price mismatch).Estimates may differ over time
SaaSMerge based on company domain + user email. Preserve trial start date and usage data.Account hierarchy
Payment ProcessingMerge based on merchant ID or tax ID. Preserve compliance documents.Regulatory requirements
Executive AssistantRarely needed – executive contacts are unique. If duplicate, manual review required.Low volume, high trust

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually merge duplicate records via a “Merge” button in CRM. Automation suggests matches but does not auto‑merge.For low‑confidence matches (e.g., same name but different phone).
NecessaryWhen merge would lose critical data (e.g., two different estimates for same lead). Must be reviewed by ops manager.Estimates differ by >20% or stages conflict.
QuestionableAutomating merge for executive assistant contacts – volume too low to justify; manual is fine.Executive industry variant.

Override Behavior

If a human manually merges two contacts (bypassing automation):

ElementBehavior
Tags addedmanual:merge, merged_from:<contact_id>
Stage changeThe target contact (kept) retains its stage; the source contact is archived or deleted.
Automation stoppedAny pending workflows (e.g., follow‑up sequences) attached to the deleted contact are transferred to the target contact (by updating contact ID in tasks/events).
Loggingevent_type = manual_merge, primary_id, secondary_id, user_id, timestamp.

Override via CRM button (GHL custom action):

javascript
// Custom JavaScript in GHL button
const primaryId = input.primaryContactId;
const secondaryId = input.secondaryContactId;
await fetch(`https://rest.gohighlevel.com/v1/contacts/${primaryId}/tasks?assignTo=${secondaryId}`, {
  method: 'PATCH',
  headers: { 'Authorization': 'Bearer GHL_API_KEY' }
});
await fetch(`https://rest.gohighlevel.com/v1/contacts/${secondaryId}`, {
  method: 'DELETE',
  headers: { 'Authorization': 'Bearer GHL_API_KEY' }
});
await logToAirtable({ event_type: 'manual_merge' });

Decision Guide

When to use this automationWhen NOT to use
Volume >100 leads/month with duplicates likelyVery low volume, duplicates rare
Multiple lead channels (form, call, chat, referral)Single channel with unique identifiers (e.g., user ID)
Need to preserve attribution across multiple touchesNo attribution tracking needed
Compliance requires audit trail of merges (medical, finance)No compliance concerns
Executive assistant – rarely needed, manual review sufficesHigh risk of merging unrelated contacts (e.g., common names)

Rules of thumb for merge confidence:

  • High confidence (auto‑merge): Exact phone match AND same name (or email match). Stage difference not critical.
  • Medium confidence (queue for review): Same phone but different name, or same email but different phone.
  • Low confidence (manual only): Same name but different phone and email; or partial phone match (last 4 digits).

Cost‑benefit:

Duplicate rateLeads/monthTime saved (rep/manual merge)ROI
<1%100MinimalNot worth building
5%100~10 hours/monthPositive
10%1000~100 hours/monthHigh

Decision: Build production‑grade if duplicate rate >5% or volume >500 leads/month. For smaller, use straightforward (manual detection) or simple rule‑based merge without full automation.

End of Pattern 2

P03
Round-Robin / Territory Assignment
Group 1 · Capture & Routing · This automation connects **LEAD** → **QUALIFY** stages (stages 1–2 of the 19‑stage pipeline). It ensures that every qualified lead is assigned to the correct sales rep, territory, or queue without manual intervention. This prevents leads from falling through cracks, balances workload, and respects geographic or product specialization.
Glue: This automation connects LEAD → QUALIFY stages (stages 1–2 of the 19‑stage pipeline). It ensures that every qualified lead is assigned to the correct sales rep, territory, or queue without manual intervention. This prevents leads from falling through cracks, balances workload, and respects geographic or product specialization.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Lead created in CRM, stage = NEW_LEAD] --> B{Lead source defined?}
    B -->|No| C[Assign to default queue for manual triage]
    B -->|Yes| D[Determine assignment rule: territory, round‑robin, or load‑balanced]
    
    D --> E{Territory-based? (zip code, region)}
    E -->|Yes| F[Map territory to rep(s)]
    F --> G{Multiple reps in territory?}
    G -->|Yes| H[Round‑robin within territory]
    G -->|No| I[Assign to single rep]
    
    E -->|No| J{Round‑robin only?}
    J -->|Yes| K[Select next rep from global list (round‑robin)]
    
    K --> L[Check rep’s current open tasks count]
    L --> M{Over capacity? (>10 open tasks)}
    M -->|Yes| N[Skip rep, go to next in round‑robin]
    M -->|No| O[Assign lead to selected rep]
    
    H --> O
    I --> O
    C --> O
    
    O --> P[Create assignment record (who, when, method)]
    P --> Q[Send Slack notification to rep (optional)]
    Q --> R[Log assignment to Airtable]

Straightforward Implementation (Fast & Fragile)

Use case: Small team (<5 reps), no territory specialization, simple round‑robin.

Steps:

  1. In CRM (e.g., GHL), use built‑in round‑robin assignment (available in many CRMs).
  2. Manually adjust if a rep is on vacation.
  3. No load balancing, no territory mapping.

Code (GHL native round‑robin setup – no coding):

yaml
# In GHL pipeline settings: 
# Round Robin Assignment: enabled
# Assignment order: sequential or random
# Users: rep1@company.com, rep2@company.com, rep3@company.com

Downsides:

  • No territory or zip code routing.
  • No load balancing (busy rep gets as many leads as idle rep).
  • No fallback if rep is unavailable.
  • No audit trail of assignment decisions.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Orchestration: Make (Integromat)
  • Data store: Airtable (territory mapping, rep capacity table)
  • Logging: Airtable (assignment log)
  • Communication: Slack (notifications)

Step‑by‑step with real code:

1. Define territory mapping (Airtable table: territory_rules)

rule_idzip_prefixrep_emailfallback_rep
185000-85999rep1@company.comrep2@company.com
286000-86999rep2@company.comrep3@company.com
3*round_robin(none)

Code to fetch territory mapping:

javascript
async function getTerritoryRep(zipCode) {
  const airtableUrl = 'https://api.airtable.com/v0/YOUR_BASE_ID/territory_rules';
  const response = await fetch(airtableUrl, {
    headers: { 'Authorization': 'Bearer YOUR_AIRTABLE_API_KEY' }
  });
  const records = await response.json();
  // Find matching zip prefix
  for (let rule of records.records) {
    const range = rule.fields.zip_prefix.split('-');
    if (range.length === 2) {
      const min = parseInt(range[0]);
      const max = parseInt(range[1]);
      const zip = parseInt(zipCode);
      if (zip >= min && zip <= max) {
        return rule.fields.rep_email;
      }
    } else if (rule.fields.zip_prefix === '*') {
      return { type: 'round_robin' };
    }
  }
  return { type: 'round_robin' };
}

2. Round‑robin with load balancing (track rep capacity)

Data store for rep capacity (Airtable: rep_capacity):

rep_emailcurrent_open_tasksmax_capacitylast_assigned_at
rep1@...7102026-05-13T10:00Z
rep2@...12102026-05-12T15:00Z

Round‑robin logic with capacity check:

javascript
async function getNextRoundRobinRep() {
  // Fetch reps with current open tasks and capacity
  const repsUrl = 'https://api.airtable.com/v0/YOUR_BASE_ID/rep_capacity?sort%5B0%5D%5Bfield%5D=last_assigned_at&sort%5B0%5D%5Bdirection%5D=asc';
  const reps = await fetch(repsUrl, {
    headers: { 'Authorization': 'Bearer YOUR_AIRTABLE_API_KEY' }
  }).then(res => res.json());
  
  // Filter reps under capacity
  const availableReps = reps.records.filter(rep => 
    rep.fields.current_open_tasks < rep.fields.max_capacity
  );
  
  if (availableReps.length === 0) {
    // No available reps – escalate
    await escalateNoAvailableRep();
    return { error: 'no_available_rep' };
  }
  
  // Round‑robin: pick the one with oldest last_assigned_at
  const selected = availableReps[0]; // already sorted by last_assigned_at asc
  return selected.fields.rep_email;
}

3. Assign lead to rep (GHL API)

javascript
async function assignLeadToRep(contactId, repEmail) {
  // GHL: update custom field 'assigned_rep' and optionally assign task
  const updatePayload = {
    customField: {
      assigned_rep: repEmail
    },
    tags: [`rep:${repEmail}`]
  };
  await fetch(`https://rest.gohighlevel.com/v1/contacts/${contactId}`, {
    method: 'PUT',
    headers: { 'Authorization': 'Bearer GHL_API_KEY', 'Content-Type': 'application/json' },
    body: JSON.stringify(updatePayload)
  });
  
  // Create task assigned to that rep
  const taskPayload = {
    title: `New lead assigned: follow up within 5 min`,
    assignedTo: repEmail,
    dueDate: new Date(Date.now() + 5*60000).toISOString(),
    contactId: contactId
  };
  await fetch('https://rest.gohighlevel.com/v1/tasks', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer GHL_API_KEY' },
    body: JSON.stringify(taskPayload)
  });
}

4. Escalation if no rep available (fallback)

javascript
async function escalateNoAvailableRep() {
  // Create ops task
  const task = {
    title: 'No available sales rep – lead unassigned',
    assignedTo: 'ops_manager@company.com',
    dueDate: new Date(Date.now() + 60*60000).toISOString(),
    description: 'All reps at capacity. Review rep capacity table and reassign.'
  };
  await fetch('https://rest.gohighlevel.com/v1/tasks', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer GHL_API_KEY' },
    body: JSON.stringify(task)
  });
  // Send Slack alert to ops channel
  await fetch('SLACK_WEBHOOK_URL', {
    method: 'POST',
    body: JSON.stringify({ text: '🚨 No available sales rep for new lead. Please adjust capacity.' })
  });
}

5. Log assignment to Airtable

javascript
async function logAssignment(contactId, repEmail, method, territoryInfo = null) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/assignment_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        assigned_rep: repEmail,
        assignment_method: method, // 'territory', 'round_robin', 'manual_override'
        territory: territoryInfo,
        timestamp: new Date().toISOString()
      }
    })
  });
}

6. Update rep capacity after assignment (increment open tasks)

javascript
async function incrementRepTaskCount(repEmail) {
  // Find rep record in Airtable, increment current_open_tasks by 1
  const findUrl = `https://api.airtable.com/v0/YOUR_BASE_ID/rep_capacity?filterByFormula={rep_email}="${repEmail}"`;
  const record = await fetch(findUrl, { headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' } }).then(r => r.json());
  if (record.records.length) {
    const recordId = record.records[0].id;
    const currentCount = record.records[0].fields.current_open_tasks;
    await fetch(`https://api.airtable.com/v0/YOUR_BASE_ID/rep_capacity/${recordId}`, {
      method: 'PATCH',
      headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY', 'Content-Type': 'application/json' },
      body: JSON.stringify({ fields: { current_open_tasks: currentCount + 1, last_assigned_at: new Date().toISOString() } })
    });
  }
}

Industry Variants

IndustryVariantReason
MedicalRoute by patient insurance type (Medicare, private, HMO). Must comply with anti‑kickback laws – no financial incentive for rep choice.Compliance, specialization
ConstructionRoute by project size ($, $$, $$$) and GC license. Subcontractors may have different specializations (foundation, framing, electrical).Skill matching
Real EstateRoute by property type (residential, commercial, land) and buyer/renter flag. Also respect agent‑client relationships (previous agent gets right of first refusal).Conflict of interest
Home ImprovementRoute by service type (patio, roofing, sunroom) and zip code. Also consider rep’s current workload (some reps close sunrooms faster).Conversion optimization
SaaSRoute by company size (enterprise vs SMB) and product line. Enterprise leads may go to account executives, SMB to inside sales.Sales specialization
Payment ProcessingRoute by monthly volume and industry risk level (high‑risk accounts go to specialized reps).Compliance, underwriting
Executive AssistantNot applicable – executive rarely has multiple reps. Use simple assignment to assistant@ceo.com.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually re‑assign a lead to another rep (e.g., if they are overloaded or it’s outside their territory).Rep clicks “Reassign” button in CRM.
NecessaryIf no rep is available (all at capacity) and fallback task is created, ops manager must manually assign or adjust capacity.escalateNoAvailableRep() is called.
QuestionableFully automated round‑robin without capacity check – works for small teams, but for high volume, some human oversight (capacity review) is better.For teams <5 reps, simple round‑robin may suffice.

Override Behavior

If a human manually reassigns a lead after automation:

ElementBehavior
Tags addedmanual:reassign, previous_rep:<email>, new_rep:<email>
Stage changeNone
Automation stoppedThe original assignment task is marked as “reassigned” and a new task is created for the new rep.
Loggingevent_type = manual_reassignment, old_rep, new_rep, reason (optional)

Override via CRM button (custom action):

javascript
// Reassign button in GHL
const newRep = input.newRepEmail;
const contactId = input.contactId;
// Remove old rep tag
await removeTag(contactId, `rep:${currentRep}`);
// Add new rep tag
await addTag(contactId, `rep:${newRep}`);
// Update assigned_rep custom field
await updateCustomField(contactId, 'assigned_rep', newRep);
// Log override
await logToAirtable({ event_type: 'manual_reassignment', old_rep: currentRep, new_rep: newRep });

Decision Guide

When to use this automationWhen NOT to use
Volume >100 leads/monthVery low volume, manual assignment fine
Multiple reps (2+)Single rep – no assignment needed
Geographic or product specialization existsNo specialization – simple round‑robin in CRM is enough
Need load balancing (reps have different capacities)All reps identical, low volume
Executive assistant – single inbox, no routingN/A

Cost‑benefit:

  • For 5 reps, 500 leads/month, territory + round‑robin saves ~20 hours/month of manual assignment.
  • Build production‑grade if you have >3 reps or >200 leads/month.

End of Pattern 3

P04
AI Classification of Intent / BANT
Group 1 · Capture & Routing · This automation connects **QUALIFY** → **APPOINT** stages (stages 2 and 3 of the 19‑stage pipeline). It analyzes unstructured lead replies (SMS, email, chat) to extract qualification data (Budget, Authority, Timeline, Need – BANT) and determines overall intent (high/medium/low). The automation then routes the lead to appointment booking, clarification, or nurture.

##

Glue: This automation connects QUALIFYAPPOINT stages (stages 2 and 3 of the 19‑stage pipeline). It analyzes unstructured lead replies (SMS, email, chat) to extract qualification data (Budget, Authority, Timeline, Need – BANT) and determines overall intent (high/medium/low). The automation then routes the lead to appointment booking, clarification, or nurture.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Lead replies to SMS/email/chat] --> B[Extract message text, lead context]
    B --> C[Call OpenAI API with classification prompt]
    C --> D{API success?}
    D -->|No, after retries| E[Fallback: rule‑based keyword matching]
    D -->|Yes| F{Confidence > 0.7?}
    F -->|Yes| G[Parse extracted fields: budget, timeline, authority, need, overall_intent]
    F -->|No| H[Send clarifying question max 2 attempts]
    H --> I{Clarification received?}
    I -->|Yes| C
    I -->|No| J[Move to REACTIVATION stage]
    
    G --> K{Score calculated?}
    K --> L[Score = budget + timeline + authority + need + source bonus]
    L --> M{Score >= 70?}
    M -->|Yes| N[Update lead score, add intent:high tag]
    N --> O[Change stage to QUALIFIED]
    O --> P[Send appointment booking link]
    
    M -->|Score 40-69| Q[Send clarifying SMS for missing fields]
    Q --> R[Wait 24h for reply, then re‑evaluate]
    
    M -->|Score < 40| S[Add intent:low tag, move to nurture sequence]
    
    E --> F
    J --> T[Log event to Airtable]
    P --> T
    S --> T
    R --> T

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, simple keyword‑based qualification, no AI.

Steps:

  1. Use CRM workflow to detect keywords in lead reply (e.g., contains “budget”, “$”, “timeline”).
  2. Manually create a task for rep to review and update qualification fields.
  3. No scoring, no confidence, no clarifying loop.

Code (GHL workflow condition – keyword detection):

javascript
// GHL workflow custom logic (pseudocode)
IF reply_text CONTAINS (“budget” OR “$” OR “price”) 
  THEN add tag “intent:maybe”
  create task “Check qualification for lead”
ELSE add tag “intent:low”

Downsides:

  • Low accuracy (keyword matching misses nuance).
  • No confidence scoring – rep must review every lead.
  • No automatic scoring or routing.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • Orchestration: Make (Integromat)
  • AI: OpenAI (GPT‑4o‑mini for classification, GPT‑4o for complex extraction)
  • CRM: GoHighLevel (GHL)
  • Logging: Airtable
  • Data store: Redis (for caching classifications)

Step‑by‑step with real code:

1. Capture lead reply and context

javascript
// In Make webhook from GHL
const leadReply = {
  contact_id: rawPayload.contact_id,
  message: rawPayload.message_text,
  channel: rawPayload.channel, // 'sms', 'email', 'chat'
  current_stage: rawPayload.stage,
  service_type: rawPayload.service_type || 'unknown',
  previous_qualification_attempts: rawPayload.qualification_attempts || 0,
  timestamp: new Date().toISOString()
};

2. Call OpenAI with classification prompt

Prompt (system message):

text
You are a lead qualification assistant for a home improvement company. 
Extract the following fields from the customer's message:
- budget_range: one of "low" (<$5k), "medium" ($5k-$15k), "high" (>$15k), or "unknown"
- timeline: one of "0-30d", "30-90d", "90+d", or "unknown"
- authority: one of "homeowner", "spouse", "renter", "investor", or "unknown"
- need: integer 1-5 (1 = just exploring, 5 = urgent/need done ASAP), or null if not specified
- overall_intent: "high", "medium", or "low"
- confidence: float 0-1

Output ONLY valid JSON. Do not include any other text.

User message (dynamic):

text
Customer message: {message}
Service type: {service_type}

Code to call OpenAI with retries:

javascript
async function classifyLead(message, serviceType, retries = 3) {
  const prompt = `Customer message: ${message}\nService type: ${serviceType}`;
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${OPENAI_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: SYSTEM_PROMPT },
        { role: 'user', content: prompt }
      ],
      temperature: 0,
      max_tokens: 200
    })
  });
  if (!response.ok && retries > 0) {
    await new Promise(resolve => setTimeout(resolve, 2000));
    return classifyLead(message, serviceType, retries - 1);
  }
  const data = await response.json();
  const result = JSON.parse(data.choices[0].message.content);
  return result;
}

3. Calculate lead score

javascript
function calculateScore(budget, timeline, authority, need, source, engagement) {
  let score = 0;
  // Budget
  if (budget === 'high') score += 30;
  else if (budget === 'medium') score += 15;
  // Timeline
  if (timeline === '0-30d') score += 30;
  else if (timeline === '30-90d') score += 15;
  // Authority
  if (authority === 'homeowner') score += 20;
  else if (authority === 'spouse') score += 10;
  // Need (1-5)
  if (need >= 4) score += 20;
  else if (need === 3) score += 10;
  // Source bonus
  if (source === 'referral') score += 15;
  else if (source === 'google') score += 10;
  else if (source === 'facebook') score += 5;
  // Engagement signals
  if (engagement.has_replied) score += 5;
  if (engagement.estimate_opened) score += 10;
  return Math.min(score, 100);
}

4. Decision routing based on confidence and score

javascript
async function processClassification(leadId, message) {
  let result;
  try {
    result = await classifyLead(message, serviceType);
  } catch (error) {
    // Fallback to rule‑based
    result = ruleBasedFallback(message);
  }
  
  if (result.confidence < 0.6) {
    // Low confidence: create task for human review
    await createTask(leadId, 'Manual qualification needed – AI low confidence');
    await logEvent(leadId, 'ai_low_confidence', result);
    return;
  }
  
  const score = calculateScore(
    result.budget_range, result.timeline, result.authority, result.need,
    leadSource, { has_replied: true, estimate_opened: false }
  );
  
  if (score >= 70) {
    await updateCRM(leadId, { lead_score: score, intent: 'high' });
    await changeStage(leadId, 'QUALIFIED');
    await sendBookingLink(leadId);
  } 
  else if (score >= 40 && score < 70) {
    // Medium intent – send clarifying question
    await sendClarifyingSMS(leadId, result.missing_fields);
    await incrementQualificationAttempt(leadId);
    // If already attempted twice, move to human
    if (attempts >= 2) {
      await createTask(leadId, 'Clarification failed – manual review');
      await changeStage(leadId, 'REACTIVATION');
    }
  }
  else {
    // Low intent
    await updateCRM(leadId, { lead_score: score, intent: 'low' });
    await addToNurtureSequence(leadId);
  }
  
  await logToAirtable(leadId, 'ai_classification', result, score);
}

5. Rule‑based fallback (when AI fails)

javascript
function ruleBasedFallback(message) {
  const msg = message.toLowerCase();
  let intent = 'medium';
  let budget_range = 'unknown';
  let timeline = 'unknown';
  let authority = 'unknown';
  let need = null;
  
  if (msg.includes('budget') || msg.includes('$') || msg.includes('price')) budget_range = 'medium';
  if (msg.includes('urgent') || msg.includes('asap') || msg.includes('immediately')) timeline = '0-30d';
  if (msg.includes('homeowner') || msg.includes('own the home')) authority = 'homeowner';
  if (msg.includes('just looking') || msg.includes('exploring')) intent = 'low';
  if (msg.includes('ready to book') || msg.includes('start next week')) intent = 'high';
  
  return {
    budget_range, timeline, authority, need,
    overall_intent: intent,
    confidence: 0.5, // low confidence, but better than nothing
    is_fallback: true
  };
}

6. Clarifying question loop (idempotent)

javascript
async function sendClarifyingSMS(leadId, missingFields) {
  const phone = await getLeadPhone(leadId);
  let question = '';
  if (missingFields.includes('budget')) question += ' What is your rough budget? (Low under $5k, Medium $5k-$15k, High over $15k)';
  if (missingFields.includes('timeline')) question += ' When are you hoping to start? (0-30 days, 30-90 days, 90+ days)';
  if (missingFields.includes('authority')) question += ' Are you the homeowner? (Yes/No)';
  
  const sms = `Thanks. To help you better:${question} Reply with your answers.`;
  await sendSMS(phone, sms);
  await addTag(leadId, 'auto:clarification_sent');
  // Start 24h timer – if no reply, move to reactivation
  await startTimer(leadId, 'clarification_timeout', 24 * 60 * 60);
}

7. Logging to Airtable

javascript
async function logToAirtable(leadId, eventType, aiResult, score) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/event_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        event_type: eventType,
        contact_id: leadId,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify({ ai_result: aiResult, score: score })
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalExtract insurance type (Medicare, private, HMO) and urgency (emergency, routine). Must comply with HIPAA – log all AI inputs but de‑identify.Compliance, patient safety
ConstructionExtract project size (sq ft), permit status, and GC license. Need to ask for subcontractor availability.Complex projects
Real EstateExtract property type, buyer/renter flag, and move‑in date. May need to detect if lead is a realtor (then route differently).Industry norms
Home ImprovementAs shown – focus on budget, timeline, authority, financing interest.High‑ticket sales
SaaSExtract company size, user count, current software, and decision‑maker role (C‑level vs manager). BANT adapted to SaaS.Product‑led growth
Payment ProcessingExtract monthly volume, average ticket, industry type, and risk tolerance. Must avoid PCI data (never log card numbers).Compliance
Executive AssistantNot applicable – exec assistant does not automate qualification; they manually screen.High trust, low volume

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can override AI‑assigned score or intent (e.g., change from medium to high). Override logged for retraining.AI confidence <0.8 or rep disagrees.
NecessaryFor low‑confidence classifications (<0.6), system creates task for rep to review and qualify manually.AI confidence <0.6.
QuestionableFully automated qualification for executive assistant – volume too low, human screening adds trust.Executive industry variant.

Override Behavior

If a human rep manually changes the lead’s qualification fields or intent:

ElementBehavior
Tags addedmanual:override_qualification, override_reason:<reason>
Stage changeRep can move lead to QUALIFIED directly (bypassing automation)
Automation stoppedAny pending clarifying questions are cancelled; idempotency tag auto:qualification_processed added to prevent re‑processing
Loggingevent_type = manual_override_qualification, original AI values, corrected values, rep name

Code for override detection in the qualification workflow:

javascript
const tags = await getContactTags(leadId);
if (tags.includes('auto:qualification_processed') || tags.includes('manual:override_qualification')) {
  console.log('Qualification already processed or manually overridden; skipping.');
  return;
}

Decision Guide

When to use this automationWhen NOT to use
Volume >100 leads/monthVery low volume, manual qualification fine
Reps spend >1 hour/day on qualificationQualification rarely needed
Need consistent BANT collectionEach lead requires nuanced conversation (e.g., high‑touch enterprise sales)
AI confidence can be monitored and model retrainedNo ability to retrain or audit AI
Executive assistant – not suitableN/A

Cost‑benefit:

  • For 500 leads/month, AI classification saves ~40 hours/month of rep time (assume 5 minutes per lead manual).
  • API cost: ~$20/month (GPT‑4o‑mini, 10k classifications).
  • Setup: 1–2 days for prompts, integration, testing.

Decision: Build production‑grade if volume >100 leads/month and qualification consistency matters. For executive assistant, skip.

End of Pattern 4

P05
Self-Service Appointment Booking with Confirmation
Group 2 · Scheduling & Reminders · This automation connects **APPOINT (for estimate)** → **ESTIMATE** stages (stages 3 and 4 of the 19‑stage pipeline). It allows a qualified lead to book a consultation, site visit, or meeting without human intervention, then confirms the booking and schedules reminders.
Glue: This automation connects APPOINT (for estimate) → ESTIMATE stages (stages 3 and 4 of the 19‑stage pipeline). It allows a qualified lead to book a consultation, site visit, or meeting without human intervention, then confirms the booking and schedules reminders.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Lead enters QUALIFIED stage] --> B[Send booking link via SMS/email]
    B --> C[Start 48h booking timer]
    C --> D{Lead clicks booking link?}
    D -->|Yes| E[Calendar integration creates event]
    D -->|No, after 48h| F[Send reminder SMS with link]
    F --> G{Booked within next 24h?}
    G -->|Yes| E
    G -->|No| H[Move to REACTIVATION stage]
    
    E --> I[Store appointment details: time, timezone, duration]
    I --> J[Send confirmation SMS with calendar invite]
    J --> K[Schedule reminders: 24h, 3h, 1h before]
    K --> L[Update CRM stage to APPOINTMENT_BOOKED]
    L --> M[Log booking event to Airtable]
    
    H --> M

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, simple calendar link, no reminders or timezone handling.

Steps:

  1. Add a static Calendly link to the booking SMS/email.
  2. No confirmation message (lead assumes it’s booked).
  3. No reminders (lead may forget).
  4. No no‑show recovery.

Code (GHL workflow – send calendar link):

javascript
// GHL workflow action
const calendarLink = 'https://calendly.com/company/discovery-call';
sendSMS(contact.phone, `Book your consultation here: ${calendarLink}`);
sendEmail(contact.email, `Click here to schedule: ${calendarLink}`);

Downsides:

  • No timezone handling (lead may book for wrong hour).
  • No reminders → high no‑show rate.
  • No tracking of who booked (link is generic).
  • No reschedule or cancellation flow.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • Calendar API: GHL native calendar or Google Calendar API via Make
  • Orchestration: Make (Integromat)
  • CRM: GoHighLevel (GHL)
  • Logging: Airtable
  • Communication: Twilio (for SMS), SendGrid (email fallback)

Step‑by‑step with real code:

1. Generate unique booking link (with pre‑filled fields)

javascript
function generateBookingLink(leadId, serviceType, repId) {
  // Create a unique, time‑bounded token for security
  const token = createJWT({ leadId, exp: Date.now() + 7*24*60*60*1000 });
  // Use GHL’s native booking link with pre‑filled fields
  return `https://app.gohighlevel.com/booking/${repId}?leadId=${leadId}&service=${encodeURIComponent(serviceType)}&token=${token}`;
}

2. Send booking invitation (idempotent)

javascript
async function sendBookingInvite(leadId, phone, email, serviceType, repId) {
  const link = generateBookingLink(leadId, serviceType, repId);
  const sms = `Great news, ${name}! You qualify for a free consultation. Pick a time: ${link} (Booking takes 30 seconds)`;
  const emailSubject = `Book your ${serviceType} consultation`;
  const emailBody = `Click here to schedule: ${link}`;
  
  // Send SMS if consent exists
  const tags = await getContactTags(leadId);
  if (!tags.includes('sms:opt_out')) {
    await sendSMS(phone, sms);
  }
  // Always send email (CAN‑SPAM allows transactional)
  await sendEmail(email, emailSubject, emailBody);
  
  // Idempotency: mark as sent
  await addTag(leadId, 'auto:booking_invite_sent');
}

3. Calendar integration (GHL native with webhook)

GHL native calendar setup:

  • Create a service (e.g., “Patio Cover Consultation – 60 min”)
  • Assign to rep(s)
  • Enable “Allow customers to book” and set buffer times (15 min between appointments)
  • Set reminder settings (24h, 3h, 1h) – GHL can send automatically

Webhook on appointment creation (Make scenario):

javascript
// When an appointment is created in GHL
const webhookPayload = {
  event: 'appointment.created',
  contact_id: payload.contactId,
  appointment_id: payload.appointmentId,
  start_time: payload.startTime,
  end_time: payload.endTime,
  timezone: payload.timezone,
  location: payload.location
};

4. Process booking confirmation

javascript
async function processBooking(webhookPayload) {
  const contactId = webhookPayload.contact_id;
  
  // Update CRM
  await updateCRM(contactId, {
    appointment_time: webhookPayload.start_time,
    appointment_timezone: webhookPayload.timezone,
    appointment_id: webhookPayload.appointment_id
  });
  await changeStage(contactId, 'APPOINTMENT_BOOKED');
  
  // Send confirmation message
  const start = new Date(webhookPayload.start_time);
  const formattedDate = start.toLocaleString('en-US', { 
    timeZone: webhookPayload.timezone,
    weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric'
  });
  const confirmSMS = `Confirmed! Your consultation is on ${formattedDate} ${webhookPayload.timezone}. Location: ${webhookPayload.location}. Add to calendar: [calendar link] To reschedule, reply RESCHEDULE.`;
  await sendSMS(contactPhone, confirmSMS);
  
  // Cancel the 48h booking timer (if still running)
  await cancelTimer(contactId, 'booking_timer');
  
  // Log to Airtable
  await logEvent(contactId, 'appointment_booked', webhookPayload);
}

5. Reminder sequence (triggered by appointment creation)

GHL native reminders (already configured in step 3) will send automatically. However, if you need custom reminders (e.g., SMS with a “confirm” button), implement this:

javascript
async function scheduleCustomReminders(appointmentId, startTime, timezone, phone) {
  const localStart = new Date(startTime);
  const reminderTimes = [24*60*60, 3*60*60, 1*60*60]; // seconds before
  
  for (let secondsBefore of reminderTimes) {
    const reminderTime = new Date(localStart.getTime() - secondsBefore * 1000);
    const now = new Date();
    if (reminderTime > now) {
      // Schedule webhook via Make (or use GHL’s built‑in)
      await scheduleMessage(reminderTime, phone, generateReminderMessage(secondsBefore));
    }
  }
}

6. No‑show detection and recovery

javascript
async function checkForNoShow(appointmentId) {
  const appointment = await getAppointment(appointmentId);
  const endTime = new Date(appointment.end_time);
  const now = new Date();
  const gracePeriod = 30 * 60 * 1000; // 30 minutes
  
  if (now > new Date(endTime.getTime() + gracePeriod) && appointment.status !== 'completed') {
    // No‑show detected
    const contactId = appointment.contact_id;
    await sendSMS(contactPhone, 'We missed you! Click here to reschedule: [reschedule link]');
    await changeStage(contactId, 'REACTIVATION');
    await addTag(contactId, 'no_show');
    await createTask(contactId, 'Follow up with no‑show lead – offer reschedule');
    await logEvent(contactId, 'appointment_no_show', { appointment_id: appointmentId });
  }
}

7. Reschedule and cancellation handling

javascript
// Triggered by lead replying “RESCHEDULE” to confirmation SMS
async function handleReschedule(contactId, currentAppointmentId) {
  // Generate a new booking link with same rep
  const newLink = generateBookingLink(contactId, serviceType, repId);
  await sendSMS(contactPhone, `Reschedule here: ${newLink}`);
  // Cancel old appointment (GHL API)
  await cancelAppointment(currentAppointmentId);
  await addTag(contactId, 'reschedule_requested');
}

async function handleCancellation(contactId, appointmentId) {
  await sendSMS(contactPhone, 'Your appointment has been cancelled. To rebook, reply BOOK.');
  await cancelAppointment(appointmentId);
  await changeStage(contactId, 'QUALIFIED'); // Allow rebooking
  await logEvent(contactId, 'appointment_cancelled');
}

8. Logging to Airtable

javascript
async function logAppointmentEvent(contactId, eventType, details) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/event_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        event_type: eventType, // 'appointment_booked', 'appointment_reminder_sent', 'appointment_no_show', 'appointment_rescheduled'
        contact_id: contactId,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(details)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalBooking requires insurance verification and consent forms before confirming. Confirmation must include HIPAA notice. Reminders must comply with TCPA (opt‑out).Compliance
ConstructionBook site visits with multiple slots (2‑hour windows). Include address and parking instructions. No‑show recovery includes charge for missed visit.Logistics, cost recovery
Real EstateBooking includes agent availability (multiple agents). Confirmation includes lockbox code or showing instructions.Security
Home ImprovementAs shown – simple, with reschedule and no‑show recovery. May include “bring photos of the area.”Standard
SaaSNot applicable (no appointments). Instead, book a demo call – same pattern.N/A
Payment ProcessingBook compliance review call. Must include disclosure that call may be recorded.Legal
Executive AssistantBooking link sent to assistant, not to executive. Assistant confirms before adding to executive’s calendar.Trust, time protection

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually book an appointment on behalf of the lead (if lead prefers phone booking).Lead calls or rep initiates.
NecessaryFor no‑show recovery, rep must follow up at least once; automation only sends link.No‑show detection.
QuestionableFully automated booking with no human override – works for low‑complexity businesses, but for high‑ticket (>$50k), a human should confirm before committing rep time.High‑value leads.

Override Behavior

If a rep manually books an appointment (instead of lead using self‑serve):

ElementBehavior
Tags addedmanual:booking
Stage changeSame as automated booking (APPOINTMENT_BOOKED)
Automation stoppedThe “booking invitation” workflow checks for auto:booking_invite_sent tag; if manual booking occurs before invitation, we add that tag to prevent duplicate invite.
Loggingevent_type = manual_booking, rep_id

Code for manual booking detection in the automated workflow:

javascript
const tags = await getContactTags(leadId);
if (tags.includes('auto:booking_invite_sent') || tags.includes('manual:booking')) {
  console.log('Booking already processed; skipping invitation.');
  return;
}

Decision Guide

When to use this automationWhen NOT to use
>20 qualified leads/monthVery low volume
Reps waste time scheduling appointmentsAppointments are rare or handled by dedicated scheduler
Customers prefer self‑service (younger demographics)Older demographics prefer phone booking
Need to reduce no‑shows with remindersNo‑shows not a problem
Executive assistant – not suitable (assistant should screen)High‑trust, low‑volume relationships

Cost‑benefit:

  • For 100 appointments/month, saves ~20 hours of rep scheduling time.
  • Reduces no‑shows from 30% to 10% (with reminders).
  • Build production‑grade if volume >50 appointments/month.

End of Pattern 5

P06
Multi-Channel Reminders with Confirmation
Group 2 · Scheduling & Reminders · This automation operates within the **APPOINT (for estimate)** stage (stage 3 of the 19‑stage pipeline). It ensures that booked appointments are attended by sending reminders via SMS, email, and (optionally) push notifications, and by requiring confirmation to reduce no‑shows.
Glue: This automation operates within the APPOINT (for estimate) stage (stage 3 of the 19‑stage pipeline). It ensures that booked appointments are attended by sending reminders via SMS, email, and (optionally) push notifications, and by requiring confirmation to reduce no‑shows.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Appointment booked, stage = APPOINTMENT_BOOKED] --> B[Schedule confirmation request: 48h before]
    B --> C{Lead confirms? reply YES}
    C -->|No response after 24h| D[Send reminder SMS + email at 24h]
    D --> E{Lead confirms?}
    E -->|No| F[Send final reminder 3h before]
    F --> G{Lead confirms?}
    G -->|No| H[Send 1h reminder with meeting link]
    H --> I[After appointment time, check if completed]
    I -->|Not completed| J[No‑show: trigger recovery]
    
    C -->|Yes| K[Mark confirmed = true]
    E -->|Yes| K
    G -->|Yes| K
    K --> L[Reduce reminder frequency]
    
    H --> M[Log all reminders to Airtable]
    J --> M
    L --> M

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, simple email reminder only, no confirmation.

Steps:

  1. CRM sends a single email reminder 24 hours before appointment.
  2. No SMS, no confirmation, no escalation.

Code (GHL native reminder – email only):

yaml
# GHL appointment settings
Reminders: 24h before appointment
Channel: Email only
Template: "Reminder: Your appointment is on {date} at {time}."

Downsides:

  • No SMS (lower open rate).
  • No confirmation → higher no‑show rate.
  • No escalation if lead doesn’t attend.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Communication: Twilio (SMS), SendGrid (email)
  • Scheduling: Google Calendar API or GHL native
  • Logging: Airtable
  • Orchestration: Make (Integromat)

Step‑by‑step with real code:

1. Schedule confirmation request (48h before)

javascript
async function scheduleConfirmationRequest(appointmentId, contactId, startTime, timezone, phone, email) {
  const localStart = new Date(startTime);
  const confirmRequestTime = new Date(localStart.getTime() - 48 * 60 * 60 * 1000);
  const now = new Date();
  
  if (confirmRequestTime > now) {
    // Schedule webhook via Make (or use GHL’s delayed actions)
    await scheduleMessage(confirmRequestTime, contactId, 'confirm_request');
  } else {
    // Already less than 48h before, send immediately
    await sendConfirmationRequest(contactId, phone, email, localStart, timezone);
  }
}

async function sendConfirmationRequest(contactId, phone, email, startTime, timezone) {
  const formatted = startTime.toLocaleString('en-US', { timeZone: timezone, weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' });
  const sms = `Reminder: Your consultation is on ${formatted}. Reply YES to confirm or RESCHEDULE.`;
  const emailSubject = `Confirm your consultation – ${formatted}`;
  const emailBody = `Please reply to this email with "YES" to confirm, or click here to reschedule: [reschedule link]`;
  
  if (!hasOptedOutSMS(contactId)) await sendSMS(phone, sms);
  await sendEmail(email, emailSubject, emailBody);
  
  // Start timer: if no confirmation in 24h, send second reminder
  await startTimer(contactId, 'confirm_reminder', 24 * 60 * 60);
}

2. Handle confirmation reply (SMS or email)

javascript
async function handleConfirmation(contactId, replyText) {
  const lower = replyText.toLowerCase();
  if (lower.includes('yes') || lower.includes('confirm') || lower === 'y') {
    await addTag(contactId, 'appointment:confirmed');
    await updateCustomField(contactId, 'appointment_confirmed', true);
    await cancelTimer(contactId, 'confirm_reminder'); // cancel second reminder
    await logEvent(contactId, 'appointment_confirmed');
    // Optionally send thank you
    await sendSMS(contactPhone, 'Thanks for confirming! We look forward to seeing you.');
  } 
  else if (lower.includes('reschedule') || lower.includes('change')) {
    await handleReschedule(contactId);
  }
}

3. Second reminder (24h before, if not confirmed)

javascript
async function sendSecondReminder(contactId, startTime, timezone, phone, email) {
  const tags = await getContactTags(contactId);
  if (tags.includes('appointment:confirmed')) return; // already confirmed
  
  const formatted = startTime.toLocaleString('en-US', { timeZone: timezone });
  const sms = `Reminder: Your consultation is tomorrow at ${formatted}. Reply YES to confirm or RESCHEDULE.`;
  const emailSubject = `Tomorrow: Your consultation at ${formatted}`;
  await sendSMS(phone, sms);
  await sendEmail(email, emailSubject, `Please confirm by replying YES to this email.`);
  await logEvent(contactId, 'reminder_24h_sent');
}

4. Final reminder (3h and 1h before)

javascript
async function sendFinalReminders(contactId, startTime, timezone, phone, email, location) {
  const tags = await getContactTags(contactId);
  const confirmed = tags.includes('appointment:confirmed');
  
  // 3h before
  const threeHourReminder = new Date(startTime.getTime() - 3 * 60 * 60 * 1000);
  await scheduleMessage(threeHourReminder, contactId, async () => {
    const sms = `Reminder: Your appointment is in 3 hours at ${location}. Call us at XXX if you need to reschedule.`;
    await sendSMS(phone, sms);
    await logEvent(contactId, 'reminder_3h_sent');
  });
  
  // 1h before (with meeting link if virtual)
  const oneHourReminder = new Date(startTime.getTime() - 1 * 60 * 60 * 1000);
  await scheduleMessage(oneHourReminder, contactId, async () => {
    const link = location.includes('virtual') ? ` Join here: ${meetingLink}` : '';
    const sms = `Your appointment starts in 1 hour.${link} Reply HELP for assistance.`;
    await sendSMS(phone, sms);
    await sendEmail(email, 'Your appointment starts soon', `Join here: ${meetingLink || 'We will call you.'}`);
    await logEvent(contactId, 'reminder_1h_sent');
  });
}

5. No‑show detection (after appointment time + grace period)

javascript
async function checkNoShow(appointmentId, contactId, endTime) {
  const graceMinutes = 30;
  const checkTime = new Date(endTime.getTime() + graceMinutes * 60 * 1000);
  await scheduleMessage(checkTime, contactId, async () => {
    const status = await getAppointmentStatus(appointmentId);
    if (status !== 'completed' && status !== 'cancelled') {
      // No‑show
      await addTag(contactId, 'no_show');
      await changeStage(contactId, 'REACTIVATION');
      await sendSMS(contactPhone, 'We missed you. Click here to reschedule: [reschedule link]');
      await createTask(contactId, 'No‑show lead – follow up to reschedule');
      await logEvent(contactId, 'appointment_no_show');
    }
  });
}

6. Logging all reminders to Airtable

javascript
async function logReminder(contactId, reminderType, channel, status) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/reminder_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        reminder_type: reminderType, // 'confirmation_request', '24h_reminder', '3h_reminder', '1h_reminder'
        channel: channel, // 'sms', 'email'
        status: status, // 'sent', 'failed', 'confirmed'
        timestamp: new Date().toISOString()
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalReminders must include HIPAA disclaimer and cannot contain appointment reason. Confirmation may require insurance verification.Compliance
ConstructionInclude weather alert and site access instructions. Allow confirmation via phone call (older contractors).Logistics
Real EstateInclude lockbox code (after confirmation) and agent contact. Confirmation required before sending code.Security
Home ImprovementAs shown – simple SMS/email with reschedule link. May include “bring photos.”Standard
SaaSDemo reminders: include calendar invite attachment, meeting link, and preparation tips.Professionalism
Payment ProcessingReminders include compliance disclosure and list of required documents. Confirmation required for compliance.Legal
Executive AssistantReminders sent to assistant, not executive. Assistant confirms on behalf.Trust, time protection

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalLead can reply “HELP” to be connected to a human rep for rescheduling or questions.Any reminder.
NecessaryFor no‑show detection, rep must follow up at least once; automation only sends reschedule link.No‑show.
QuestionableAutomated confirmation for high‑value leads (>$50k) – some businesses prefer a human to call and confirm to build relationship.Lead value > $50k.

Override Behavior

If a rep manually confirms an appointment on behalf of the lead:

ElementBehavior
Tags addedmanual:confirmation, confirmed_by_rep
Stage changeNone (already in APPOINTMENT_BOOKED)
Automation stoppedCancel pending reminder timers for that appointment.
Loggingevent_type = manual_confirmation, rep_id, timestamp.

Code to cancel reminders after manual confirmation:

javascript
await cancelTimer(contactId, 'confirm_reminder');
await cancelTimer(contactId, 'reminder_24h');
await cancelTimer(contactId, 'reminder_3h');
await cancelTimer(contactId, 'reminder_1h');

Decision Guide

When to use this automationWhen NOT to use
Appointment volume >20/monthVery low volume, manual reminder calls fine
No‑show rate >15%No‑shows not a problem
Customers have smartphones (SMS)Customers are elderly or prefer phone calls
Compliance requires consent loggingNo consent tracking needed
Executive assistant – use email onlyExecutive assistant prefers to manage calendar personally

Cost‑benefit:

  • Reduces no‑shows from 25% to 10% → saves rep time (assume 1 hour per no‑show).
  • For 100 appointments/month, saves ~15 hours of wasted rep time.
  • Build production‑grade if no‑show rate >15% or volume >50 appointments/month.

End of Pattern 6

P07
No-Show Detection and Auto-Reschedule
Group 2 · Scheduling & Reminders · This automation operates within the **APPOINT (for estimate)** stage (stage 3 of the 19‑stage pipeline) and connects to **REACTIVATE** (stage 19). It detects when a lead fails to attend a scheduled appointment, automatically triggers a reschedule link, and if the lead remains unresponsive, moves them to reactivation for future follow‑up.
Glue: This automation operates within the APPOINT (for estimate) stage (stage 3 of the 19‑stage pipeline) and connects to REACTIVATE (stage 19). It detects when a lead fails to attend a scheduled appointment, automatically triggers a reschedule link, and if the lead remains unresponsive, moves them to reactivation for future follow‑up.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Appointment end time reached] --> B[Wait 30 minutes grace period]
    B --> C{Appointment marked completed?}
    C -->|Yes| D[End – no action]
    C -->|No| E[Detect no‑show]
    
    E --> F[Send immediate SMS: “We missed you – click to reschedule”]
    F --> G[Generate unique reschedule link same rep, available slots]
    G --> H[Create task for rep: “Follow up with no‑show lead”]
    H --> I[Add tag `no_show` and move stage to `REACTIVATION`]
    I --> J[Log no‑show event to Airtable]
    
    J --> K{Lead reschedules within 24h?}
    K -->|Yes via link| L[Remove `no_show` tag, move back to `APPOINTMENT_BOOKED`]
    L --> M[Send confirmation of new time, cancel old task]
    M --> N[Log reschedule to Airtable]
    
    K -->|No after 24h| O[Escalate: second SMS, reassign task to manager]
    O --> P[After 7 days with no reschedule, mark as lost lead]
    P --> Q[Move to `CLOSED_LOST` with reason = “no_show”]
    Q --> R[Schedule reactivation after 90 days]

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual follow‑up only.

Steps:

  1. At the end of the day, rep reviews which appointments were not completed.
  2. Rep calls or emails the lead to ask if they want to reschedule.
  3. No automation, no tracking of no‑show rate.

Code (none – purely manual):

javascript
// No automation. Rep uses a spreadsheet to track attendance.

Downsides:

  • No immediate reaction – lead may have forgotten and still available.
  • No auto‑reschedule link – relies on rep’s manual coordination.
  • No escalation – leads fall through cracks.
  • No data on no‑show patterns (by day, time, source).

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Orchestration: Make (Integromat)
  • Scheduling: GHL native calendar + custom reschedule link
  • Communication: Twilio (SMS), SendGrid (email)
  • Logging: Airtable

Step‑by‑step with real code:

1. No‑show detection timer

javascript
// When appointment is created, schedule a no‑show check
async function scheduleNoShowCheck(appointmentId, endTime, contactId) {
  const graceMinutes = 30;
  const checkTime = new Date(new Date(endTime).getTime() + graceMinutes * 60 * 1000);
  // Use Make's scheduled webhook or GHL's "Wait" step
  await scheduleWebhook(checkTime, 'no_show_check', { appointmentId, contactId });
}

2. No‑show detection logic

javascript
async function checkNoShow(payload) {
  const { appointmentId, contactId } = payload;
  const appointment = await getAppointment(appointmentId);
  
  // If already marked completed or canceled, exit
  if (appointment.status === 'completed' || appointment.status === 'cancelled') return;
  
  // No‑show confirmed
  await addTag(contactId, 'no_show');
  await changeStage(contactId, 'REACTIVATION');
  
  // Send immediate reschedule SMS
  const rescheduleLink = generateRescheduleLink(contactId, appointment.repId);
  const sms = `We missed you at your ${appointment.serviceType} consultation. Click here to reschedule: ${rescheduleLink}`;
  await sendSMS(contactPhone, sms);
  
  // Create task for rep (one follow‑up)
  await createTask(contactId, `No‑show lead – follow up to reschedule`, appointment.repId, 60);
  
  // Log event
  await logNoShow(contactId, appointmentId);
  
  // Start 24h timer for reschedule window
  await startRescheduleTimer(contactId, 24 * 60 * 60);
}

3. Generate unique reschedule link

javascript
function generateRescheduleLink(contactId, repId) {
  const token = createJWT({ contactId, repId, exp: Date.now() + 7*24*60*60*1000 });
  return `https://app.gohighlevel.com/booking/${repId}?reschedule=true&leadId=${contactId}&token=${token}`;
}

4. Handle reschedule via link

javascript
async function handleReschedule(leadId, newTimeSlot, repId) {
  // Remove no‑show tag
  await removeTag(leadId, 'no_show');
  // Move stage back to APPOINTMENT_BOOKED
  await changeStage(leadId, 'APPOINTMENT_BOOKED');
  // Create new appointment
  const newAppointment = await createAppointment(leadId, repId, newTimeSlot);
  // Cancel the old task
  await cancelTasksForLead(leadId, 'No‑show lead – follow up');
  // Send confirmation
  await sendSMS(contactPhone, `Rescheduled! Your new consultation is on ${newTimeSlot.formatted}.`);
  await logEvent(leadId, 'rescheduled_after_no_show', { old_appointment: oldId, new_appointment: newAppointment.id });
  // Cancel the 24h reschedule timer
  await cancelTimer(leadId, 'reschedule_window');
}

5. Escalation after 24h no reschedule

javascript
async function escalateNoReschedule(contactId) {
  const tags = await getContactTags(contactId);
  if (!tags.includes('no_show')) return; // already rescheduled
  
  // Send second SMS
  await sendSMS(contactPhone, `Last chance to reschedule your consultation: [reschedule link] This offer expires in 7 days.`);
  
  // Reassign task to manager
  await reassignTask(contactId, 'No‑show lead – follow up', 'sales_manager@company.com');
  
  // Start 7‑day timer
  await startTimer(contactId, 'final_no_show_archive', 7 * 24 * 60 * 60);
}

6. Archive after 7 days (mark as lost)

javascript
async function archiveNoShow(contactId) {
  const tags = await getContactTags(contactId);
  if (!tags.includes('no_show')) return;
  
  await changeStage(contactId, 'CLOSED_LOST');
  await updateCustomField(contactId, 'loss_reason', 'no_show');
  
  // Schedule reactivation after 90 days
  const reactivationDate = new Date();
  reactivationDate.setDate(reactivationDate.getDate() + 90);
  await updateCustomField(contactId, 'reactivation_date', reactivationDate.toISOString());
  
  await logEvent(contactId, 'no_show_archived', { reason: 'no_reschedule_within_7d' });
}

7. Logging to Airtable

javascript
async function logNoShow(contactId, appointmentId) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/no_show_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        appointment_id: appointmentId,
        no_show_time: new Date().toISOString(),
        rescheduled: false,
        archived: false
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalNo‑show may incur a fee (automatically invoice). Reschedule link includes fee waiver if rescheduled within 24h.Revenue protection, policy
ConstructionNo‑show triggers a truck roll fee (automatically added to estimate). Site visit may be rescheduled only once before moving to lost.High cost of dispatch
Real EstateNo‑show to a showing triggers agent notification and loss of priority for future showings. Reschedule requires agent approval.Agent time protection
Home ImprovementAs shown – simple reschedule link, one follow‑up, then reactivation.Standard
SaaSNot applicable (appointments are demos). Similar pattern: no‑show to demo → reschedule link → lost lead.N/A
Payment ProcessingNo‑show to compliance call triggers automatic rescheduling with compliance officer. May affect underwriting timeline.Legal
Executive AssistantNo‑show is not possible – assistant confirms executive availability. If executive misses, it’s a reschedule by assistant.Trust

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually reschedule the lead without using the automated link (e.g., by phone).Lead calls to reschedule.
NecessaryAfter 24h without reschedule, manager must review and decide whether to archive or attempt another contact.Escalation.
QuestionableAutomated reschedule for high‑value leads (>$50k) – some businesses prefer a human to call and apologize personally.Lead value > $50k.

Override Behavior

If a rep manually reschedules a no‑show lead (bypassing the auto‑reschedule link):

ElementBehavior
Tags addedmanual:reschedule, manual:no_show_override
Stage changeMove from REACTIVATION back to APPOINTMENT_BOOKED
Automation stoppedCancel pending reschedule timers and tasks.
Loggingevent_type = manual_reschedule, rep_id, old appointment, new appointment.

Code for manual override detection in the auto‑reschedule workflow:

javascript
const tags = await getContactTags(contactId);
if (tags.includes('manual:reschedule') || tags.includes('manual:no_show_override')) {
  console.log('Lead manually rescheduled; skipping auto‑reschedule flow.');
  return;
}

Decision Guide

When to use this automationWhen NOT to use
Volume >20 appointments/monthVery low volume, manual follow‑up fine
No‑show rate >10%No‑shows rare
Appointment type has low trust (e.g., initial consultation)High‑trust relationships (e.g., existing client)
Lead expects self‑service (younger demographic)Older demographic prefers phone call
Executive assistant – not suitable (assistant manages calendar)Executive assistant manually handles reschedules

Cost‑benefit:

  • For 100 appointments/month, no‑show rate 20% → 20 no‑shows.
  • Automated reschedule recovers ~30% of no‑shows → 6 additional appointments.
  • Each recovered appointment worth (close rate × average ticket) – assume 30% × $15k = $4.5k per recovered deal → $27k recovered per month.
  • Build production‑grade if no‑show rate >10% or volume >50 appointments/month.

End of Pattern 7

P08
Document Generation from Template + Dynamic Data
Group 3 · Document & Estimate · This automation connects **ESTIMATE** → **approve or reject est** stages (stages 4 and 5 of the 19‑stage pipeline). It generates professional documents (estimates, proposals, contracts, invoices) by populating a template with lead‑specific data, and delivers them to the customer.
Glue: This automation connects ESTIMATE → approve or reject est stages (stages 4 and 5 of the 19‑stage pipeline). It generates professional documents (estimates, proposals, contracts, invoices) by populating a template with lead‑specific data, and delivers them to the customer.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Trigger: estimate ready to send] --> B{Fetch lead data: name, service, amount, options}
    B --> C[Load template from document store DOCX/PDF/HTML]
    C --> D{Data validation: all required fields present?}
    D -->|No| E[Create task for rep: missing information]
    D -->|Yes| F[Populate template with dynamic fields]
    F --> G[Generate final document PDF]
    G --> H[Save document to cloud storage Google Drive, AWS S3]
    H --> I[Generate unique access link signed URL, short expiry]
    I --> J[Attach link to CRM record `estimate_document_link`]
    J --> K[Send delivery SMS/email to customer]
    K --> L[Log document generation event to Airtable]
    L --> M[Move stage to `ESTIMATE_SENT`]

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual generation using Word/Google Docs mail merge.

Steps:

  1. Rep copies lead data from CRM into a document template (Word or Google Doc).
  2. Saves as PDF, attaches to email, sends manually.
  3. No tracking, no version control, no access link.

Code (Google Docs mail merge – script):

javascript
// Google Apps Script for mail merge (manual trigger)
function generateEstimate() {
  const doc = DocumentApp.openById('TEMPLATE_ID');
  const body = doc.getBody();
  body.replaceText('{{name}}', leadName);
  body.replaceText('{{amount}}', estimateAmount);
  const pdf = doc.getAs('application/pdf');
  DriveApp.createFile(pdf).setName(`Estimate_${leadName}.pdf`);
  // Manual email sending
}

Downsides:

  • Manual step – rep must remember to generate.
  • No access link (email attachment only → large files, spam risk).
  • No tracking (cannot see if customer opened document).
  • No versioning (overwrites template).

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • Document generation: Docmosis (API), Google Docs API, or PDF generation with Puppeteer
  • Storage: Google Drive, AWS S3, or cloud storage with signed URLs
  • CRM: GoHighLevel (GHL)
  • Orchestration: Make (Integromat)
  • Logging: Airtable
  • Template engine: Handlebars (for HTML/PDF) or Docmosis

Step‑by‑step with real code (example using HTML + Puppeteer for PDF, Google Drive for storage):

1. Fetch lead data from CRM

javascript
async function getLeadData(contactId) {
  const response = await fetch(`https://rest.gohighlevel.com/v1/contacts/${contactId}`, {
    headers: { 'Authorization': 'Bearer GHL_API_KEY' }
  });
  const contact = await response.json();
  return {
    name: contact.name,
    phone: contact.phone,
    email: contact.email,
    service_type: contact.customField.service_type,
    estimate_amount: contact.customField.estimate_amount,
    estimate_options: contact.customField.estimate_options || [],
    created_at: contact.createdAt
  };
}

2. Load template (HTML with Handlebars placeholders)

html
<!-- estimate_template.html -->
<html>
<body>
  <h1>Estimate for {{name}}</h1>
  <p>Service: {{service_type}}</p>
  <p>Total: ${{estimate_amount}}</p>
  <ul>
  {{#each estimate_options}}
    <li>{{this}}</li>
  {{/each}}
  </ul>
  <p>Valid until: {{expiry_date}}</p>
</body>
</html>

3. Populate template using Handlebars

javascript
const Handlebars = require('handlebars');
const fs = require('fs');
const puppeteer = require('puppeteer');

async function populateTemplate(leadData) {
  const templateHtml = fs.readFileSync('estimate_template.html', 'utf8');
  const template = Handlebars.compile(templateHtml);
  
  // Add calculated fields
  leadData.expiry_date = new Date();
  leadData.expiry_date.setDate(leadData.expiry_date.getDate() + 30);
  
  const populatedHtml = template(leadData);
  return populatedHtml;
}

4. Generate PDF using Puppeteer

javascript
async function htmlToPdf(html, outputPath) {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: 'networkidle0' });
  await page.pdf({ path: outputPath, format: 'A4' });
  await browser.close();
}

5. Upload to cloud storage and generate signed URL

javascript
const { Storage } = require('@google-cloud/storage');
const storage = new Storage({ keyFilename: 'service-account-key.json' });
const bucket = storage.bucket('estimates-bucket');

async function uploadAndGetSignedUrl(contactId, pdfPath) {
  const filePath = `estimates/${contactId}_${Date.now()}.pdf`;
  await bucket.upload(pdfPath, { destination: filePath });
  
  // Generate signed URL valid for 30 days
  const [url] = await bucket.file(filePath).getSignedUrl({
    version: 'v4',
    action: 'read',
    expires: Date.now() + 30 * 24 * 60 * 60 * 1000
  });
  return url;
}

6. Attach link to CRM record and confirm generation

javascript
async function attachEstimateLink(contactId, documentUrl) {
  await fetch(`https://rest.gohighlevel.com/v1/contacts/${contactId}`, {
    method: 'PUT',
    headers: { 'Authorization': 'Bearer GHL_API_KEY', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      customField: {
        estimate_document_link: documentUrl,
        estimate_sent_at: new Date().toISOString()
      }
    })
  });
  // Change stage to ESTIMATE_SENT (if not already)
  await changeStage(contactId, 'ESTIMATE_SENT');
}

7. Send delivery message to customer

javascript
async function sendEstimateDelivery(contactId, phone, email, documentUrl) {
  const sms = `Your estimate is ready: ${documentUrl}`;
  const emailSubject = `Your estimate for ${service_type} is ready`;
  const emailBody = `Click here to view your estimate: ${documentUrl}. This link expires in 30 days.`;
  
  if (!hasOptedOutSMS(contactId)) await sendSMS(phone, sms);
  await sendEmail(email, emailSubject, emailBody);
}

8. Log to Airtable

javascript
async function logDocumentGeneration(contactId, documentUrl, templateVersion) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/event_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        event_type: 'estimate_generated',
        contact_id: contactId,
        document_url: documentUrl,
        template_version: templateVersion,
        timestamp: new Date().toISOString()
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalMust include HIPAA notice, insurance codes, and disclaimer. Document must be stored in compliant cloud (e.g., AWS with BAA).Compliance
ConstructionInclude AIA contract format, lien waiver, change order history. Document may need to be signed electronically.Legal, industry standard
Real EstateInclude disclosure forms, MLS data, commission breakdown. Must be sent via secure portal (not just email).Legal, security
Home ImprovementAs shown – simple estimate with financing options. May include photo gallery of similar projects.Standard
SaaSQuote includes pricing tiers, feature comparison, SLA terms. Auto‑expiry after 30 days.Sales process
Payment ProcessingInclude fee schedule, compliance disclosures, PCI attestation. Document must be retained for 7 years.Regulatory
Executive AssistantNot applicable – executive does not send mass estimates.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can review the generated document before sending (sandbox mode).Estimate amount > $50k or first time using template.
NecessaryIf template is missing required fields or data validation fails, rep must fill missing data manually.Missing fields.
QuestionableAutomated document generation for executive assistant – volume too low; manual creation acceptable.Executive industry variant.

Override Behavior

If a rep manually generates an estimate outside the automation (e.g., creates PDF manually and attaches):

ElementBehavior
Tags addedmanual:estimate_created
Stage changeRep manually sets stage to ESTIMATE_SENT
Automation stoppedThe automated generation workflow checks for auto:estimate_processed tag; if present, it skips.
Loggingevent_type = manual_estimate_generation, rep_id, document link.

Override detection code:

javascript
const tags = await getContactTags(contactId);
const hasDoc = await getCustomField(contactId, 'estimate_document_link');
if (tags.includes('auto:estimate_processed') || (hasDoc && tags.includes('manual:estimate_created'))) {
  console.log('Estimate already generated manually; skipping automation.');
  return;
}

Decision Guide

When to use this automationWhen NOT to use
Volume >50 estimates/monthVery low volume, manual fine
Estimates are formulaic (few variations)Highly customized, unique per lead
Need tracking (opens, clicks) – see Pattern 9No tracking needed
Compliance requires audit trail of document versionsNo compliance need
Executive assistant – not suitableN/A

Cost‑benefit:

  • For 200 estimates/month, saves ~30 hours of rep time (assume 10 minutes per document).
  • Build production‑grade if volume >100/month or compliance requires audit trail.

End of Pattern 8

P09
Open/Click Tracking with Webhooks
Group 3 · Document & Estimate · This automation operates within the **ESTIMATE** stage (stage 4 of the 19‑stage pipeline). It monitors whether a recipient opens a document or clicks a link, logs these events, and can trigger follow‑up actions based on engagement.
Glue: This automation operates within the ESTIMATE stage (stage 4 of the 19‑stage pipeline). It monitors whether a recipient opens a document or clicks a link, logs these events, and can trigger follow‑up actions based on engagement.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Estimate document sent with tracking pixel and links] --> B[Embed invisible pixel and unique click URLs]
    B --> C[Recipient opens email or clicks link]
    C --> D[Tracking pixel fires OR link redirects via tracking server]
    D --> E[Webhook sends event to orchestration layer]
    E --> F{Event type?}
    F -->|Open| G[Update `estimate_opened = true`, increment `open_count`]
    F -->|Click on link X| H[Determine link type: accept, financing, question]
    H --> I[Trigger specific action: e.g., send financing breakdown]
    G --> J[If open_count > 3 with no action, flag for rep review]
    I --> J
    J --> K[Log event to Airtable]
    K --> L[If engagement signal high, optionally send follow‑up]

Straightforward Implementation (Fast & Fragile)

Use case: No tracking – just send document and hope.

Steps:

  • Email client does not track opens (no pixel).
  • Rep manually asks “Did you receive my estimate?” after 3 days.

Code (none – purely manual).

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • Tracking pixel: Cloudflare Workers or TinyPixel service
  • Link shortener/redirect: Bitly, custom short domain
  • Orchestration: Make (Integromat)
  • CRM: GoHighLevel (GHL)
  • Logging: Airtable

Step‑by‑step with real code (Node.js for tracking server):

1. Generate tracking pixel URL

javascript
function getTrackingPixelUrl(contactId, estimateId) {
  return `https://track.yourdomain.com/pixel?contact=${contactId}&estimate=${estimateId}&type=open`;
}

2. Generate wrapped links for each actionable link

javascript
function generateWrappedLink(contactId, estimateId, destination, action) {
  // destination could be 'accept_estimate', 'financing_link', 'faq', etc.
  return `https://track.yourdomain.com/click?contact=${contactId}&estimate=${estimateId}&action=${action}&to=${encodeURIComponent(destination)}`;
}

3. Tracking server endpoints (Express.js example)

javascript
const express = require('express');
const app = express();

// Tracking pixel (1x1 transparent gif)
app.get('/pixel', async (req, res) => {
  const { contact, estimate, type } = req.query;
  // Asynchronously log without blocking response
  logOpenEvent(contact, estimate, type);
  // Send 1x1 transparent gif
  res.set('Content-Type', 'image/gif');
  res.send(Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'));
});

// Click tracker
app.get('/click', async (req, res) => {
  const { contact, estimate, action, to } = req.query;
  await logClickEvent(contact, estimate, action);
  // Redirect to original destination
  res.redirect(302, decodeURIComponent(to));
});

4. Logging events to Airtable and triggering actions

javascript
async function logOpenEvent(contactId, estimateId, type) {
  // Update CRM: increment open_count, set estimate_opened = true
  await updateGHLContact(contactId, {
    estimate_opened: true,
    estimate_open_count: `increment` // custom logic
  });
  
  // Log to Airtable
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/event_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        event_type: 'estimate_opened',
        contact_id: contactId,
        estimate_id: estimateId,
        timestamp: new Date().toISOString()
      }
    })
  });
  
  // Check if open count > threshold (e.g., 3) and no decision yet
  const openCount = await getOpenCount(contactId);
  if (openCount > 3) {
    const stage = await getContactStage(contactId);
    if (stage === 'ESTIMATE_SENT' || stage === 'DECISION_PENDING') {
      await createTask(contactId, 'Lead opened estimate 3+ times – may need clarification');
    }
  }
}

async function logClickEvent(contactId, estimateId, action) {
  await updateGHLContact(contactId, {
    [`click_${action}`]: true,
    last_click_at: new Date().toISOString()
  });
  
  // Trigger specific action based on action type
  switch(action) {
    case 'accept':
      await autoAcceptEstimate(contactId, estimateId);
      break;
    case 'financing':
      await sendFinancingBreakdown(contactId);
      break;
    case 'question':
      await createTask(contactId, 'Lead clicked question link – follow up');
      break;
  }
  
  // Log to Airtable
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/click_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        estimate_id: estimateId,
        action: action,
        timestamp: new Date().toISOString()
      }
    })
  });
}

5. Integrate with CRM (GHL) – set up webhook from tracking server to Make

javascript
// After logging, send webhook to Make for further processing (e.g., update workflow)
await fetch('https://hook.make.com/your_webhook', {
  method: 'POST',
  body: JSON.stringify({ contactId, estimateId, event: 'opened' })
});

6. Email template embedding

html
<!-- In estimate email -->
<img src="https://track.yourdomain.com/pixel?contact={{contactId}}&estimate={{estimateId}}" width="1" height="1" />
<a href="https://track.yourdomain.com/click?contact={{contactId}}&estimate={{estimateId}}&action=accept&to=https://yourapp.com/accept">Accept Estimate</a>
<a href="https://track.yourdomain.com/click?contact={{contactId}}&estimate={{estimateId}}&action=financing&to=https://lender.com/apply">Financing Options</a>

Industry Variants

IndustryVariantReason
MedicalPixel must not collect PHI; use anonymous ID. Click tracking must not reveal condition.HIPAA
ConstructionTrack clicks on “Change Order” and “Approve” links separately. Log who clicked (subcontractor vs owner).Role‑based actions
Real EstateTrack disclosure acceptance and electronic signature clicks. Log IP addresses for legal audit.Compliance
Home ImprovementAs shown – standard tracking.Standard
SaaSTrack trial activation link clicks; use UTM parameters to attribute signups.Marketing attribution
Payment ProcessingTrack compliance document opens; store proof of disclosure.Regulatory
Executive AssistantNot applicable – executive rarely clicks tracking links; assistant does it.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually mark estimate as opened (if lead tells them).Lead calls or emails.
NecessaryIf open count high and no action, system creates task for rep to call.After 3 opens with no decision.
QuestionableAutomated follow‑up after open – some businesses find it pushy. Test A/B.After first open, immediate follow‑up may annoy.

Override Behavior

If a rep manually marks an estimate as opened (bypassing tracking):

ElementBehavior
Tags addedmanual:estimate_opened
Stage changeNone
Automation stoppedOriginal tracking will still work; override just adds a log.
Loggingevent_type = manual_mark_opened, rep_id.

Decision Guide

When to use this automationWhen NOT to use
Need to know if customer engaged with estimateNo need to track
Volume >100 estimates/monthLow volume, manual follow‑up fine
Want to trigger actions based on clicks (financing, accept)No interactive links
Compliance requires proof of disclosure (medical, finance)No compliance need
Executive assistant – not suitableN/A

Cost‑benefit:

  • For 200 estimates/month, tracking recovers ~15% of lost deals by enabling timely follow‑up.
  • Build production‑grade if close rate is sensitive to follow‑up timing (most home improvement).

End of Pattern 9

P10
Timer-Based Follow-Up Sequence
Group 3 · Document & Estimate · This automation operates within the **ESTIMATE** stage (stage 4 of the 19‑stage pipeline). It sends a series of timed messages after an estimate is delivered, nudging the lead to make a decision (accept, object, or reject). The sequence escalates in urgency and can be cancelled if the lead takes action early.
Glue: This automation operates within the ESTIMATE stage (stage 4 of the 19‑stage pipeline). It sends a series of timed messages after an estimate is delivered, nudging the lead to make a decision (accept, object, or reject). The sequence escalates in urgency and can be cancelled if the lead takes action early.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Estimate sent, stage = ESTIMATE_SENT] --> B[Start decision timer 72 hours]
    B --> C[Send follow‑up 1 hour after estimate]
    C --> D[Send follow‑up 24 hours after estimate]
    D --> E[Send follow‑up 48 hours after estimate]
    E --> F[Send follow‑up 72 hours after estimate]
    F --> G[Move to DECISION_PENDING]
    G --> H[Send final reminder after 5 days]
    H --> I[If no response after 7 days, move to REACTIVATION]
    
    B --> J{Lead action detected? accept or reject or objection}
    J -->|Accept| K[Cancel timer and sequence]
    J -->|Reject| K
    J -->|Objection| L[Pause follow‑up sequence, route to objection handling]
    L --> M[After objection resolved, resume sequence]
    K --> N[Log sequence cancelled]
    M --> O[Reset timer]

Straightforward Implementation (Fast & Fragile)

Use case: Manual follow‑up – rep calls or emails after a few days.

Steps:

  • Rep sets a calendar reminder to follow up in 3 days.
  • Sends a generic “Did you receive my estimate?” email.

Code (none – manual).

Downsides:

  • Rep forgets → lost deal.
  • No timing optimization (generic 3 days may be too late).
  • No escalation.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Orchestration: Make (Integromat)
  • Communication: Twilio (SMS), SendGrid (email)
  • Logging: Airtable

Step‑by‑step with real code (GHL workflows + Make timers):

1. Start decision timer when estimate is sent

javascript
async function startDecisionTimer(contactId, estimateAmount, serviceType) {
  // Store timer start in custom field
  await updateCustomField(contactId, 'decision_timer_start', new Date().toISOString());
  
  // Schedule follow‑up steps using GHL workflow wait steps or Make timers
  const steps = [
    { delayHours: 1, messageType: 'gentle' },
    { delayHours: 24, messageType: 'reminder' },
    { delayHours: 48, messageType: 'urgency' },
    { delayHours: 72, messageType: 'final' }
  ];
  
  for (const step of steps) {
    const triggerTime = new Date(Date.now() + step.delayHours * 60 * 60 * 1000);
    await scheduleMessage(triggerTime, contactId, `follow_up_${step.delayHours}h`, { messageType: step.messageType });
  }
}

2. Send follow‑up messages (template per interval)

javascript
async function sendFollowUp(contactId, intervalHours, messageType) {
  const lead = await getLeadData(contactId);
  const stage = await getContactStage(contactId);
  
  // If lead already moved to CLOSED_WON, CLOSED_LOST, or OBJECTION_HANDLING, cancel sequence
  if (stage !== 'ESTIMATE_SENT' && stage !== 'DECISION_PENDING') {
    console.log(`Lead ${contactId} already moved from ESTIMATE_SENT; cancelling follow‑up.`);
    return;
  }
  
  let message = '';
  switch(messageType) {
    case 'gentle':
      message = `Hi ${lead.name}, did you have a chance to review your ${lead.service_type} estimate? I'm available to answer any questions.`;
      break;
    case 'reminder':
      message = `Just checking in, ${lead.name}. Your estimate is still valid. Would you like to schedule a call to discuss?`;
      break;
    case 'urgency':
      message = `Hi ${lead.name}, our installation schedule is filling up. If you'd like to lock in your date, let me know within 48 hours.`;
      break;
    case 'final':
      message = `Last chance, ${lead.name}. Your estimate expires in 3 days. Reply YES to proceed or let me know if you have questions.`;
      break;
  }
  
  await sendSMS(lead.phone, message);
  await sendEmail(lead.email, `Follow‑up on your ${lead.service_type} estimate`, message);
  
  // Log sent message
  await logFollowUp(contactId, intervalHours, messageType);
  
  // If this was the final message (72h) and no action, move to DECISION_PENDING
  if (messageType === 'final') {
    await changeStage(contactId, 'DECISION_PENDING');
  }
}

3. Cancel sequence on early action (accept, reject, objection)

javascript
async function onEstimateAction(contactId, action) {
  if (action === 'accept' || action === 'reject') {
    // Cancel all pending follow‑ups
    await cancelAllScheduledMessages(contactId);
    await updateCustomField(contactId, 'decision_timer_cancelled_at', new Date().toISOString());
    await logEvent(contactId, 'decision_sequence_cancelled', { reason: action });
  } else if (action === 'objection') {
    // Pause sequence without cancelling
    await pauseScheduledMessages(contactId);
    await updateCustomField(contactId, 'decision_timer_paused', true);
    await logEvent(contactId, 'decision_sequence_paused', { reason: 'objection_detected' });
  }
}

4. Resume sequence after objection resolved

javascript
async function resumeDecisionSequence(contactId) {
  const paused = await getCustomField(contactId, 'decision_timer_paused');
  if (!paused) return;
  
  // Calculate remaining time based on original timer start
  const timerStart = await getCustomField(contactId, 'decision_timer_start');
  const elapsed = (Date.now() - new Date(timerStart)) / (1000 * 60 * 60);
  const remainingSteps = [1, 24, 48, 72].filter(h => h > elapsed);
  
  for (const delayHours of remainingSteps) {
    const triggerTime = new Date(Date.now() + (delayHours - elapsed) * 60 * 60 * 1000);
    await scheduleMessage(triggerTime, contactId, `follow_up_${delayHours}h`);
  }
  
  await updateCustomField(contactId, 'decision_timer_paused', false);
}

5. Escalation after 5 days in DECISION_PENDING

javascript
async function escalateToManager(contactId) {
  const stage = await getContactStage(contactId);
  if (stage !== 'DECISION_PENDING') return;
  
  // Create task for sales manager
  await createTask(contactId, 'Stalled decision – no response for 5 days', 'sales_manager@company.com', 24);
  
  // Send final SMS
  await sendSMS(lead.phone, `We haven't heard from you. Your estimate is still valid. Reply YES or call us at XXX.`);
  
  // Start 7‑day archive timer
  await startTimer(contactId, 'archive_stalled', 7 * 24 * 60 * 60);
}

6. Archive after 7 days (move to reactivation)

javascript
async function archiveStalledLead(contactId) {
  const stage = await getContactStage(contactId);
  if (stage !== 'DECISION_PENDING') return;
  
  await changeStage(contactId, 'REACTIVATION');
  await addTag(contactId, 'stalled_no_response');
  await updateCustomField(contactId, 'loss_reason', 'no_response');
  
  // Schedule reactivation in 60 days
  const reactivationDate = new Date();
  reactivationDate.setDate(reactivationDate.getDate() + 60);
  await updateCustomField(contactId, 'reactivation_date', reactivationDate.toISOString());
  
  await logEvent(contactId, 'stalled_lead_archived');
}

7. Logging to Airtable

javascript
async function logFollowUp(contactId, intervalHours, messageType) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/follow_up_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        interval_hours: intervalHours,
        message_type: messageType,
        sent_at: new Date().toISOString()
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalFollow‑up must respect HIPAA; do not mention condition. Use neutral language like “your treatment plan.”Compliance
ConstructionLonger intervals (7 days instead of 72h) due to longer decision cycles. Include “change order” option.Project complexity
Real EstateFollow‑up includes “open house” or “final offer deadline” urgency. May include agent contact info.Time sensitivity
Home ImprovementAs shown – 1h, 24h, 48h, 72h sequence with financing option in urgency message.Standard
SaaSTrial follow‑up sequence: day 1 welcome, day 3 feature tip, day 7 upgrade, day 14 last chance.Product‑led growth
Payment ProcessingFollow‑up must include compliance reminders and rate lock expiration.Legal
Executive AssistantNot applicable – no estimate follow‑up.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually send a follow‑up outside the sequence (e.g., personalized note).At any time.
NecessaryAfter 5 days in DECISION_PENDING with no response, manager must review before archiving.Escalation step.
QuestionableAutomated follow‑up for high‑value leads (>$100k) – some prefer personal call only.Lead value threshold.

Override Behavior

If a rep manually sends a follow‑up message (outside the sequence):

ElementBehavior
Tags addedmanual:followup_sent
Stage changeNone
Automation stoppedThe automated sequence continues; manual message does not cancel timers. (We could add a rule: if manual message = “accept”, cancel sequence.)
Loggingevent_type = manual_followup, rep_id, message preview.

Optional enhancement: Allow rep to cancel remaining sequence by adding tag manual:stop_sequence.

Decision Guide

When to use this automationWhen NOT to use
Volume >50 estimates/monthVery low volume, manual follow‑up fine
Close rate sensitive to follow‑up timingClose rate unaffected
Need consistent outreach (avoid rep forgetting)Reps are diligent
Customers expect digital communicationCustomers prefer phone only
Executive assistant – not applicableN/A

Cost‑benefit:

  • For 200 estimates/month, automated follow‑up increases close rate by 5–10% (studies show).
  • On $15k average ticket, 10 extra closed deals = $150k revenue.
  • Build production‑grade for any volume >50/month.

End of Pattern 10

P11
Objection Detection and Auto-Response
Group 3 · Document & Estimate · This automation connects **approve or reject est** stage (stage 5) and feeds into **retention** or **reactivation** (stage 18/19). It identifies common objections from customer replies (price, trust, timing, competitor, confusion) and sends appropriate automated responses, escalating to a human when confidence is low or objection persists.
Glue: This automation connects approve or reject est stage (stage 5) and feeds into retention or reactivation (stage 18/19). It identifies common objections from customer replies (price, trust, timing, competitor, confusion) and sends appropriate automated responses, escalating to a human when confidence is low or objection persists.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Lead reply to estimate follow‑up] --> B[Capture message text, lead context]
    B --> C[Classify objection type via AI]
    C --> D{Confidence > 0.8?}
    D -->|Yes| E[Route to predefined response type]
    D -->|No| F[Create task for human review]
    E --> G[price → send financing offer]
    E --> H[trust → send testimonials, license info]
    E --> I[timing → send urgency, availability message]
    E --> J[competitor → send differentiators, guarantee]
    E --> K[confusion → send one‑page summary, offer call]
    E --> L[accept → move to CLOSED_WON]
    E --> M[reject → move to CLOSED_LOST]
    G --> N[Log response and increment attempt counter]
    H --> N
    I --> N
    J --> N
    K --> N
    N --> O{Attempt count > 2?}
    O -->|Yes| P[Escalate to human rep]
    O -->|No| Q[Wait for lead reply]
    F --> P
    P --> R[Create task: call lead, review objection]

Straightforward Implementation (Fast & Fragile)

Use case: Manual objection handling – rep reads reply and responds.

Steps:

  • Rep checks email/SMS, identifies objection, manually replies.
  • No auto‑response, no classification.

Downsides:

  • Slow response (rep may be busy).
  • Inconsistent handling.
  • Missed follow‑up.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • AI classification: OpenAI GPT‑4o‑mini
  • Orchestration: Make (Integromat)
  • CRM: GoHighLevel (GHL)
  • Communication: Twilio, SendGrid
  • Logging: Airtable

Step‑by‑step with real code:

1. Capture lead reply and classify objection

javascript
async function classifyObjection(messageText, estimateAmount, serviceType) {
  const prompt = `
    You are an objection classifier for a home improvement company.
    Classify the customer message into one of:
    - price: concerns about cost, budget, financing
    - trust: concerns about reliability, license, insurance, reviews
    - timing: not ready, need to delay, seasonal
    - competitor: comparing with other companies
    - confusion: unclear about estimate, scope, or next steps
    - accept: positive acceptance (let's do it, yes, proceed)
    - reject: explicit rejection (no thanks, went with someone else)
    - other: none of the above

    Return JSON: {"intent": "...", "confidence": 0.95, "suggested_response_type": "financing"}
  `;
  
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [
      { role: 'system', content: prompt },
      { role: 'user', content: `Message: ${messageText}\nEstimate amount: ${estimateAmount}\nService: ${serviceType}` }
    ],
    temperature: 0,
    max_tokens: 150
  });
  
  return JSON.parse(response.choices[0].message.content);
}

2. Predefined response templates

javascript
const responseTemplates = {
  price: (name, amount) => `We understand, ${name}. Many clients use our financing – payments as low as $${Math.round(amount * 0.02)}/month. Would you like me to send a breakdown?`,
  trust: (name) => `Great question, ${name}. We're licensed Licensed ABC123 and insured. Here are 3 recent projects with reviews: link to reviews`,
  timing: (name) => `No problem, ${name}. Our lead time is currently 3–4 weeks, so starting sooner avoids delays. Want to lock in your date?`,
  competitor: (name) => `We'd love the opportunity, ${name}. Our warranty is 10 years vs. their 5, and we use premium materials. Happy to compare side‑by‑side.`,
  confusion: (name, amount, scope) => `I'll simplify: your estimate covers ${scope}. The price is $${amount}. Next step: accept here: link. Questions? I can call you.`
};

3. Send auto‑response and update CRM

javascript
async function handleObjection(contactId, messageText, estimateAmount, serviceType) {
  const classification = await classifyObjection(messageText, estimateAmount, serviceType);
  const intent = classification.intent;
  const confidence = classification.confidence;
  const lead = await getLeadData(contactId);
  
  if (confidence < 0.7) {
    // Low confidence – escalate to human
    await createTask(contactId, `Low confidence objection classification (${intent}). Review and respond.`);
    await logEvent(contactId, 'objection_low_confidence', { intent, confidence, message: messageText });
    return;
  }
  
  // Handle accept/reject immediately
  if (intent === 'accept') {
    await changeStage(contactId, 'CLOSED_WON');
    await sendSMS(lead.phone, `Great! We'll send over the contract and next steps shortly.`);
    await logEvent(contactId, 'auto_accept', { source: 'objection_handler' });
    return;
  }
  
  if (intent === 'reject') {
    await changeStage(contactId, 'CLOSED_LOST');
    await updateCustomField(contactId, 'loss_reason', 'explicit_reject');
    await logEvent(contactId, 'auto_reject');
    return;
  }
  
  // For objection intents, send auto‑response
  let responseText = '';
  switch(intent) {
    case 'price':
      responseText = responseTemplates.price(lead.name, estimateAmount);
      // Also trigger financing workflow
      await triggerFinancingWorkflow(contactId);
      break;
    case 'trust':
      responseText = responseTemplates.trust(lead.name);
      break;
    case 'timing':
      responseText = responseTemplates.timing(lead.name);
      break;
    case 'competitor':
      responseText = responseTemplates.competitor(lead.name);
      break;
    case 'confusion':
      responseText = responseTemplates.confusion(lead.name, estimateAmount, lead.service_type);
      break;
    default:
      responseText = `Thanks for your message, ${lead.name}. I'll have a specialist reach out shortly.`;
  }
  
  await sendSMS(lead.phone, responseText);
  await sendEmail(lead.email, `Regarding your estimate`, responseText);
  
  // Increment objection attempt counter
  let attemptCount = (await getCustomField(contactId, 'objection_attempt_count')) || 0;
  attemptCount++;
  await updateCustomField(contactId, 'objection_attempt_count', attemptCount);
  await addTag(contactId, `objection:${intent}`);
  
  // If attempt count > 2, escalate to human
  if (attemptCount > 2) {
    await createTask(contactId, `Multiple objections (${attemptCount}) unresolved. Call lead.`);
    await sendSlackAlert(`Lead ${lead.name} has had ${attemptCount} objection attempts.`);
  }
  
  // Log event
  await logEvent(contactId, 'auto_objection_response', { intent, confidence, response: responseText });
}

4. Escalation and fallback

javascript
// If AI classification fails (timeout, error), use keyword fallback
function keywordFallback(messageText) {
  const lower = messageText.toLowerCase();
  if (lower.includes('expensive') || lower.includes('price') || lower.includes('cost')) return 'price';
  if (lower.includes('trust') || lower.includes('license') || lower.includes('reviews')) return 'trust';
  if (lower.includes('later') || lower.includes('not ready') || lower.includes('delay')) return 'timing';
  if (lower.includes('competitor') || lower.includes('other company')) return 'competitor';
  if (lower.includes('confused') || lower.includes('understand') || lower.includes('explain')) return 'confusion';
  if (lower.includes('yes') || lower.includes('accept') || lower.includes('proceed')) return 'accept';
  if (lower.includes('no') || lower.includes('cancel') || lower.includes('unsubscribe')) return 'reject';
  return 'other';
}

Industry Variants

IndustryVariantReason
MedicalObjection: insurance coverage → response includes pre‑auth link. Must comply with HIPAA.Compliance
ConstructionObjection: change order cost → auto‑send breakdown with line items. Include “approve” link.Complex pricing
Real EstateObjection: commission → send comparison chart. Objection: disclosure → send secure portal link.Legal
Home ImprovementAs shown – price leads to financing, trust to testimonials.Standard
SaaSObjection: feature gap → send roadmap link with date. Objection: price → send comparison with competitors.Sales cycle
Payment ProcessingObjection: hidden fees → send fee schedule. Objection: security → send PCI attestation.Compliance
Executive AssistantNot applicable – assistant handles objections manually.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can modify auto‑response before sending (if configured).Low confidence (0.6–0.7).
NecessaryAfter 2 auto‑responses without resolution, human must call.Attempt count >2.
QuestionableAuto‑response for high‑value leads (>$100k) – human call may be more effective.Lead value threshold.

Override Behavior

If a rep manually responds to an objection (bypassing automation):

ElementBehavior
Tags addedmanual:objection_handled
Stage changeNone, but rep may move to next stage.
Automation stoppedThe automated objection handler checks for auto:objection_response_sent tag; if present, it skips. Or we check manual:objection_handled.
Loggingevent_type = manual_objection_response, rep_id, resolution.

Override detection code:

javascript
const tags = await getContactTags(contactId);
if (tags.includes('auto:objection_response_sent') || tags.includes('manual:objection_handled')) {
  console.log('Objection already handled; skipping auto‑response.');
  return;
}

Decision Guide

When to use this automationWhen NOT to use
Volume >100 replies/monthVery low volume
Objections are predictable (price, trust, timing)Highly complex, custom objections
Reps spend >2 hours/day on objection responsesReps have capacity
Need consistent response qualityResponses are highly personalized
Executive assistant – not suitableN/A

Cost‑benefit:

  • For 300 objections/month, saves ~20 hours of rep time.
  • Increases resolution rate (auto‑responses within seconds).
  • Build production‑grade for any volume >50/month.

End of Pattern 11

P12
Multi-Stage Approval Workflow
Group 4 · Approval & Signature · This automation connects **contract** → **initial pay** stages (stages 6 and 7 of the 19‑stage pipeline). It routes documents (contracts, change orders, discount requests) through one or more approvers, tracks each approval step, escalates on delay, and logs the entire audit trail.
Glue: This automation connects contract → initial pay stages (stages 6 and 7 of the 19‑stage pipeline). It routes documents (contracts, change orders, discount requests) through one or more approvers, tracks each approval step, escalates on delay, and logs the entire audit trail.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Document submitted for approval] --> B[Determine approval chain based on amount, type]
    B --> C[Create approval record with step list]
    C --> D[Step 1: Notify approver A via Slack, email]
    D --> E{Approver A approves?}
    E -->|Yes within SLA| F[Move to step 2]
    E -->|No after SLA| G[Escalate to manager, reassign step]
    E -->|Reject| H[Notify submitter, stop workflow]
    F --> I[Step 2: Notify approver B]
    I --> J{Approver B approves?}
    J -->|Yes within SLA| K[All approvals complete]
    J -->|No after SLA| L[Escalate]
    J -->|Reject| H
    K --> M[Update document status to Approved]
    M --> N[Trigger next stage initial pay]
    G --> O[Log escalation to Airtable]
    L --> O
    H --> O
    O --> P[Create task for ops]

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, email‑based approvals without tracking.

Steps:

  • Submitter emails document to approver.
  • Approver replies “approved” or “rejected”.
  • Submitter manually tracks status in spreadsheet.

Code (none – manual).

Downsides:

  • No SLA, no escalation.
  • No audit trail.
  • Approver may forget.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Document storage: Google Drive or DocuSign
  • Approval orchestration: Make (Integromat)
  • Communication: Slack, email, SMS
  • Logging: Airtable

Step‑by‑step with real code:

1. Define approval chain based on document amount and type

javascript
async function getApprovalChain(documentType, amount, submitterId) {
  // Example: contracts over $50k need VP Sales then Legal
  if (documentType === 'contract' && amount > 50000) {
    return [
      { role: 'VP Sales', email: 'vp.sales@company.com', order: 1 },
      { role: 'Legal', email: 'legal@company.com', order: 2 }
    ];
  }
  // Discount over 20% needs Sales Manager only
  if (documentType === 'discount' && amount > 20) {
    return [
      { role: 'Sales Manager', email: 'sales.mgr@company.com', order: 1 }
    ];
  }
  // Default: single approver
  return [
    { role: 'Manager', email: 'manager@company.com', order: 1 }
  ];
}

2. Create approval record and start workflow

javascript
async function startApproval(documentId, documentType, amount, submitterId, documentLink) {
  const chain = await getApprovalChain(documentType, amount, submitterId);
  const approvalId = generateUUID();
  
  // Store approval record in Airtable
  await createApprovalRecord(approvalId, documentId, documentType, amount, chain);
  
  // Process first approver
  await processApproverStep(approvalId, chain[0], 0);
}

async function processApproverStep(approvalId, approver, stepIndex) {
  const stepId = generateUUID();
  const approval = await getApprovalRecord(approvalId);
  
  // Notify approver via Slack and email
  const message = `Please approve document: ${approval.documentLink}. Reply APPROVE or REJECT with reason.`;
  await sendSlackDM(approver.email, message);
  await sendEmail(approver.email, `Approval required for ${approval.documentType}`, message);
  
  // Create record for this step
  await createStepRecord(stepId, approvalId, stepIndex, approver.email, new Date());
  
  // Start SLA timer (e.g., 4 hours for normal, 1 hour for urgent)
  const slaHours = approval.amount > 100000 ? 1 : 4;
  await scheduleStepTimer(stepId, slaHours);
}

3. Handle approver response (webhook or email parse)

javascript
async function handleApproverResponse(stepId, response, reason) {
  const step = await getStepRecord(stepId);
  const approval = await getApprovalRecord(step.approvalId);
  const responseLower = response.toLowerCase().trim();
  
  // Cancel the SLA timer
  await cancelStepTimer(stepId);
  
  if (responseLower === 'approve' || responseLower === 'approved' || responseLower === 'ok') {
    await updateStepRecord(stepId, { status: 'approved', responded_at: new Date(), reason });
    
    const nextStepIndex = step.step_index + 1;
    if (nextStepIndex < approval.approval_chain.length) {
      // Move to next approver
      await processApproverStep(approval.approvalId, approval.approval_chain[nextStepIndex], nextStepIndex);
    } else {
      // All approvals complete
      await completeApproval(approval.approvalId);
    }
  } 
  else if (responseLower === 'reject' || responseLower === 'rejected' || responseLower === 'no') {
    await updateStepRecord(stepId, { status: 'rejected', responded_at: new Date(), reason });
    await rejectApproval(approval.approvalId, step.approver_email, reason);
  }
  else {
    // Invalid response – ask again
    await sendSlackDM(step.approver_email, `Please reply with APPROVE or REJECT. Current response "${response}" not recognized.`);
  }
}

async function completeApproval(approvalId) {
  await updateApprovalRecord(approvalId, { status: 'approved', completed_at: new Date() });
  // Notify submitter
  const approval = await getApprovalRecord(approvalId);
  await sendEmail(approval.submitter_email, `Document approved`, `Your ${approval.documentType} has been approved.`);
  // Trigger next stage (e.g., move to initial pay)
  await triggerNextStage(approval.documentId, 'initial_pay');
}

async function rejectApproval(approvalId, rejectedBy, reason) {
  await updateApprovalRecord(approvalId, { status: 'rejected', rejected_at: new Date(), rejected_by: rejectedBy, reject_reason: reason });
  const approval = await getApprovalRecord(approvalId);
  await sendEmail(approval.submitter_email, `Document rejected`, `${approval.documentType} was rejected by ${rejectedBy}. Reason: ${reason}`);
}

4. Escalation on SLA timeout

javascript
async function escalateStep(stepId) {
  const step = await getStepRecord(stepId);
  if (step.status !== 'pending') return;
  
  const approval = await getApprovalRecord(step.approvalId);
  const escalations = await getStepEscalations(stepId);
  
  if (escalations.length === 0) {
    // First escalation – notify manager of the approver
    const managerEmail = await getManagerEmail(step.approver_email);
    await sendSlackDM(managerEmail, `Urgent: ${step.approver_email} has not approved step. Please follow up.`);
    await createTask(approval.documentId, `Approval step overdue – contact ${step.approver_email}`, managerEmail, 2);
    await logEscalation(stepId, 'first_escalation', managerEmail);
    // Schedule second escalation in 2 hours
    await scheduleStepTimer(stepId, 2);
  } 
  else if (escalations.length === 1) {
    // Second escalation – escalate to department head
    const deptHead = await getDepartmentHead(step.approver_email);
    await sendSMS(deptHead.phone, `CRITICAL: Approval step for ${approval.documentId} overdue. ${step.approver_email} not responding.`);
    await createTask(approval.documentId, `Critical approval overdue – contact department head`, deptHead.email, 1);
    await logEscalation(stepId, 'second_escalation', deptHead.email);
    // No further automatic escalation – becomes manual
  }
}

5. Logging to Airtable

javascript
async function createApprovalRecord(approvalId, documentId, documentType, amount, chain) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/approvals', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        approval_id: approvalId,
        document_id: documentId,
        document_type: documentType,
        amount: amount,
        approval_chain: JSON.stringify(chain),
        status: 'pending',
        created_at: new Date().toISOString()
      }
    })
  });
}

async function logEscalation(stepId, escalationLevel, escalatedTo) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/escalations', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        step_id: stepId,
        escalation_level: escalationLevel,
        escalated_to: escalatedTo,
        timestamp: new Date().toISOString()
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalApproval chain includes compliance officer and IRB for research. SLA often 48 hours.Regulation
ConstructionChange order approval: project manager → client → architect. May need electronic signature at each step.Contractual
Real EstateOffer approval: buyer's agent → seller's agent → seller. Multiple parties may approve simultaneously (not sequential).Process
Home ImprovementDiscount approval: rep → sales manager → owner for >20%. Simple.Standard
SaaSCustom pricing approval: sales → sales manager → finance for >30% discount.Deal desk
Payment ProcessingUnderwriting approval: analyst → compliance officer → risk committee for high‑risk. SLA 5 days.Regulation
Executive AssistantNot applicable – executive approves directly.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalApprover can delegate approval to another person via reply “delegate to X”.Before SLA expires.
NecessaryIf approver rejects, submitter can appeal to a higher authority (manual).Rejection with reason.
QuestionableFully automated approval for low‑risk, low‑value documents – may skip multi‑stage.Amount < $1000, document type = routine.

Override Behavior

If an admin manually approves a document (bypassing the multi‑stage workflow):

ElementBehavior
Tags addedmanual:approval_override, override_by_admin
Stage changeDocument moves directly to approved regardless of chain.
Automation stoppedCancel all pending step timers, mark all pending steps as skipped.
Loggingevent_type = manual_approval_override, override_by, original_chain, reason.

Override detection code:

javascript
if (manualOverride) {
  for (let step of pendingSteps) {
    await updateStepRecord(step.id, { status: 'skipped', skipped_reason: 'manual_override' });
  }
  await completeApproval(approvalId);
}

Decision Guide

When to use this automationWhen NOT to use
Approval volume >20/monthVery low volume
Multiple approvers required (2+)Single approver, simple approval
Compliance requires audit trailNo compliance need
Approvers are frequently unavailableAlways available
Executive assistant – not applicableN/A

Cost‑benefit:

  • Saves ~2 hours per approval (tracking, chasing).
  • Prevents revenue delays (faster approvals → faster payments).
  • Build production‑grade for any multi‑stage approval with >20 documents/month.

End of Pattern 12

P13
E-Signature Collection with Audit Trail
Group 4 · Approval & Signature · This automation connects **final approval** → **contract** stages (stages 6 and 7 of the 19‑stage pipeline). It sends a document for electronic signature, tracks signing status, collects a complete audit trail (IP address, timestamp, consent), and triggers next steps upon completion.
Glue: This automation connects final approval → contract stages (stages 6 and 7 of the 19‑stage pipeline). It sends a document for electronic signature, tracks signing status, collects a complete audit trail (IP address, timestamp, consent), and triggers next steps upon completion.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Document ready for signature] --> B[Create signature envelope via DocuSign or PandaDoc]
    B --> C[Send signature request to customer email and SMS]
    C --> D[Store envelope ID in CRM, start monitoring]
    D --> E{Customer opens email? tracking pixel}
    E -->|Yes| F[Log opened event]
    E -->|No| G[Send reminder after 24h]
    F --> H{Customer clicks sign link}
    H -->|Yes| I[Redirect to signature portal]
    I --> J{Customer signs and completes}
    J -->|Yes| K[Webhook received: signing completed]
    K --> L[Download signed PDF, store in cloud]
    L --> M[Update CRM: contract_signed = true, signed_at timestamp]
    M --> N[Attach signed PDF link to contact record]
    N --> O[Trigger next stage: initial pay]
    G --> P{No signature after 48h}
    P -->|Yes| Q[Escalate to rep for follow‑up]
    Q --> R[Create task: call customer about signature]

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual signature collection.

Steps:

  • Rep emails PDF to customer.
  • Customer prints, signs, scans, emails back.
  • Rep manually saves to folder.

Downsides:

  • No audit trail (who signed, when, from where).
  • No reminders.
  • No integration with CRM.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • E‑signature platform: DocuSign or PandaDoc
  • Orchestration: Make (Integromat)
  • CRM: GoHighLevel (GHL)
  • Storage: AWS S3 or Google Drive
  • Logging: Airtable

Step‑by‑step with real code (DocuSign API and webhooks):

1. Create signature envelope

javascript
async function createSignatureEnvelope(contactId, documentUrl, documentName, signerEmail, signerName) {
  const docusign = require('docusign-esign');
  const apiClient = new docusign.ApiClient();
  apiClient.setBasePath(process.env.DOCUSIGN_BASE_PATH);
  apiClient.addDefaultHeader('Authorization', `Bearer ${getAccessToken()}`);
  
  const envelopesApi = new docusign.EnvelopesApi(apiClient);
  
  const envelopeDefinition = {
    status: 'sent',
    emailSubject: `Please sign your ${documentName}`,
    documents: [{
      documentId: '1',
      name: documentName,
      documentBase64: await getBase64FromUrl(documentUrl)
    }],
    recipients: {
      signers: [{
        email: signerEmail,
        name: signerName,
        recipientId: '1',
        routingOrder: '1',
        tabs: {
          signHereTabs: [{ documentId: '1', pageNumber: '1', xPosition: '100', yPosition: '100' }]
        }
      }]
    }
  };
  
  const results = await envelopesApi.createEnvelope(accountId, { envelopeDefinition });
  const envelopeId = results.envelopeId;
  
  // Store envelopeId in CRM
  await updateCustomField(contactId, 'docusign_envelope_id', envelopeId);
  await updateCustomField(contactId, 'contract_sent_at', new Date().toISOString());
  
  // Log to Airtable
  await logEvent(contactId, 'signature_request_sent', { envelopeId, documentName });
  
  return envelopeId;
}

2. Monitor signature status via webhook

javascript
// Express webhook endpoint for DocuSign events
app.post('/docusign-webhook', async (req, res) => {
  const event = req.body;
  const envelopeId = event.envelopeId;
  const status = event.status; // 'sent', 'delivered', 'completed', 'declined', 'voided'
  
  const contactId = await getContactIdByEnvelopeId(envelopeId);
  
  if (status === 'completed') {
    await handleSignatureCompleted(contactId, envelopeId);
  } else if (status === 'declined') {
    await handleSignatureDeclined(contactId, envelopeId, event.declineReason);
  } else if (status === 'delivered') {
    await logEvent(contactId, 'signature_opened', { envelopeId });
  }
  
  res.send('OK');
});

async function handleSignatureCompleted(contactId, envelopeId) {
  // Download signed PDF
  const docusign = require('docusign-esign');
  const apiClient = new docusign.ApiClient();
  const envelopesApi = new docusign.EnvelopesApi(apiClient);
  const documents = await envelopesApi.listDocuments(accountId, envelopeId);
  const signedDoc = documents.envelopeDocuments.find(doc => doc.type === 'signature');
  const pdfBytes = await envelopesApi.getDocument(accountId, envelopeId, signedDoc.documentId);
  
  // Upload to cloud storage
  const fileName = `contract_${contactId}_${Date.now()}.pdf`;
  const signedUrl = await uploadToS3(pdfBytes, fileName);
  
  // Update CRM
  await updateCustomField(contactId, 'contract_signed_pdf_url', signedUrl);
  await updateCustomField(contactId, 'contract_signed_at', new Date().toISOString());
  await changeStage(contactId, 'CONTRACT_SIGNED');
  
  // Log to Airtable
  await logEvent(contactId, 'signature_completed', { envelopeId, signedUrl });
  
  // Trigger next action (e.g., initial payment)
  await triggerNextStage(contactId, 'initial_pay');
}

async function handleSignatureDeclined(contactId, envelopeId, reason) {
  await updateCustomField(contactId, 'contract_declined_reason', reason);
  await changeStage(contactId, 'CONTRACT_DECLINED');
  await createTask(contactId, `Customer declined to sign contract. Reason: ${reason}. Follow up.`);
  await logEvent(contactId, 'signature_declined', { envelopeId, reason });
}

3. Reminder sequence (if no signature after 24h, 48h)

javascript
async function scheduleSignatureReminders(contactId, envelopeId) {
  // 24h reminder
  await scheduleMessage(new Date(Date.now() + 24*60*60*1000), contactId, async () => {
    const status = await getEnvelopeStatus(envelopeId);
    if (status !== 'completed') {
      await sendSMS(lead.phone, `Reminder: Please sign your contract. Link: ${getSigningLink(envelopeId)}`);
      await sendEmail(lead.email, `Reminder to sign contract`, `Please complete signing here: ${getSigningLink(envelopeId)}`);
      await logEvent(contactId, 'signature_reminder_24h', { envelopeId });
    }
  });
  
  // 48h reminder + escalation to rep
  await scheduleMessage(new Date(Date.now() + 48*60*60*1000), contactId, async () => {
    const status = await getEnvelopeStatus(envelopeId);
    if (status !== 'completed') {
      await createTask(contactId, `Customer has not signed contract after 48h. Follow up.`, lead.assigned_rep, 4);
      await sendSlackAlert(`Contract unsigned for 48h – Lead ${lead.name}`);
      await logEvent(contactId, 'signature_reminder_48h_escalated', { envelopeId });
    }
  });
}

4. Logging to Airtable

javascript
async function logEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/esign_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalMust use HIPAA‑compliant e‑sign (DocuSign for Healthcare). Audit trail must include IP, consent, signed document hash.Compliance
ConstructionUse AIA contract templates with e‑signature fields. May require multiple signers (owner, contractor, architect).Industry standard
Real EstateIntegration with MLS and title company. Signature must be witnessed or notarized for some documents.Legal
Home ImprovementSimple contract with financing terms. Signature can be collected via PandaDoc with payment link embedded.Standard
SaaSClick‑to‑accept (not full signature) for terms of service. No e‑sign required for standard subscriptions.Low friction
Payment ProcessingMust include PCI attestation and disclosure acknowledgment. Audit trail required for 7 years.Regulatory
Executive AssistantNot applicable – executive signs manually via DocuSign, but assistant does not automate.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually send a signature link via email if automation fails.After 24h no delivery.
NecessaryIf customer declines signature, rep must call to understand reason and possibly send revised contract.Signature declined.
QuestionableAutomated signature reminders for high‑value contracts – some prefer personal call.Contract value > $100k.

Override Behavior

If a rep manually uploads a signed PDF (bypassing e‑sign automation):

ElementBehavior
Tags addedmanual:contract_uploaded
Stage changeMove to CONTRACT_SIGNED
Automation stoppedCancel pending signature reminders, ignore webhooks for that envelope.
Loggingevent_type = manual_contract_upload, rep_id, file_url.

Override detection in webhook handler:

javascript
const manualOverride = await getCustomField(contactId, 'manual_contract_uploaded');
if (manualOverride) {
  console.log('Contract manually uploaded; ignoring e‑sign webhook.');
  return;
}

Decision Guide

When to use this automationWhen NOT to use
Volume >20 contracts/monthVery low volume, manual signature OK
Compliance requires audit trailNo compliance need
Multiple signers or remote customersIn‑person signing possible
Need reminders and trackingFine with manual follow‑up
Executive assistant – not applicableN/A

Cost‑benefit:

  • Saves ~30 minutes per document (printing, scanning, filing).
  • Reduces signature turnaround from 3 days to 1 hour.
  • Build production‑grade for any business that sends >20 contracts/month.

End of Pattern 13

P14
Payment Processing with Idempotent Webhooks
Group 5 · Payment & Financing · This automation connects **initial pay** → **FU pay** → **PAY full** stages (stages 7, 8, and 12 of the 19‑stage pipeline). It handles payment capture, receipt generation, and reconciliation, ensuring that duplicate webhooks do not cause double charging and that failed payments are retried or escalated.
Glue: This automation connects initial pay → FU pay → PAY full stages (stages 7, 8, and 12 of the 19‑stage pipeline). It handles payment capture, receipt generation, and reconciliation, ensuring that duplicate webhooks do not cause double charging and that failed payments are retried or escalated.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Customer initiates payment] --> B[Redirect to payment gateway Stripe, Square, Authorize.net]
    B --> C[Gateway processes payment, sends webhook]
    C --> D[Webhook endpoint receives event with idempotency key]
    D --> E{Check if event already processed? lookup key in data store}
    E -->|Already processed| F[Ignore duplicate, log skipped event]
    E -->|New| G[Parse event type payment succeeded, failed, refunded]
    
    G --> H{Event type?}
    H -->|payment_intent.succeeded| I[Update CRM: payment_status = paid, amount, timestamp]
    H -->|payment_intent.payment_failed| J[Increment retry count, send failure notification]
    H -->|charge.refunded| K[Update CRM: payment_status = refunded, log reason]
    
    I --> L[Send receipt to customer email/SMS]
    L --> M[If this is final payment, change stage to FULFILL]
    L --> N[If progress payment, update milestone, schedule next payment]
    
    J --> O{Retry count < 3?}
    O -->|Yes| P[Retry after delay 1 day, 3 days, 7 days]
    O -->|No| Q[Create task for collections team, move stage to PAYMENT_FAILED]
    
    K --> R[Notify customer of refund, update accounting]
    
    M --> S[Log all events to Airtable]
    N --> S
    P --> S
    Q --> S
    R --> S

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual payment reconciliation.

Steps:

  • Send invoice via email with a payment link.
  • Customer pays, rep manually checks bank account and marks as paid in CRM.
  • No webhook, no idempotency, no automatic receipt.

Downsides:

  • Duplicate payments possible if customer clicks twice.
  • No automatic reconciliation → delayed order fulfillment.
  • No retry on failure.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • Payment gateway: Stripe (with idempotency keys)
  • Orchestration: Make (Integromat) or direct webhook endpoint
  • CRM: GoHighLevel (GHL)
  • Data store: Redis or Airtable for idempotency keys
  • Logging: Airtable

Step‑by‑step with real code (Node.js Express webhook endpoint):

1. Stripe webhook endpoint with idempotency key

javascript
const express = require('express');
const app = express();
const redis = require('redis');
const client = redis.createClient();

// Stripe webhook with idempotency header
app.post('/stripe-webhook', express.raw({type: 'application/json'}), async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'] || req.body.idempotency_key;
  if (!idempotencyKey) {
    return res.status(400).send('Missing idempotency key');
  }
  
  // Check if already processed
  const processed = await client.get(idempotencyKey);
  if (processed) {
    console.log(`Duplicate webhook for key ${idempotencyKey}, skipping`);
    return res.status(200).send('OK duplicate');
  }
  
  const sig = req.headers['stripe-signature'];
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    console.error(`Webhook signature verification failed: ${err.message}`);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  
  // Mark idempotency key as processed (TTL 24 hours)
  await client.setex(idempotencyKey, 86400, 'processed');
  
  // Handle event
  await handlePaymentEvent(event);
  
  res.status(200).json({received: true});
});

2. Handle different payment event types

javascript
async function handlePaymentEvent(event) {
  const eventType = event.type;
  
  switch(eventType) {
    case 'payment_intent.succeeded':
      await handlePaymentSuccess(event.data.object);
      break;
    case 'payment_intent.payment_failed':
      await handlePaymentFailure(event.data.object);
      break;
    case 'charge.refunded':
      await handleRefund(event.data.object);
      break;
    default:
      console.log(`Unhandled event type ${eventType}`);
  }
}

async function handlePaymentSuccess(paymentIntent) {
  const contactId = paymentIntent.metadata.contact_id;
  const estimateId = paymentIntent.metadata.estimate_id;
  const amount = paymentIntent.amount / 100; // cents to dollars
  const transactionId = paymentIntent.id;
  
  // Update CRM
  await updateCRM(contactId, {
    payment_status: 'paid',
    payment_amount: amount,
    payment_transaction_id: transactionId,
    payment_received_at: new Date().toISOString()
  });
  
  // Determine payment stage initial or progress or final
  const paymentType = paymentIntent.metadata.payment_type; // deposit, progress_1, final
  await handlePaymentType(contactId, paymentType, amount);
  
  // Send receipt
  await sendReceipt(contactId, amount, transactionId, paymentType);
  
  // Log to Airtable
  await logPaymentEvent(contactId, 'payment_succeeded', { amount, transactionId, paymentType });
}

async function handlePaymentFailure(paymentIntent) {
  const contactId = paymentIntent.metadata.contact_id;
  const failureReason = paymentIntent.last_payment_error?.message || 'Unknown';
  
  let retryCount = await getCustomField(contactId, 'payment_retry_count');
  retryCount = (retryCount || 0) + 1;
  await updateCustomField(contactId, 'payment_retry_count', retryCount);
  
  if (retryCount < 3) {
    // Schedule retry after delay
    const delays = [1, 3, 7]; // days
    const delayDays = delays[retryCount - 1];
    await schedulePaymentRetry(contactId, delayDays);
    await sendSMS(lead.phone, `Your payment failed. We will retry in ${delayDays} day. Update payment method here: link`);
  } else {
    // Max retries exceeded
    await changeStage(contactId, 'PAYMENT_FAILED');
    await createTask(contactId, `Payment failed after ${retryCount} retries. Contact customer.`, 'collections@company.com');
  }
  
  await logPaymentEvent(contactId, 'payment_failed', { failureReason, retryCount });
}

3. Send receipt to customer

javascript
async function sendReceipt(contactId, amount, transactionId, paymentType) {
  const lead = await getLeadData(contactId);
  const receiptMessage = `Receipt: Thank you for your ${paymentType} payment of $${amount}. Transaction ID: ${transactionId}`;
  await sendSMS(lead.phone, receiptMessage);
  await sendEmail(lead.email, `Receipt for your ${paymentType} payment`, receiptMessage);
}

4. Schedule payment retry (using Make scenario or delayed jobs)

javascript
async function schedulePaymentRetry(contactId, delayDays) {
  const triggerTime = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000);
  await scheduleJob(triggerTime, 'retry_payment', { contactId });
}

async function retryPayment(contactId) {
  // Call Stripe API to retry the payment using saved payment method
  const paymentIntentId = await getCustomField(contactId, 'payment_intent_id');
  try {
    const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId);
    await stripe.paymentIntents.update(paymentIntentId, { payment_method: paymentIntent.payment_method });
    // Retry will trigger new webhook
  } catch (err) {
    console.error(`Retry failed: ${err.message}`);
  }
}

5. Handle refunds

javascript
async function handleRefund(charge) {
  const contactId = charge.metadata.contact_id;
  const refundAmount = charge.amount_refunded / 100;
  await updateCRM(contactId, {
    payment_status: 'refunded',
    refund_amount: refundAmount,
    refunded_at: new Date().toISOString()
  });
  await sendSMS(lead.phone, `A refund of $${refundAmount} has been processed.`);
  await logPaymentEvent(contactId, 'payment_refunded', { refundAmount });
}

6. Logging to Airtable

javascript
async function logPaymentEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/payment_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalMust use HIPAA‑compliant payment processor. Store minimal patient info in webhook logs.Compliance
ConstructionProgress payments: invoice percentage complete. Webhook triggers lien waiver generation.Contractual
Real EstateEarnest money deposit handling. Webhook notifies title company.Legal
Home ImprovementAs shown – deposit, progress, final payments. Financing integration often separate.Standard
SaaSSubscription recurring payments with dunning. Webhook handles subscription cancellation on payment failure.Recurring revenue
Payment ProcessingRisk scoring before capture. Webhook may trigger fraud review.Risk management
Executive AssistantNot applicable – executive does not process payments directly.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually mark payment as received via CRM button.Customer pays by check or cash.
NecessaryAfter 3 payment failures, collections team must contact customer.Max retries exhausted.
QuestionableAutomated retry for high‑value payments >$10k – some businesses prefer manual call before retry.Payment amount > $10k.

Override Behavior

If a rep manually marks a payment as successful (bypassing webhook):

ElementBehavior
Tags addedmanual:payment_recorded
Stage changeMove to next stage depending on payment type.
Automation stoppedThe webhook handler checks for manual:payment_recorded tag; if present, it ignores future webhooks for that payment.
Loggingevent_type = manual_payment_entry, rep_id, amount, date.

Override detection in webhook handler:

javascript
const manualRecorded = await getTag(contactId, 'manual:payment_recorded');
if (manualRecorded) {
  console.log(`Payment manually recorded for contact ${contactId}; ignoring webhook.`);
  return;
}

Decision Guide

When to use this automationWhen NOT to use
Volume >50 payments/monthVery low volume, manual reconciliation fine
Need idempotency to prevent double chargePayment gateway provides idempotency natively? Still recommended
Multiple payment stages deposit, progress, finalSingle payment only
Compliance requires audit trail of payment attemptsNo compliance need
Executive assistant – not applicableN/A

Cost‑benefit:

  • Prevents double charging (costly mistakes).
  • Saves hours of manual reconciliation.
  • Build production‑grade for any business accepting online payments.

End of Pattern 14

P15
Dunning Management for Recurring Payments
Group 5 · Payment & Financing · This automation connects **PAY full** → **RETAIN** stages (stages 12 and 18 of the 19‑stage pipeline). It handles failed recurring payments (subscriptions, installment plans, memberships) by notifying the customer, retrying with backoff, updating payment methods, and escalating when retries exhaust.
Glue: This automation connects PAY full → RETAIN stages (stages 12 and 18 of the 19‑stage pipeline). It handles failed recurring payments (subscriptions, installment plans, memberships) by notifying the customer, retrying with backoff, updating payment methods, and escalating when retries exhaust.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Recurring payment attempt fails] --> B[Capture failure reason decline, expired card, insufficient funds]
    B --> C[Notify customer via SMS/email: payment failed]
    C --> D{Retry count < max attempts}
    D -->|Yes| E[Schedule retry with exponential backoff day 1, day 3, day 7]
    E --> F[Customer updates payment method via link]
    F --> G{Payment method updated?}
    G -->|Yes| H[Retry immediately with new method]
    G -->|No| I[Continue scheduled retries]
    H --> J{Retry succeeds?}
    J -->|Yes| K[Mark subscription as active, clear failure flags]
    J -->|No| L[If still failing after max retries, escalate]
    D -->|No after 3 retries| L
    L --> M[Move customer to dunning state]
    M --> N[Create task for collections team]
    N --> O[Send final notice: account will be suspended]
    O --> P{Payment received within grace period}
    P -->|Yes| K
    P -->|No| Q[Cancel subscription, move to REACTIVATION]
    K --> R[Log all events to Airtable]
    Q --> R

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual retry.

Steps:

  • Payment fails; rep receives email notification.
  • Rep manually retries payment or calls customer for new card.
  • No automated sequence, no escalation.

Downsides:

  • Rep may miss failures → lost revenue.
  • No automatic retry.
  • Customer not notified promptly.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • Payment gateway: Stripe (invoices, subscriptions, dunning) or Recurly
  • Orchestration: Make (Integromat) or direct webhook
  • CRM: GoHighLevel (GHL)
  • Communication: Twilio, SendGrid
  • Logging: Airtable

Step‑by‑step with real code (Node.js webhook for Stripe invoice.payment_failed):

1. Webhook handler for payment failure

javascript
app.post('/stripe-webhook', express.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  switch (event.type) {
    case 'invoice.payment_failed':
      await handlePaymentFailure(event.data.object);
      break;
    case 'invoice.payment_succeeded':
      await handlePaymentSuccess(event.data.object);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionCancelled(event.data.object);
      break;
  }

  res.status(200).json({received: true});
});

2. Handle payment failure with retry logic

javascript
async function handlePaymentFailure(invoice) {
  const customerId = invoice.customer;
  const subscriptionId = invoice.subscription;
  const contactId = await getContactIdByStripeCustomerId(customerId);
  const attemptCount = invoice.attempt_count;

  // Log failure
  await logDunningEvent(contactId, 'payment_failed', { attemptCount, invoiceId: invoice.id });

  // Notify customer
  await sendSMS(lead.phone, `Your recurring payment of $${invoice.amount_due/100} failed. Please update payment method: ${getPaymentUpdateLink(customerId)}`);
  await sendEmail(lead.email, `Payment failed for your subscription`, `Please update your payment method here: ${getPaymentUpdateLink(customerId)}`);

  // Retrievable flag indicates payment method might still work after retry
  const canRetry = invoice.attempt_count < 3 && invoice.next_payment_attempt;
  if (canRetry) {
    // Stripe automatically schedules retries with backoff
    await logDunningEvent(contactId, 'retry_scheduled', { nextAttempt: invoice.next_payment_attempt });
  } else {
    // Max retries exceeded or no further attempts
    await escalateToCollections(contactId, customerId, subscriptionId, invoice);
  }
}

3. Handle payment success (recovered)

javascript
async function handlePaymentSuccess(invoice) {
  const customerId = invoice.customer;
  const contactId = await getContactIdByStripeCustomerId(customerId);
  
  // Update CRM: subscription active, last payment date
  await updateCRM(contactId, {
    subscription_status: 'active',
    last_payment_date: new Date().toISOString(),
    payment_failure_count: 0
  });
  
  // Remove any dunning flags
  await removeTag(contactId, 'dunning:payment_failed');
  await removeTag(contactId, 'dunning:escalated');
  
  // Send receipt
  await sendSMS(lead.phone, `Your payment of $${invoice.amount_paid/100} was successfully processed. Thank you.`);
  
  await logDunningEvent(contactId, 'payment_recovered', { invoiceId: invoice.id });
  
  // If there was a collections task, mark it resolved
  await resolveOpenTasks(contactId, 'Collections follow-up');
}

4. Escalation to collections after max retries

javascript
async function escalateToCollections(contactId, stripeCustomerId, subscriptionId, invoice) {
  // Mark subscription as dunning in CRM
  await updateCRM(contactId, {
    subscription_status: 'dunning',
    dunning_started_at: new Date().toISOString(),
    payment_failure_count: invoice.attempt_count
  });
  await addTag(contactId, 'dunning:escalated');
  
  // Create high-priority task for collections team
  await createTask(contactId, `Recurring payment failed after ${invoice.attempt_count} attempts. Contact customer immediately.`, 'collections@company.com', 4);
  
  // Send final notice to customer
  const graceEnd = new Date();
  graceEnd.setDate(graceEnd.getDate() + 7);
  await sendSMS(lead.phone, `Final notice: Your account will be suspended on ${graceEnd.toDateString()} if payment not received. Update payment method: ${getPaymentUpdateLink(stripeCustomerId)}`);
  await sendEmail(lead.email, `Action required: Payment failed`, `Your subscription will be suspended in 7 days. Please update your payment method immediately.`);
  
  // Schedule final suspension if no payment received
  await scheduleJob(graceEnd, 'suspend_subscription', { contactId, stripeCustomerId, subscriptionId });
  
  await logDunningEvent(contactId, 'escalated_to_collections', { graceEnd: graceEnd.toISOString() });
}

5. Suspend subscription after grace period

javascript
async function suspendSubscription(contactId, stripeCustomerId, subscriptionId) {
  // Check if payment was recovered in the meantime
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  if (subscription.status === 'active') return;
  
  // Cancel subscription in Stripe
  await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true });
  
  // Update CRM
  await updateCRM(contactId, {
    subscription_status: 'cancelled',
    cancelled_at: new Date().toISOString(),
    cancellation_reason: 'payment_failure_no_recovery'
  });
  await changeStage(contactId, 'REACTIVATION');
  
  // Send cancellation notice
  await sendSMS(lead.phone, `Your subscription has been cancelled due to non-payment. To reactivate, click here: reactivation link`);
  
  await logDunningEvent(contactId, 'subscription_cancelled', { reason: 'no_payment_after_grace' });
}

6. Handle customer updating payment method

javascript
// When customer clicks payment update link, Stripe hosts a page.
// After update, Stripe sends a `customer.updated` webhook or you can poll.
app.post('/stripe-webhook', async (req, res) => {
  // ... existing code ...
  case 'customer.updated':
    await handleCustomerUpdated(event.data.object);
    break;
});

async function handleCustomerUpdated(customer) {
  const contactId = await getContactIdByStripeCustomerId(customer.id);
  const hasValidPaymentMethod = customer.invoice_settings.default_payment_method || customer.default_source;
  if (hasValidPaymentMethod) {
    // Retry the latest invoice
    const invoices = await stripe.invoices.list({ customer: customer.id, limit: 1, status: 'open' });
    if (invoices.data.length > 0) {
      await stripe.invoices.pay(invoices.data[0].id);
      await logDunningEvent(contactId, 'payment_method_updated_retry_triggered', { invoiceId: invoices.data[0].id });
    }
  }
}

7. Logging to Airtable

javascript
async function logDunningEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/dunning_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalDunning for patient payment plans must follow FDCPA rules for debt collection. Escalation requires compliance officer review.Legal
ConstructionProgress payments rarely fail; handle with lien rights notices.Contractual
Real EstateHOA dues or rent collection: state‑specific notice periods before eviction.Legal
Home ImprovementFinancing payments – lender handles dunning, not installer.Standard
SaaSAs shown – subscription dunning. High‑touch: offer downgrade option before cancellation.Churn reduction
Payment ProcessingMerchant account reserve triggers. Dunning may affect underwriting.Risk
Executive AssistantNot applicable – no recurring payments.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalCollections rep can manually retry a payment or offer a discount.After first failure.
NecessaryAfter 3 retries, escalation to collections team required.Max retries exceeded.
QuestionableAutomated suspension for high‑value subscriptions – some businesses prefer manual call to retain customer.MRR > $10k/month.

Override Behavior

If a collections rep manually marks a subscription as active (bypassing dunning):

ElementBehavior
Tags addedmanual:dunning_override, override_reason
Stage changeMove from PAYMENT_FAILED or REACTIVATION back to PAY_FULL.
Automation stoppedCancel pending suspension jobs; ignore further dunning webhooks for that customer.
Loggingevent_type = manual_dunning_override, rep_id, reason.

Decision Guide

When to use this automationWhen NOT to use
Recurring payments volume >50/monthVery low volume, manual follow‑up fine
High customer churn due to payment failuresChurn already low
Need to preserve revenue from accidental failuresPayments rarely fail
Compliance requires timely collection attemptsNo compliance need
Executive assistant – not applicableN/A

Cost‑benefit:

  • Recovers 30–50% of failed payments (industry average).
  • For 100 subscriptions at $100/mo, recovers $3k–$5k monthly.
  • Build production‑grade for any business with recurring billing.

End of Pattern 15

P16
Financing Application and Approval Workflow
Group 5 · Payment & Financing · This automation connects **approve or reject est** → **initial pay** stages (stages 5 and 7 of the 19‑stage pipeline). It handles customer requests for financing, submits applications to lenders, receives approval/denial webhooks, and updates the CRM accordingly, triggering next steps based on outcome.
Glue: This automation connects approve or reject est → initial pay stages (stages 5 and 7 of the 19‑stage pipeline). It handles customer requests for financing, submits applications to lenders, receives approval/denial webhooks, and updates the CRM accordingly, triggering next steps based on outcome.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Price objection detected or customer requests financing] --> B[Send financing offer SMS/email with application link]
    B --> C[Customer clicks link, fills lender application pre-filled with estimate amount]
    C --> D[Lender processes application]
    D --> E{Webhook received from lender}
    E -->|approved| F[Update CRM: financing_status = approved, approved_amount, terms]
    E -->|denied| G[Update CRM: financing_status = denied, denial_reason]
    E -->|pending more info| H[Create task for rep: request additional docs from customer]
    
    F --> I[Send approval SMS: Your financing is approved! Accept estimate here link]
    I --> J[Reset decision timer, move to DECISION_PENDING or CLOSED_WON if auto-accept]
    J --> K[Notify rep via Slack: Lead approved for financing]
    
    G --> L[Send denial SMS with in‑house payment plan offer]
    L --> M[Create task for rep: offer in‑house plan]
    M --> N[Move back to OBJECTION_HANDLING with price objection]
    
    H --> O[Rep follows up, resubmits application]
    O --> D
    
    K --> P[Log all events to Airtable]
    N --> P

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual financing coordination.

Steps:

  • Rep sends a generic link to a lender website.
  • Customer applies, rep manually checks approval status (calls lender).
  • Rep updates CRM manually.

Downsides:

  • No webhook → delays in updating CRM.
  • No automated follow‑up.
  • No in‑house plan fallback.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • Lender integration: Enhancify, Wisetack, or custom lender API
  • Orchestration: Make (Integromat)
  • CRM: GoHighLevel (GHL)
  • Communication: Twilio, SendGrid
  • Logging: Airtable

Step‑by‑step with real code (Node.js webhook endpoint for lender):

1. Send financing application link

javascript
async function sendFinancingOffer(contactId, estimateAmount, serviceType) {
  const lead = await getLeadData(contactId);
  const applicationLink = generateFinancingLink(contactId, estimateAmount, lead.name, lead.email, lead.phone);
  
  const sms = `Good news! Your $${estimateAmount} ${serviceType} project can be financed starting at $${Math.round(estimateAmount * 0.02)}/month. Apply here: ${applicationLink}`;
  const emailSubject = `Financing options for your ${serviceType}`;
  const emailBody = `Click here to apply for financing: ${applicationLink}. Approval takes 2 minutes.`;
  
  await sendSMS(lead.phone, sms);
  await sendEmail(lead.email, emailSubject, emailBody);
  
  await changeStage(contactId, 'FINANCING_REVIEW');
  await addTag(contactId, 'financing:offered');
  await logEvent(contactId, 'financing_offer_sent', { estimateAmount });
}

2. Generate unique financing link with pre‑filled data

javascript
function generateFinancingLink(contactId, amount, name, email, phone) {
  // Use lender's API to create a pre-filled application
  const payload = {
    amount: amount,
    customer_name: name,
    customer_email: email,
    customer_phone: phone,
    metadata: { contact_id: contactId }
  };
  // Store in your database mapping application_id to contact_id
  const applicationId = generateUUID();
  await storeMapping(applicationId, contactId);
  return `https://lender.com/apply?id=${applicationId}&prefill=true`;
}

3. Lender webhook handler (receives approval/denial)

javascript
app.post('/lender-webhook', express.json(), async (req, res) => {
  const { application_id, status, approved_amount, terms, monthly_payment, denial_reason } = req.body;
  
  // Look up contact_id from your mapping table
  const contactId = await getContactIdByApplicationId(application_id);
  if (!contactId) {
    return res.status(404).send('Contact not found');
  }
  
  // Idempotency: check if already processed
  const processed = await getFinancingProcessed(application_id);
  if (processed) {
    return res.status(200).send('Already processed');
  }
  
  if (status === 'approved') {
    await handleFinancingApproved(contactId, approved_amount, terms, monthly_payment);
  } else if (status === 'denied') {
    await handleFinancingDenied(contactId, denial_reason);
  }
  
  // Mark as processed
  await setFinancingProcessed(application_id);
  res.status(200).send('OK');
});

4. Handle financing approval

javascript
async function handleFinancingApproved(contactId, approvedAmount, terms, monthlyPayment) {
  // Update CRM
  await updateCustomField(contactId, 'financing_status', 'approved');
  await updateCustomField(contactId, 'financing_approved_amount', approvedAmount);
  await updateCustomField(contactId, 'financing_terms', terms);
  await updateCustomField(contactId, 'financing_monthly_payment', monthlyPayment);
  await addTag(contactId, 'financing:approved');
  
  // Send approval notification to customer
  const lead = await getLeadData(contactId);
  const acceptLink = generateAcceptEstimateLink(contactId);
  await sendSMS(lead.phone, `Great news! Your financing is approved. Accept your estimate here: ${acceptLink}`);
  await sendEmail(lead.email, `Financing approved for your ${lead.service_type} project`, `Click here to accept your estimate and proceed: ${acceptLink}`);
  
  // Notify sales rep
  await sendSlackAlert(`🎉 Financing approved for ${lead.name} ($${approvedAmount}). Ready to close.`);
  
  // Move stage to DECISION_PENDING (or directly to CLOSED_WON if auto-accept enabled)
  await changeStage(contactId, 'DECISION_PENDING');
  
  // Reset decision timer (give customer 48h to accept)
  await resetDecisionTimer(contactId, 48);
  
  await logEvent(contactId, 'financing_approved', { approvedAmount, terms });
}

5. Handle financing denial with in‑house fallback

javascript
async function handleFinancingDenied(contactId, denialReason) {
  await updateCustomField(contactId, 'financing_status', 'denied');
  await updateCustomField(contactId, 'financing_denial_reason', denialReason);
  await addTag(contactId, 'financing:denied');
  
  const lead = await getLeadData(contactId);
  
  // Send denial SMS with in‑house plan offer
  const inHouseOffer = `We understand. We offer an in‑house payment plan: 50% deposit, 50% on completion. No credit check. Reply YES to learn more.`;
  await sendSMS(lead.phone, inHouseOffer);
  await sendEmail(lead.email, `Alternative payment plan`, `We have an in‑house plan available. Please reply to this email or call us to discuss.`);
  
  // Create task for rep to follow up
  await createTask(contactId, `Financing denied (${denialReason}). Offer in‑house plan.`, lead.assigned_rep, 4);
  
  // Move back to OBJECTION_HANDLING (price objection unresolved)
  await changeStage(contactId, 'OBJECTION_HANDLING');
  await addTag(contactId, 'objection:price');
  
  await logEvent(contactId, 'financing_denied', { denialReason });
}

6. In‑house plan acceptance (handled by rep or auto‑reply)

javascript
async function handleInHousePlanAcceptance(contactId) {
  // Customer replies "YES" to the in‑house offer SMS
  const lead = await getLeadData(contactId);
  
  // Send payment link for 50% deposit
  const depositLink = generatePaymentLink(contactId, lead.estimate_amount * 0.5, 'deposit');
  await sendSMS(lead.phone, `Great! Pay your 50% deposit here: ${depositLink}. The remaining 50% is due upon completion.`);
  
  // Update CRM
  await updateCustomField(contactId, 'financing_status', 'in_house');
  await changeStage(contactId, 'CLOSED_WON'); // or move to INITIAL_PAY stage
  
  await logEvent(contactId, 'in_house_plan_accepted');
}

7. Logging to Airtable

javascript
async function logEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/financing_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalFinancing for medical procedures: CareCredit integration. Must comply with healthcare lending laws.Compliance
ConstructionEquipment financing for contractors. Lender may require lien waiver before funding.Contractual
Real EstateBridge loans or hard money financing. Longer approval times, manual underwriting.Complexity
Home ImprovementAs shown – Enhancify or Wisetack integration. In‑house plan as fallback.Standard
SaaSNot applicable – annual prepay discounts instead of financing.N/A
Payment ProcessingMerchant cash advance or equipment financing. Requires business financials upload.Risk
Executive AssistantNot applicable – executive does not offer financing.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually submit a financing application on behalf of the customer (if customer prefers phone).Customer calls.
NecessaryIf financing denied and in‑house plan also declined, rep must negotiate alternative (discount, smaller scope).Both options fail.
QuestionableAutomated approval handling for large amounts (>$50k) – some lenders require human review anyway.Amount > $50k.

Override Behavior

If a rep manually marks financing as approved (bypassing lender webhook):

ElementBehavior
Tags addedmanual:financing_override, override_reason
Stage changeMove to DECISION_PENDING or CLOSED_WON.
Automation stoppedThe webhook handler checks for manual:financing_override tag; if present, it ignores subsequent webhooks for that application.
Loggingevent_type = manual_financing_override, rep_id.

Decision Guide

When to use this automationWhen NOT to use
Volume >20 financing requests/monthVery low volume, manual handling fine
Average ticket >$5k (financing critical)Low ticket, financing rarely used
Lender provides webhook APILender requires manual portal access
Need to recover price objectionsPrice objections rare
Executive assistant – not applicableN/A

Cost‑benefit:

  • Increases close rate for price objections by 30–50%.
  • Automates application follow‑up, saving hours per deal.
  • Build production‑grade if >20% of leads ask about financing.

End of Pattern 16

P17
Purchase Order Generation and Vendor Routing
Group 6 · Supply Chain & Delivery · This automation connects **initial pay** → **Order supplies** stages (stages 7 and 8 of the 19‑stage pipeline). It automatically generates purchase orders based on approved estimates or contracts, routes them to the appropriate vendors, tracks acknowledgment, and escalates if vendors do not confirm.
Glue: This automation connects initial pay → Order supplies stages (stages 7 and 8 of the 19‑stage pipeline). It automatically generates purchase orders based on approved estimates or contracts, routes them to the appropriate vendors, tracks acknowledgment, and escalates if vendors do not confirm.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Job approved and deposit received] --> B[Extract materials list from estimate]
    B --> C[Determine vendor for each item based on price, availability]
    C --> D[Generate purchase order PDF]
    D --> E[Send PO to vendor via email or vendor portal]
    E --> F[Store PO copy in cloud storage, link to CRM]
    F --> G[Start acknowledgment timer 24h]
    G --> H{Vendor acknowledges PO?}
    H -->|Yes| I[Update CRM: PO_acknowledged = true]
    H -->|No after 24h| J[Send reminder to vendor]
    J --> K{No acknowledgment after 48h}
    K -->|Yes| L[Escalate to purchasing manager, create task]
    I --> M[Schedule delivery tracking Pattern 18]
    L --> N[Log escalation to Airtable]
    M --> O[Log PO generation event]
    N --> O

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual PO generation.

Steps:

  • Rep manually creates PO in accounting software (QuickBooks).
  • Emails PDF to vendor.
  • Follows up manually if no response.

Downsides:

  • Manual entry errors.
  • No automatic tracking of acknowledgment.
  • No escalation on delays.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Document generation: Docmosis or PDF generation library
  • Vendor communication: Email (SendGrid) or vendor API
  • Storage: AWS S3 or Google Drive
  • Orchestration: Make (Integromat)
  • Logging: Airtable

Step‑by‑step with real code (Node.js):

1. Extract materials list from estimate

javascript
async function getMaterialsList(estimateId) {
  // Assume estimate stored in CRM with line items
  const estimate = await getEstimate(estimateId);
  return estimate.line_items.map(item => ({
    sku: item.sku,
    description: item.description,
    quantity: item.quantity,
    unit_price: item.unit_price,
    preferred_vendor: item.preferred_vendor || getDefaultVendor(item.category)
  }));
}

2. Generate purchase order PDF

javascript
const PDFDocument = require('pdfkit');
const fs = require('fs');

async function generatePurchaseOrder(poData) {
  const doc = new PDFDocument();
  const filename = `po_${poData.po_number}_${Date.now()}.pdf`;
  const writeStream = fs.createWriteStream(filename);
  doc.pipe(writeStream);
  
  doc.fontSize(20).text('PURCHASE ORDER', { align: 'center' });
  doc.moveDown();
  doc.fontSize(12).text(`PO Number: ${poData.po_number}`);
  doc.text(`Date: ${poData.date}`);
  doc.text(`Vendor: ${poData.vendor_name}`);
  doc.text(`Job: ${poData.job_name}`);
  doc.moveDown();
  
  // Table header
  doc.text('SKU', 50, doc.y);
  doc.text('Description', 150, doc.y);
  doc.text('Qty', 350, doc.y);
  doc.text('Unit Price', 450, doc.y);
  doc.text('Total', 550, doc.y);
  doc.moveDown();
  
  // Line items
  poData.line_items.forEach(item => {
    doc.text(item.sku, 50, doc.y);
    doc.text(item.description.substring(0, 30), 150, doc.y);
    doc.text(item.quantity, 350, doc.y);
    doc.text(`$${item.unit_price}`, 450, doc.y);
    doc.text(`$${item.quantity * item.unit_price}`, 550, doc.y);
    doc.moveDown();
  });
  
  doc.end();
  return filename;
}

3. Send PO to vendor and store

javascript
async function sendPOToVendor(poData, vendorEmail, poPdfPath) {
  // Upload PDF to cloud storage
  const s3 = new AWS.S3();
  const s3Key = `pos/${poData.po_number}.pdf`;
  await s3.upload({ Bucket: 'your-bucket', Key: s3Key, Body: fs.createReadStream(poPdfPath) }).promise();
  const poLink = `https://your-bucket.s3.amazonaws.com/${s3Key}`;
  
  // Store link in CRM
  await updateCustomField(poData.contact_id, 'purchase_order_link', poLink);
  
  // Send email to vendor
  const emailBody = `Dear ${poData.vendor_name},\n\nPlease find attached Purchase Order ${poData.po_number}.\n\nClick to view: ${poLink}\n\nPlease acknowledge receipt by replying to this email.\n\nThank you.`;
  await sendEmail(vendorEmail, `Purchase Order ${poData.po_number}`, emailBody);
  
  // Log event
  await logPOEvent(poData.contact_id, 'po_sent', { po_number: poData.po_number, vendor: vendorEmail });
  
  // Start acknowledgment timer
  await scheduleAcknowledgmentTimer(poData.po_number, 24);
}

4. Handle vendor acknowledgment (via email reply or webhook)

javascript
// Email parsing: when vendor replies to PO email, extract acknowledgment
async function handleVendorReply(email) {
  const poNumber = extractPONumber(email.subject);
  if (!poNumber) return;
  
  const isAcknowledged = email.body.toLowerCase().includes('acknowledge') || email.body.toLowerCase().includes('received');
  if (isAcknowledged) {
    await updatePOStatus(poNumber, 'acknowledged');
    await cancelTimer(poNumber, 'ack_timer');
    await logPOEvent(null, 'po_acknowledged', { po_number: poNumber });
  }
}

5. Escalation on no acknowledgment

javascript
async function escalatePO(poNumber) {
  const po = await getPOData(poNumber);
  if (po.status === 'acknowledged') return;
  
  // Second reminder after 24h
  await sendEmail(po.vendor_email, `Reminder: Please acknowledge PO ${poNumber}`, `We have not received acknowledgment. Please respond within 24h.`);
  await scheduleJob(new Date(Date.now() + 24*60*60*1000), 'po_second_escalation', { poNumber });
  await logPOEvent(null, 'po_reminder_sent', { po_number: poNumber });
}

async function secondEscalation(poNumber) {
  const po = await getPOData(poNumber);
  if (po.status === 'acknowledged') return;
  
  // Create task for purchasing manager
  await createTask(po.contact_id, `Vendor ${po.vendor_name} has not acknowledged PO ${poNumber}. Follow up.`, 'purchasing@company.com', 4);
  await sendSlackAlert(`🚨 PO ${poNumber} not acknowledged after 48h. Vendor: ${po.vendor_name}`);
  await logPOEvent(null, 'po_escalated', { po_number: poNumber });
}

6. Logging to Airtable

javascript
async function logPOEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/po_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalPOs for medical supplies must include lot numbers and expiration dates. Compliance: track recalls.Safety
ConstructionPOs for subcontractors require insurance certificates and lien waivers before sending.Legal
Real EstatePOs for repairs to rental properties; vendor must provide W-9 before payment.Accounting
Home ImprovementAs shown – simple PO for materials. May integrate with supplier API (e.g., Home Depot Pro).Standard
SaaSNot applicable – no physical supplies.N/A
Payment ProcessingPOs for hardware terminals; require serial number tracking.Inventory
Executive AssistantNot applicable – executive does not generate POs.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalPurchasing manager can manually edit PO before sending.Before generation.
NecessaryIf vendor does not acknowledge after 48h, purchasing manager must call.Escalation.
QuestionableAutomated vendor routing for new vendors – requires human approval first.First order with vendor.

Override Behavior

If a rep manually creates a PO (bypassing automation):

ElementBehavior
Tags addedmanual:po_created
Stage changeNone.
Automation stoppedThe automated PO generation workflow checks for existing PO link; if present, it skips.
Loggingevent_type = manual_po_creation, rep_id.

Decision Guide

When to use this automationWhen NOT to use
Volume >20 purchase orders/monthVery low volume, manual fine
Multiple vendors with regular itemsSingle vendor, simple process
Need to track acknowledgment and delaysNo time sensitivity
Executive assistant – not applicableN/A

Cost‑benefit:

  • Saves ~15 minutes per PO (data entry, email).
  • Reduces delays by escalating unacknowledged POs.
  • Build production‑grade if >50 POs/month.

End of Pattern 17

P18
Shipment Tracking with Delay Alerts
Group 6 · Supply Chain & Delivery · This automation connects **Order supplies** → **monitor delivery** → **schedule work** stages (stages 8, 9, and 10 of the 19‑stage pipeline). It monitors carrier tracking numbers, detects delays, and proactively notifies customers and internal teams, adjusting installation schedules accordingly.
Glue: This automation connects Order supplies → monitor delivery → schedule work stages (stages 8, 9, and 10 of the 19‑stage pipeline). It monitors carrier tracking numbers, detects delays, and proactively notifies customers and internal teams, adjusting installation schedules accordingly.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Purchase order fulfilled, tracking number received] --> B[Store tracking number and carrier in CRM]
    B --> C[Fetch initial estimated delivery date from carrier API]
    C --> D[Schedule daily tracking check webhook]
    D --> E[Call carrier API for current status]
    E --> F{Status changed?}
    F -->|Delayed| G[Calculate new estimated delivery date]
    F -->|On time| H[Log status, continue monitoring]
    F -->|Delivered| I[Stop monitoring, trigger schedule work]
    G --> J{Delay > 2 days?}
    J -->|Yes| K[Send delay alert SMS to customer]
    J -->|No| L[Log delay, no customer notification]
    K --> M[Create internal task: reschedule installation]
    M --> N[Notify ops team via Slack]
    L --> O[Continue monitoring]
    I --> P[Change stage to DELIVERY_COMPLETE]
    P --> Q[Trigger schedule work workflow]
    O --> D
    N --> D

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual tracking.

Steps:

  • Rep manually checks carrier website daily.
  • If delay, calls customer to reschedule.

Downsides:

  • Rep forgets → missed delays → customer unhappy.
  • No proactive notification.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • Carrier APIs: FedEx, UPS, USPS, or third‑aggregator like Shippo or AfterShip
  • Orchestration: Make (Integromat) scheduled scenarios
  • CRM: GoHighLevel (GHL)
  • Communication: Twilio (SMS), SendGrid (email)
  • Logging: Airtable

Step‑by‑step with real code (Node.js + AfterShip API):

1. Store tracking information and start monitoring

javascript
async function startTracking(contactId, trackingNumber, carrier, purchaseOrderId) {
  // Store in CRM
  await updateCustomField(contactId, 'tracking_number', trackingNumber);
  await updateCustomField(contactId, 'carrier', carrier);
  await updateCustomField(contactId, 'tracking_started_at', new Date().toISOString());
  
  // Fetch initial estimated delivery date from carrier API
  const trackingInfo = await fetchTrackingInfo(trackingNumber, carrier);
  const estimatedDelivery = trackingInfo.estimated_delivery_date;
  await updateCustomField(contactId, 'estimated_delivery_date', estimatedDelivery);
  
  // Schedule daily tracking check (cron job at 8 AM)
  await scheduleDailyTrackingJob(contactId, trackingNumber, carrier);
  
  await logTrackingEvent(contactId, 'tracking_started', { trackingNumber, carrier, estimatedDelivery });
}

2. Fetch tracking info from carrier API (using AfterShip)

javascript
async function fetchTrackingInfo(trackingNumber, carrier) {
  const response = await fetch('https://api.aftership.com/v4/trackings', {
    method: 'POST',
    headers: {
      'aftership-api-key': process.env.AFTERSHIP_API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      tracking: {
        tracking_number: trackingNumber,
        slug: carrier, // fedex, ups, usps
        title: `Order for job ${trackingNumber}`
      }
    })
  });
  const data = await response.json();
  return {
    estimated_delivery_date: data.data.tracking.estimated_delivery,
    current_status: data.data.tracking.tag, // 'Pending', 'InfoReceived', 'InTransit', 'Delivered', 'Exception'
    last_event: data.data.tracking.checkpoints[0]?.message
  };
}

3. Daily tracking check (scheduled job)

javascript
async function checkTrackingStatus(contactId, trackingNumber, carrier) {
  const info = await fetchTrackingInfo(trackingNumber, carrier);
  const previousStatus = await getCustomField(contactId, 'tracking_status');
  const previousEstimate = await getCustomField(contactId, 'estimated_delivery_date');
  
  // Update CRM with latest status
  await updateCustomField(contactId, 'tracking_status', info.current_status);
  await updateCustomField(contactId, 'last_tracking_check', new Date().toISOString());
  await updateCustomField(contactId, 'last_tracking_event', info.last_event);
  
  // Handle delivered
  if (info.current_status === 'Delivered') {
    await handleDeliveryCompleted(contactId);
    return;
  }
  
  // Handle delay: compare estimated delivery date
  if (info.estimated_delivery_date && info.estimated_delivery_date !== previousEstimate) {
    const oldDate = new Date(previousEstimate);
    const newDate = new Date(info.estimated_delivery_date);
    const delayDays = Math.ceil((newDate - oldDate) / (1000 * 60 * 60 * 24));
    
    if (delayDays > 0) {
      await handleDelay(contactId, oldDate, newDate, delayDays);
    }
  }
  
  // Handle exception (lost, damaged, etc.)
  if (info.current_status === 'Exception') {
    await handleTrackingException(contactId, info.last_event);
  }
  
  await logTrackingEvent(contactId, 'tracking_check', { status: info.current_status, estimatedDelivery: info.estimated_delivery_date });
}

4. Handle delivery completion

javascript
async function handleDeliveryCompleted(contactId) {
  await updateCustomField(contactId, 'delivery_completed_at', new Date().toISOString());
  await changeStage(contactId, 'DELIVERY_COMPLETE');
  await addTag(contactId, 'delivery:completed');
  
  // Stop daily tracking jobs (cancel scheduled checks)
  await cancelTrackingJobs(contactId);
  
  // Notify ops that materials are ready
  await sendSlackAlert(`📦 Delivery completed for job ${contactId}. Ready to schedule installation.`);
  
  // Trigger schedule work workflow (Pattern 19)
  await triggerScheduleWorkflow(contactId);
  
  await logTrackingEvent(contactId, 'delivery_completed', {});
}

5. Handle delivery delay

javascript
async function handleDelay(contactId, oldDate, newDate, delayDays) {
  const lead = await getLeadData(contactId);
  
  // Store delay information
  await updateCustomField(contactId, 'delivery_delay_days', delayDays);
  await addTag(contactId, 'delivery:delayed');
  
  // Only notify customer if delay > 2 days (avoid spam for small delays)
  if (delayDays >= 2) {
    await sendSMS(lead.phone, `Update: Your materials delivery has been delayed by ${delayDays} days. New estimated arrival: ${newDate.toDateString()}. We will reschedule your installation accordingly.`);
    await sendEmail(lead.email, `Delivery delay for your project`, `The estimated delivery date for your materials has changed to ${newDate.toDateString()}. We will notify you when installation is rescheduled.`);
  }
  
  // Create task for ops to reschedule installation
  await createTask(contactId, `Delivery delayed by ${delayDays} days. Reschedule installation.`, 'ops@company.com', 24);
  
  // Notify ops via Slack
  await sendSlackAlert(`⚠️ Delivery delay for job ${contactId}: ${delayDays} days. New ETA: ${newDate.toDateString()}`);
  
  await logTrackingEvent(contactId, 'delivery_delay_detected', { oldDate, newDate, delayDays });
}

6. Handle tracking exception (lost, damaged, etc.)

javascript
async function handleTrackingException(contactId, exceptionMessage) {
  const lead = await getLeadData(contactId);
  
  await updateCustomField(contactId, 'tracking_exception', exceptionMessage);
  await addTag(contactId, 'delivery:exception');
  
  // Immediately notify customer
  await sendSMS(lead.phone, `Urgent: There's an issue with your materials delivery: ${exceptionMessage.substring(0, 100)}. We are investigating and will update you.`);
  
  // Create high-priority task for purchasing manager
  await createTask(contactId, `Tracking exception: ${exceptionMessage}. Contact carrier immediately.`, 'purchasing@company.com', 2);
  
  // Send Slack alert to ops channel
  await sendSlackAlert(`🚨 Tracking exception for job ${contactId}: ${exceptionMessage}`);
  
  await logTrackingEvent(contactId, 'tracking_exception', { exceptionMessage });
}

7. Logging to Airtable

javascript
async function logTrackingEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/tracking_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalTrack medical supplies (implants, devices) with lot numbers. Delay alerts trigger patient notification.Safety, compliance
ConstructionTrack critical path materials (steel, concrete). Delay alerts automatically adjust subcontractor schedules.Logistics
Real EstateTrack appliances or fixtures for rental renovations. Delay may affect tenant move‑in date.Contractual
Home ImprovementAs shown – delay alerts to customer and ops.Standard
SaaSNot applicable.N/A
Payment ProcessingTrack hardware terminals; delay alerts to merchant.Customer experience
Executive AssistantNot applicable.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalOps can manually update tracking status if carrier API is down.API error.
NecessaryTracking exception (lost/damaged) requires purchasing manager to contact carrier.Exception detected.
QuestionableAutomated customer notification for small delays (<2 days) – some prefer no notification to avoid worry.Delay <2 days.

Override Behavior

If an ops rep manually marks delivery as completed (bypassing carrier API):

ElementBehavior
Tags addedmanual:delivery_complete
Stage changeMove to DELIVERY_COMPLETE.
Automation stoppedCancel daily tracking jobs for that contact.
Loggingevent_type = manual_delivery_complete, rep_id.

Decision Guide

When to use this automationWhen NOT to use
Volume >20 shipments/month with trackingVery low volume, manual tracking fine
Delays cause significant customer dissatisfactionDelays rare
Need to reschedule installation proactivelyNo downstream scheduling dependencies
Executive assistant – not applicableN/A

Cost‑benefit:

  • Saves ops team hours of manual tracking.
  • Improves customer satisfaction by proactive notifications.
  • Build production‑grade if >50 shipments/month.

End of Pattern 18

P19
Resource-Aware Scheduling (Crews, Rooms, Equipment)
Group 7 · Job / Work Scheduling · This automation connects **monitor delivery** → **schedule work/site** stages (stages 9 and 10 of the 19‑stage pipeline). It assigns available resources (crews, technicians, rooms, equipment) to a job based on skill requirements, location, and availability, then books the appointment and notifies all parties.
Glue: This automation connects monitor delivery → schedule work/site stages (stages 9 and 10 of the 19‑stage pipeline). It assigns available resources (crews, technicians, rooms, equipment) to a job based on skill requirements, location, and availability, then books the appointment and notifies all parties.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Delivery complete, ready to schedule] --> B[Extract resource requirements from estimate service type, size, complexity]
    B --> C[Query resource availability calendar for matching skills]
    C --> D{Available resources found?}
    D -->|No| E[Create task for ops manager: find alternative resource]
    D -->|Yes| F[Select best match proximity, skill match, workload balance]
    F --> G[Propose time slots to customer via SMS link]
    G --> H{Customer selects time within 48h?}
    H -->|Yes| I[Book resource, confirm with calendar invite]
    H -->|No| J[Send reminder, auto-select best available after 48h]
    I --> K[Send confirmation to customer with resource name, arrival window]
    K --> L[Send notification to resource crew lead with job details]
    L --> M[Update CRM stage to WORK_SCHEDULED]
    M --> N[Log scheduling event to Airtable]
    J --> I
    E --> N

Straightforward Implementation (Fast & Fragile)

Use case: Manual scheduling by ops team.

Steps:

  • Ops manager checks crew availability on a spreadsheet.
  • Calls or emails customer to find a time.
  • Manually updates CRM.

Downsides:

  • Slow, error‑prone.
  • No automatic conflict detection.
  • No customer self‑service.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • Calendar/Resource management: Google Calendar API, Microsoft Bookings, or dedicated resource scheduling tool (e.g., Resource Guru)
  • Orchestration: Make (Integromat)
  • CRM: GoHighLevel (GHL)
  • Communication: Twilio, SendGrid
  • Logging: Airtable

Step‑by‑step with real code (Node.js + Google Calendar API for resource calendars):

1. Define resource requirements based on job type

javascript
const resourceRequirements = {
  'patio_cover': { skill: 'carpentry', crew_size: 2, equipment: ['truck', 'saw', 'drill'] },
  'roofing': { skill: 'roofing', crew_size: 3, equipment: ['ladder', 'nail_gun'] },
  'electrical': { skill: 'electrician', crew_size: 1, equipment: ['van'] }
};

async function getResourceRequirements(serviceType) {
  return resourceRequirements[serviceType] || resourceRequirements.default;
}

2. Query resource availability from Google Calendar

javascript
const { google } = require('googleapis');
const calendar = google.calendar({ version: 'v3', auth: new google.auth.JWT(...) });

async function findAvailableResources(requiredSkill, startDate, endDate, durationHours) {
  // List all resources (crews) from a dedicated calendar or CRM custom field
  const resources = await getResourceList(); // [{ id, name, skill, calendarId }]
  const available = [];
  
  for (const resource of resources) {
    if (resource.skill !== requiredSkill) continue;
    
    // Check calendar for free/busy
    const freeBusy = await calendar.freebusy.query({
      requestBody: {
        timeMin: startDate.toISOString(),
        timeMax: endDate.toISOString(),
        items: [{ id: resource.calendarId }]
      }
    });
    
    const busySlots = freeBusy.data.calendars[resource.calendarId].busy;
    if (busySlots.length === 0) {
      // Resource fully available in the window
      available.push(resource);
    } else {
      // Check if there is a contiguous free block of durationHours
      // For simplicity, assume fixed slots; real implementation would compute gaps
    }
  }
  
  return available;
}

3. Propose time slots to customer

javascript
async function proposeTimeSlots(contactId, availableResources, jobDurationHours) {
  const lead = await getLeadData(contactId);
  // Generate time slots for next 7 days, 9 AM to 5 PM
  const slots = [];
  const startDate = new Date();
  for (let i = 0; i < 7; i++) {
    let slotDate = new Date(startDate);
    slotDate.setDate(startDate.getDate() + i);
    // Skip weekends if needed
    if (slotDate.getDay() === 0 || slotDate.getDay() === 6) continue;
    for (let hour = 9; hour <= 16; hour++) {
      const slotStart = new Date(slotDate);
      slotStart.setHours(hour, 0, 0);
      const slotEnd = new Date(slotStart);
      slotEnd.setHours(slotStart.getHours() + jobDurationHours);
      slots.push({
        start: slotStart,
        end: slotEnd,
        resource: availableResources[0] // simplified: pick first
      });
    }
  }
  
  // Send SMS with booking link pre‑filled with slots
  const bookingLink = generateBookingLink(contactId, slots);
  await sendSMS(lead.phone, `Your materials are ready! Choose an installation time: ${bookingLink}`);
}

4. Handle customer selection and book resource

javascript
async function bookResource(contactId, selectedSlot, resourceId) {
  const lead = await getLeadData(contactId);
  
  // Add event to resource calendar
  const event = {
    summary: `Installation for ${lead.name} - ${lead.service_type}`,
    description: `Address: ${lead.address}, Phone: ${lead.phone}`,
    start: { dateTime: selectedSlot.start.toISOString(), timeZone: 'America/New_York' },
    end: { dateTime: selectedSlot.end.toISOString(), timeZone: 'America/New_York' },
    attendees: [{ email: lead.email }]
  };
  await calendar.events.insert({ calendarId: resourceId, requestBody: event });
  
  // Update CRM
  await updateCustomField(contactId, 'scheduled_resource', resourceId);
  await updateCustomField(contactId, 'scheduled_start', selectedSlot.start.toISOString());
  await updateCustomField(contactId, 'scheduled_end', selectedSlot.end.toISOString());
  await changeStage(contactId, 'WORK_SCHEDULED');
  
  // Notify customer
  const startFormatted = selectedSlot.start.toLocaleString();
  await sendSMS(lead.phone, `Installation scheduled for ${startFormatted}. Your crew will be Crew ${resourceId}.`);
  await sendEmail(lead.email, `Installation scheduled`, `Date: ${startFormatted}\nCrew: Crew ${resourceId}\nAddress: ${lead.address}`);
  
  // Notify crew (via SMS/email)
  const crewLead = await getCrewLead(resourceId);
  await sendSMS(crewLead.phone, `New job scheduled for ${startFormatted}: ${lead.name}, ${lead.address}, ${lead.service_type}.`);
  await sendEmail(crewLead.email, `New installation job`, `Details: ${JSON.stringify(lead)}`);
  
  // Log
  await logSchedulingEvent(contactId, 'work_scheduled', { resourceId, start: selectedSlot.start });
}

5. Auto‑select best available after 48h timeout

javascript
async function autoSelectBestSlot(contactId) {
  const lead = await getLeadData(contactId);
  const bestSlot = await getBestAvailableSlot(contactId); // picks earliest
  if (bestSlot) {
    await bookResource(contactId, bestSlot.slot, bestSlot.resourceId);
    await sendSMS(lead.phone, `We've automatically scheduled your installation for ${bestSlot.start.toLocaleString()} to avoid delays. Reply RESCHEDULE if needed.`);
  }
}

6. Load balancing across resources

javascript
async function selectBestResource(availableResources) {
  // Prefer resource with fewest upcoming jobs
  const workload = {};
  for (const resource of availableResources) {
    const events = await calendar.events.list({
      calendarId: resource.calendarId,
      timeMin: new Date().toISOString(),
      maxResults: 50
    });
    workload[resource.id] = events.data.items.length;
  }
  availableResources.sort((a,b) => workload[a.id] - workload[b.id]);
  return availableResources[0];
}

7. Logging to Airtable

javascript
async function logSchedulingEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/scheduling_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalSchedule operating rooms, anesthesiologists, nurses. Must respect patient privacy.Safety
ConstructionSchedule subcontractors (electricians, plumbers). Requires sequencing (framing before drywall).Dependencies
Real EstateSchedule cleaners, handymen, photographers for rental turnover.Logistics
Home ImprovementAs shown – simple crew scheduling.Standard
SaaSNot applicable.N/A
Payment ProcessingSchedule technician for terminal installation.Field service
Executive AssistantNot applicable – executive schedules own meetings.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalOps can manually override resource assignment.Before confirmation.
NecessaryIf no resource available, ops manager must find alternative (overtime, subcontractor).No availability.
QuestionableAutomated load balancing for premium resources (e.g., lead technician) – may require human judgment.Resource seniority.

Override Behavior

If ops manually schedules a job (bypassing automation):

ElementBehavior
Tags addedmanual:work_scheduled
Stage changeMove to WORK_SCHEDULED.
Automation stoppedCancel pending auto‑selection timers.
Loggingevent_type = manual_scheduling, rep_id.

Decision Guide

When to use this automationWhen NOT to use
Volume >20 jobs/monthVery low volume
Multiple resources with overlapping skillsSingle dedicated crew
Need to balance workloadWorkload trivial
Executive assistant – not applicableN/A

Cost‑benefit:

  • Saves ops team hours of manual scheduling.
  • Reduces scheduling conflicts and delays.
  • Build production‑grade if >50 jobs/month.

End of Pattern 19

P20
Multi-Phase Milestone Tracking and Notifications
Group 7 · Job / Work Scheduling · This automation connects **schedule work/site** → **job finished** stages (stages 10 through 13 of the 19‑stage pipeline). It tracks progress across multiple phases of a project (e.g., foundation, framing, roofing, finishing), sends automated notifications to the customer and internal teams when milestones are reached, and escalates if phases fall behind schedule.
Glue: This automation connects schedule work/site → job finished stages (stages 10 through 13 of the 19‑stage pipeline). It tracks progress across multiple phases of a project (e.g., foundation, framing, roofing, finishing), sends automated notifications to the customer and internal teams when milestones are reached, and escalates if phases fall behind schedule.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Job scheduled, stage = WORK_SCHEDULED] --> B[Load project template with predefined phases and durations]
    B --> C[Create milestone records in CRM]
    C --> D[Phase 1 start date reached]
    D --> E[Send start notification to crew and customer]
    E --> F[Crew marks phase as complete in mobile app or SMS]
    F --> G{Phase completed on time?}
    G -->|Yes| H[Log completion, update phase status]
    G -->|No after SLA| I[Escalate to project manager]
    H --> J{More phases remaining?}
    J -->|Yes| K[Schedule next phase start notification]
    J -->|No| L[All phases complete, move to job finished]
    I --> M[Send delay alert to customer, create task for ops]
    M --> K
    K --> D
    L --> N[Trigger final inspection and sign‑off workflow]

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual milestone tracking.

Steps:

  • Project manager tracks phases in a spreadsheet.
  • Calls customer at each phase completion.

Downsides:

  • No automated reminders.
  • Missed milestones → delays.
  • Customer uninformed.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL) – custom fields for milestones
  • Mobile interface: GHL mobile app or SMS commands for crew
  • Orchestration: Make (Integromat)
  • Communication: Twilio (SMS), SendGrid (email)
  • Logging: Airtable

Step‑by‑step with real code (Node.js):

1. Define project template with phases

javascript
const projectTemplates = {
  'patio_cover': {
    phases: [
      { name: 'Site preparation', durationDays: 1, sequence: 1 },
      { name: 'Foundation pouring', durationDays: 2, sequence: 2 },
      { name: 'Framing', durationDays: 3, sequence: 3 },
      { name: 'Roof installation', durationDays: 2, sequence: 4 },
      { name: 'Finishing', durationDays: 2, sequence: 5 }
    ]
  },
  'roofing': {
    phases: [
      { name: 'Old roof removal', durationDays: 1, sequence: 1 },
      { name: 'Underlayment installation', durationDays: 1, sequence: 2 },
      { name: 'New shingles', durationDays: 2, sequence: 3 },
      { name: 'Cleanup and inspection', durationDays: 1, sequence: 4 }
    ]
  }
};

async function getProjectPhases(serviceType) {
  return projectTemplates[serviceType] || projectTemplates.default;
}

2. Create milestone records when job is scheduled

javascript
async function initializeMilestones(contactId, serviceType, startDate) {
  const template = await getProjectPhases(serviceType);
  const milestones = [];
  let currentDate = new Date(startDate);
  
  for (const phase of template.phases) {
    const milestone = {
      contact_id: contactId,
      phase_name: phase.name,
      sequence: phase.sequence,
      scheduled_start: currentDate.toISOString(),
      scheduled_end: new Date(currentDate.getTime() + phase.durationDays * 24*60*60*1000).toISOString(),
      status: 'pending',
      completed_at: null
    };
    milestones.push(milestone);
    await createMilestoneRecord(milestone);
    // Advance date for next phase
    currentDate = new Date(currentDate.getTime() + phase.durationDays * 24*60*60*1000);
  }
  
  // Log initialization
  await logMilestoneEvent(contactId, 'milestones_initialized', { phases: template.phases.length });
  
  // Notify customer of project timeline
  const lead = await getLeadData(contactId);
  const timelineSummary = milestones.map(m => `${m.phase_name}: ${new Date(m.scheduled_start).toLocaleDateString()} - ${new Date(m.scheduled_end).toLocaleDateString()}`).join('\n');
  await sendEmail(lead.email, `Your project timeline for ${serviceType}`, `Estimated phases:\n${timelineSummary}\nWe will notify you at each step.`);
}

3. Phase start notification (scheduled job)

javascript
async function notifyPhaseStart(contactId, phaseName, scheduledStartDate) {
  const lead = await getLeadData(contactId);
  const startFormatted = new Date(scheduledStartDate).toLocaleDateString();
  
  // Notify crew
  const crewLead = await getAssignedCrew(contactId);
  await sendSMS(crewLead.phone, `Phase "${phaseName}" for job ${contactId} should start today (${startFormatted}). Mark complete when done.`);
  
  // Notify customer (optional – some customers want updates)
  if (await getCustomField(contactId, 'send_phase_updates') !== false) {
    await sendSMS(lead.phone, `Update: Your project is entering the "${phaseName}" phase. We'll notify you when it's complete.`);
  }
  
  await logMilestoneEvent(contactId, 'phase_start_notified', { phase: phaseName, scheduledStart: scheduledStartDate });
}

4. Crew marks phase complete (via SMS reply or mobile app)

javascript
async function handlePhaseComplete(contactId, phaseName, crewId) {
  // Find the milestone record
  const milestone = await getCurrentPendingMilestone(contactId, phaseName);
  if (!milestone) return;
  
  const now = new Date();
  const scheduledEnd = new Date(milestone.scheduled_end);
  const isLate = now > scheduledEnd;
  
  // Update milestone
  await updateMilestoneRecord(milestone.id, {
    status: 'completed',
    completed_at: now.toISOString(),
    completed_by: crewId,
    is_late: isLate
  });
  
  // Notify customer
  const lead = await getLeadData(contactId);
  await sendSMS(lead.phone, `Great news! The "${phaseName}" phase of your project is complete.`);
  
  // If late, escalate
  if (isLate) {
    const delayHours = Math.ceil((now - scheduledEnd) / (1000*60*60));
    await escalateDelay(contactId, phaseName, delayHours);
  }
  
  // Move to next phase
  const nextMilestone = await getNextPendingMilestone(contactId);
  if (nextMilestone) {
    // Schedule start notification for next phase
    const startDate = new Date();
    // If next phase scheduled earlier than today, adjust
    if (new Date(nextMilestone.scheduled_start) < startDate) {
      await updateMilestoneRecord(nextMilestone.id, { scheduled_start: startDate.toISOString() });
    }
    await notifyPhaseStart(contactId, nextMilestone.phase_name, nextMilestone.scheduled_start);
  } else {
    // All phases complete
    await allPhasesComplete(contactId);
  }
  
  await logMilestoneEvent(contactId, 'phase_completed', { phase: phaseName, isLate, completedAt: now });
}

5. Escalate delay

javascript
async function escalateDelay(contactId, phaseName, delayHours) {
  const lead = await getLeadData(contactId);
  
  // Notify customer of delay
  await sendSMS(lead.phone, `The "${phaseName}" phase is ${delayHours} hours behind schedule. We're working to get back on track.`);
  
  // Create task for project manager
  await createTask(contactId, `Phase "${phaseName}" delayed by ${delayHours}h. Investigate and adjust schedule.`, 'pm@company.com', 4);
  
  // Send Slack alert to ops channel
  await sendSlackAlert(`⚠️ Delay on job ${contactId}: Phase "${phaseName}" ${delayHours}h behind.`);
  
  // Adjust subsequent phase dates
  await rescheduleRemainingPhases(contactId);
}

6. Reschedule remaining phases after delay

javascript
async function rescheduleRemainingPhases(contactId) {
  const pendingMilestones = await getPendingMilestones(contactId);
  if (pendingMilestones.length === 0) return;
  
  let currentDate = new Date();
  for (const milestone of pendingMilestones) {
    const durationDays = (new Date(milestone.scheduled_end) - new Date(milestone.scheduled_start)) / (1000*60*60*24);
    const newStart = currentDate;
    const newEnd = new Date(currentDate.getTime() + durationDays * 24*60*60*1000);
    await updateMilestoneRecord(milestone.id, {
      scheduled_start: newStart.toISOString(),
      scheduled_end: newEnd.toISOString()
    });
    currentDate = newEnd;
  }
  
  // Notify customer of revised timeline
  const lead = await getLeadData(contactId);
  const newTimeline = pendingMilestones.map(m => `${m.phase_name}: ${new Date(m.scheduled_start).toLocaleDateString()} - ${new Date(m.scheduled_end).toLocaleDateString()}`).join('\n');
  await sendEmail(lead.email, `Revised project timeline`, `Due to a delay, your updated schedule is:\n${newTimeline}`);
  
  await logMilestoneEvent(contactId, 'schedule_rescheduled', { pendingPhases: pendingMilestones.length });
}

7. All phases complete – trigger final steps

javascript
async function allPhasesComplete(contactId) {
  await updateCustomField(contactId, 'all_phases_completed_at', new Date().toISOString());
  await changeStage(contactId, 'JOB_FINISHED');
  await addTag(contactId, 'job:completed');
  
  // Notify customer
  const lead = await getLeadData(contactId);
  await sendSMS(lead.phone, `All phases of your project are now complete! We'll schedule a final inspection shortly.`);
  
  // Trigger final inspection and sign‑off workflow (Pattern 21)
  await triggerFinalInspection(contactId);
  
  await logMilestoneEvent(contactId, 'all_phases_complete', {});
}

8. Logging to Airtable

javascript
async function logMilestoneEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/milestone_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalPhases: pre‑op, surgery, recovery, follow‑up. Compliance: notify patient of each phase with instructions.Safety
ConstructionPhases: excavation, foundation, framing, MEP, drywall, finishing. Must coordinate inspections.Legal, logistics
Real EstatePhases for renovation: demo, rough, finish, cleanup. Notify landlord and tenant.Coordination
Home ImprovementAs shown – simple phase tracking.Standard
SaaSNot applicable.N/A
Payment ProcessingPhases: risk review, underwriting, approval, onboarding.Compliance
Executive AssistantNot applicable.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalProject manager can manually mark a phase complete via CRM.Crew forgot to report.
NecessaryIf phase delay >2 days, PM must investigate and update schedule.Delay exceeds threshold.
QuestionableAutomated customer notifications for every phase – some customers prefer fewer updates.Low‑touch customer preference.

Override Behavior

If a PM manually updates phase status (bypassing crew report):

ElementBehavior
Tags addedmanual:phase_complete
Stage changeNone (milestone status changes).
Automation stoppedThe crew SMS reply handler checks for manual:phase_complete tag; if present, it ignores duplicate reports.
Loggingevent_type = manual_phase_update, pm_id.

Decision Guide

When to use this automationWhen NOT to use
Projects with 3+ distinct phasesSingle‑phase project (no need)
Need to keep customer informed of progressCustomer does not want updates
Multiple crews or subcontractors involvedSingle crew handles all phases
Executive assistant – not applicableN/A

Cost‑benefit:

  • Reduces project management overhead.
  • Improves customer satisfaction through proactive updates.
  • Build production‑grade for any project‑based business with >20 projects/month.

End of Pattern 20

P21
Digital Sign-Off on Completion
Group 8 · Fulfillment & Sign-Off · This automation connects **job finished** → **FU payments** → **PAY full** → **FULFILL** stages (stages 13, 14, 15, and 16 of the 19‑stage pipeline). It enables customers to digitally sign off that a job is complete and satisfactory, triggers final payment collection, and archives the signed document for warranty and audit purposes.
Glue: This automation connects job finished → FU payments → PAY full → FULFILL stages (stages 13, 14, 15, and 16 of the 19‑stage pipeline). It enables customers to digitally sign off that a job is complete and satisfactory, triggers final payment collection, and archives the signed document for warranty and audit purposes.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[All phases complete, stage = JOB_FINISHED] --> B[Generate digital sign‑off request with completion summary]
    B --> C[Send sign‑off link via SMS and email]
    C --> D[Customer opens link, reviews completion details]
    D --> E{Customer approves?}
    E -->|Yes| F[Capture signature, timestamp, IP address]
    E -->|No with issues| G[Customer submits issues in form]
    F --> H[Generate signed PDF, store in cloud]
    H --> I[Update CRM: signed_off = true, signed_at timestamp]
    I --> J[Trigger final payment invoice Pattern 14]
    J --> K[Notify ops: job ready for final close]
    G --> L[Create task for project manager: address issues]
    L --> M[Send acknowledgment to customer: we will fix issues]
    M --> N[Log issue report to Airtable]
    K --> O[Archive signed document, log completion]
    N --> O

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, paper‑based sign‑off.

Steps:

  • Customer signs paper completion form.
  • Rep scans and saves to file.

Downsides:

  • No digital audit trail.
  • Delays in final payment.
  • Issues not tracked systematically.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • E‑signature: DocuSign, PandaDoc, or simple embedded signature pad (HTML/CSS/JS)
  • Storage: AWS S3 or Google Drive
  • CRM: GoHighLevel (GHL)
  • Communication: Twilio, SendGrid
  • Orchestration: Make (Integromat)
  • Logging: Airtable

Step‑by‑step with real code (Node.js + custom signature pad):

1. Generate sign‑off request with completion summary

javascript
async function generateSignOffRequest(contactId) {
  const lead = await getLeadData(contactId);
  const completionSummary = {
    job_address: lead.address,
    service_type: lead.service_type,
    completion_date: new Date().toISOString(),
    final_amount: lead.estimate_amount,
    notes: await getCustomField(contactId, 'completion_notes') || 'Job completed as specified.'
  };
  
  // Store summary in CRM
  await updateCustomField(contactId, 'completion_summary', JSON.stringify(completionSummary));
  
  // Generate unique sign‑off token (expires in 7 days)
  const token = createJWT({ contactId, exp: Date.now() + 7*24*60*60*1000 });
  const signOffLink = `https://yourdomain.com/signoff/${token}`;
  
  return { signOffLink, completionSummary };
}

2. Send sign‑off link to customer

javascript
async function sendSignOffRequest(contactId) {
  const { signOffLink, completionSummary } = await generateSignOffRequest(contactId);
  const lead = await getLeadData(contactId);
  
  const sms = `Your project is complete! Please review and sign off here: ${signOffLink}`;
  const emailSubject = `Final sign‑off for your ${lead.service_type} project`;
  const emailBody = `
    <p>Dear ${lead.name},</p>
    <p>Your project is complete. Please review the summary below and sign off using the link:</p>
    <p><a href="${signOffLink}">Click here to sign off</a></p>
    <p><strong>Completion summary:</strong><br>
    Address: ${completionSummary.job_address}<br>
    Service: ${completionSummary.service_type}<br>
    Final amount: $${completionSummary.final_amount}<br>
    Completion date: ${new Date(completionSummary.completion_date).toLocaleDateString()}<br>
    Notes: ${completionSummary.notes}</p>
    <p>If you have any issues, please reply to this email.</p>
  `;
  
  await sendSMS(lead.phone, sms);
  await sendEmail(lead.email, emailSubject, emailBody);
  
  // Schedule reminder if no sign‑off after 3 days
  await scheduleReminder(contactId, 'signoff_reminder', 3*24*60*60*1000);
  
  await logSignOffEvent(contactId, 'signoff_request_sent', { link: signOffLink });
}

3. Sign‑off page (HTML + signature pad)

html
<!-- signoff.html served by Express -->
<!DOCTYPE html>
<html>
<head>
    <title>Project Sign‑Off</title>
    <script src="https://cdn.jsdelivr.net/npm/signature_pad@4.0.0/dist/signature_pad.umd.min.js"></script>
</head>
<body>
    <h1>Project Completion Sign‑Off</h1>
    <div id="summary"></div>
    <canvas id="signature" width="400" height="200" style="border:1px solid #ccc;"></canvas>
    <button id="clear">Clear</button>
    <button id="submit">Approve & Sign</button>
    <button id="reportIssue">Report Issue</button>
    <div id="issueForm" style="display:none;">
        <textarea id="issueDescription" placeholder="Describe the issue..."></textarea>
        <button id="submitIssue">Submit Issue</button>
    </div>
    
    <script>
        const canvas = document.getElementById('signature');
        const signaturePad = new SignaturePad(canvas);
        const token = window.location.pathname.split('/').pop();
        
        // Load completion summary
        fetch(`/api/signoff/summary/${token}`).then(res => res.json()).then(data => {
            document.getElementById('summary').innerHTML = `
                <p><strong>Address:</strong> ${data.address}</p>
                <p><strong>Service:</strong> ${data.service_type}</p>
                <p><strong>Final amount:</strong> $${data.final_amount}</p>
                <p><strong>Notes:</strong> ${data.notes}</p>
            `;
        });
        
        document.getElementById('submit').onclick = async () => {
            if (signaturePad.isEmpty()) {
                alert('Please provide your signature.');
                return;
            }
            const signatureData = signaturePad.toDataURL();
            const response = await fetch(`/api/signoff/approve/${token}`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ signature: signatureData })
            });
            if (response.ok) {
                alert('Thank you! Your sign‑off has been recorded.');
                window.location.href = '/thankyou';
            } else {
                alert('Error. Please try again.');
            }
        };
        
        document.getElementById('reportIssue').onclick = () => {
            document.getElementById('issueForm').style.display = 'block';
        };
        
        document.getElementById('submitIssue').onclick = async () => {
            const issue = document.getElementById('issueDescription').value;
            if (!issue) return;
            await fetch(`/api/signoff/issue/${token}`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ issue })
            });
            alert('Thank you. We will contact you to resolve the issue.');
            window.location.href = '/thankyou';
        };
    </script>
</body>
</html>

4. Handle sign‑off approval

javascript
app.post('/api/signoff/approve/:token', async (req, res) => {
  const { token } = req.params;
  const { signature } = req.body;
  const payload = verifyJWT(token);
  const contactId = payload.contactId;
  
  // Generate signed PDF (using PDFKit or similar)
  const pdfPath = await generateSignedPDF(contactId, signature);
  
  // Upload to cloud storage
  const signedUrl = await uploadToS3(pdfPath, `signoffs/${contactId}_${Date.now()}.pdf`);
  
  // Update CRM
  await updateCustomField(contactId, 'signed_off', true);
  await updateCustomField(contactId, 'signed_off_at', new Date().toISOString());
  await updateCustomField(contactId, 'signed_document_url', signedUrl);
  await changeStage(contactId, 'CUSTOMER_SIGNED_OFF');
  
  // Cancel any pending reminders
  await cancelReminders(contactId, 'signoff_reminder');
  
  // Trigger final payment invoice
  await triggerFinalPayment(contactId);
  
  // Notify ops
  await sendSlackAlert(`✅ Customer ${contactId} has signed off. Ready for final payment.`);
  
  await logSignOffEvent(contactId, 'signoff_approved', { signedUrl });
  
  res.status(200).send('OK');
});

5. Handle issue reporting

javascript
app.post('/api/signoff/issue/:token', async (req, res) => {
  const { token } = req.params;
  const { issue } = req.body;
  const payload = verifyJWT(token);
  const contactId = payload.contactId;
  
  await updateCustomField(contactId, 'completion_issues', issue);
  await addTag(contactId, 'completion:issues_reported');
  
  // Create high‑priority task for project manager
  await createTask(contactId, `Customer reported issue: ${issue.substring(0, 100)}. Contact customer to resolve.`, 'pm@company.com', 4);
  
  // Send acknowledgment to customer
  const lead = await getLeadData(contactId);
  await sendSMS(lead.phone, `We received your issue report. A project manager will contact you within 24 hours.`);
  
  await logSignOffEvent(contactId, 'signoff_issue_reported', { issue });
  
  res.status(200).send('OK');
});

6. Generate signed PDF

javascript
async function generateSignedPDF(contactId, signatureDataURL) {
  const lead = await getLeadData(contactId);
  const PDFDocument = require('pdfkit');
  const fs = require('fs');
  const filename = `signoff_${contactId}_${Date.now()}.pdf`;
  const doc = new PDFDocument();
  const writeStream = fs.createWriteStream(filename);
  doc.pipe(writeStream);
  
  doc.fontSize(20).text('JOB COMPLETION SIGN‑OFF', { align: 'center' });
  doc.moveDown();
  doc.fontSize(12).text(`Customer: ${lead.name}`);
  doc.text(`Address: ${lead.address}`);
  doc.text(`Service: ${lead.service_type}`);
  doc.text(`Completion Date: ${new Date().toLocaleDateString()}`);
  doc.text(`Final Amount: $${lead.estimate_amount}`);
  doc.moveDown();
  doc.text('I confirm that the job has been completed to my satisfaction and authorize final payment.', { underline: true });
  doc.moveDown();
  
  // Add signature image
  const base64Data = signatureDataURL.replace(/^data:image\/png;base64,/, '');
  const signatureBuffer = Buffer.from(base64Data, 'base64');
  doc.image(signatureBuffer, { width: 200, height: 80 });
  doc.text(`Signed: ${lead.name}`);
  doc.text(`Date: ${new Date().toLocaleString()}`);
  doc.text(`IP: ${lead.signoff_ip || 'collected at signing'}`);
  
  doc.end();
  return filename;
}

7. Logging to Airtable

javascript
async function logSignOffEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/signoff_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalSign‑off for treatment completion must comply with HIPAA. Signature must be witnessed or electronic with audit trail.Compliance
ConstructionSign‑off may trigger lien waiver release. Must include certification that all subcontractors paid.Legal
Real EstateTenant move‑out sign‑off includes security deposit reconciliation.Contractual
Home ImprovementAs shown – simple sign‑off with issue reporting.Standard
SaaSNot applicable.N/A
Payment ProcessingMerchant account sign‑off may require compliance officer approval.Regulation
Executive AssistantNot applicable.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalProject manager can manually mark job as signed off via CRM (if customer signs paper).Customer unavailable digitally.
NecessaryIf customer reports issues, project manager must resolve before final payment.Issue reported.
QuestionableAutomated sign‑off for large commercial projects – often requires in‑person walkthrough.Project value > $100k.

Override Behavior

If a PM manually marks sign‑off complete (bypassing customer signature):

ElementBehavior
Tags addedmanual:signoff
Stage changeMove to CUSTOMER_SIGNED_OFF.
Automation stoppedCancel pending sign‑off reminders.
Loggingevent_type = manual_signoff, pm_id, reason.

Decision Guide

When to use this automationWhen NOT to use
Volume >20 jobs/month requiring sign‑offVery low volume
Need digital audit trail for warranty or disputesNo compliance or warranty needs
Customers expect digital convenienceCustomers prefer paper
Executive assistant – not applicableN/A

Cost‑benefit:

  • Speeds up final payment collection (reduces days outstanding).
  • Provides legal proof of completion.
  • Build production‑grade for any business with >30 jobs/month.

End of Pattern 21

P22
Warranty Registration and Document Delivery
Group 8 · Fulfillment & Sign-Off · This automation connects **FULFILL** → **poach for review** stages (stages 16 and 17 of the 19‑stage pipeline). After a job is completed and signed off, it automatically registers the warranty with the manufacturer (if applicable), delivers warranty documents to the customer, and schedules reminders for warranty expiration or service renewals.
Glue: This automation connects FULFILL → poach for review stages (stages 16 and 17 of the 19‑stage pipeline). After a job is completed and signed off, it automatically registers the warranty with the manufacturer (if applicable), delivers warranty documents to the customer, and schedules reminders for warranty expiration or service renewals.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Job completed and signed off] --> B[Extract product information from estimate materials list]
    B --> C{Manufacturer warranty registration required?}
    C -->|Yes| D[Call manufacturer API or submit web form]
    C -->|No| E[Generate warranty document from template]
    D --> F[Store warranty registration confirmation]
    F --> G[Generate customer warranty PDF with registration number]
    E --> G
    G --> H[Upload warranty PDF to cloud storage]
    H --> I[Send warranty document link to customer via SMS/email]
    I --> J[Store warranty expiry date in CRM]
    J --> K[Schedule renewal reminders at 11 months, 30 days, 7 days before expiry]
    K --> L[Log warranty registration event to Airtable]
    L --> M[Trigger review request Pattern 23]

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual warranty handling.

Steps:

  • Rep manually registers warranty on manufacturer website.
  • Emails PDF to customer.

Downsides:

  • Rep may forget to register.
  • No reminders for renewal.
  • No central storage.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Manufacturer APIs: HVAC, roofing, appliance brands (e.g., Trane, GAF, Whirlpool)
  • Document generation: PDFKit or Docmosis
  • Storage: AWS S3 or Google Drive
  • Orchestration: Make (Integromat)
  • Communication: Twilio, SendGrid
  • Logging: Airtable

Step‑by‑step with real code (Node.js):

1. Extract product information for warranty registration

javascript
async function getWarrantyInfo(contactId) {
  const lead = await getLeadData(contactId);
  const materials = await getMaterialsList(lead.estimate_id); // from Pattern 17
  
  // Identify products that require warranty registration
  const registrations = [];
  for (const material of materials) {
    if (material.warranty_required) {
      registrations.push({
        product_name: material.description,
        model: material.model,
        serial_number: material.serial_number, // captured during installation
        manufacturer: material.manufacturer,
        warranty_years: material.warranty_years,
        registration_deadline: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days from completion
      });
    }
  }
  return registrations;
}

2. Register warranty with manufacturer API (example for HVAC)

javascript
async function registerWarranty(contactId, product) {
  const lead = await getLeadData(contactId);
  
  // Example: Trane warranty API (hypothetical)
  const response = await fetch('https://api.trane.com/warranty/register', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer TRANE_API_KEY', 'Content-Type': 'application/json' },
    body: JSON.stringify({
      customer_name: lead.name,
      customer_email: lead.email,
      customer_phone: lead.phone,
      installation_address: lead.address,
      installation_date: lead.completion_date,
      product_model: product.model,
      serial_number: product.serial_number,
      dealer_id: process.env.TRANE_DEALER_ID
    })
  });
  
  const result = await response.json();
  if (result.status === 'registered') {
    await updateCustomField(contactId, `warranty_${product.model}_reg_number`, result.registration_number);
    await updateCustomField(contactId, `warranty_${product.model}_expiry`, result.expiry_date);
    return { success: true, registration_number: result.registration_number, expiry: result.expiry_date };
  } else {
    await createTask(contactId, `Warranty registration failed for ${product.model}. Manual registration required.`, 'ops@company.com', 24);
    return { success: false };
  }
}

3. Generate warranty document for customer

javascript
async function generateWarrantyPDF(contactId, warrantyInfo, registrations) {
  const lead = await getLeadData(contactId);
  const PDFDocument = require('pdfkit');
  const fs = require('fs');
  const filename = `warranty_${contactId}_${Date.now()}.pdf`;
  const doc = new PDFDocument();
  const writeStream = fs.createWriteStream(filename);
  doc.pipe(writeStream);
  
  doc.fontSize(20).text('WARRANTY CERTIFICATE', { align: 'center' });
  doc.moveDown();
  doc.fontSize(12).text(`Customer: ${lead.name}`);
  doc.text(`Address: ${lead.address}`);
  doc.text(`Service: ${lead.service_type}`);
  doc.text(`Completion Date: ${new Date(lead.completion_date).toLocaleDateString()}`);
  doc.moveDown();
  doc.text(`This warranty covers the following products:`, { underline: true });
  doc.moveDown();
  
  for (const reg of registrations) {
    doc.text(`Product: ${reg.product_name}`);
    doc.text(`Model: ${reg.model}`);
    doc.text(`Serial Number: ${reg.serial_number}`);
    doc.text(`Warranty Period: ${reg.warranty_years} years`);
    doc.text(`Registration Number: ${reg.registration_number || 'N/A'}`);
    doc.text(`Expiry Date: ${new Date(reg.expiry).toLocaleDateString()}`);
    doc.moveDown();
  }
  
  doc.text(`Warranty Terms:`, { underline: true });
  doc.text(`This warranty covers defects in materials and workmanship for the period stated above.`);
  doc.text(`To make a claim, contact our service department at 555-123-4567 or warranty@company.com.`);
  doc.text(`Retain this certificate for your records.`);
  
  doc.end();
  return filename;
}

4. Deliver warranty document to customer

javascript
async function deliverWarranty(contactId, pdfPath) {
  // Upload to cloud storage
  const s3 = new AWS.S3();
  const s3Key = `warranties/${contactId}_${Date.now()}.pdf`;
  await s3.upload({ Bucket: 'your-bucket', Key: s3Key, Body: fs.createReadStream(pdfPath) }).promise();
  const warrantyLink = `https://your-bucket.s3.amazonaws.com/${s3Key}`;
  
  // Store link in CRM
  await updateCustomField(contactId, 'warranty_document_link', warrantyLink);
  await updateCustomField(contactId, 'warranty_delivered_at', new Date().toISOString());
  
  // Send to customer
  const lead = await getLeadData(contactId);
  const sms = `Your warranty certificate is ready: ${warrantyLink}. Please save it for your records.`;
  const emailSubject = `Warranty certificate for your ${lead.service_type}`;
  const emailBody = `Thank you for choosing us. Your warranty certificate is available at: <a href="${warrantyLink}">${warrantyLink}</a>. Please keep this for future reference.`;
  
  await sendSMS(lead.phone, sms);
  await sendEmail(lead.email, emailSubject, emailBody);
  
  await logWarrantyEvent(contactId, 'warranty_delivered', { link: warrantyLink });
}

5. Schedule warranty renewal reminders

javascript
async function scheduleWarrantyReminders(contactId, expiryDate) {
  const expiry = new Date(expiryDate);
  const now = new Date();
  
  const reminders = [
    { daysBefore: 30, message: `Your warranty expires in 30 days. Renew to extend coverage.` },
    { daysBefore: 14, message: `Your warranty expires in 14 days. Renew now: ${getRenewalLink(contactId)}` },
    { daysBefore: 7, message: `FINAL REMINDER: Your warranty expires in 7 days. Renew today.` }
  ];
  
  for (const reminder of reminders) {
    const reminderDate = new Date(expiry.getTime() - reminder.daysBefore * 24 * 60 * 60 * 1000);
    if (reminderDate > now) {
      await scheduleMessage(reminderDate, contactId, async () => {
        const lead = await getLeadData(contactId);
        await sendSMS(lead.phone, reminder.message);
        await sendEmail(lead.email, `Warranty renewal reminder`, reminder.message);
        await logWarrantyEvent(contactId, 'warranty_reminder_sent', { daysBefore: reminder.daysBefore });
      });
    }
  }
}

6. Handle warranty claim (customer-initiated)

javascript
async function handleWarrantyClaim(contactId, issueDescription) {
  const lead = await getLeadData(contactId);
  const warrantyLink = await getCustomField(contactId, 'warranty_document_link');
  
  // Create high‑priority task for service department
  await createTask(contactId, `Warranty claim: ${issueDescription.substring(0, 100)}. Customer: ${lead.name}`, 'service@company.com', 4);
  
  // Send acknowledgment
  await sendSMS(lead.phone, `We received your warranty claim. A service technician will contact you within 2 business days.`);
  
  await logWarrantyEvent(contactId, 'warranty_claim_filed', { issue: issueDescription });
}

7. Logging to Airtable

javascript
async function logWarrantyEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/warranty_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalWarranty for medical devices often requires patient consent and physician sign‑off. Not fully automatable.Compliance
ConstructionWarranty for workmanship (e.g., roof, foundation) may require periodic inspections. Reminders for annual check‑up.Liability
Real EstateAppliance warranties for rental properties; tenant receives copy, landlord tracks expiry.Lease terms
Home ImprovementAs shown – simple manufacturer warranty registration and customer delivery.Standard
SaaSNot applicable (software warranties are terms of service).N/A
Payment ProcessingEquipment warranties for terminals; includes maintenance plans.Service contracts
Executive AssistantNot applicable.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalService manager can manually register warranty if API fails.Registration error.
NecessaryIf warranty claim requires inspection, technician must visit customer before approval.Claim filed.
QuestionableAutomated renewal reminders for high‑value warranties – some customers prefer a call.Premium product.

Override Behavior

If a rep manually delivers a warranty document (bypassing automation):

ElementBehavior
Tags addedmanual:warranty_delivered
Stage changeNone.
Automation stoppedThe automated delivery workflow checks for existing warranty link; if present, it skips.
Loggingevent_type = manual_warranty_delivery, rep_id.

Decision Guide

When to use this automationWhen NOT to use
Volume >20 jobs/month requiring warranty registrationVery low volume
Manufacturers offer API integrationOnly paper‑based registration
Need to retain customers for future service (reminders)No warranty renewal revenue
Executive assistant – not applicableN/A

Cost‑benefit:

  • Ensures warranty registration happens (protects liability).
  • Generates service revenue from warranty renewals.
  • Build production‑grade if >50 jobs/month with warranties.

End of Pattern 22

P23
Review Request with Reputation Monitoring
Group 9 · Retention & Reactivation · This automation connects **FULFILL** → **poach for review** stages (stages 16 and 17 of the 19‑stage pipeline). After job completion and sign‑off, it requests a review from the customer on Google, Facebook, or other platforms, monitors incoming reviews, and triggers actions based on rating (positive → referral offer, negative → ops task).
Glue: This automation connects FULFILL → poach for review stages (stages 16 and 17 of the 19‑stage pipeline). After job completion and sign‑off, it requests a review from the customer on Google, Facebook, or other platforms, monitors incoming reviews, and triggers actions based on rating (positive → referral offer, negative → ops task).

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Job completed and signed off] --> B[Wait 5 days for customer to experience result]
    B --> C[Send review request via SMS and email with direct links]
    C --> D[Start review reminder timer 7 days]
    D --> E{Review submitted? webhook received}
    E -->|Yes| F{Review rating >= 4?}
    E -->|No after 14d| G[Stop asking, tag review_unresponsive]
    F -->|Yes| H[Send thank you message with referral link Pattern 24]
    F -->|No rating <= 3| I[Create high-priority task for ops manager]
    I --> J[Send apology SMS, offer resolution]
    H --> K[Log review to Airtable]
    J --> K
    G --> K

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual review requests.

Steps:

  • Rep sends email asking for review.
  • No tracking, no reminders, no monitoring.

Downsides:

  • Low response rate.
  • Negative reviews not handled promptly.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • Review platforms: Google My Business API, Facebook Graph API
  • Review aggregator: Reputation.com, Birdeye, or direct API
  • CRM: GoHighLevel (GHL)
  • Communication: Twilio, SendGrid
  • Orchestration: Make (Integromat)
  • Logging: Airtable

Step‑by‑step with real code (Node.js + Google My Business API):

1. Send review request after 5 days

javascript
async function sendReviewRequest(contactId) {
  const lead = await getLeadData(contactId);
  const completionDate = new Date(lead.completion_date);
  const now = new Date();
  const daysSinceCompletion = (now - completionDate) / (1000*60*60*24);
  
  if (daysSinceCompletion < 5) return; // wait 5 days
  
  // Generate review links
  const googleReviewLink = `https://search.google.com/local/writereview?placeid=${process.env.GOOGLE_PLACE_ID}`;
  const fbReviewLink = `https://www.facebook.com/${process.env.FB_PAGE_ID}/reviews/`;
  
  const sms = `Hi ${lead.name}, we hope you love your new ${lead.service_type}! Please share your experience on Google: ${googleReviewLink} or Facebook: ${fbReviewLink}. Thank you!`;
  const emailSubject = `How did we do? Please review your ${lead.service_type}`;
  const emailBody = `Dear ${lead.name},<br><br>Thank you for choosing us. We'd love your feedback!<br><br><a href="${googleReviewLink}">Leave a Google Review</a><br><a href="${fbReviewLink}">Leave a Facebook Review</a><br><br>Your feedback helps other homeowners make informed decisions.<br><br>Thank you!`;
  
  await sendSMS(lead.phone, sms);
  await sendEmail(lead.email, emailSubject, emailBody);
  
  await addTag(contactId, 'review:requested');
  await updateCustomField(contactId, 'review_requested_at', new Date().toISOString());
  
  // Schedule reminder after 7 days
  await scheduleReviewReminder(contactId, 7);
  
  await logReviewEvent(contactId, 'review_request_sent', { googleLink, fbLink });
}

2. Webhook to receive review from Google My Business API

javascript
app.post('/webhook/google-review', express.json(), async (req, res) => {
  // Google sends review data when a new review is posted
  const { name, reviewUrl, rating, comment, reviewerName } = req.body;
  // Match customer by name, phone, or email (fuzzy)
  const contactId = await findContactByReviewerInfo(reviewerName, comment);
  if (!contactId) {
    // Could not match – store in unclaimed queue
    await storeUnclaimedReview({ reviewerName, rating, comment });
    return res.status(200).send('OK');
  }
  
  await handleReviewSubmitted(contactId, rating, comment, 'google');
  res.status(200).send('OK');
});

3. Handle review submission

javascript
async function handleReviewSubmitted(contactId, rating, comment, platform) {
  const lead = await getLeadData(contactId);
  
  // Store in CRM
  await updateCustomField(contactId, 'review_given', true);
  await updateCustomField(contactId, 'review_rating', rating);
  await updateCustomField(contactId, 'review_comment', comment);
  await updateCustomField(contactId, 'review_platform', platform);
  await updateCustomField(contactId, 'review_submitted_at', new Date().toISOString());
  await addTag(contactId, `review:${rating >= 4 ? 'positive' : 'negative'}`);
  
  if (rating >= 4) {
    // Positive review: send thank you and trigger referral offer
    await sendSMS(lead.phone, `Thank you for your ${rating}-star review! As a token of appreciation, here's a $200 referral link to share with friends: ${getReferralLink(contactId)}`);
    await triggerReferralOffer(contactId); // Pattern 24
  } else {
    // Negative review: create ops task immediately
    await createTask(contactId, `Customer left a ${rating}-star review: "${comment.substring(0, 100)}". Respond and resolve.`, 'ops_manager@company.com', 4);
    await sendSMS(lead.phone, `We're sorry to hear about your experience. A manager will contact you within 24 hours to make things right.`);
    
    // Also send Slack alert
    await sendSlackAlert(`⚠️ Negative review (${rating} stars) from ${lead.name}: ${comment.substring(0, 100)}`);
  }
  
  // Cancel any pending review reminders
  await cancelReviewReminders(contactId);
  
  await logReviewEvent(contactId, 'review_submitted', { rating, platform, commentPreview: comment.substring(0, 100) });
}

4. Review reminder sequence

javascript
async function scheduleReviewReminder(contactId, daysLater) {
  const reminderDate = new Date(Date.now() + daysLater * 24 * 60 * 60 * 1000);
  await scheduleMessage(reminderDate, contactId, async () => {
    const lead = await getLeadData(contactId);
    const reviewGiven = await getCustomField(contactId, 'review_given');
    if (reviewGiven) return;
    
    // Second reminder
    const googleLink = `https://search.google.com/local/writereview?placeid=${process.env.GOOGLE_PLACE_ID}`;
    await sendSMS(lead.phone, `Hi ${lead.name}, we'd still love your feedback. It takes only 30 seconds: ${googleLink}`);
    await logReviewEvent(contactId, 'review_reminder_sent', { daysLater });
    
    // Schedule final reminder at 14 days
    if (daysLater === 7) {
      await scheduleReviewReminder(contactId, 14);
    } else if (daysLater === 14) {
      // Final reminder – no further action
      await addTag(contactId, 'review:unresponsive');
      await logReviewEvent(contactId, 'review_unresponsive', {});
    }
  });
}

5. Respond to reviews automatically (positive)

javascript
async function autoRespondToReview(contactId, rating, comment, platform) {
  // Only auto-respond to positive reviews
  if (rating < 4) return;
  
  let responseText = `Thank you for your kind words! We're thrilled you had a great experience. Enjoy your ${service_type}! - The Team`;
  
  if (platform === 'google') {
    // Post reply to Google review using API
    await fetch(`https://mybusiness.googleapis.com/v4/accounts/${accountId}/locations/${locationId}/reviews/${reviewId}/reply`, {
      method: 'PUT',
      headers: { 'Authorization': `Bearer ${googleAccessToken}` },
      body: JSON.stringify({ reply: responseText })
    });
  } else if (platform === 'facebook') {
    // Post reply to Facebook comment
    await fetch(`https://graph.facebook.com/v18.0/${commentId}/comments`, {
      method: 'POST',
      params: { message: responseText, access_token: fbAccessToken }
    });
  }
  
  await logReviewEvent(contactId, 'auto_response_sent', { platform, response: responseText });
}

6. Logging to Airtable

javascript
async function logReviewEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/review_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalCannot solicit reviews that include PHI. Review request must be generic, no mention of treatment.HIPAA
ConstructionReviews on Google and Houzz. May ask for project photos.Visual proof
Real EstateReviews on Zillow and Realtor.com. Must not incentivize reviews (fair housing laws).Legal
Home ImprovementAs shown – Google and Facebook reviews. Incentives allowed (e.g., $200 referral).Standard
SaaSReviews on G2, Capterra. May offer extended trial for review.Marketing
Payment ProcessingCannot solicit reviews that disclose merchant rates.Compliance
Executive AssistantNot applicable.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually request a review via phone.Customer didn't respond to SMS.
NecessaryNegative review requires ops manager to respond within 24h.Rating ≤3.
QuestionableAuto‑responding to reviews – some prefer human‑written responses for authenticity.All reviews.

Override Behavior

If a rep manually marks review as handled (bypassing automation):

ElementBehavior
Tags addedmanual:review_handled
Stage changeNone.
Automation stoppedCancel pending review reminders.
Loggingevent_type = manual_review_handling, rep_id.

Decision Guide

When to use this automationWhen NOT to use
Volume >20 jobs/monthVery low volume, manual fine
Online reputation critical for acquisitionReputation not important
Need to respond to negative reviews quicklyLow risk of negative reviews
Executive assistant – not applicableN/A

Cost‑benefit:

  • Increases review count by 3–5x (automated requests).
  • Prevents reputation damage by fast response to negatives.
  • Build production‑grade for any business with >30 jobs/month.

End of Pattern 23

P24
Referral Program with Unique Links and Reward Tracking
Group 9 · Retention & Reactivation · This automation connects **poach for review** → **RETAIN** stages (stages 17 and 18 of the 19‑stage pipeline). After a positive review or at a configurable time after job completion, it generates a unique referral link for the customer, tracks clicks and conversions, and automatically rewards the referrer when a referred lead closes.
Glue: This automation connects poach for review → RETAIN stages (stages 17 and 18 of the 19‑stage pipeline). After a positive review or at a configurable time after job completion, it generates a unique referral link for the customer, tracks clicks and conversions, and automatically rewards the referrer when a referred lead closes.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Trigger: positive review OR job completion + 14 days] --> B[Generate unique referral code and link for customer]
    B --> C[Send referral offer SMS/email with link]
    C --> D[Store referral code in CRM, track clicks]
    D --> E[Friend clicks link, fills lead form]
    E --> F[Create lead with source=referral, referrer_id]
    F --> G[Referral lead progresses through pipeline]
    G --> H{Referred lead reaches CLOSED_WON?}
    H -->|Yes| I[Mark referral as successful]
    I --> J[Calculate reward amount based on program rules]
    J --> K[Update referrer reward balance]
    K --> L[Send notification to referrer: reward earned]
    L --> M[Trigger reward fulfillment gift card, discount, cash]
    M --> N[Log referral conversion to Airtable]
    H -->|No after 90 days| O[Expire referral offer, tag as expired]
    N --> O

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual referral tracking.

Steps:

  • Rep asks satisfied customer to refer friends.
  • Customer mentions referral over phone, rep manually tracks in spreadsheet.
  • When friend closes, rep manually processes reward.

Downsides:

  • No tracking of which customer sent which lead.
  • Missed referrals.
  • Delayed rewards.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Referral link generation: Custom short domain (e.g., refer.company.com)
  • Reward fulfillment: Gift card API (Tremendous, Tango Card) or Stripe for cash
  • Orchestration: Make (Integromat)
  • Communication: Twilio, SendGrid
  • Logging: Airtable

Step‑by‑step with real code (Node.js):

1. Generate unique referral code and link

javascript
async function generateReferralCode(contactId) {
  // Check if already exists
  let existingCode = await getCustomField(contactId, 'referral_code');
  if (existingCode) return existingCode;
  
  const code = generateShortCode(contactId); // e.g., "JOHND5F"
  const referralLink = `https://refer.company.com/${code}`;
  
  await updateCustomField(contactId, 'referral_code', code);
  await updateCustomField(contactId, 'referral_link', referralLink);
  await updateCustomField(contactId, 'referral_created_at', new Date().toISOString());
  
  return { code, link: referralLink };
}

2. Send referral offer (after positive review or 14 days)

javascript
async function sendReferralOffer(contactId) {
  const lead = await getLeadData(contactId);
  const { code, link } = await generateReferralCode(contactId);
  
  const sms = `Love your ${lead.service_type}? Refer a friend and you both get $200 off! Share your unique link: ${link}`;
  const emailSubject = `Refer a friend, get $200`;
  const emailBody = `Thank you for being a valued customer. Share your unique referral link: ${link}<br>When your friend books a project, you both receive $200 off.`;
  
  await sendSMS(lead.phone, sms);
  await sendEmail(lead.email, emailSubject, emailBody);
  
  await addTag(contactId, 'referral:offered');
  await updateCustomField(contactId, 'referral_offered_at', new Date().toISOString());
  
  // Schedule reminders at 30, 60, 90 days
  await scheduleReferralReminders(contactId);
  
  await logReferralEvent(contactId, 'referral_offer_sent', { link });
}

3. Handle referral link click and lead creation

javascript
// Landing page route: /:code
app.get('/:code', async (req, res) => {
  const code = req.params.code;
  const referrerId = await getContactIdByReferralCode(code);
  if (!referrerId) {
    return res.status(404).send('Invalid referral code');
  }
  
  // Store referrer_id in session or hidden form field
  res.send(`
    <html>
    <body>
      <h1>You've been referred!</h1>
      <form action="/lead" method="POST">
        <input type="hidden" name="referrer_code" value="${code}">
        <input type="text" name="name" placeholder="Your name" required>
        <input type="phone" name="phone" placeholder="Phone" required>
        <input type="email" name="email" placeholder="Email">
        <button type="submit">Request a free estimate</button>
      </form>
    </body>
    </html>
  `);
});

app.post('/lead', async (req, res) => {
  const { referrer_code, name, phone, email } = req.body;
  const referrerId = await getContactIdByReferralCode(referrer_code);
  
  // Create lead in CRM
  const newContact = await createLead({
    name, phone, email,
    source: 'referral',
    referrer_id: referrerId,
    status: 'NEW_LEAD',
    tags: [`referral:from_${referrerId}`]
  });
  
  // Increment referrer's click count
  await incrementReferralClickCount(referrerId);
  await logReferralEvent(referrerId, 'referral_click', { leadId: newContact.id });
  
  res.send('Thank you! A specialist will contact you soon.');
});

4. Track referral lead through pipeline

javascript
// When a lead reaches CLOSED_WON, webhook triggers
async function handleReferralClosedWon(contactId) {
  const referrerId = await getCustomField(contactId, 'referrer_id');
  if (!referrerId) return;
  
  // Mark this referral as converted
  await updateCustomField(contactId, 'referral_converted', true);
  await updateCustomField(contactId, 'referral_converted_at', new Date().toISOString());
  
  // Update referrer's record
  await addTag(referrerId, 'referral:converted');
  const currentReward = await getCustomField(referrerId, 'referral_reward_earned') || 0;
  const rewardAmount = 200; // configurable
  await updateCustomField(referrerId, 'referral_reward_earned', currentReward + rewardAmount);
  await incrementReferralCount(referrerId);
  
  // Send notification to referrer
  const referrer = await getLeadData(referrerId);
  await sendSMS(referrer.phone, `Your friend ${name} booked a project! Your $${rewardAmount} reward is on its way.`);
  await sendEmail(referrer.email, `Referral reward earned`, `Congratulations! Your friend booked a project. We'll process your $${rewardAmount} reward within 7 days.`);
  
  // Trigger reward fulfillment
  await fulfillReward(referrerId, rewardAmount);
  
  await logReferralEvent(referrerId, 'referral_converted', { referredLeadId: contactId, rewardAmount });
}

5. Reward fulfillment (gift card via Tremendous API)

javascript
async function fulfillReward(referrerId, amount) {
  const referrer = await getLeadData(referrerId);
  
  // Call Tremendous API to send gift card
  const response = await fetch('https://api.tremendous.com/v1/orders', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.TREMENDOUS_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      payment: {
        method: 'balance'
      },
      reward: {
        value: { denomination: amount, currency: 'USD' },
        delivery: { method: 'email', email: referrer.email },
        products: [{ product_id: 'gift_card_product_id' }]
      }
    })
  });
  
  const result = await response.json();
  await updateCustomField(referrerId, 'referral_last_reward_fulfilled_at', new Date().toISOString());
  await addTag(referrerId, 'referral:reward_sent');
  
  await logReferralEvent(referrerId, 'reward_fulfilled', { amount, orderId: result.order.id });
}

6. Referral reminders (30, 60, 90 days)

javascript
async function scheduleReferralReminders(contactId) {
  const intervals = [30, 60, 90]; // days
  for (const days of intervals) {
    const reminderDate = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
    await scheduleMessage(reminderDate, contactId, async () => {
      const lead = await getLeadData(contactId);
      const referralLink = await getCustomField(contactId, 'referral_link');
      const alreadyConverted = await getCustomField(contactId, 'referral_converted_count') > 0;
      if (alreadyConverted) return;
      
      await sendSMS(lead.phone, `Still have friends who need ${lead.service_type}? Share your link and earn $200: ${referralLink}`);
      await logReferralEvent(contactId, 'referral_reminder_sent', { days });
    });
  }
}

7. Expire referral after 90 days (if no conversion)

javascript
async function expireReferral(contactId) {
  const hasConverted = await getCustomField(contactId, 'referral_converted_count') > 0;
  if (hasConverted) return;
  
  await addTag(contactId, 'referral:expired');
  await logReferralEvent(contactId, 'referral_expired', {});
}

8. Logging to Airtable

javascript
async function logReferralEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/referral_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalCannot offer cash incentives for referrals (anti‑kickback). Offer charitable donation instead.Legal
ConstructionReferral reward may be a discount on future work or a gift card to home improvement store.Practical
Real EstateReferral fees must comply with RESPA. Offer nominal gift card, not percentage of commission.Legal
Home ImprovementAs shown – $200 off for both parties.Standard
SaaSReferral program offers free months of subscription. Tracks via unique promo codes.Marketing
Payment ProcessingReferral reward may be cash or reduced processing fees. Must comply with card brand rules.Compliance
Executive AssistantNot applicable.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalRep can manually issue a referral reward if automation fails.Customer complains reward not received.
NecessaryIf referral lead disputes that they were referred, manager must investigate.Lead claims no referral.
QuestionableAutomated reward fulfillment for high-value rewards (>$500) – may require manager approval.Reward amount > $500.

Override Behavior

If a rep manually marks a referral as successful (bypassing pipeline tracking):

ElementBehavior
Tags addedmanual:referral_credit
Stage changeNone.
Automation stoppedThe automation checks for existing referral_converted flag; if set, skips.
Loggingevent_type = manual_referral_credit, rep_id, amount.

Decision Guide

When to use this automationWhen NOT to use
Volume >30 jobs/monthVery low volume, manual tracking fine
Customers are likely to refer (high satisfaction)Low referral likelihood
Need to track attribution and reward automaticallyNo referral program
Executive assistant – not applicableN/A

Cost‑benefit:

  • Increases referral rate by 2–5x (automated reminders, easy links).
  • Reduces admin time for reward processing.
  • Build production‑grade for any business with >20 jobs/month.

End of Pattern 24

P25
Reactivation Sequence (Lost Leads / Dormant Customers)
Group 9 · Retention & Reactivation · This automation connects **REACTIVATE** → **LEAD** stages (stage 19 back to stage 1 of the 19‑stage pipeline). It targets leads that were lost (CLOSED_LOST with a cooldown period) or customers who have not made a repeat purchase within a defined timeframe. It sends a timed sequence of re‑engagement messages and, if successful, moves the contact back to NEW_LEAD or QUALIFIED with preserved history.
Glue: This automation connects REACTIVATE → LEAD stages (stage 19 back to stage 1 of the 19‑stage pipeline). It targets leads that were lost (CLOSED_LOST with a cooldown period) or customers who have not made a repeat purchase within a defined timeframe. It sends a timed sequence of re‑engagement messages and, if successful, moves the contact back to NEW_LEAD or QUALIFIED with preserved history.

Mermaid Diagram – Production‑Grade Flow

Production-grade flow
flowchart TD
    A[Trigger: reactivation_date reached OR 365d since last job] --> B[Move contact from CLOSED_LOST or DORMANT to REACTIVATION stage]
    B --> C[Send first reactivation SMS/email offer]
    C --> D[Start 14d timer for second touch]
    D --> E{Lead replies?}
    E -->|Yes| F[Move back to NEW_LEAD or QUALIFIED preserving estimate history]
    E -->|No after 14d| G[Send second reactivation message with stronger offer]
    G --> H[Start 14d timer for third touch]
    H --> I{Lead replies?}
    I -->|Yes| F
    I -->|No after 14d| J[Send final reactivation message with expiry]
    J --> K[Start 14d timer for archive]
    K --> L{Lead replies?}
    L -->|Yes| F
    L -->|No after 14d| M[Archive to DORMANT_ARCHIVE, suppress future contact]
    F --> N[Log reactivation event to Airtable]
    M --> N

Straightforward Implementation (Fast & Fragile)

Use case: Low volume, manual win‑back calls.

Steps:

  • Sales rep reviews lost leads quarterly.
  • Calls or emails manually.

Downsides:

  • Inconsistent.
  • No tracking of which messages work.
  • Low coverage.

Production‑Grade Implementation (Logged, Idempotent, Recoverable)

Tools (2026 concrete stack):

  • CRM: GoHighLevel (GHL)
  • Scheduling: Make cron scenarios
  • Communication: Twilio, SendGrid
  • Logging: Airtable

Step‑by‑step with real code (Node.js + Make cron):

1. Daily cron to move leads to REACTIVATION stage

javascript
// Scheduled every day at 8 AM (Make scenario or Node cron)
async function moveToReactivation() {
  // Query GHL for contacts with reactivation_date <= today AND stage != REACTIVATION
  const today = new Date().toISOString().split('T')[0];
  const contacts = await searchContacts({
    filters: [
      { field: 'reactivation_date', operator: '<=', value: today },
      { field: 'stage', operator: '!=', value: 'REACTIVATION' },
      { field: 'stage', operator: '!=', value: 'CLOSED_WON' } // exclude active customers
    ]
  });
  
  for (const contact of contacts) {
    await changeStage(contact.id, 'REACTIVATION');
    await addTag(contact.id, 'reactivation:entered');
    await logReactivationEvent(contact.id, 'moved_to_reactivation', { previous_stage: contact.stage });
    
    // Send first message immediately
    await sendFirstReactivationMessage(contact.id);
  }
}

2. Send first reactivation message

javascript
async function sendFirstReactivationMessage(contactId) {
  const lead = await getLeadData(contactId);
  const previousEstimate = await getCustomField(contactId, 'estimate_amount');
  const lossReason = await getCustomField(contactId, 'loss_reason');
  
  let offer = '';
  if (lossReason === 'price') {
    offer = `We now offer financing – payments as low as $${Math.round(previousEstimate * 0.02)}/month.`;
  } else if (lossReason === 'timing') {
    offer = `We're scheduling for next month – lock in your project now.`;
  } else {
    offer = `We have a special offer for past leads: $${Math.round(previousEstimate * 0.1)} off your estimate.`;
  }
  
  const sms = `Hi ${lead.name}, still thinking about your ${lead.service_type}? ${offer} Reply YES to revive your estimate.`;
  const emailSubject = `We haven't forgotten about your ${lead.service_type}`;
  const emailBody = `Dear ${lead.name},<br><br>We noticed you were interested in a ${lead.service_type} project. ${offer}<br><br><a href="${getReviveLink(contactId)}">Click here to revive your estimate</a><br><br>Reply to this email or call us.`;
  
  await sendSMS(lead.phone, sms);
  await sendEmail(lead.email, emailSubject, emailBody);
  
  await updateCustomField(contactId, 'reactivation_attempts', 1);
  await updateCustomField(contactId, 'last_reactivation_at', new Date().toISOString());
  await addTag(contactId, 'reactivation:attempt1');
  
  // Schedule second attempt in 14 days
  await scheduleReactivationStep(contactId, 14, 2);
  
  await logReactivationEvent(contactId, 'first_message_sent', { offer });
}

3. Handle lead reply (revive intent)

javascript
async function handleReactivationReply(contactId, replyText) {
  const lower = replyText.toLowerCase();
  if (lower.includes('yes') || lower.includes('revive') || lower.includes('start') || lower === 'y') {
    // Preserve estimate amount and service type
    const estimateAmount = await getCustomField(contactId, 'estimate_amount');
    const serviceType = await getCustomField(contactId, 'service_type');
    
    // Move back to NEW_LEAD or QUALIFIED (depending on whether estimate still valid)
    await changeStage(contactId, 'QUALIFIED');
    await updateCustomField(contactId, 'lead_score', 70); // reset to qualified
    await removeTags(contactId, ['reactivation:attempt1', 'reactivation:attempt2', 'reactivation:attempt3']);
    await addTag(contactId, 'reactivated:from_lost');
    
    // Notify rep
    await createTask(contactId, `Reactivated lead: ${contactId}. Follow up immediately.`, lead.assigned_rep, 2);
    
    await sendSMS(lead.phone, `Great! Your estimate has been revived. A specialist will contact you shortly.`);
    
    await logReactivationEvent(contactId, 'lead_reactivated', { reply: replyText });
  } else if (lower.includes('stop') || lower.includes('unsubscribe')) {
    // Opt out permanently
    await updateCustomField(contactId, 'do_not_contact', true);
    await changeStage(contactId, 'CLOSED_LOST');
    await addTag(contactId, 'unsubscribed');
    await logReactivationEvent(contactId, 'lead_unsubscribed', { reply: replyText });
  } else {
    // Unclear intent – create task for rep to follow up
    await createTask(contactId, `Lead replied to reactivation with unclear message: "${replyText}". Review.`, lead.assigned_rep, 24);
    await logReactivationEvent(contactId, 'unclear_reply', { reply: replyText });
  }
}

4. Subsequent reactivation steps (2nd and 3rd messages)

javascript
async function sendReactivationStep(contactId, stepNumber) {
  const lead = await getLeadData(contactId);
  const stage = await getContactStage(contactId);
  if (stage !== 'REACTIVATION') return; // already revived
  
  await updateCustomField(contactId, 'reactivation_attempts', stepNumber);
  await addTag(contactId, `reactivation:attempt${stepNumber}`);
  
  let message = '';
  if (stepNumber === 2) {
    message = `Hi ${lead.name}, last chance to revive your ${lead.service_type} estimate with a 10% discount. Reply YES within 7 days.`;
  } else if (stepNumber === 3) {
    message = `FINAL REMINDER: Your estimate expires in 7 days. Reply YES now to lock in your price.`;
  }
  
  await sendSMS(lead.phone, message);
  await sendEmail(lead.email, `Reminder: Your estimate is about to expire`, message);
  
  await logReactivationEvent(contactId, `message_${stepNumber}_sent`, { message });
  
  // Schedule next step or archive
  if (stepNumber < 3) {
    await scheduleReactivationStep(contactId, 14, stepNumber + 1);
  } else {
    // Final step: schedule archive in 14 days if no reply
    await scheduleArchive(contactId, 14);
  }
}

5. Archive if no response after all steps

javascript
async function archiveUnresponsiveLead(contactId) {
  const stage = await getContactStage(contactId);
  if (stage !== 'REACTIVATION') return;
  
  await changeStage(contactId, 'DORMANT_ARCHIVE');
  await addTag(contactId, 'reactivation:archived');
  await updateCustomField(contactId, 'archived_at', new Date().toISOString());
  
  await logReactivationEvent(contactId, 'lead_archived', {});
}

6. Logging to Airtable

javascript
async function logReactivationEvent(contactId, eventType, metadata) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/reactivation_log', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        contact_id: contactId,
        event_type: eventType,
        timestamp: new Date().toISOString(),
        metadata: JSON.stringify(metadata)
      }
    })
  });
}

Industry Variants

IndustryVariantReason
MedicalReactivation for annual check‑up reminders. Must comply with HIPAA; cannot mention specific conditions.Compliance
ConstructionReactivation for past bids (e.g., after losing a bid). Offer includes updated pricing or value engineering.Competitive
Real EstateReactivation for expired listings or past buyers. Offer includes market update report.Relationship
Home ImprovementAs shown – win‑back with discount or financing.Standard
SaaSReactivation for cancelled users: offer a discount or feature upgrade. Track via usage data.Churn reduction
Payment ProcessingReactivation for merchants who cancelled: offer rate review or new features.Retention
Executive AssistantNot applicable.N/A

Human Handoff

Handoff typeDescriptionWhen triggered
OptionalSales rep can manually revive a lost lead at any time.Lead calls in.
NecessaryIf lead replies with complex objection (not simple yes/no), rep must handle.Unclear reply.
QuestionableAutomated reactivation for high‑value lost leads (>$50k) – some prefer personal call.Lead value > $50k.

Override Behavior

If a rep manually marks a lost lead as reactivated (bypassing the sequence):

ElementBehavior
Tags addedmanual:reactivated
Stage changeMove to QUALIFIED.
Automation stoppedCancel any pending reactivation timers.
Loggingevent_type = manual_reactivation, rep_id.

Decision Guide

When to use this automationWhen NOT to use
Volume >30 lost leads/monthVery low volume
Close rate on reactivated leads >5%Low recovery rate
Need systematic win‑back programNo reactivation strategy
Executive assistant – not applicableN/A

Cost‑benefit:

  • Recovers 5–15% of lost leads (industry average).
  • On 200 lost leads/month at $15k LTV, 10% recovery = $300k/month.
  • Build production‑grade for any business with >50 lost leads/month.

End of Pattern 25

Part III – Cross‑Cutting Concerns

Chapter 26 – Compliance Wrappers (HIPAA, TCPA, SOC2, SOX)

CH26
Compliance Wrappers
HIPAA, TCPA, SOC2, and SOX layers around the workflow.
Reading time: 16 minutesAudience: Builders, ops leads, compliance officers

26.1 Why Compliance Cannot Be an Afterthought

Every automation that touches customer data, communication, or financial transactions is subject to regulations. The three most common families in business process automations are:

  • TCPA (Telephone Consumer Protection Act) – regulates SMS and calls.
  • HIPAA (Health Insurance Portability and Accountability Act) – regulates protected health information (PHI).
  • SOC2 / SOX – regulate financial and operational controls for publicly traded or service organizations.

The mistake most builders make: They design the automation for function, then try to “add compliance” later. That leads to brittle, expensive patches or outright violations.

The correct approach: Build compliance wrappers – layers that enforce consent, audit logging, data retention, and access control around your automation, not inside it. This keeps the core automation simple while ensuring the wrapper handles all regulatory requirements.

26.2 The Compliance Wrapper Pattern

Production-grade flow
flowchart TD
    A[Incoming data or request] --> B[Compliance Wrapper]
    B --> C{Check: consent on file?}
    C -->|No| D[Reject, log, notify compliance officer]
    C -->|Yes| E{Check: data retention policy}
    E -->|Expired| F[Purge old data before processing]
    E -->|OK| G[Anonymize PHI if not required]
    G --> H[Execute core automation]
    H --> I[Log all actions to immutable audit trail]
    I --> J[If data stored, encrypt at rest]
    J --> K[If data transmitted, use TLS]

Each compliance wrapper must handle:

  • Consent collection and verification – before any SMS, email, or data use.
  • Audit logging – every access, modification, or transmission.
  • Data minimization – only collect what is necessary.
  • Retention and deletion – purge after legal period.
  • Breach notification – automated alerting on anomalies.

26.3 TCPA Wrapper – SMS and Call Compliance

Key requirements:

  • Prior express written consent for automated SMS (including welcome messages).
  • Opt‑out mechanism (“Reply STOP to unsubscribe”).
  • Sender identification.
  • Time‑of‑day restrictions (8 AM – 9 PM local time).
  • Proof of consent must be stored.

Implementation pattern:

javascript
// TCPA wrapper before sending any SMS
async function sendSMSWithTCPA(contactId, message) {
  const consent = await getSMSConsent(contactId);
  if (!consent || consent.status !== 'active') {
    await logViolation(contactId, 'attempted_sms_without_consent');
    return { error: 'No consent', sent: false };
  }
  
  const localTime = getLocalTime(contactId.timezone);
  if (localTime < 8 || localTime > 21) {
    await queueMessageForNextMorning(contactId, message);
    return { error: 'Outside allowed hours', queued: true };
  }
  
  // Send via provider
  const result = await smsProvider.send(contactId.phone, message);
  
  // Log every message (audit trail)
  await logSMS(contactId, message, result);
  
  return result;
}

Consent collection (at lead capture):

html
<input type="checkbox" name="sms_consent" required>
<label>By checking this box, you agree to receive SMS messages from [Company Name] (up to 5 messages per month). Reply STOP to opt out. Message and data rates may apply.</label>

Opt‑out handling (detect STOP):

javascript
async function handleOptOut(contactId, replyText) {
  if (replyText.toLowerCase() === 'stop') {
    await updateCustomField(contactId, 'sms_consent', false);
    await addTag(contactId, 'sms:opt_out');
    await sendSMS(contactId.phone, 'You have been unsubscribed from SMS messages. No further messages will be sent.');
    await logEvent(contactId, 'sms_opt_out', {});
  }
}

TCPA wrapper checklist for every automation:

  • [ ] Consent check before any outbound SMS.
  • [ ] Opt‑out detection on every inbound SMS.
  • [ ] Time‑of‑day check (or use a queue for off‑hours).
  • [ ] Log every message (content, timestamp, outcome).
  • [ ] Consent proof stored (timestamp, IP, checkbox text).

26.4 HIPAA Wrapper – Protected Health Information (PHI)

Key requirements:

  • Only collect PHI when absolutely necessary.
  • Encrypt data at rest and in transit.
  • Access logs for any PHI view or modification.
  • Business Associate Agreement (BAA) with all vendors.
  • Automatic data purging after retention period (typically 6 years).
  • Breach notification within 60 days.

Implementation pattern:

javascript
// HIPAA wrapper before storing any patient data
async function storePHI(contactId, phiData) {
  // 1. Log access attempt
  await logPHIAccess(contactId, 'store', getUserRole());
  
  // 2. Validate that user has permission
  if (!hasPHIPermission(getUserRole())) {
    await logViolation('unauthorized_phi_access', getUserRole());
    throw new Error('Unauthorized');
  }
  
  // 3. Encrypt PHI fields before storage
  const encrypted = encryptFields(phiData, ['diagnosis', 'treatment', 'patient_id']);
  
  // 4. Store with retention metadata
  const record = await database.insert({
    ...encrypted,
    retention_expiry: new Date(Date.now() + 6 * 365 * 24 * 60 * 60 * 1000) // 6 years
  });
  
  // 5. Log the action (audit trail)
  await logPHIEvent(contactId, 'stored', record.id, getUserRole());
  
  // 6. Schedule deletion after retention period
  await scheduleDeletion(record.id, retention_expiry);
  
  return record;
}

Data minimization rule: Never send PHI via SMS or unencrypted email. Use a secure patient portal link instead.

javascript
async function notifyPatient(contactId, message) {
  // Do NOT send PHI in plaintext SMS/email
  const secureLink = generateSecurePortalLink(contactId);
  await sendSMS(contactId.phone, `You have a new secure message: ${secureLink}`);
}

Business Associate Agreement (BAA): Ensure that every vendor (GHL, Airtable, Twilio) signs a BAA before processing any PHI. Store the signed BAAs in your compliance Grimoire.

HIPAA wrapper checklist:

  • [ ] Encrypt PHI at rest (AES‑256) and in transit (TLS 1.3).
  • [ ] Role‑based access control (who can see PHI).
  • [ ] Audit log of every PHI access.
  • [ ] Automatic data purging after 6 years.
  • [ ] BAAs signed with all subcontractors.
  • [ ] No PHI in logs or error messages.

26.5 SOC2 / SOX Wrapper – Financial and Operational Controls

Key requirements (SOC2):

  • Security – access controls, encryption, firewalls.
  • Availability – uptime monitoring, disaster recovery.
  • Processing integrity – data validation, error handling.
  • Confidentiality – data classification, non‑disclosure.
  • Privacy – consent, notice, choice.

Key requirements (SOX – for publicly traded companies):

  • Separation of duties (no single person can both approve and process payment).
  • Audit trail of all financial transactions.
  • Access logging for financial systems.
  • Change management (no unapproved code changes).

Implementation pattern – Separation of duties:

javascript
// SOX wrapper: payment approval requires two different users
async function processPayment(paymentId, approverId, processorId) {
  if (approverId === processorId) {
    await logViolation('sox_separation_of_duties', { paymentId, approverId, processorId });
    throw new Error('Approver and processor must be different users');
  }
  
  // Log both actions
  await logFinancialEvent(paymentId, 'approved', approverId);
  await logFinancialEvent(paymentId, 'processed', processorId);
  
  // Process payment
  await paymentGateway.charge(paymentId);
}

Change management wrapper (for automation code):

javascript
// Every workflow deployment requires approval
async function deployWorkflow(workflowCode, requestorId) {
  // Check if code has been reviewed
  const review = await getCodeReview(workflowCode.id);
  if (!review.approved) {
    await createTask(`Code review required for workflow ${workflowCode.id}`, 'security@company.com');
    throw new Error('Cannot deploy unreviewed code');
  }
  
  // Deploy and log
  await deploy(workflowCode);
  await logChange(workflowCode.id, requestorId, 'deployed');
}

SOC2 wrapper – Availability monitoring:

javascript
// Automated health check for every critical automation
async function healthCheck() {
  const tests = [
    { name: 'lead_capture', execute: testLeadCapture },
    { name: 'sms_gateway', execute: testSMS },
    { name: 'payment_webhook', execute: testPaymentWebhook }
  ];
  
  for (const test of tests) {
    const result = await test.execute();
    if (!result.ok) {
      await logOutage(test.name, result.error);
      await escalateToOps(test.name);
    }
  }
}
// Run every 15 minutes
setInterval(healthCheck, 15 * 60 * 1000);

SOC2/SOX wrapper checklist:

  • [ ] Role‑based access with least privilege.
  • [ ] Separation of duties for financial actions.
  • [ ] Automated health checks with alerting.
  • [ ] Change approval workflow for automation code.
  • [ ] Audit trail for all financial and configuration changes.

26.6 Multi‑Regulation Compliance Matrix

RegulationApplies toWrapper must enforce
TCPASMS, callsConsent, opt‑out, time‑of‑day, logging
HIPAAPHI (medical data)Encryption, access controls, audit logs, BAAs, retention, breach notification
SOC2Service organizationsSecurity, availability, processing integrity, confidentiality, privacy
SOXPublicly traded companiesSeparation of duties, audit trails, change management

26.7 Compliance‑as‑Code (Terraform / Config as Code)

Store your compliance wrappers as code in version control. Example: Terraform for AWS infrastructure with HIPAA controls.

hcl
resource "aws_s3_bucket" "phi_storage" {
  bucket = "company-phi-data"
  server_side_encryption_configuration {
    rule {
      apply_server_side_encryption_by_default {
        sse_algorithm = "AES256"
      }
    }
  }
  
  lifecycle_rule {
    enabled = true
    expiration {
      days = 2190 # 6 years
    }
  }
}

26.8 Chapter Summary

RegulationCore requirementAutomation impact
TCPAConsent before SMSAdd consent check and opt‑out handler to every SMS automation
HIPAAPHI protectionEncrypt data, limit access, log every action, sign BAAs
SOC2Security + availabilityAdd health checks, access controls, audit trails
SOXSeparation of dutiesRequire multiple approvers for financial actions

One sentence takeaway:

Build compliance wrappers around your automations – not inside them – to enforce consent, encryption, audit trails, and retention without cluttering core logic.

End of Chapter 26

CH27
Tool Roles & 2026 Norms
Execution, orchestration, logging, intelligence, reporting, and compliance roles.
Reading time: 14 minutesAudience: Builders, ops leads, architects

27.1 The Five Tool Roles (Not Brands)

In 2026, there are thousands of automation tools. But they all fit into five roles. A well‑built automation stack has exactly one tool per role – not five CRMs, not zero logging.

RoleFunctionExamples (2026)Must have
ExecutionState machine, CRM, workflows, timersGoHighLevel, HubSpot Operations Hub, PipedriveIdempotency, stage changes, task creation
OrchestrationConnect tools, handle retries, route dataMake (Integromat), Zapier, n8n, custom webhooksError queues, conditional branching, dead‑letter queues
IntelligenceAI classification, extraction, scoringOpenAI (GPT‑4o‑mini, GPT‑4o), Claude, Gemini, fine‑tuned local modelsConfidence scores, fallback to rules
LoggingImmutable audit trail, metricsAirtable, BigQuery, Snowflake, DatadogAppend‑only, timestamped, queryable
ReportingDashboards, visualizationLooker Studio, Tableau, Power BI, MetabaseData freshness, sharing, alerts

The golden rule: No single tool can play two roles well. GHL is great at execution but terrible at logging. Airtable is great at logging but cannot execute complex workflows. Make is great at orchestration but should not store data.

27.2 Execution Role (CRM + Workflow Engine)

What it must do:

  • Store contact records and pipeline stages.
  • Trigger automations on state changes (timers, stage entry, tags).
  • Send communications (SMS, email) natively or via orchestration.
  • Create tasks and assign to users.
  • Maintain idempotency via tags or custom fields.

2026 norms for execution tools:

  • Built‑in timers with cancellation capability.
  • Round‑robin assignment with capacity limits.
  • Custom fields (no arbitrary limits).
  • Webhook triggers for every event.
  • SLA monitoring (time since state entry).

When to choose GoHighLevel (GHL) over others:

  • You need SMS as a primary channel.
  • You serve home improvement, local services, or agencies.
  • You want all‑in‑one without per‑contact pricing.

When to choose HubSpot Operations Hub:

  • You already use HubSpot CRM.
  • You need advanced reporting and attribution.
  • B2B SaaS or enterprise.

Anti‑pattern: Using your CRM as a logging tool (activity logs expire, cannot query historically). Execution tools are for current state, not history.

27.3 Orchestration Role (The Nervous System)

What it must do:

  • Receive webhooks and route to different actions.
  • Handle API calls with retries and backoff.
  • Write to multiple destinations (logging, CRM, intelligence).
  • Implement dead‑letter queues for failed events.
  • Conditional branching based on data.

2026 norms for orchestration tools:

  • Visual builder (no‑code / low‑code) for business logic.
  • Error handling with retry policies and error queues.
  • Data stores for idempotency keys and caching.
  • Scheduled scenarios (cron triggers).
  • Real‑time webhooks with <1s latency.

Make (Integromat) vs Zapier vs n8n:

FeatureMakeZapiern8n
Retry with backoffYesLimitedYes (self‑hosted)
Dead‑letter queueVia AirtableNoYes
Cost per operationLowerHigherFree (self‑hosted)
Learning curveMediumLowHigher

Recommendation: Use Make for most SMB automations. Use n8n if you need self‑hosted for compliance (HIPAA, SOC2). Avoid Zapier for high‑volume or complex error handling.

Anti‑pattern: Using orchestration as a database. Orchestration tools are stateless; they should not store long‑term data.

27.4 Intelligence Role (AI as a Cog)

What it must do:

  • Classify intents (price, trust, timing, etc.).
  • Extract structured fields from free text (BANT).
  • Generate personalized messages (drafts).
  • Score leads based on attributes.
  • Provide confidence scores (0–1).

2026 norms for intelligence tools:

  • Fast inference (<2 seconds for classification).
  • Low cost (fractions of a cent per call).
  • Fine‑tuning capability (retrain on your data).
  • Fallback to rule‑based when confidence low.
  • No storage of sensitive data (use orchestration to log).

OpenAI GPT‑4o‑mini is the default for most tasks (fast, cheap, accurate). Use GPT‑4o for complex generation or summarization. Use fine‑tuned local models (Llama 3, Mistral) for high‑volume, low‑latency, or offline compliance.

Anti‑pattern: Relying on AI as the sole decision maker. Always wrap AI with confidence thresholds and deterministic fallbacks (Chapter 1).

27.5 Logging Role (The Truth Layer)

What it must do:

  • Immutable, append‑only records.
  • Timestamp every event.
  • Store metadata in structured format (JSON).
  • Allow querying (filter by contact, event type, date range).
  • Retain data for compliance periods (6+ years for HIPAA, 7 years for SOX).

2026 norms for logging tools:

  • No deletion of log records (only archiving).
  • API‑first for ingestion from orchestration.
  • Scalable to millions of rows.
  • Access controls (logs are sensitive).

Airtable is sufficient for SMBs (up to 500k rows per base). For larger volumes, use BigQuery (free tier up to 1TB) or Snowflake. Use Datadog for real‑time operational logs (not long‑term audit).

Anti‑pattern: Using your CRM’s activity log as the primary logging tool. CRM logs expire, are not queryable, and cannot be used for compliance audits.

27.6 Reporting Role (Dashboards & Visualization)

What it must do:

  • Read from aggregated logs or data warehouse.
  • Display funnel conversion rates, SLA adherence, revenue attribution.
  • Allow filtering by date, source, rep.
  • Send scheduled reports (email, Slack).

2026 norms for reporting tools:

  • Live or near‑live (data lag <15 min).
  • Shareable with external stakeholders (no login required).
  • Embeddable in client portals or internal wikis.

Looker Studio (free) works for most SMBs. For enterprise, use Tableau or Power BI. For embedded analytics, use Metabase or Superset.

Anti‑pattern: Building dashboards directly from raw logs without aggregation (slow, expensive). Pre‑aggregate metrics in a Google Sheet or BigQuery.

27.7 The 2026 Default Stack (SMB)

RoleToolMonthly costSetup effort
ExecutionGoHighLevel Agency Pro$497Medium
OrchestrationMake Pro$109Medium
IntelligenceOpenAI GPT‑4o‑mini$20–$50Low
LoggingAirtable Pro$20Low
ReportingLooker Studio$0Low

Total: ~$650–$700/month for up to 10k events/day.

27.8 When to Deviate from the Default

ScenarioChange
Enterprise (>10k leads/month)Replace Airtable with BigQuery; add Tableau for dashboards.
HIPAA complianceAdd BAA with all vendors; use self‑hosted n8n instead of Make.
No budget for GHLUse HubSpot free tier (limited) or SuiteCRM (self‑hosted).
Low volume (<100 leads/month)Drop Make; use GHL workflows only. Drop Airtable; log to Google Sheets.
Executive assistant use caseUse no automation – manual is fine (Pattern 1 executive variant).

27.9 Tool Decision Guide

When evaluating a new tool, ask:

  1. Which role does it fill? (If it tries to fill two, be skeptical.)
  2. Does it export data? (Lock‑in is expensive.)
  3. What is the SLA? (99.9% uptime required for execution and orchestration.)
  4. Does it have audit logs? (You need to know who changed what.)
  5. Is there a fallback? (If this tool goes down, does your automation stop?)

Never choose a tool because it has the most features. Choose it because it plays exactly one role perfectly.

27.10 Chapter Summary

RoleFunction2026 defaultFailure if missing
ExecutionState machine, workflowsGoHighLevelLeads never move through pipeline
OrchestrationCross‑tool glue, retriesMakeBroken automations, lost data
IntelligenceAI classification, extractionOpenAIWrong responses, manual review overload
LoggingImmutable audit trailAirtableNo debugging, compliance failures
ReportingDashboards, alertsLooker StudioBlind decisions, missed SLAs

One sentence takeaway:

Build your automation stack with one tool per role – execution, orchestration, intelligence, logging, reporting – and never let a single tool play two roles.

End of Chapter 27

CH28
Idempotency, Retries, and Dead-Letter Queues
The recovery backbone that stops duplicate actions and silent failures.
Reading time: 15 minutesAudience: Builders, automation engineers

28.1 The Three Pillars of Reliable Automation

No matter how well you design your automations, things will fail. The difference between a fragile automation and a robust one is not whether it handles failures, but how.

Three patterns make automations production‑grade:

PatternProblem it solvesWithout it
IdempotencyDuplicate execution (same event processed twice)Duplicate SMS, double payments, duplicated tasks
RetriesTransient failures (network timeout, API 5xx)Lost events, incomplete workflows
Dead‑letter queuesPermanent failures (invalid data, API 404 after retries)Silent data loss, no recovery path

These three patterns work together. A robust automation:

  1. Is idempotent – running it twice produces the same result.
  2. Retries transient failures – with exponential backoff.
  3. Uses a dead‑letter queue – for events that fail after all retries, so a human can reprocess them.

28.2 Idempotency – Doing the Same Thing Once

Definition: Idempotency means that performing the same action multiple times has the same effect as performing it once.

Example – sending a welcome SMS:

  • Non‑idempotent: send SMS every time the workflow runs. If the workflow runs twice (e.g., duplicate webhook), the customer gets two SMS messages.
  • Idempotent: check a tag auto:welcome_sent before sending. If the tag exists, skip. After sending, add the tag.

Why idempotency is critical: Webhooks can be delivered multiple times. Payment gateways send duplicate webhooks. Users may click buttons twice. Without idempotency, your automation will act on duplicates.

Implementation patterns:

MethodHow it worksBest for
Tag/flag checkBefore action, check if a tag or custom field exists. After action, set it.CRM workflows (GHL, HubSpot)
Idempotency keyGenerate a unique key for each operation (e.g., lead_{id}_welcome_sms). Store in a data store with TTL. If key exists, skip.API endpoints, webhook handlers
Database unique constraintUse a unique index on (event_type, contact_id, timestamp) to prevent duplicate inserts.Logging tables

Example – idempotency key in Make (using Data store):

javascript
// Before sending SMS
const idempotencyKey = `welcome_sms_${contactId}`;
const exists = await dataStore.get(idempotencyKey);
if (exists) {
  console.log('Duplicate, skipping');
  return;
}
// Send SMS
await sendSMS(contactId, message);
// Store key with 1 hour TTL
await dataStore.set(idempotencyKey, 'sent', 3600);

Idempotency for state changes: Moving a lead from NEW_LEAD to CONTACTED is naturally idempotent – moving it twice does nothing. But creating a task is not; you need to check if a task already exists for that action.

Idempotency checklist for every automation:

  • [ ] Every side effect (SMS, email, task, API call) has an idempotency check.
  • [ ] Idempotency keys have a reasonable TTL (1 hour to 7 days).
  • [ ] Duplicate detection is logged (so you can audit).

28.3 Retries – Handling Transient Failures

Definition: A retry is an automatic re‑attempt of a failed action, typically with a delay between attempts.

When to retry:

  • Network timeouts (API takes >5 seconds).
  • HTTP 5xx errors (server issues).
  • Rate limits (429 – retry after delay).
  • Temporary service unavailability.

When NOT to retry:

  • HTTP 4xx errors (client error – bad request, unauthorized).
  • Business logic failures (missing required field).
  • Actions that are not idempotent (retry would cause duplication).

Retry strategy – exponential backoff with jitter:

AttemptDelay (fixed)Delay with jitter (±20%)
10s (original)0s
22s1.6–2.4s
35s4–6s
415s12–18s
545s36–54s

Implementation in Make (custom error handler):

Production-grade flow
flowchart LR
    A[Module execution] --> B{Success?}
    B -->|Yes| C[Continue]
    B -->|No| D[Retry count <5?]
    D -->|Yes| E[Sleep 2^retry seconds]
    E --> A
    D -->|No| F[Send to dead‑letter queue]

Implementation in GHL workflows: GHL has limited retry. For critical API calls, use Make instead of GHL’s native HTTP module.

Retry for payment processing (Stripe webhook):

javascript
async function handlePaymentWebhook(event, retries = 3) {
  try {
    await processPayment(event);
  } catch (err) {
    if (retries > 0 && (err.status === 500 || err.code === 'ETIMEDOUT')) {
      await new Promise(resolve => setTimeout(resolve, 2000 * (4 - retries)));
      return handlePaymentWebhook(event, retries - 1);
    }
    throw err; // permanent failure – send to DLQ
  }
}

Retry checklist:

  • [ ] Retries only for transient failures (not 4xx, not logic errors).
  • [ ] Exponential backoff (not fixed intervals).
  • [ ] Max retries (usually 3–5).
  • [ ] After max retries, fail to dead‑letter queue (not silent).

28.4 Dead‑Letter Queues (DLQ) – No Event Left Behind

Definition: A dead‑letter queue is a storage location for events that failed all retry attempts. A human (or a periodic job) can review and reprocess them.

Why you need a DLQ: Without a DLQ, failed events are simply lost. You have no visibility into why they failed, and no way to recover them.

Implementation – Airtable as a DLQ:

javascript
async function handleFailure(eventData, error, retryCount) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/dead_letter_queue', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        event_type: eventData.type,
        payload: JSON.stringify(eventData),
        error_message: error.message,
        retry_count: retryCount,
        created_at: new Date().toISOString(),
        status: 'pending'
      }
    })
  });
  
  // Send alert to ops
  await sendSlackAlert(`🚨 Event failed after ${retryCount} retries: ${eventData.type}. Check DLQ.`);
}

Periodic DLQ reprocessing (cron every hour):

javascript
async function reprocessDeadLetterQueue() {
  const records = await fetchDLQ({ status: 'pending', created_at: { $lt: '24 hours ago' } });
  for (const record of records) {
    try {
      await reprocessEvent(record.payload);
      await updateDLQStatus(record.id, 'processed');
    } catch (err) {
      await updateDLQStatus(record.id, 'failed_again', err.message);
      if (record.retry_count > 3) {
        await createTask(`Manual intervention needed for DLQ record ${record.id}`, 'ops@company.com');
      }
    }
  }
}

What to store in a DLQ:

  • Original payload (enough to replay).
  • Timestamp of failure.
  • Error message and stack trace.
  • Retry count.
  • Status (pending, processed, failed_again).

DLQ checklist:

  • [ ] Every critical automation writes to a DLQ after max retries.
  • [ ] DLQ is monitored (alert on new entries).
  • [ ] Periodic reprocessing job exists.
  • [ ] Manual recovery task created if reprocessing fails.

28.5 The Complete Failure Handling Pipeline

Production-grade flow
flowchart TD
    A[Event received] --> B[Idempotency check]
    B -->|Duplicate| C[Log duplicate, ignore]
    B -->|New| D[Execute action]
    D --> E{Success?}
    E -->|Yes| F[Log success, done]
    E -->|No| G{Transient error?}
    G -->|Yes| H[Retry with backoff]
    H --> I{Retry count < max?}
    I -->|Yes| D
    I -->|No| J[Send to dead‑letter queue]
    G -->|No| J
    J --> K[Alert ops, create task]
    K --> L[Manual or automated reprocess]
    L --> D

28.6 Real‑World Examples

Example 1 – Lead capture webhook with all three patterns:

javascript
// Idempotency: check if lead already exists
if (await findContactByPhone(phone)) {
  await logDuplicate(phone);
  return;
}
// Retry: webhook handler will retry on 5xx
await createLead(data);
// DLQ: if createLead fails after retries, Make or your webhook handler sends to DLQ

Example 2 – Payment processing with idempotency key:

javascript
const idempotencyKey = `payment_${invoiceId}_${paymentIntent}`;
const processed = await redis.get(idempotencyKey);
if (processed) return;
await processPayment();
await redis.setex(idempotencyKey, 86400, 'processed');

Example 3 – Estimate follow‑up sequence with retry:

javascript
async function sendFollowUp(contactId, step) {
  for (let i = 0; i < 3; i++) {
    try {
      await smsProvider.send(contactId.phone, message);
      break;
    } catch (err) {
      if (i === 2) throw err;
      await sleep(2000 * (i + 1));
    }
  }
}

28.7 Monitoring Idempotency, Retries, and DLQ

MetricWhat to measureAlert if
Idempotency hit rate% of events skipped because duplicate>5% (investigate duplicate webhooks)
Retry success rate% of failed actions that succeed on retry<80% (increase backoff or fix root cause)
DLQ sizeNumber of pending DLQ records>10 for more than 1 hour
DLQ ageOldest pending record>24 hours

Dashboard (Looker Studio):

text
Idempotency: 1,234 duplicates skipped (2.1%) – OK
Retry success: 87% – OK
DLQ pending: 3 records – oldest 2 hours – check

28.8 Chapter Summary

PatternPurposeImplementationFailure without it
IdempotencyPrevent duplicate actionsTag check, idempotency key, unique constraintDuplicate SMS, double payments, duplicated tasks
RetriesRecover from transient failuresExponential backoff, max attemptsLost events, incomplete workflows
Dead‑letter queueCapture permanently failed eventsAirtable or database table, reprocessing jobSilent data loss, no recovery path

One sentence takeaway:

Every production automation must be idempotent, retry transient failures with exponential backoff, and send permanent failures to a dead‑letter queue for human review – or you will lose data and customers will notice.

End of Chapter 28

CH29
Testing Automations
Unit, integration, chaos, and synthetic health checks for workflow systems.
Reading time: 14 minutesAudience: Builders, QA engineers, ops leads

29.1 Why Testing Automations Is Different from Testing Software

Automations are not traditional software. They:

  • Run in production with live data from the first day.
  • Involve external services (CRMs, email, SMS, payment gateways).
  • Have non‑deterministic inputs (human replies, API delays, network hiccups).
  • Must recover gracefully from failures (not just crash).

Therefore, testing automations requires four layers, not just one:

LayerWhat it testsWhen to runWho runs it
UnitA single action or function (e.g., idempotency check)During development, on every code changeDeveloper
IntegrationInteraction between two systems (e.g., webhook → CRM)Before deployment to stagingDeveloper + QA
ChaosBehavior under failure conditions (e.g., API timeout, rate limit)Weekly in stagingOps + QA
Synthetic health checksEnd‑to‑end production functionality (e.g., form → SMS)Every 15 minutes in productionOps (automated)

The mistake most teams make: They test the happy path (unit + integration) and skip chaos + synthetic. Then production failures surprise them.

29.2 Unit Testing – Isolate and Verify

What to unit test:

  • Idempotency logic (duplicate detection).
  • Score calculations (lead scoring, ROI).
  • Data transformations (normalizing webhook payloads).
  • Conditional routing (if confidence >0.7 → auto‑act, else escalate).

How to unit test (using JavaScript with Jest):

javascript
// Function to test
function calculateLeadScore(budget, timeline, authority, need, source) {
  let score = 0;
  if (budget === 'high') score += 30;
  if (timeline === '0-30d') score += 30;
  if (authority === 'homeowner') score += 20;
  if (need >= 4) score += 20;
  if (source === 'referral') score += 15;
  return Math.min(score, 100);
}

// Unit test
test('calculates score correctly for high‑intent lead', () => {
  const score = calculateLeadScore('high', '0-30d', 'homeowner', 5, 'referral');
  expect(score).toBe(100); // 30+30+20+20+15 = 115 capped at 100
});

test('calculates score correctly for low‑intent lead', () => {
  const score = calculateLeadScore('low', '90+d', 'renter', 1, 'facebook');
  expect(score).toBe(0);
});

Unit testing for idempotency:

javascript
test('idempotency check prevents duplicate SMS', async () => {
  const contactId = 'test_123';
  await sendWelcomeSMS(contactId); // first attempt
  await sendWelcomeSMS(contactId); // second attempt
  const smsCount = await getSMSCount(contactId, 'welcome');
  expect(smsCount).toBe(1);
});

What not to unit test:

  • External API calls (mock them).
  • End‑to‑end flows (that’s integration and synthetic).

29.3 Integration Testing – Verify System Interactions

What to integration test:

  • Webhook → CRM creates contact.
  • AI classification → correct tag added.
  • Payment webhook → CRM updated with status.
  • Email delivery → receipt sent.

How to integration test (using a staging environment):

javascript
// Integration test for lead capture
test('lead capture webhook creates contact in CRM', async () => {
  const testLead = {
    name: 'Integration Test',
    phone: '+15551234567',
    email: 'test@example.com',
    service_type: 'patio_cover'
  };
  const response = await request(app).post('/webhook/lead').send(testLead);
  expect(response.status).toBe(200);
  
  // Verify contact exists in staging CRM
  const contact = await searchCRMContact(testLead.phone);
  expect(contact.name).toBe(testLead.name);
  expect(contact.tags).toContain('auto:welcome_sent');
});

Integration test environment requirements:

  • Separate CRM sub‑account (staging).
  • Separate webhook endpoints (staging URLs).
  • Test data that is cleaned up after tests.
  • Mock external APIs (OpenAI, Stripe) to avoid costs and variability.

Integration test checklist:

  • [ ] Happy path works (no errors).
  • [ ] Error path triggers fallback (e.g., AI timeout → rule‑based).
  • [ ] Idempotency prevents duplicates.
  • [ ] Logs are written to Airtable.

29.4 Chaos Testing – Break Things on Purpose

What to chaos test:

  • API timeouts (simulate OpenAI returning 503).
  • Rate limits (simulate Airtable 429).
  • Network partitions (webhook unreachable).
  • Invalid data (missing fields, malformed JSON).

How to chaos test (using a staging environment with fault injection):

javascript
// Mock OpenAI to always timeout
jest.mock('openai', () => ({
  chat: {
    completions: {
      create: jest.fn().mockRejectedValue(new Error('Timeout'))
    }
  }
}));

test('classify_lead_intent falls back to rule‑based on timeout', async () => {
  const result = await classifyLeadIntent('This is too expensive');
  expect(result.fallback).toBe(true);
  expect(result.intent).toBe('price');
});

Chaos test scenarios to run weekly:

ScenarioHow to simulateExpected behavior
OpenAI API downMock timeout or 500Rule‑based classification, log error, escalate after 3 attempts
Airtable rate limitSend 10 writes in 1 secondBatching or queuing, fallback to Google Sheets
GHL webhook unreachableDisable Make scenarioGHL retries 3x, then writes to DLQ, ops task created
SMS carrier failureMock SMS send failureFallback to email, create task for rep to call

Chaos test automation (using Make or custom script):

javascript
// Weekly chaos test: disable AI API key and verify fallback
async function chaosTestAI() {
  const originalKey = process.env.OPENAI_API_KEY;
  process.env.OPENAI_API_KEY = 'invalid_key';
  const result = await classifyLeadIntent('This is too expensive');
  process.env.OPENAI_API_KEY = originalKey;
  
  if (!result.fallback) {
    await sendAlert('Chaos test failed: AI fallback did not trigger');
  }
}

29.5 Synthetic Health Checks – Production Monitoring

What to monitor:

  • Lead capture (form → CRM → SMS) – every 15 minutes.
  • AI classification (test message → correct intent) – every hour.
  • Payment webhook (simulate payment → CRM update) – every hour.
  • Estimate tracking (simulate open → event logged) – every hour.

How to implement a synthetic health check (lead capture example):

javascript
// Scheduled every 15 minutes (Make cron)
async function healthCheckLeadCapture() {
  const testEmail = `test_${Date.now()}@healthcheck.com`;
  const testPhone = `+1555000${Date.now() % 10000}`;
  
  // Submit form
  await fetch('https://yourdomain.com/form', {
    method: 'POST',
    body: JSON.stringify({
      name: 'Health Check',
      phone: testPhone,
      email: testEmail,
      service_type: 'test'
    })
  });
  
  // Wait 1 minute
  await sleep(60 * 1000);
  
  // Verify contact appears in CRM
  const contact = await searchCRMByPhone(testPhone);
  if (!contact) {
    await sendCriticalAlert('Lead capture health check failed: contact not found');
    return;
  }
  
  // Verify welcome SMS sent
  const smsSent = await checkSMSSent(contact.id, 'welcome');
  if (!smsSent) {
    await sendCriticalAlert('Lead capture health check failed: welcome SMS not sent');
  }
  
  // Clean up (delete test lead)
  await deleteTestData(contact.id);
}

Health check dashboard (Looker Studio):

CheckLast runStatusLatency
Lead capture2 min ago1.2s
AI classification5 min ago0.8s
Estimate tracking15 min ago❌ failed
Payment webhook1 hour ago2.1s

Alerting rules:

  • If a health check fails once → send warning (Slack).
  • If it fails twice in a row → send critical alert (SMS to ops manager).

29.6 Testing Checklist by Automation Type

Automation typeUnitIntegrationChaosSynthetic
Lead captureIdempotency, dedupeWebhook → CRMWebhook timeoutEvery 15 min
AI classificationScore calculation, fallbackOpenAI → CRMAPI timeout, 429Every hour
Payment processingIdempotency keyStripe webhook → CRMDuplicate webhookEvery hour
Estimate follow‑upTimer logicFollow‑up sequenceNo responseTwice a day
DunningRetry countFailed payment → taskMax retries exceededDaily
Referral programReward calculationReferral link → leadExpired linkDaily

29.7 The Test Pyramid for Automations

Production-grade flow
graph TD
    A[Synthetic health checks] --> B[Chaos tests]
    B --> C[Integration tests]
    C --> D[Unit tests]
    
    style A fill:#f66,stroke:#333,stroke-width:2px
    style B fill:#f96,stroke:#333,stroke-width:2px
    style C fill:#ff9,stroke:#333,stroke-width:2px
    style D fill:#9f9,stroke:#333,stroke-width:2px
  • Unit tests – many, fast, cheap. Run on every commit.
  • Integration tests – fewer, slower. Run before staging deployment.
  • Chaos tests – few, slow, destructive. Run weekly.
  • Synthetic health checks – always running in production. Alert on failure.

29.8 Automating Test Execution

CI/CD pipeline (GitHub Actions example):

yaml
name: Test Automations
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: npm install
      - run: npm run test:unit
      - run: npm run test:integration -- --staging
      - run: npm run test:chaos -- --dry-run # non‑destructive

Schedule chaos tests (cron):

javascript
// Run every Sunday at 2 AM
scheduleJob('0 2 * * 0', async () => {
  await runChaosTests();
});

29.9 Chapter Summary

Test typePurposeFrequencyFailure response
UnitVerify isolated logicOn every code changeFix before merge
IntegrationVerify system interactionsBefore deploymentBlock deployment
ChaosVerify failure recoveryWeeklyFix root cause, add test case
SyntheticMonitor production healthEvery 15–60 minutesCritical alert on consecutive failures

One sentence takeaway:

Test automations at four levels – unit, integration, chaos, and synthetic health checks – because the happy path is never the only path in production.

End of Chapter 29

CH30
Documentation and Runbooks for Each Pattern
Operational memory for every automation you deploy.
Reading time: 12 minutesAudience: Builders, ops leads, support teams

30.1 Why Documentation Is Not Optional

An automation without documentation is a liability. When it breaks:

  • Who knows what it does?
  • Who knows how to fix it?
  • Who knows which vendors or API keys it uses?
  • Who knows what data it touches?

Without documentation, you have a bus factor of one. The only person who can fix it is the person who built it – and when they are on vacation, revenue stops.

The solution: Every automation pattern in this book must have a companion runbook – a living document that answers four questions:

  1. What does this automation do? (Purpose, trigger, actions)
  2. How do I monitor it? (Health checks, metrics, logs)
  3. What can go wrong? (Failure modes, error codes)
  4. How do I fix it? (Step‑by‑step recovery, escalation contacts)

30.2 The Runbook Template

Every automation pattern in Part II should have a runbook stored in your Grimoire (Chapter 60). Use this template:

markdown
# Runbook: [Pattern Name]

## 1. Overview
- **Pattern ID:** PXX (e.g., P01 – Lead Capture)
- **Purpose:** One sentence.
- **Trigger:** What starts this automation?
- **Key actions:** Bullet list.
- **Owners:** Who is responsible (Dev, Ops, Sales)?

## 2. Monitoring
- **Health check:** Name of synthetic test, frequency.
- **Key metrics:** Success rate, latency, error count.
- **Dashboard link:** [URL to Looker Studio]

## 3. Failure Modes
| Error / symptom | Probable cause | Detection |
|----------------|----------------|-----------|
| ... | ... | ... |

## 4. Recovery Steps
### Step 1 – Verify
- How to check if the automation is actually broken.

### Step 2 – Diagnose
- Commands or queries to run (Airtable, GHL, Make logs).

### Step 3 – Fix
- Step‑by‑step instructions.
- If manual fix, provide screenshots or links.

### Step 4 – Escalate
- Who to contact if steps fail (name, Slack, phone).

## 5. Dependencies
- **Internal:** CRM stages, custom fields, tags.
- **External:** API keys, vendor services (OpenAI, Twilio, etc.).

## 6. Testing
- How to manually test after fix.
- How to run the health check manually.

## 7. Change Log
| Date | Change | Author |
|------|--------|--------|

30.3 Example Runbook – Pattern 1 (Lead Capture)

markdown
# Runbook: P01 – Lead Capture

## 1. Overview
- **Purpose:** Capture inbound leads from web form, call, chat, or referral, create CRM contact, send welcome SMS, assign to rep.
- **Trigger:** Webhook to `https://hook.make.com/lead_capture` or GHL contact creation.
- **Key actions:** Deduplicate, create contact, send SMS (idempotent), create task, change stage to CONTACTED.
- **Owners:** Dev – Jane; Ops – Mike; Sales – rep on‑call.

## 2. Monitoring
- **Health check:** `lead_capture_health` (every 15 min). Pass if test lead appears in CRM within 2 min and SMS sent.
- **Key metrics:** Success rate (>99%), latency (<2s), dedupe rate (<5%).
- **Dashboard:** [Lead Capture Dashboard](https://lookerstudio.google.com/...)

## 3. Failure Modes
| Error / symptom | Probable cause | Detection |
|----------------|----------------|-----------|
| No leads in CRM for >1 hour | Webhook URL changed; form integration broken | Health check fails |
| Welcome SMS not sent | GHL SMS balance low; phone invalid | Check `sms_sent` tag in CRM; health check |
| Duplicate leads | No deduplication; race condition | High duplicate rate in dashboard |

## 4. Recovery Steps
### Step 1 – Verify
- Check health check dashboard. If health check fails, proceed.
- Run a manual test lead: submit form with unique email, wait 2 min.
- If lead not in CRM, go to Step 2.

### Step 2 – Diagnose
- Check GHL webhook logs: Settings → Webhooks → `lead_capture`. Status 200?
- Check Make scenario `lead_capture` last execution. Any errors?
- Check Airtable `dead_letter_queue` for pending leads.

### Step 3 – Fix
- **If webhook URL changed:** Update GHL webhook URL to `https://hook.make.com/lead_capture`.
- **If SMS balance low:** Go to GHL Settings → SMS → Add funds.
- **If duplicate detection broken:** Check dedupe logic in Make; ensure phone search uses exact match.

### Step 4 – Escalate
- If not resolved in 15 min: contact Jane (Dev) on Slack `#automation-alerts`.
- If revenue impact (no leads >2h): SMS ops manager at +15551234567.

## 5. Dependencies
- **Internal:** GHL pipeline stage `NEW_LEAD`, custom fields `source`, `service_type`.
- **External:** OpenAI API key (for spam detection, optional), Twilio (via GHL).

## 6. Testing
- Manual test: submit test lead via form, verify:
  - Lead appears in CRM within 30s.
  - Welcome SMS received.
  - Task created for rep.
  - Event logged to Airtable.
- Run health check manually: `curl -X POST https://hook.make.com/lead_capture_health`

## 7. Change Log
| Date | Change | Author |
|------|--------|--------|
| 2026-01-15 | Added deduplication logic | Jane |
| 2026-02-01 | Increased SMS retry to 3 attempts | Mike |

30.4 Runbook Storage and Access

LocationWho can accessUpdate frequency
Grimoire (Notion) – master copyAll team membersAfter every change
Slack pinned message – quick referenceOps, supportAfter major changes
PDF export – printed emergency binderOn‑call engineerQuarterly

Rule: A runbook is not “done” until it has been tested in a fire drill (Chapter 48). If you cannot follow your own runbook under pressure, rewrite it.

30.5 Minimal Runbook for Simple Automations

Not every automation needs a 7‑page runbook. For low‑criticality automations (e.g., internal daily report), use a minimal runbook:

markdown
# Runbook: Daily Sales Report

**Purpose:** Email sales report every morning at 8 AM.
**Owner:** Ops
**If broken:** Run `node daily_report.js` manually on server.
**Escalate:** Slack `#ops` if not resolved by 9 AM.
**Dependencies:** GHL API key stored in `.env`.

30.6 Runbook Maintenance Checklist

FrequencyTaskOwner
After every incidentUpdate runbook with root cause and recovery steps.Incident commander
MonthlyReview runbooks for stale links, outdated API keys, changed escalation contacts.Ops lead
QuarterlyFire drill: follow runbook for a simulated failure. Update any unclear steps.Ops + QA

30.7 Chapter Summary

ElementPurposeRequired for
RunbookStep‑by‑step recovery for a specific automationEvery production automation
Health checkAutomated synthetic testCritical path automations (lead capture, payments)
Escalation contactsWho to call when automation breaksAll automations
Change logTrack modifications for audit and debuggingAll automations

One sentence takeaway:

An automation without a runbook is a liability – document the purpose, monitoring, failure modes, recovery steps, and escalation contacts for every pattern, and test the runbook quarterly.

End of Chapter 30

CH31
Handoff Decision Guide
Flowcharts and industry rules for when humans stay in the loop.
Reading time: 12 minutesAudience: Builders, ops leads, product managers

31.1 The Core Question: Automate or Involve a Human?

Every automation pattern in this book includes a handoff type (optional, necessary, questionable). But how do you decide which handoff type applies to your specific automation, in your industry, with your risk tolerance?

This chapter provides a decision framework – flowcharts, industry‑specific rules, and a one‑page cheat sheet – to answer that question for any automation you design.

The three handoff types, defined again for clarity:

TypeDefinitionAutomation’s roleHuman’s role
OptionalAutomation works 95%+, but human can override if they wishSuggest, execute, logReview, correct, approve
NecessaryLegal, compliance, trust, or high‑stakes judgment requires human actionPrepare, notify, escalate – never decideDecide, approve, reject
QuestionableAutomation could be built, but cost/effort > benefit, or human touch adds disproportionate valueDo not automate (or build a helper)Own the action entirely

31.2 The Universal Handoff Decision Flowchart

Production-grade flow
graph TD
    A[Identify automation candidate] --> B{Is human input legally or regulatory required?}
    B -->|Yes| C[Necessary Handoff]
    B -->|No| D{Can AI achieve >95% accuracy consistently?}
    D -->|Yes| E[Fully automatable no handoff]
    D -->|No| F{What is the cost of a wrong automation decision?}
    F -->|High > $1,000 or reputation damage| C
    F -->|Medium $100–$1,000| G[Optional Handoff allow override]
    F -->|Low < $100| H{Does human add trust or relationship value?}
    H -->|Yes| G
    H -->|No| I{Volume > 100/month?}
    I -->|Yes| J[Build automation with fallback to human]
    I -->|No| K[Questionable keep manual or build helper]

Examples using the flowchart:

  • Lead capture (Pattern 1): No legal requirement for human at capture. AI can capture with >99% accuracy. Cost of wrong decision? None (duplicate lead). Fully automatable.
  • Financing approval (Pattern 16): Lender approval is automated, but human must offer in‑house plan if denied. Necessary for denial follow‑up.
  • Estimate objection (Pattern 11): AI accuracy 85%, cost of wrong decision medium (lost deal). Optional – auto‑respond but allow rep override.
  • Executive assistant calendar management: Volume low, trust high → Questionable. Do not automate; build helper only.

31.3 Industry‑Specific Handoff Rules

Different industries have different default handoff types for the same pattern. Use this table to override the generic decision.

PatternIndustryDefault handoffIndustry ruleReason
P01 Lead captureMedicalFully automatable➕ Necessary for insurance verificationHIPAA requires human to verify coverage
P01 Lead captureExecutive assistantQuestionableKeep manualTrust and low volume
P04 AI classificationMedicalOptional➕ Necessary for diagnosisCannot auto‑classify without physician review
P04 AI classificationExecutive assistantQuestionableKeep manualAssistant knows executive’s preferences
P11 Objection responseHome improvementOptional✅ Fully automatable for price objectionFinancing offer works well
P11 Objection responseMedicalOptional➕ Necessary for insurance objectionAuto‑send info, but human must verify
P12 Multi‑stage approvalConstructionNecessary✅ Fully automatable for change orders < $5kLow risk, can auto‑approve
P12 Multi‑stage approvalExecutive assistantNecessaryKeep manualExecutive signs all approvals personally
P13 E‑signatureReal estateNecessary✅ Auto‑trigger after human approvesAgent reviews contract, then auto‑send for signature
P15 DunningSaaSOptional✅ Fully automatable for low‑value subscriptions (<$50/mo)Churn risk low, automation recovers most
P15 DunningMedicalOptional➕ Necessary for payment plans > $10kRequires collections team review
P21 Sign‑offConstructionOptional➕ Necessary for projects > $100kLegal liability requires in‑person walkthrough
P23 Review requestHome improvementOptional✅ Fully automatableNo downside to automation
P23 Review requestMedicalOptional➕ Necessary (cannot solicit reviews with PHI)Must use generic, non‑PHI language

Legend: ✅ = more automation than default; ➕ = more human handoff than default.

31.4 Handoff by Risk and Volume Matrix

Plot your automation on this 2x2 matrix to determine handoff type:

Low risk (cost of error < $100)High risk (cost of error > $1,000)
High volume (>100/month)Fully automatable (no handoff)Optional (auto with human override)
Low volume (<100/month)Questionable (keep manual or helper)Necessary (human must decide)

Examples:

  • Low risk, high volume – Welcome SMS after lead capture. Automate fully.
  • High risk, high volume – Fraud detection in payments. Auto‑flag low confidence, human reviews borderline.
  • Low risk, low volume – Executive assistant calendar. Keep manual.
  • High risk, low volume – Approving a $500k contract. Necessary human sign‑off.

31.5 Handoff Decision Checklist (One‑Page)

Use this checklist for any new automation design:

Step 1 – Legal & compliance

  • [ ] Is human approval required by law? (e.g., medical diagnosis, contract over $X) → Necessary
  • [ ] Is an audit trail required? → Still can be automated, but log all actions.

Step 2 – AI accuracy

  • [ ] Can AI achieve >95% accuracy on this task? → Consider fully automatable.
  • [ ] If accuracy is 80–95% → Optional (auto with human override).
  • [ ] If accuracy <80% → Necessary or Questionable.

Step 3 – Cost of error

  • [ ] Estimate financial impact of a wrong automation decision.
  • [ ] If >$1,000 → Necessary or Optional (with mandatory review).
  • [ ] If <$100 → Fully automatable or Questionable.

Step 4 – Volume

  • [ ] Volume >100/month → Automate (even if questionable, reconsider).
  • [ ] Volume <10/month → Keep manual (Questionable).

Step 5 – Trust & relationship

  • [ ] Does a human touch add disproportionate value? (e.g., high‑value client) → Optional or Questionable.
  • [ ] Is this a repeatable, low‑trust interaction? → Automate.

31.6 Override Behavior Summary

When a human overrides an automation (for Optional or Necessary handoffs), the system must handle it consistently:

Override typeTags addedStage changeAutomation stoppedLogging
Rep overrides AI classificationmanual:override_classificationNone (or to MANUAL_REVIEW)Cancel pending auto‑responsesevent_type = manual_override
Rep manually creates leadmanual:lead_creation, auto:welcome_sentSet to CONTACTEDIntake workflow skipsevent_type = manual_lead_creation
Rep manually approves contractmanual:approval_overrideMove to APPROVEDCancel approval timersevent_type = manual_approval
Rep manually marks no‑show as rescheduledmanual:reschedule, no_show_overrideMove back to APPOINTMENT_BOOKEDCancel no‑show escalationevent_type = manual_reschedule

Code pattern for override detection (in every automation):

javascript
const tags = await getContactTags(contactId);
if (tags.includes(`manual:${automationName}`)) {
  console.log(`Manual override detected for ${automationName}, skipping automation.`);
  return;
}

31.7 Industry Handoff Quick Reference Table

IndustryTypical handoff biasExample
MedicalHeavy on necessary handoffDiagnosis, insurance verification, prescription approval
ConstructionMix: necessary for change orders >$5k, optional for sub‑$5kMaterial orders can be automated, change orders need PM approval
Real EstateNecessary for contract signing, optional for showing schedulingDisclosures require human review, appointment booking can be automated
Home ImprovementLight on necessary; most automations optional or fullPrice objection auto‑respond, financing auto‑approve
SaaSOptional for most; necessary only for enterprise contractsTrial signup fully automated, discount approval optional
Payment ProcessingNecessary for fraud review, optional for recurring dunningHigh‑risk transactions require human, low‑risk auto‑retry
Executive AssistantQuestionable for most; keep humanCalendar invites, email filtering, expense reports

31.8 When to Change Handoff Type Over Time

Handoff types are not static. As your AI improves, volume grows, or risk changes, you may move an automation from one category to another.

Example evolution of objection handling (Pattern 11):

PhaseAI accuracyVolumeHandoff typeReason
Month 170%50/monthOptional (human override)Too many mistakes to auto‑act
Month 690%200/monthFully automatableAccuracy high, volume justifies
Month 1295%500/monthFully automatable with confidence thresholdKeep fallback for low confidence

How to transition:

  1. Run both old (optional) and new (automatic) in parallel for 2 weeks.
  2. Compare error rates and customer satisfaction.
  3. If new is better, switch. Keep old as fallback.

31.9 Chapter Summary

ToolPurpose
FlowchartDecide handoff type (optional/necessary/questionable) based on legal, accuracy, cost, volume, trust
Industry rules tableOverride generic decisions for medical, construction, real estate, home improvement, SaaS, payments, executive
Risk/volume matrixQuick visual for handoff type
One‑page checklistPrintable reference for designing new automations
Override behavior tableStandard tags, stage changes, logging for manual overrides

One sentence takeaway:

Use the handoff decision flowchart, industry rules, and risk/volume matrix to determine whether an automation should be fully automatic, optional with human override, necessary with human decision, or questionable (keep manual).

End of Chapter 31

End of Part III

This concludes Part III – Cross‑Cutting Concerns. The remaining sections are:

  • Part IV – Appendices (A through F)
  • Appendix A – Pipeline Stage Matrix (Maps 19 stages to patterns)
  • Appendix B – Pseudocode & Real‑Code Library
  • Appendix C – Tool Comparison Tables (2026)
  • Appendix D – Decision Guides (One‑Page Checklist)
  • Appendix E – Sample Runbooks for Failure Scenarios
  • Appendix F – Human Handoff Cheat Sheet
APP A
Appendix A: Pipeline Stage Matrix
Maps all 19 pipeline stages to the patterns that affect them.
Purpose: Maps each of the 19 pipeline stages (defined in the Foreword) to the automation patterns that connect or operate within them. Use this appendix to answer: “Which automations affect this stage of my business process?”

A.1 The 19 Pipeline Stages (Recap)

#Stage NameDescription
1LEADCapture inbound inquiry
2QUALIFYDetermine fit (budget, authority, need, timeline)
3APPOINT (for estimate)Schedule consultation or site visit
4ESTIMATEDeliver quote or scope of work
5approve or reject estCustomer decides to accept, reject, or request changes
6FU contractFollow‑up on contract
7contractNegotiate and sign contract
8initial payDeposit or down payment
9FU payFollow‑up on payments
10Order suppliesProcurement and purchase orders
11monitor deliveryTrack shipments, manage delays
12schedule work/siteAssign crews, book installation dates
13job in progressMulti‑phase milestone tracking
14job finishedWork completed
15FU paymentsFinal payment collection
16PAY fullFinal payment received
17FULFILLFinal delivery, sign‑off, warranty
18poach for reviewRequest reviews and referrals
19REACTIVATEWin‑back lost leads or dormant customers

A.2 Pattern to Stage Mapping

Each pattern (P01–P25) is listed with the stages it primarily affects. “Connects” means the pattern bridges those stages. “Operates within” means it lives inside a single stage.

PatternNameStages (Affected)Type
P01Capture inbound lead1 (LEAD) → 2 (QUALIFY)Connects
P02Deduplicate and merge1 (LEAD)Operates within
P03Round‑robin / territory assignment1 (LEAD) → 2 (QUALIFY)Connects
P04AI classification / BANT2 (QUALIFY) → 3 (APPOINT)Connects
P05Self‑service appointment booking3 (APPOINT) → 4 (ESTIMATE)Connects
P06Multi‑channel reminders with confirmation3 (APPOINT)Operates within
P07No‑show detection and auto‑reschedule3 (APPOINT) → 19 (REACTIVATE)Connects
P08Document generation from template4 (ESTIMATE) → 5 (approve or reject)Connects
P09Open/click tracking with webhooks4 (ESTIMATE)Operates within
P10Timer‑based follow‑up sequence4 (ESTIMATE) → 5 (approve or reject)Connects
P11Objection detection and auto‑response5 (approve or reject) → 6 (FU contract)Connects
P12Multi‑stage approval workflow6 (FU contract) → 7 (contract)Connects
P13E‑signature collection with audit trail7 (contract) → 8 (initial pay)Connects
P14Payment processing with idempotent webhooks8 (initial pay) → 9 (FU pay) → 16 (PAY full)Connects
P15Dunning management for recurring payments9 (FU pay) → 16 (PAY full) → 18 (poach for review)Connects
P16Financing application & approval workflow5 (approve or reject) → 8 (initial pay)Connects
P17Purchase order generation and vendor routing8 (initial pay) → 10 (Order supplies)Connects
P18Shipment tracking with delay alerts10 (Order supplies) → 11 (monitor delivery) → 12 (schedule work/site)Connects
P19Resource‑aware scheduling11 (monitor delivery) → 12 (schedule work/site)Connects
P20Multi‑phase milestone tracking & notifications12 (schedule work/site) → 13 (job in progress) → 14 (job finished)Connects
P21Digital sign‑off on completion14 (job finished) → 15 (FU payments) → 16 (PAY full) → 17 (FULFILL)Connects
P22Warranty registration and document delivery17 (FULFILL) → 18 (poach for review)Connects
P23Review request with reputation monitoring17 (FULFILL) → 18 (poach for review)Connects
P24Referral program with unique links & reward tracking18 (poach for review) → 19 (REACTIVATE) → back to 1 (LEAD)Connects (loop)
P25Reactivation sequence (lost leads / dormant customers)19 (REACTIVATE) → 1 (LEAD)Connects (loop)

A.3 Stage to Pattern Reverse Lookup

For a given stage, which patterns affect it? (Useful when designing a specific process.)

StagePatterns
1 – LEADP01, P02, P03, P24 (loop), P25 (loop)
2 – QUALIFYP01, P03, P04
3 – APPOINTP04, P05, P06, P07
4 – ESTIMATEP05, P08, P09, P10
5 – approve or reject estP08, P10, P11, P16
6 – FU contractP11, P12
7 – contractP12, P13
8 – initial payP13, P14, P16, P17
9 – FU payP14, P15
10 – Order suppliesP17, P18
11 – monitor deliveryP18, P19
12 – schedule work/siteP18, P19, P20
13 – job in progressP20
14 – job finishedP20, P21
15 – FU paymentsP21
16 – PAY fullP14, P15, P21
17 – FULFILLP21, P22, P23
18 – poach for reviewP15, P22, P23, P24
19 – REACTIVATEP07, P24, P25

A.4 How to Use This Matrix

ScenarioAction
You are designing a new automation for a specific stageLook up the stage in the reverse lookup table. See which patterns already exist – you may reuse or adapt them.
You are auditing a business processWalk through stages 1–19. For each stage, check if the corresponding patterns are implemented. Missing patterns indicate automation gaps.
You are troubleshooting a failureIdentify which stage the failure occurred in. Check the patterns that affect that stage – one of them may be the root cause.

End of Appendix A

APP B
Appendix B: Pseudocode & Real-Code Library
Reusable snippets for idempotency, DLQs, timers, retries, logging, referrals, and overrides.
Purpose: Reusable code snippets for common automation patterns. Each snippet includes both pseudocode (for understanding) and real code (JavaScript/Node.js) that can be adapted to your stack. Use this library to avoid reinventing common patterns.

B.1 Idempotency Key Pattern

Problem: Prevent duplicate processing of the same event (e.g., duplicate webhook, user double‑click).

Pseudocode:

text
idempotency_key = generate_unique_key(event_type, contact_id, timestamp_rounded)
if key_exists_in_datastore(idempotency_key):
    log_duplicate()
    return
process_event()
store_key_with_ttl(idempotency_key, ttl_1_hour)

Real code (Node.js with Redis):

javascript
const crypto = require('crypto');
const redis = require('redis');
const client = redis.createClient();

async function withIdempotency(eventType, contactId, processor) {
  // Generate consistent key for this event (idempotent across retries)
  const keyBase = `${eventType}_${contactId}`;
  const idempotencyKey = crypto.createHash('sha256').update(keyBase).digest('hex');
  
  const exists = await client.get(idempotencyKey);
  if (exists) {
    console.log(`Duplicate event ${eventType} for ${contactId}, skipping`);
    return { duplicate: true };
  }
  
  // Process the event
  const result = await processor();
  
  // Store key with 1 hour TTL (adjust as needed)
  await client.setex(idempotencyKey, 3600, 'processed');
  return result;
}

// Usage
await withIdempotency('welcome_sms', contactId, async () => {
  await sendSMS(contactId, 'Welcome!');
});

Alternative (Airtable as data store):

javascript
async function isDuplicate(idempotencyKey) {
  const records = await fetchAirtable('idempotency_log', {
    filterByFormula: `{key} = '${idempotencyKey}'`
  });
  return records.length > 0;
}

async function markProcessed(idempotencyKey) {
  await createAirtableRecord('idempotency_log', {
    key: idempotencyKey,
    processed_at: new Date().toISOString()
  });
}

B.2 Webhook Handler with Dead‑Letter Queue

Problem: Receive webhook, process, retry on failure, send to DLQ if permanent failure.

Pseudocode:

text
webhook_handler(event):
    try:
        process_event(event)
    catch error:
        if is_transient(error) and retry_count < 3:
            wait_exponential_backoff()
            webhook_handler(event)
        else:
            write_to_dead_letter_queue(event, error)
            alert_ops()

Real code (Express endpoint):

javascript
const express = require('express');
const app = express();
const MAX_RETRIES = 3;
const RETRY_DELAYS = [2000, 5000, 15000]; // 2s, 5s, 15s

app.post('/webhook/lead', express.json(), async (req, res) => {
  const event = req.body;
  const idempotencyKey = req.headers['idempotency-key'] || `${event.type}_${event.contact_id}`;
  
  try {
    await processWithRetry(event, idempotencyKey, 0);
    res.status(200).send('OK');
  } catch (err) {
    await deadLetterQueue(event, err);
    res.status(500).send('Failed – check DLQ');
  }
});

async function processWithRetry(event, idempotencyKey, attempt) {
  try {
    // Check idempotency
    if (await isProcessed(idempotencyKey)) return;
    
    // Business logic
    await createContact(event);
    await sendSMS(event.phone);
    await markProcessed(idempotencyKey);
    
  } catch (err) {
    const isTransient = err.status === 500 || err.code === 'ETIMEDOUT';
    if (isTransient && attempt < MAX_RETRIES) {
      await sleep(RETRY_DELAYS[attempt]);
      return processWithRetry(event, idempotencyKey, attempt + 1);
    }
    throw err;
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function deadLetterQueue(event, error) {
  await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/dead_letter_queue', {
    method: 'POST',
    headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
    body: JSON.stringify({
      fields: {
        payload: JSON.stringify(event),
        error: error.message,
        timestamp: new Date().toISOString(),
        status: 'pending'
      }
    })
  });
  await sendSlackAlert(`⚠️ Webhook failed: ${error.message}`);
}

B.3 Timer‑Based Escalation

Problem: Schedule a future action (e.g., follow‑up after 24 hours), but allow cancellation if the lead takes action early.

Pseudocode:

text
schedule_action(contact_id, action_name, delay_seconds):
    timer_id = create_unique_id()
    store {timer_id, contact_id, action_name, created_at}
    schedule_delayed_job(timer_id, delay_seconds)
    
on_timer_fire(timer_id):
    if not cancelled(timer_id):
        execute_action()
        log_event()

Real code (using Redis + Bull queue):

javascript
const Queue = require('bull');
const followUpQueue = new Queue('follow-up', 'redis://localhost:6379');

async function scheduleFollowUp(contactId, delayHours) {
  const jobId = `followup_${contactId}_${Date.now()}`;
  await followUpQueue.add(
    { contactId, type: 'estimate_followup' },
    { delay: delayHours * 60 * 60 * 1000, jobId }
  );
  // Store jobId in CRM for potential cancellation
  await updateCustomField(contactId, 'pending_followup_job', jobId);
  return jobId;
}

async function cancelFollowUp(contactId) {
  const jobId = await getCustomField(contactId, 'pending_followup_job');
  if (jobId) {
    const job = await followUpQueue.getJob(jobId);
    if (job) await job.remove();
    await updateCustomField(contactId, 'pending_followup_job', null);
  }
}

// Worker
followUpQueue.process(async (job) => {
  const { contactId } = job.data;
  const stage = await getContactStage(contactId);
  if (stage === 'ESTIMATE_SENT') {
    await sendFollowUpSMS(contactId);
    await logEvent(contactId, 'auto_followup_sent');
  }
});

Alternative (GHL native – using “Wait” steps with stage change detection):

In GHL workflow, you can schedule a 24h wait, but you cannot cancel it easily. Workaround: on entering ESTIMATE_SENT, start a timer workflow. On state change to CLOSED_WON or OBJECTION_HANDLING, have a separate workflow that adds a tag, and the timer workflow checks for that tag before proceeding.

B.4 Retry with Exponential Backoff

Problem: Call an external API that may fail transiently. Retry with increasing delays.

Pseudocode:

text
function call_with_retry(api_call, max_retries=5):
    for attempt in 0..max_retries:
        try:
            return api_call()
        catch error:
            if is_transient(error) and attempt < max_retries:
                delay = 2^attempt * 1000 ms ± jitter
                sleep(delay)
            else:
                throw error

Real code:

javascript
async function callWithRetry(apiFn, maxRetries = 5) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await apiFn();
    } catch (err) {
      const isTransient = err.status >= 500 || err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET';
      if (!isTransient || attempt === maxRetries) throw err;
      
      const baseDelay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s, 16s
      const jitter = baseDelay * 0.2 * (Math.random() - 0.5);
      const delay = baseDelay + jitter;
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
await callWithRetry(() => openai.chat.completions.create({...}));

B.5 Rule‑Based Fallback for AI Classification

Problem: When AI is unavailable or confidence is low, use keyword matching as fallback.

Pseudocode:

text
if ai_confidence < 0.7 or ai_timeout:
    intent = keyword_fallback(message)
else:
    intent = ai_result

Real code:

javascript
function keywordFallback(message) {
  const lower = message.toLowerCase();
  if (lower.includes('expensive') || lower.includes('price') || lower.includes('cost')) return 'price';
  if (lower.includes('trust') || lower.includes('license') || lower.includes('reviews')) return 'trust';
  if (lower.includes('later') || lower.includes('not ready') || lower.includes('delay')) return 'timing';
  if (lower.includes('competitor') || lower.includes('other company') || lower.includes('compare')) return 'competitor';
  if (lower.includes('confused') || lower.includes('understand') || lower.includes('explain')) return 'confusion';
  if (lower.includes('yes') || lower.includes('accept') || lower.includes('proceed')) return 'accept';
  if (lower.includes('no') || lower.includes('cancel')) return 'reject';
  return 'other';
}

async function classifyWithFallback(message) {
  try {
    const result = await openaiCall(message);
    if (result.confidence >= 0.7) return result;
    return { intent: keywordFallback(message), isFallback: true };
  } catch (err) {
    console.error('AI failed, using fallback', err);
    return { intent: keywordFallback(message), isFallback: true };
  }
}

B.6 Batch Logging to Avoid Rate Limits

Problem: Airtable rate limit (5 requests per second). Writing each event individually causes 429 errors.

Solution: Accumulate events and write in batches.

Pseudocode:

text
batch = []
function log_event(event):
    batch.push(event)
    if batch.length >= 10 or timer(5s) expired:
        write_batch_to_airtable(batch)
        batch = []

Real code (Make scenario – using “Collector” module):

In Make, use the “Collector” tool (under Flow Control). It accumulates incoming events and outputs an array when the size reaches a limit or a timeout occurs. Then use Airtable’s “Create multiple records” (max 10 per call).

Alternative (Node.js with setInterval):

javascript
let eventBatch = [];
let batchTimer = null;

function logEvent(event) {
  eventBatch.push(event);
  if (eventBatch.length >= 10) flushBatch();
  else if (!batchTimer) startBatchTimer();
}

function startBatchTimer() {
  batchTimer = setTimeout(() => flushBatch(), 5000);
}

async function flushBatch() {
  if (eventBatch.length === 0) return;
  const batch = [...eventBatch];
  eventBatch = [];
  if (batchTimer) clearTimeout(batchTimer);
  batchTimer = null;
  
  try {
    await fetch('https://api.airtable.com/v0/YOUR_BASE_ID/event_log', {
      method: 'POST',
      headers: { 'Authorization': 'Bearer AIRTABLE_API_KEY' },
      body: JSON.stringify({ records: batch.map(e => ({ fields: e })) })
    });
  } catch (err) {
    console.error('Batch write failed', err);
    // Fallback: write individually or to Google Sheets
  }
}

B.7 Generate Unique Referral Code

Problem: Generate a short, unique, human‑readable code for referral links.

Pseudocode:

text
code = base64_encode(contact_id)[0:8]
if code already exists:
    code = code + random_digit

Real code:

javascript
const crypto = require('crypto');

function generateReferralCode(contactId) {
  // Create a hash of contactId and take first 6 characters
  const hash = crypto.createHash('sha256').update(contactId.toString()).digest('hex');
  let code = hash.substring(0, 6).toUpperCase();
  // Ensure uniqueness (if collision, append number)
  // In practice, odds are extremely low; add collision check if needed
  return code;
}

B.8 Scheduled Cron Job (Make)

Problem: Run an automation daily (e.g., reactivation cron).

Solution (Make): Create a scenario with “Schedule” module set to “At regular intervals” → “Daily” → time 8 AM. Then add modules to query GHL for leads with reactivation_date = today and process them.

Pseudocode (Node.js cron):

javascript
const cron = require('node-cron');

cron.schedule('0 8 * * *', async () => {
  console.log('Running reactivation cron');
  const leads = await getLeadsForReactivation();
  for (const lead of leads) {
    await moveToReactivation(lead.id);
  }
});

B.9 Human Override Detection

Problem: Detect whether a human has manually intervened, so automation can skip.

Real code (GHL tag check):

javascript
async function hasManualOverride(contactId, automationName) {
  const tags = await getContactTags(contactId);
  return tags.includes(`manual:${automationName}`);
}

// At the start of automation
if (await hasManualOverride(contactId, 'lead_intake')) {
  console.log('Manual override present, skipping automation');
  return;
}

B.10 Template for Error Handling in Make

Make scenario structure (textual representation):

  1. Webhook module – receives event.
  2. Try‑catch wrapper (simulated by error handler route):
  3. Main path: call OpenAI, parse JSON, update CRM.
  4. Error path: on 5xx → sleep 2^retry seconds → re‑run module. On 4xx → go to fallback.
  5. Fallback router:
  6. Rule‑based classification.
  7. If fallback succeeds, continue.
  8. If fallback fails, write to Airtable DLQ and send Slack alert.

Real code pattern for Make (not code, but configuration): Use “Error handling” module with “Retry on connection error” set to 3 times, and custom “Error handler” with “Sleep” and “Re‑run”.

End of Appendix B

APP C
Appendix C: Tool Comparison Tables (2026)
A role-based comparison instead of a tool endorsement list.
Purpose: Compare popular tools in each of the five roles (Execution, Orchestration, Intelligence, Logging, Reporting) based on 2026 capabilities, pricing, and suitability for SMB to mid‑market. Use this appendix when selecting a tool for a new automation project or evaluating your current stack.

C.1 Execution Role (CRM + Workflow Engine)

ToolBest forPricing (2026)Key strengthsWeaknessesIdempotencyTimersWebhooks
GoHighLevel (GHL)Home improvement, agencies, local services$297–$497/mo (agency plan)SMS native, workflows, calendars, round‑robin, custom fieldsLimited logging, basic reportingVia tagsYes (wait steps)Yes
HubSpot Operations HubB2B SaaS, enterprise$800–$3,600/moStrong reporting, data sync, error handlingExpensive, no native SMSVia custom objectsYes (delays)Yes
SalesforceLarge enterprise$150–$300/user/moUnlimited customization, enterprise featuresComplex, expensive, slow to changeVia ApexYesYes
PipedriveSmall sales teams$15–$99/user/moSimple, easy to useLimited automation, no SMSVia webhooksLimitedYes
SuiteCRM (self‑hosted)Budget‑constrained, HIPAAFree (hosting cost)Full control, no per‑user feesRequires dev resources, no native SMSVia custom codeVia cronYes

Recommendation: GHL for most SMBs. HubSpot for B2B SaaS with budget. SuiteCRM if you need self‑hosted compliance.

C.2 Orchestration Role (Workflow Glue)

ToolBest forPricing (2026)Key strengthsWeaknessesRetryDLQConditional branching
Make (Integromat)High‑volume, complex branching$9–$109/mo (10k–100k ops)Visual builder, error handler, data stores, cheapSteeper learning curve than ZapierYes (custom)Via AirtableYes
ZapierSimple, low‑volume$20–$600/moEasiest to learn, huge app libraryExpensive at scale, limited error handlingYes (fixed)NoLimited
n8n (self‑hosted)Compliance, high‑volumeFree (self‑host)Full control, no operation limitsRequires dev resourcesYesYesYes
Tray.ioEnterpriseCustom ($2k+/mo)Advanced error handling, governanceVery expensiveYesYesYes
Custom webhooks (Node.js)Developer teamsHosting costFull control, no third‑party riskMaintenance burdenImplement yourselfImplement yourselfYes

Recommendation: Make for most SMBs. n8n for self‑hosted compliance. Zapier only for very simple, low‑volume needs.

C.3 Intelligence Role (AI)

ToolBest forPricing (2026)Key strengthsWeaknessesFine‑tuningConfidence scoresFallback
OpenAI GPT‑4o‑miniClassification, extraction$0.15/1M input tokensCheap, fast, accurateNo local hostingYesYesRule‑based
OpenAI GPT‑4oComplex generation, summarization$2.50/1M input tokensHighest qualityMore expensiveYesYesRule‑based
Claude 3.5 SonnetLong context, safety$3/1M tokensLarge context window, safety featuresMore expensive, slowerLimitedYesRule‑based
Llama 3 (self‑hosted)High‑volume, offline complianceHosting costNo API cost, full controlRequires GPU, lower accuracyYesYesBuilt‑in
Gemini 1.5 ProGoogle ecosystem$1.25/1M tokensLong context, Google integrationLess fine‑tuningLimitedYesRule‑based

Recommendation: GPT‑4o‑mini for 90% of tasks. Self‑hosted Llama 3 if you have compliance requirements (HIPAA, on‑prem). GPT‑4o for complex summarization.

C.4 Logging Role (Audit Trail)

ToolBest forPricing (2026)Key strengthsWeaknessesAppend‑onlyQueryableRetention
AirtableSMB (<500k rows)$20–$45/moEasy to use, good UI, APIRow limit, rate limit (5 req/s)Via permissionsYes6–12 months
BigQueryHigh‑volume (>10k events/day)Free tier up to 1TB, then $5/TBScalable, fast, no row limitRequires SQL knowledgeVia designYesUnlimited
Google SheetsVery low volume (<5k rows/mo)FreeSimple, easy to shareNot append‑only, slow queriesNoLimitedUnlimited
DatadogReal‑time ops logs$0.10/GB ingestedReal‑time alerts, integrationsExpensive for long‑term storageYesYesConfigurable
AWS CloudWatchAWS‑native stacks$0.50/GBIntegrated with AWSComplex to queryYesYesConfigurable

Recommendation: Airtable for SMBs. BigQuery for high volume. Google Sheets only for internal, non‑critical logs.

C.5 Reporting Role (Dashboards)

ToolBest forPricing (2026)Key strengthsWeaknessesData freshnessEmbeddingAlerts
Looker StudioFree, Google ecosystem$0 (free)Free, easy sharing, connects to Sheets/BigQueryLimited to 5 data sources per report15 min+Yes (iframe)No (via Make)
TableauEnterprise, large data$70/user/moPowerful visualizations, large dataExpensive, steep learning curveReal‑timeYesYes
Power BIMicrosoft shops$10–$20/user/moGood integration with Excel, AzureWindows‑centricReal‑timeYesYes
Metabase (self‑hosted)Embedded analyticsFree (self‑host)Open source, easy for non‑technicalRequires hostingReal‑timeYesNo
Superset (self‑hosted)Large‑scale open sourceFree (self‑host)Powerful, SQL‑basedComplex setupReal‑timeYesNo

Recommendation: Looker Studio for most SMBs. Metabase for self‑hosted embedded dashboards. Tableau or Power BI for enterprise.

C.6 Decision Matrix – When to Choose Which Tool

Use caseExecutionOrchestrationIntelligenceLoggingReporting
SMB, home improvementGHLMakeOpenAI GPT‑4o‑miniAirtableLooker Studio
B2B SaaSHubSpotMakeOpenAI GPT‑4oBigQueryLooker Studio / Power BI
Healthcare (HIPAA)SuiteCRM (self‑host)n8n (self‑host)Llama 3 (self‑host)BigQueryMetabase
EnterpriseSalesforceTray.ioOpenAI GPT‑4oSnowflakeTableau
Executive assistant (low volume)None (manual)NoneNoneGoogle SheetsNone

End of Appendix C

APP D
Appendix D: Decision Guides
One-page checklists for automation readiness and production-grade decisions.
Purpose: Printable one‑page checklists for making quick decisions during automation design. Each checklist is a standalone reference.

D.1 Should I Automate This Process?

Use this checklist when evaluating a new automation candidate.

QuestionYesNoAction
Does this task happen more than 20 times per month?If No, consider manual or helper only (Questionable).
Is the task repetitive and rule‑based?If No, it may require human judgment (Necessary handoff).
Does the task have a high cost of error (>$1,000 per mistake)?If Yes, design Optional handoff (human override).
Is the task time‑sensitive (response within minutes)?If Yes, automation is strongly recommended.
Do you have access to the required data in structured format?If No, fix data capture first.
Is the task on a critical revenue path (lead capture, payment, follow‑up)?If Yes, prioritize automation.
Can you tolerate occasional failures without customer impact?If No, build production‑grade with idempotency, retries, DLQ.

Decision: If you answered Yes to 4+ questions, automate. If 2–3, consider a pilot. If 0–1, keep manual.

D.2 Which Handoff Type? (Optional / Necessary / Questionable)

Use this decision tree as a one‑page visual (text version).

text
START
  │
  ▼
Is human input legally required? (HIPAA, SOX, contract over X)
  │
  ├─ Yes → NECESSARY (human must decide, automation prepares)
  │
  └─ No → Can AI achieve >95% accuracy consistently?
         │
         ├─ Yes → FULLY AUTOMATABLE (no handoff)
         │
         └─ No → Cost of wrong decision?
                 │
                 ├─ >$1,000 → NECESSARY (escalate to human)
                 │
                 ├─ $100–$1,000 → OPTIONAL (auto with human override)
                 │
                 └─ <$100 → Volume >100/month?
                            │
                            ├─ Yes → OPTIONAL or FULLY AUTOMATABLE
                            └─ No → QUESTIONABLE (keep manual or helper)

Checklist version:

ConditionHandoff type
Legal/compliance requirementNecessary
AI accuracy <80% + high riskNecessary
AI accuracy 80–95% + medium riskOptional (human override available)
AI accuracy >95% + low riskFully automatable
Volume <20/month + low riskQuestionable (keep manual)

D.3 Production‑Grade vs Straightforward

FactorStraightforward (fast, fragile)Production‑grade (logged, idempotent, recoverable)
Volume<50 executions/day>100 executions/day
Customer‑facing?No (internal tools only)Yes
Compliance required?NoYes (TCPA, HIPAA, SOC2)
Failure cost<$100 per incident>$1,000 per incident
Team time to buildDaysWeeks
IdempotencyNoYes
LoggingNone or consoleImmutable audit trail
RetriesNoExponential backoff
Dead‑letter queueNoYes
Health checksNoYes (synthetic)

Rule of thumb: If you check 3+ items in the Production‑grade column, build production‑grade. Otherwise, straightforward may be acceptable for internal tools.

D.4 Tool Selection (By Role)

When to choose which tool:

RoleFirst choiceSecond choiceWhen to choose second
Execution (CRM)GoHighLevelHubSpotB2B SaaS, enterprise budget
OrchestrationMaken8n (self‑host)HIPAA, SOC2 compliance
Intelligence (AI)OpenAI GPT‑4o‑miniLlama 3 (self‑host)Offline, on‑prem, high volume
LoggingAirtableBigQuery>500k rows/month
ReportingLooker StudioMetabase (self‑host)Embedding, open source

D.5 When to Use Each Automation Pattern

PatternUse if...Avoid if...
P01 – Lead captureVolume >100 leads/month, multiple channelsSingle channel, low volume
P02 – DeduplicateDuplicate rate >5%Duplicates rare, manual merge fine
P03 – Round‑robin>3 reps, territoriesSingle rep
P04 – AI classification>100 leads/month, consistent BANT needsHighly nuanced, low volume
P05 – Self‑bookingCustomers prefer self‑service, younger demoElderly customers, low volume
P06 – RemindersNo‑show rate >15%No‑shows rare
P07 – No‑show recoveryAppointments scheduled, no‑show cost highLow appointment volume
P08 – Document generation>50 estimates/month, formulaicCustom, one‑off estimates
P09 – Open/click trackingNeed to know if customer engagedNo tracking needed
P10 – Follow‑up sequenceClose rate sensitive to timingReps already diligent
P11 – Objection handling>50 objections/month, predictable typesHighly varied, complex objections
P12 – Multi‑stage approval>20 approvals/month, multiple approversSingle approver
P13 – E‑signatureRemote customers, compliance needsIn‑person signing possible
P14 – Payment processingOnline payments acceptedPaper checks only
P15 – DunningRecurring billing >50 customersOne‑time payments
P16 – FinancingAverage ticket >$10k, price objection commonLow ticket, financing rarely used
P17 – Purchase orders>20 POs/month, multiple vendorsSingle vendor, low volume
P18 – Shipment tracking>20 shipments/month, delays costlyLocal pickup, no tracking
P19 – Resource schedulingMultiple crews, complex availabilitySingle crew, simple calendar
P20 – Milestone trackingProjects with >3 phasesSingle‑phase projects
P21 – Digital sign‑offNeed audit trail for completionPaper sign‑off acceptable
P22 – Warranty registrationManufacturers require registrationNo warranty
P23 – Review requestsVolume >20 jobs/monthLow volume, poor reputation risk
P24 – Referral programHigh customer satisfactionLow satisfaction, no word‑of‑mouth
P25 – ReactivationLost leads >100/monthLost leads rare

D.6 Failure Handling Quick Reference

Failure typeDetectionRecovery action
API timeoutError log, health checkRetry with backoff (3–5 attempts) → fallback → DLQ
Missing dataValidation ruleSend clarifying question → create task for rep
Duplicate eventIdempotency checkLog skip, do not process
Human ignores taskTask overdue (SLA)Remind (2 min) → reassign (5 min) → escalate (10 min)
Webhook lostNo event log after triggerRetry 3x → DLQ → ops task
AI misclassificationRep override rate >15%Log override → retrain model monthly

End of Appendix D

APP E
Appendix E: Sample Runbooks for Failure Scenarios
Ready-to-adapt runbooks for the most common automation failures.
Purpose: Ready‑to‑use runbooks for the most common automation failures. Each runbook follows the structure from Chapter 30 and can be copied into your Grimoire with minimal modification. Replace placeholders [Company Name], [Slack channel], [Phone number] with your actual values.

E.1 Runbook #1 – Lead Capture Failure (No Leads in CRM)

Severity: SEV‑1 (Critical) Expected response time: 5 minutes

Overview

  • Automation: P01 – Lead Capture from web form / call / chat
  • Symptoms: Zero leads in CRM for >30 minutes during expected traffic hours.
  • Health check: lead_capture_health (fails every 15 min)

Step 1 – Verify

  • Open Looker Studio dashboard “Lead Capture Health”. Check last 5 runs.
  • Manually submit a test lead via the website form. Wait 2 minutes.
  • Check CRM for contact with test email test_{timestamp}@healthcheck.com.

Step 2 – Diagnose

  • If form submissions appear in form tool (Typeform, Gravity Forms) but not in CRM:

Webhook issue. Go to GHL → Settings → Webhooks → lead_capture. Check last delivery status. Look for HTTP 4xx or 5xx.

  • If form tool also shows no submissions:

Form is down or ad campaigns paused. Contact marketing team.

  • If webhook returns 404: URL changed. Update webhook URL in GHL to https://hook.make.com/lead_capture.
  • If webhook returns 401: API key expired. Regenerate GHL API key and update in Make.

Step 3 – Recover

  • Recover lost leads: Export submissions from form tool (CSV). Run Make scenario import_lost_leads (or manually import to GHL via CSV).
  • Clear backlog: After fixing, monitor new leads for 10 minutes.

Step 4 – Escalate

  • If not resolved in 15 minutes: Page on‑call engineer via Slack #engineering-oncall and SMS to +15551234567.
  • If no leads for 2 hours: Pause ad campaigns to avoid waste. Contact agency.

Dependencies

  • GHL API key (stored in Make)
  • Webhook URL: https://hook.make.com/lead_capture
  • Form tool credentials

Post‑Mortem

  • Update this runbook with root cause.
  • Add regression test to health check.

E.2 Runbook #2 – SMS Not Sending (Welcome or Follow‑up)

Severity: SEV‑1 (Critical) Expected response time: 10 minutes

Overview

  • Automation: P01 (welcome SMS) or P10 (follow‑up sequence)
  • Symptoms: Customer reports no SMS; health check sms_delivery fails; GHL workflow logs show SMS action failed.

Step 1 – Verify

  • Check GHL SMS balance: Settings → SMS → Balance. If low or zero, add funds immediately.
  • Check a specific failed lead: open contact record, look for auto:welcome_sent tag. If missing, SMS was not sent.

Step 2 – Diagnose

  • If balance is zero: Add funds ($25 minimum). Retry failed leads via “Resend SMS” button in GHL (manual).
  • If balance is sufficient but SMS fails: Check phone number validity (must be 10 digits, no letters). If invalid, fallback to email (should already work). Create task for rep to call via alternative contact.
  • If valid numbers still fail: Check carrier filtering (e.g., Verizon may block marketing SMS). Test by sending SMS to a known working number (team member). If test works, the issue is specific leads – no fix other than fallback.
  • If all SMS fail: Contact GHL support with timestamps of failed messages.

Step 3 – Recover

  • For leads already in CRM: Use bulk SMS send via GHL campaigns with apology: “We are experiencing technical difficulties. A specialist will call you within 1 hour.”
  • For new leads: Increase email usage (fallback already in workflow) and create high‑priority call tasks for reps.

Step 4 – Escalate

  • If not resolved in 30 minutes: Page GHL support (phone: 1‑888‑XXX‑XXXX) and notify client.

Dependencies

  • GHL SMS balance
  • Twilio (underlying carrier)

E.3 Runbook #3 – AI Classification Failing (OpenAI Down)

Severity: SEV‑2 (High) Expected response time: 30 minutes

Overview

  • Automation: P04 (AI classification of intent) / P11 (objection detection)
  • Symptoms: Health check ai_classification fails; Slack alerts show OpenAI API errors; leads receiving fallback (rule‑based) classification.

Step 1 – Verify

  • Check OpenAI status page: status.openai.com. If outage, proceed to Step 2.
  • If status is green, check API key: In Make, test the OpenAI module (send a test message). Look for 401 Unauthorized.

Step 2 – Diagnose

  • If outage: No immediate fix – rely on fallback.
  • If API key expired: Generate a new key in OpenAI dashboard → update in Make (Data store OPENAI_API_KEY).
  • If rate limit (429): Check usage in OpenAI dashboard. If near limit, upgrade plan or implement request queuing.

Step 3 – Recover

  • Fallback is active: Rule‑based classification will continue (lower accuracy but still functional). Do nothing.
  • If fallback also fails: Create ops task: “Manually review pending classifications in dead‑letter queue.”
  • Reprocess failed AI calls: Run Make scenario reprocess_ai_failures (sends queued events again).

Step 4 – Escalate

  • If outage >1 hour: Send email to sales team: “AI classification degraded. Please manually review leads in DECISION_PENDING.”

Dependencies

  • OpenAI API key
  • Rule‑based fallback keyword list (stored in Airtable keyword_rules)

E.4 Runbook #4 – Airtable Logging Full or Rate‑Limited

Severity: SEV‑3 (Medium) Expected response time: 4 hours

Overview

  • Automation: All patterns (logging to Airtable)
  • Symptoms: Health check airtable_logging fails; alerts show “429 Too Many Requests” or “Record limit exceeded”.

Step 1 – Verify

  • Check Airtable base row count (Pro plan limit 500k). If near limit, archive old records.
  • Check rate limit: Airtable allows 5 requests per second. If exceeding, batching may be broken.

Step 2 – Diagnose

  • If row limit near capacity: Run archive script (Make scenario archive_old_logs) to move records older than 6 months to BigQuery or Google Sheets.
  • If rate‑limited: Check if batching is implemented. In Make, the log_event_to_airtable scenario should use “Collector” module (batch size 10, timeout 5s). If not, enable batching.

Step 3 – Recover

  • Fallback to Google Sheets: Already configured; confirm that logs are writing to fallback sheet. No customer impact.
  • Clear backlog: Run reprocess_dlq to retry failed writes from dead‑letter queue.

Step 4 – Escalate

  • If not resolved in 4 hours: Create task for ops manager to review Airtable usage and upgrade to Enterprise plan if needed.

Dependencies

  • Airtable API key
  • Google Sheets fallback (shared drive)

E.5 Runbook #5 – Webhook Lost (No Event Logged)

Severity: SEV‑2 (High) Expected response time: 30 minutes

Overview

  • Automation: Any pattern relying on webhooks (lead capture, estimate tracking, payment)
  • Symptoms: Events expected but not appearing in Airtable or CRM; health check fails.

Step 1 – Verify

  • Check Make scenario last execution. If webhook never received, look for “No data” in history.
  • Test webhook manually using Postman or curl.

Step 2 – Diagnose

  • If webhook URL changed: Update in GHL (or other source) to current Make webhook URL: https://hook.make.com/[scenario_id].
  • If authentication failed: Regenerate API key and update in source system.
  • If network timeout: Increase timeout in source system (if configurable) or move to more reliable orchestration.

Step 3 – Recover

  • Lost events: Source system may have retry logs. If not, use dead‑letter queue (DLQ) to reprocess.
  • Manual reprocess: From Airtable DLQ, manually trigger events via Make button.

Step 4 – Escalate

  • If webhook loss persists >1 hour, escalate to automation engineer.

Dependencies

  • Make webhook URL
  • Idempotency key storage

E.6 Runbook – Generic Template (Copy for New Automations)

markdown
# Runbook: [Automation Name]

**Severity:** SEV‑1 / SEV‑2 / SEV‑3  
**Expected response time:** [X] minutes  

## Overview
- **Automation:** [Pattern ID and name]
- **Symptoms:** [What you see when broken]
- **Health check:** [Name of synthetic test, if any]

## Step 1 – Verify
- [Command or dashboard to confirm failure]

## Step 2 – Diagnose
- [Check A, B, C]

## Step 3 – Recover
- [Fix step by step]

## Step 4 – Escalate
- [Who to contact if unresolved]

## Dependencies
- [API keys, vendor services]

## Post‑Mortem
- [Root cause to be filled after incident]

End of Appendix E

APP F
Appendix F: Human Handoff Cheat Sheet
Fast lookup for override tags, escalation rules, and handoff types.
Purpose: One‑page reference for handoff types, override tags, escalation rules, and industry‑specific human involvement requirements. Keep this printed near your ops team.

F.1 Handoff Types – Quick Reference

TypeWhen to useAutomation roleHuman roleOverride tag
OptionalAI confidence 80–95%, medium riskSuggest, auto‑executeReview, override if wrongmanual:override_[pattern]
NecessaryLegal/compliance, high risk, >$1k error costPrepare, notify, escalateDecide, approve, rejectmanual:[action]_override
QuestionableLow volume (<20/mo), human adds trust valueDo not automate (build helper only)Own the actionN/A

F.2 Override Behavior – What Happens When a Human Steps In

ElementStandard behaviorExample
Tags addedmanual:override_[pattern], plus reason tagmanual:override_classification, override_reason:price_should_be_trust
Stage changeUsually none (or move to MANUAL_REVIEW stage if design includes it)DECISION_PENDING → stays, but tag added
Automation stoppedCancel pending timers, suppress further auto‑actionsCancel decision timer, suppress second auto‑response
Loggingevent_type = manual_override, original value, corrected value, human IDevent_type = manual_override_classification, original=intent:price, corrected=intent:trust
DownstreamMay skip normal auto‑sequencesNo automatic follow‑up after manual override

Detection pattern in automation code:

javascript
const tags = await getContactTags(contactId);
if (tags.includes(`manual:${automationName}`)) {
  console.log('Manual override present, skipping automation');
  return;
}

F.3 Override Tags by Pattern (Quick Lookup)

PatternAutomation nameOverride tagWhen to use
P01 – Lead capturelead_capturemanual:lead_creationRep manually creates contact
P02 – Deduplicatededupemanual:mergeOps manually merges duplicates
P03 – Round‑robinassignmentmanual:reassignRep changes lead owner
P04 – AI classificationclassificationmanual:override_classificationRep corrects AI intent
P05 – Self‑bookingbookingmanual:bookingRep books on behalf of customer
P06 – Remindersremindersmanual:confirmationRep confirms appointment manually
P07 – No‑showno_showmanual:rescheduleRep reschedules no‑show lead
P08 – Document generationdocument_genmanual:estimate_createdRep manually creates estimate
P09 – Trackingtrackingmanual:estimate_openedRep manually marks as opened
P10 – Follow‑upfollow_upmanual:followup_sentRep sends manual follow‑up
P11 – Objectionobjectionmanual:override_objectionRep overrides AI‑sent response
P12 – Approvalapprovalmanual:approval_overrideAdmin approves without approver chain
P13 – E‑signatureesignmanual:contract_uploadedRep uploads signed PDF manually
P14 – Paymentpaymentmanual:payment_recordedRep marks payment as received
P15 – Dunningdunningmanual:dunning_overrideRep reactivates subscription manually
P16 – Financingfinancingmanual:financing_overrideRep approves financing outside lender
P17 – POpurchase_ordermanual:po_createdRep creates PO manually
P18 – Shipmentshipmentmanual:delivery_completeOps marks delivery as received
P19 – Schedulingschedulingmanual:work_scheduledOps manually schedules job
P20 – Milestonesmilestonesmanual:phase_completePM manually marks phase done
P21 – Sign‑offsignoffmanual:signoffPM marks signed off without customer
P22 – Warrantywarrantymanual:warranty_deliveredRep manually sends warranty
P23 – Reviewreviewmanual:review_handledOps marks negative review as resolved
P24 – Referralreferralmanual:referral_creditRep manually credits referral reward
P25 – Reactivationreactivationmanual:reactivatedRep revives lost lead manually

F.4 Escalation Rules for Necessary Handoffs

PatternNecessary handoff triggerEscalation pathSLA
P11 – ObjectionAI confidence <0.6Task for rep → after 4h, manager4h
P12 – ApprovalNo approver action within SLAReminder (2h) → reassign (4h) → VP (8h)4h (standard), 1h (urgent)
P13 – E‑signatureNo signature after 48hReminder → rep call → after 7d, archive48h
P15 – DunningPayment failure after 3 retriesCollections team task → after 7d, suspension7d
P21 – Sign‑offCustomer reports issuesProject manager task → after 24h, escalate to ops director24h
P23 – ReviewRating ≤3Ops manager task → respond within 24h24h

F.5 Industry‑Specific Handoff Overrides

IndustryPatternDefaultOverride toReason
MedicalP01 Lead captureFully automatableNecessary (insurance verification)HIPAA
MedicalP04 AI classificationOptionalNecessary (diagnosis)Physician review required
MedicalP23 Review requestOptionalNecessary (no PHI in review)Compliance
ConstructionP12 ApprovalNecessaryFully automatable for change orders <$5kLow risk
ConstructionP21 Sign‑offOptionalNecessary for projects >$100kLegal liability
Real EstateP13 E‑signatureNecessaryOptional after agent approvesAgent reviews first
SaaSP15 DunningOptionalFully automatable for <$50/moChurn risk low
ExecutiveMost patternsAutomatableQuestionable (keep manual)Trust, low volume

F.6 Human Handoff Decision Flow (Printed)

text
START
 │
 ▼
Is human input legally required? ──Yes──→ NECESSARY
 │                                    (human decides)
 No
 │
 ▼
Can AI achieve >95% accuracy? ──Yes──→ FULLY AUTOMATABLE
 │                                    (no handoff)
 No
 │
 ▼
Cost of wrong decision?
 │
 ├─ >$1,000 ──→ NECESSARY (escalate to human)
 ├─ $100–$1,000 ──→ OPTIONAL (auto, human override)
 └─ <$100 ──→ Volume >100/month?
              │
              ├─ Yes ──→ OPTIONAL
              └─ No ──→ QUESTIONABLE (manual)

End of Appendix F

End of Appendices

You have completed all 6 appendices (A through F). This concludes The Absolute Guide to Business Process Automations (2026).

The full book includes:

  • Foreword – How to use this reference
  • Part I – Foundation (Chapters 1–4)
  • Part II – Automation Patterns (Patterns 1–25)
  • Part III – Cross‑Cutting Concerns (Chapters 26–31)
  • Part IV – Appendices A–F
INDEX
Symptom Index
Start from the failure you see, then jump to the pattern or chapter that owns it.
What you're seeingFailure familyGo toFirst check
Leads not appearing in CRMTechP01 · Ch03Check webhook URL, last execution, and DLQ.
Duplicate contacts being createdDataP02Check phone-first deduplication and idempotency tags.
Welcome SMS sending twiceDataP01 · Ch28Check auto:welcome_sent and duplicate webhook delivery.
AI classifying with wrong intentLogicP04 · P11Check override rate and confidence threshold.
Follow-up SMS not sending after estimateTech / LogicP10Check timer start and cancellation rules.
Leads stuck in a stage for daysHumanCh03Check task completion rate and SLA escalation ladder.
Payment processed twiceData / TechP14 · Ch28Check idempotency key, duplicate gateway webhook, and TTL.
Appointment not confirmed, no-show spikeTech / LogicP06 · P07Check reminder sequence, opt-out status, and confirmation webhook.
Contract sent but unsigned after 48hHumanP13Check envelope status, reminders, and rep call task.
Health check failing but CRM looks fineTechCh29Run the health check manually and inspect the test event path.
SOURCES
Source Corpus Included
Every Markdown file used to rebuild this publication-ready Grimoire page.

This section is a publication QA ledger: it lists the local Markdown source files that were converted into the final HTML. It is meant to make completeness auditable instead of vibes-based.

01
00 The Absolute Guide to Business Process Automations (2026).md
8k chars · 12 headings
02
Chapter 1 – Why AI Is Just a Cog.md
9k chars · 8 headings
03
Chapter 2 – Straightforward vs Production‑Grade.md
11k chars · 10 headings
04
Chapter 26 – Compliance Wrappers (HIPAA, TCPA, SOC2, SOX).md
11k chars · 8 headings
05
Chapter 27 – Tool Roles & 2026 Norms.md
9k chars · 10 headings
06
Chapter 28 – Idempotency, Retries, and Dead‑Letter Queues.md
10k chars · 8 headings
07
Chapter 29 – Testing Automations (Unit, Integration, Chaos, Synthetic Health Checks).md
11k chars · 9 headings
08
Chapter 3 – Failure Families & Observability.md
14k chars · 11 headings
09
Chapter 30 – Documentation and Runbooks for Each Pattern.md
7k chars · 29 headings
10
Chapter 31 – Handoff Decision Guide (Flowcharts & Industry Rules).md
11k chars · 10 headings
11
Chapter 4 – The Human Handoff Taxonomy.md
13k chars · 10 headings
12
Fin Appendix.md
49k chars · 83 headings
13
Part II – Automation Patterns.md
14k chars · 17 headings
14
Pattern 10 – Timer‑Based Follow‑Up Sequence.md
10k chars · 14 headings
15
Pattern 11 – Objection Detection and Auto‑Response.md
11k chars · 11 headings
16
Pattern 12 – Multi‑Stage Approval Workflow.md
11k chars · 12 headings
17
Pattern 13 – E‑Signature Collection with Audit Trail.md
10k chars · 11 headings
18
Pattern 14 – Payment Processing with Idempotent Webhooks.md
11k chars · 13 headings
19
Pattern 15 – Dunning Management for Recurring Payments.md
12k chars · 14 headings
20
Pattern 16 – Financing Application and Approval Workflow.md
11k chars · 14 headings
21
Pattern 17 – Purchase Order Generation and Vendor Routing.md
9k chars · 13 headings
22
Pattern 18 – Shipment Tracking with Delay Alerts.md
11k chars · 14 headings
23
Pattern 19 – Resource‑Aware Scheduling (Crews, Rooms, Equipment).md
10k chars · 14 headings
24
Pattern 2 – Deduplicate and Merge Duplicate Records.md
14k chars · 12 headings
25
Pattern 20 – Multi‑Phase Milestone Tracking and Notifications.md
12k chars · 15 headings
26
Pattern 21 – Digital Sign‑off on Completion.md
13k chars · 14 headings
27
Pattern 22 – Warranty Registration and Document Delivery.md
12k chars · 14 headings
28
Pattern 23 – Review Request with Reputation Monitoring.md
11k chars · 13 headings
29
Pattern 24 – Referral Program with Unique Links and Reward Tracking.md
12k chars · 15 headings
30
Pattern 25 – Reactivation Sequence (Lost Leads - Dormant Customers).md
11k chars · 15 headings
31
Pattern 3 – Round‑Robin - Territory Assignment.md
12k chars · 13 headings
32
Pattern 4 – AI Classification of Intent BANT.md
13k chars · 15 headings
33
Pattern 5 – Self‑Service Appointment Booking with Confirmation.md
12k chars · 15 headings
34
Pattern 6 – Multi‑Channel Reminders with Confirmation.md
11k chars · 13 headings
35
Pattern 7 – No‑Show Detection and Auto‑Reschedule.md
10k chars · 14 headings
36
Pattern 8 – Document Generation from Template + Dynamic Data.md
10k chars · 15 headings
37
Pattern 9 – OpenClick Tracking with Webhooks.md
8k chars · 13 headings
Related Grimoires
004
n8n 101: AI Workflow Automation
Where you build the patterns from this grimoire, using n8n. G006 is the design layer. G004 is the implementation.
005
Zapier 101: Business Automation
The Zapier path to the same patterns. Easier to start, different cost curve. G006 for what to build. G005 for how.
007
Agentic AI 101: Practical AI Workflows
When the workflow needs to reason, not just route. G006 is the automation foundation. G007 is the layer on top.
014
Remote Work 101
The front gate — 29 chapters on remote careers, systems, survival, and the Philippine reality.