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.
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.
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 book is a reference. You are not expected to read it cover to cover. Instead, you will:
- Identify the automation pattern you need (e.g., “capture leads”, “send reminders”, “approve contract”).
- Turn to that pattern in Part II.
- 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:
## 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):
** 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.
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.
** 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:
- Logged – so you can audit and retrain.
- Confidence‑scored – so you know when to trust it.
- Fallback‑ready – if AI times out or returns low confidence, a deterministic rule (or human) takes over.
- 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 toQUALIFIED. - 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)
| Zone | What AI does | Example | Why it works |
|---|---|---|---|
| Classification | Map an input to one of a fixed set of categories | “Intent = price / trust / timing” | Output is bounded; fallback rule exists |
| Extraction | Pull specific fields from unstructured text | “Budget = $15k, Timeline = 30 days” | Fields are well‑defined; missing values default to “unknown” |
| Generation | Create draft content from a template | “Your estimate of $15k can be financed from $250/month” | Human reviews before sending (or after, with override) |
** 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_SENTtoCLOSED_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:
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
| Industry | AI cog use | Deterministic wrapper | Human handoff |
|---|---|---|---|
| Medical | Extract ICD‑10 codes from doctor’s notes | If confidence <0.9, flag for manual coding | Required for final coding (compliance) |
| Construction | Classify RFI (Request for Information) urgency | If urgency = “critical”, notify project manager immediately | Optional – PM can override urgency |
| Real Estate | Generate listing description from property features | Draft saved to CMS; human must approve before publishing | Necessary (legal liability) |
| Home Improvement | Score lead intent from form fields | If score >70, auto‑book appointment; if <30, send nurture sequence | Optional – rep can override score |
| SaaS | Classify support ticket severity | If severity = “P1”, page on‑call engineer immediately | Questionable – some teams auto‑page, others require human triage |
| Payment Processing | Detect potential fraud from transaction pattern | If fraud score >0.9, block transaction; if 0.5–0.9, queue for manual review | Necessary for high‑risk transactions |
| Executive Assistant | Parse email into calendar invite (date, time, location) | If all fields extracted with high confidence, auto‑add to calendar; else, create draft for assistant to review | Necessary – 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
| Concept | Key takeaway |
|---|---|
| AI is a cog | It augments deterministic logic, does not replace it. |
| Three cogs | Deterministic + AI + Human. Each has a role. |
| Safe zones | Classification, extraction, generation – all bounded and fallback‑ready. |
| No‑go zones | State changes, final approvals, deterministic logic. |
| Override loop | Human 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
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.
** 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:
| Aspect | Description |
|---|---|
| Idempotency | None – duplicate events cause duplicate actions |
| Logging | None or minimal (e.g., console log) |
| Retries | None – if an API call fails, the automation stops |
| Fallbacks | None – if a service is down, the process fails |
| Escalation | None – failures are silent |
| Audit trail | None – you cannot replay what happened |
| Human override | Not supported – automation makes final decision |
| Testing | Manual 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:
| Aspect | Description |
|---|---|
| Idempotency | Explicit check (tag, database, or idempotency key) before every action |
| Logging | Every event written to immutable log (Airtable, BigQuery, or custom DB) |
| Retries | Exponential backoff for transient failures (API timeouts, rate limits) |
| Fallbacks | If primary service fails, use secondary (e.g., rule‑based fallback when AI times out) |
| Escalation | If fallback fails, create a task for a human and send alert |
| Audit trail | Complete history of every event, decision, and override |
| Human override | Explicit override mechanism with logging |
| Testing | Automated 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
| Activity | Straightforward (hours) | Production‑grade (hours) |
|---|---|---|
| Initial build | 1–4 | 8–40 |
| Testing | 0.5 | 4–8 |
| Documentation | 0 | 2–4 |
| Maintenance per month | 0–1 (when broken) | 1–2 (proactive) |
| debugging a failure | Hours (no logs) | Minutes (rich logs) |
| Total first year | ~50–100 hours | ~100–200 hours |
But the hidden cost of straightforward is failure expense:
| Failure type | Straightforward cost | Production‑grade cost |
|---|---|---|
| Duplicate welcome SMS | Annoyed 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 fallback | Wrong follow‑up, lost deal | $0 (confidence threshold escalates to human) |
| No audit trail for compliance | Regulatory 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:
| Condition | Recommendation |
|---|---|
| Volume < 50 executions/day AND internal users only | Straightforward |
| Volume > 100 executions/day OR customer‑facing | Production‑grade |
| No compliance requirements | Straightforward possible |
| Any TCPA, HIPAA, SOC2, SOX, or financial regulation | Production‑grade (mandatory) |
| Failure leads to lost revenue > $1,000 per incident | Production‑grade |
| Team has time to build and maintain | Production‑grade |
| Prototype or proof‑of‑concept | Straightforward (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.
** 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:
- Internal analytics pipeline – A daily script that aggregates data and emails a CSV. If it fails, someone can re‑run it manually.
- One‑time data migration – Moving historical data from an old system. After migration, the automation is discarded.
- Personal assistant automation – A script that organizes your own files. If it duplicates a file, only you are affected.
- 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:
- Phase 1 – Straightforward – Build the happy path. No error handling. Test manually.
- Phase 2 – Add logging – Wrap each action in try/catch and write to a log (even just a file).
- Phase 3 – Add idempotency – Add a tag or key check before each action.
- Phase 4 – Add retries – Wrap API calls in a retry loop with backoff.
- Phase 5 – Add fallback – If a service fails, use a simpler alternative.
- 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
| Industry | What “production‑grade” adds beyond the checklist |
|---|---|
| Medical | Audit trail must be immutable and tamper‑evident (e.g., write to blockchain or append‑only database). |
| Construction | Escalation must include text messages to project managers (email may not be read on site). |
| Real Estate | Logging must retain client consent for communications (TCPA). |
| Home Improvement | Fallbacks must include in‑house financing if lender API fails. |
| SaaS | Idempotency is critical for billing – duplicate charges are catastrophic. |
| Payment Processing | Retries must respect card network rules (e.g., no retries after decline for certain reason codes). |
| Executive Assistant | Human override is not optional – every calendar invite must be approved by the executive or their assistant. |
2.10 Chapter Summary
| Aspect | Straightforward | Production‑Grade |
|---|---|---|
| Speed to build | Fast (hours to days) | Slow (days to weeks) |
| Failure tolerance | Low – failures are silent | High – failures are logged, retried, escalated |
| Cost | Low upfront, high long‑term (failure expense) | Higher upfront, low long‑term |
| Best for | Internal tools, prototypes, low volume (>50/day) | Customer‑facing, high volume, compliance |
| Idempotency | No | Yes |
| Logging | None or minimal | Complete audit trail |
| Escalation | None | Task + alert |
| Human override | No | Yes (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
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:
| Family | Root cause | Example |
|---|---|---|
| Data | Incorrect, incomplete, or malformed input | Missing phone number, invalid email, duplicate submission |
| Logic | The automation does the wrong thing, but executed as programmed | Wrong follow‑up timing, misconfigured threshold, missing transition |
| Human | A person fails to perform an expected action | Rep ignores task, manager doesn’t approve, customer doesn’t reply |
| Tech | Software or network component fails | API timeout, webhook 404, database down, rate limit exceeded |
** 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:
| Scenario | Failure | Consequence |
|---|---|---|
| Lead capture form does not require phone | phone field empty | No SMS can be sent → lower contact rate |
| UTM parameters lost between pages | source = direct | Attribution broken → wrong ad spend decisions |
| Customer enters “six thousand” instead of “6000” | Budget parsing fails | Lead 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:unknownspikes 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:
| Scenario | Failure | Consequence |
|---|---|---|
| Decision timer set to 72h, but optimal is 24h | Leads receive follow‑up too late | Conversion drops |
Guard requires estimate_opened = true, but tracking broken | Leads never move to DECISION_PENDING | Stalled deals |
| AI classifies price objection as trust | Sends testimonials instead of financing | Lost 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.
** 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:
| Scenario | Failure | Consequence |
|---|---|---|
| Rep forgets to mark appointment as completed | Estimate workflow never triggers | Lost deal |
| Rep closes a lost deal without selecting loss reason | Reactivation not scheduled | No win‑back opportunity |
| Customer no‑shows without canceling | Wasted rep time, no reschedule | Lost revenue |
Detection:
- Task completion rate (tasks completed within SLA / total created).
- Handoff validation logs (e.g.,
handoff_logshows 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.
** 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:
| Scenario | Failure | Consequence |
|---|---|---|
| OpenAI API returns 503 (Service Unavailable) | No classification → fallback to rule‑based | Lower accuracy |
| Webhook from form to CRM times out | Lead never created | Lost lead |
| Airtable rate limit (5 req/sec) exceeded | Some events not logged | Gaps 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:
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:
| Stage | Action |
|---|---|
| Retry | Retry 3 times with 2s, 5s, 15s delay |
| Fallback | Write payload to Google Sheets fallback |
| Escalate | After 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:
| Pillar | What it is | Implementation | Purpose |
|---|---|---|---|
| Logs | Immutable, timestamped records of every event | Airtable event_log, BigQuery, or custom DB | Debugging, auditing, replay |
| Metrics | Aggregated numerical data (counts, rates, latencies) | Looker Studio, Prometheus, or custom dashboards | Trend analysis, SLA reporting |
| Traces | End‑to‑end view of a single request across services | OpenTelemetry, Datadog, or custom correlation IDs | Finding 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.
** 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):
- Make scenario submits a test lead with a unique email (e.g.,
test_{timestamp}@example.com). - Wait 1 minute.
- Query CRM for contact with that email.
- If not found → escalate (Slack alert, create ops task).
- If found, verify
auto:welcome_senttag exists. If missing → escalate. - (Optional) Delete the test lead to avoid clutter.
Critical health checks for common automations:
| Automation | Health check | Frequency |
|---|---|---|
| Lead capture | Submit form → verify CRM record → verify SMS sent | 15 min |
| AI classification | Send test message → verify intent classification | 1 hour |
| Estimate tracking | Create estimate → simulate open → verify log | 1 hour |
| Payment webhook | Simulate payment success → verify order status updated | 1 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):
| Metric | Target | Alert 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:
┌─────────────────────────────────────────────────────────────┐
│ 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
| Industry | Most common failure family | Why | Mitigation |
|---|---|---|---|
| Medical | Data (missing insurance info, wrong ICD codes) | Manual entry errors | Validation rules, AI extraction with high threshold |
| Construction | Human (site delays, no‑shows, missed approvals) | Weather, subcontractor issues | Auto‑reschedule, escalation to PM |
| Real Estate | Logic (commission calculation errors, disclosure mismatches) | Complex rules | Decision tables, audit logs |
| Home Improvement | Tech (estimate tracking pixels blocked, financing webhook down) | Email privacy, lender API stability | Fallback SMS, in‑house plan |
| SaaS | Tech (webhook from Stripe delayed, subscription sync lag) | Third‑party reliability | Retry with backoff, idempotent billing |
| Payment Processing | Tech + Data (fraud detection false positives) | Model drift | Human review queue for borderline scores |
| Executive Assistant | Human (executive ignores automated calendar invites) | Trust | Always require human confirmation before adding to calendar |
3.11 Chapter Summary
| Failure family | Root cause | Detection | Recovery | Prevention |
|---|---|---|---|---|
| Data | Bad input | Completeness audit | Fallback defaults, enrichment | Validation, dropdowns, dedupe |
| Logic | Wrong rules | Event log comparison, rep override rate | Fix rule, add missing transition | Decision tables, simulation tests |
| Human | Person fails to act | Task completion rate, handoff logs | Auto‑detect, reassign, escalate | Automate, enforce, remind |
| Tech | Software/network down | Health checks, error logs | Retry, fallback, dead‑letter queue | Multi‑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
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.
** 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:
| Type | Definition | Example | Automation’s role |
|---|---|---|---|
| Optional | Automation works 95%+, but human can override if they wish | AI classifies intent as “price”; rep can change it to “trust” before follow‑up sends | Suggest, not decide |
| Necessary | Legal, compliance, trust, or high‑stakes judgment requires human action | Approving a contract over $50k, signing off on a medical procedure, accepting a counter‑offer | Prepare, notify, escalate – but not decide |
| Questionable | Automation could be built, but cost/effort > benefit, or human touch adds disproportionate value | Personal follow‑up call for a high‑value lead ($100k+), reviewing a complex construction change order | Keep 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:
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):
| Element | Behavior |
|---|---|
| Tags added | manual:override_intent, override_reason:trust |
| Stage change | None (remains in OBJECTION_HANDLING) |
| Automation stopped | The auto‑response already sent; but the second follow‑up is suppressed if override occurs before it |
| Logging | event_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:
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 --> GExample – 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:
| Element | Behavior |
|---|---|
| Tags added | manual:approved, manual:rejected, approver:vp_sales |
| Stage change | Moves from PENDING_APPROVAL to APPROVED or REJECTED |
| Automation stopped | If rejected, no further actions; if approved, continues |
| Logging | Full 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%.
** 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:
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:
| Failure | Example | Fix |
|---|---|---|
| Human ignores notification | Task sits in CRM for days | Escalate after SLA, re‑assign, send SMS |
| No escalation path | One approver is on vacation | Define backup approver, time‑based escalation |
| Handoff not logged | No record of who approved what | Mandatory logging before automation proceeds |
| Human overrides incorrectly | Approver accepts a fraudulent transaction | Require two‑factor approval for high‑risk actions |
| Automation assumes human is always available | Task created at 2 AM, no alert | Time‑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
| Industry | Necessary handoff examples | Optional handoff examples | Questionable examples |
|---|---|---|---|
| Medical | Diagnosis sign‑off, prescription approval, insurance pre‑auth | Appointment rescheduling, medication refill reminders | Patient triage for non‑emergency (risk too high) |
| Construction | Change order approval, permit sign‑off, safety incident report | Subcontractor scheduling, material order confirmation | RFI prioritization (usually fine, but some require PM judgment) |
| Real Estate | Contract signing, counter‑offer acceptance, disclosure review | Showing confirmation, document upload reminders | Lead scoring (realtors often prefer to judge) |
| Home Improvement | Final sign‑off, warranty claim approval | Estimate objection handling (price → financing) | Follow‑up call for high‑value leads (human touch wins) |
| SaaS | Discount approval (>30%), contract terms deviation | Support ticket triage, trial extension | New feature request prioritization (product manager wants to see) |
| Payment Processing | Fraud investigation, high‑risk transaction review | Recurring payment retry management | Dispute resolution (some automate, some keep human) |
| Executive Assistant | Calendar changes (always necessary for some execs) | Email filtering, expense report categorization | Meeting 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:
- Retrain AI models – Export overrides, label them as ground truth, fine‑tune the model monthly.
- Detect drift – If override rate spikes, the AI or process has drifted.
- 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:
| timestamp | pattern | contact_id | original | corrected | reason |
|---|---|---|---|---|---|
| 2026-05-13 10:32 | Pattern 4 (intent) | lead_12345 | price | trust | Lead 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 type | When to use | Automation’s role | Escalation if human fails |
|---|---|---|---|
| Optional | AI confidence moderate, cost of error medium | Suggest, allow override | Re‑assign to another human |
| Necessary | Legal, compliance, high risk, judgment | Prepare, notify, escalate | Escalate to next level (manager, CEO) |
| Questionable | Low volume, human touch adds value | Do not automate; build helper | N/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
Group 1 – Capture & Routing
Pattern 1 – Capture Inbound Lead from Any Channel
Glue: Connects LEAD → QUALIFY 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
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 --> LStraightforward Implementation (Fast & Fragile)
Use case: Low volume (<50 leads/day), internal tool, no compliance needs.
Steps:
- Form submits to a Google Sheet via webhook (no deduplication).
- Zapier (or Make) sends a raw email notification to sales.
- Rep manually copies lead into CRM (or uses a spreadsheet).
Code (JavaScript webhook – Google Apps Script):
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)
// 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)
// 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
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)
// 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
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
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
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Lead 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) |
| Construction | Lead 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 Estate | Lead 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 Improvement | Lead 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 |
| SaaS | Lead 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 Processing | Lead capture includes monthly volume, average transaction, industry type. Compliance: must include legal disclosures. Auto‑response includes link to compliance doc. | Risk and compliance |
| Executive Assistant | Lead 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 type | Description | When triggered |
|---|---|---|
| Optional | Rep 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. |
| Necessary | For 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). |
| Questionable | Sending 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):
| Element | Behavior |
|---|---|
| Tags added | manual:lead_creation, auto:welcome_sent (to prevent duplicate welcome SMS) |
| Stage change | Set directly to CONTACTED (skip NEW_LEAD) |
| Automation stopped | The normal lead intake workflow will check for auto:welcome_sent tag and exit early. |
| Logging | event_type = manual_lead_creation, trigger = rep_override, metadata includes rep ID and reason (optional) |
| Downstream | No 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):
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 automation | When NOT to use |
|---|---|
| Volume >20 leads/month | Volume 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 laws | No consent required (e.g., internal leads) |
| Executive assistant scenario: automate email parsing, but skip SMS | Executive 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) |
|---|---|---|---|
| 50 | 20 | 1 | Low – may not break even |
| 200 | 20 | 2 | Positive (saves ~10 rep hours/month) |
| 1000 | 30 | 4 | High (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
Mermaid Diagram – 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:
- In Google Sheets, use a formula to check for duplicate phone numbers:
=COUNTIF(A:A, A2)>1. - Manually review highlighted rows, delete or merge rows by copy‑pasting data.
- No automation – purely manual.
Code (Google Sheets script):
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)
// 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:
| Field | Merge rule |
|---|---|
created_at | Keep the oldest (preserve original entry timestamp) |
source | Append new source to a list (e.g., ["facebook", "google"]) |
last_contacted | Update to most recent timestamp |
stage | Keep the most advanced stage (e.g., if existing is QUALIFIED and new is NEW_LEAD, keep QUALIFIED) |
tags | Merge unique tags; do not duplicate |
custom fields | If 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:
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.
// 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.
// 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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Merge 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 |
| Construction | Merge based on project address + contractor license number, not just person. Preserve change order history. | Unique project identifiers |
| Real Estate | Merge based on property address + client name. Preserve showing history and offer details. | Multiple buyers for same property |
| Home Improvement | Merge based on phone and address. If duplicate leads have different estimate amounts, flag for review (possible price mismatch). | Estimates may differ over time |
| SaaS | Merge based on company domain + user email. Preserve trial start date and usage data. | Account hierarchy |
| Payment Processing | Merge based on merchant ID or tax ID. Preserve compliance documents. | Regulatory requirements |
| Executive Assistant | Rarely needed – executive contacts are unique. If duplicate, manual review required. | Low volume, high trust |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep 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). |
| Necessary | When 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. |
| Questionable | Automating 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):
| Element | Behavior |
|---|---|
| Tags added | manual:merge, merged_from:<contact_id> |
| Stage change | The target contact (kept) retains its stage; the source contact is archived or deleted. |
| Automation stopped | Any 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). |
| Logging | event_type = manual_merge, primary_id, secondary_id, user_id, timestamp. |
Override via CRM button (GHL custom action):
// 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 automation | When NOT to use |
|---|---|
| Volume >100 leads/month with duplicates likely | Very 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 touches | No attribution tracking needed |
| Compliance requires audit trail of merges (medical, finance) | No compliance concerns |
| Executive assistant – rarely needed, manual review suffices | High 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 rate | Leads/month | Time saved (rep/manual merge) | ROI |
|---|---|---|---|
| <1% | 100 | Minimal | Not worth building |
| 5% | 100 | ~10 hours/month | Positive |
| 10% | 1000 | ~100 hours/month | High |
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
Mermaid Diagram – 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:
- In CRM (e.g., GHL), use built‑in round‑robin assignment (available in many CRMs).
- Manually adjust if a rep is on vacation.
- No load balancing, no territory mapping.
Code (GHL native round‑robin setup – no coding):
# In GHL pipeline settings:
# Round Robin Assignment: enabled
# Assignment order: sequential or random
# Users: rep1@company.com, rep2@company.com, rep3@company.comDownsides:
- 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_id | zip_prefix | rep_email | fallback_rep |
|---|---|---|---|
| 1 | 85000-85999 | rep1@company.com | rep2@company.com |
| 2 | 86000-86999 | rep2@company.com | rep3@company.com |
| 3 | * | round_robin | (none) |
Code to fetch territory mapping:
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_email | current_open_tasks | max_capacity | last_assigned_at |
|---|---|---|---|
| rep1@... | 7 | 10 | 2026-05-13T10:00Z |
| rep2@... | 12 | 10 | 2026-05-12T15:00Z |
Round‑robin logic with capacity check:
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)
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)
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
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)
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Route by patient insurance type (Medicare, private, HMO). Must comply with anti‑kickback laws – no financial incentive for rep choice. | Compliance, specialization |
| Construction | Route by project size ($, $$, $$$) and GC license. Subcontractors may have different specializations (foundation, framing, electrical). | Skill matching |
| Real Estate | Route 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 Improvement | Route by service type (patio, roofing, sunroom) and zip code. Also consider rep’s current workload (some reps close sunrooms faster). | Conversion optimization |
| SaaS | Route by company size (enterprise vs SMB) and product line. Enterprise leads may go to account executives, SMB to inside sales. | Sales specialization |
| Payment Processing | Route by monthly volume and industry risk level (high‑risk accounts go to specialized reps). | Compliance, underwriting |
| Executive Assistant | Not applicable – executive rarely has multiple reps. Use simple assignment to assistant@ceo.com. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep 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. |
| Necessary | If no rep is available (all at capacity) and fallback task is created, ops manager must manually assign or adjust capacity. | escalateNoAvailableRep() is called. |
| Questionable | Fully 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:
| Element | Behavior |
|---|---|
| Tags added | manual:reassign, previous_rep:<email>, new_rep:<email> |
| Stage change | None |
| Automation stopped | The original assignment task is marked as “reassigned” and a new task is created for the new rep. |
| Logging | event_type = manual_reassignment, old_rep, new_rep, reason (optional) |
Override via CRM button (custom action):
// 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 automation | When NOT to use |
|---|---|
| Volume >100 leads/month | Very low volume, manual assignment fine |
| Multiple reps (2+) | Single rep – no assignment needed |
| Geographic or product specialization exists | No 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 routing | N/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
##
Glue: 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.
Mermaid Diagram – 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 --> TStraightforward Implementation (Fast & Fragile)
Use case: Low volume, simple keyword‑based qualification, no AI.
Steps:
- Use CRM workflow to detect keywords in lead reply (e.g., contains “budget”, “$”, “timeline”).
- Manually create a task for rep to review and update qualification fields.
- No scoring, no confidence, no clarifying loop.
Code (GHL workflow condition – keyword detection):
// 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
// 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):
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):
Customer message: {message}
Service type: {service_type}Code to call OpenAI with retries:
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
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
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)
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)
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Extract insurance type (Medicare, private, HMO) and urgency (emergency, routine). Must comply with HIPAA – log all AI inputs but de‑identify. | Compliance, patient safety |
| Construction | Extract project size (sq ft), permit status, and GC license. Need to ask for subcontractor availability. | Complex projects |
| Real Estate | Extract property type, buyer/renter flag, and move‑in date. May need to detect if lead is a realtor (then route differently). | Industry norms |
| Home Improvement | As shown – focus on budget, timeline, authority, financing interest. | High‑ticket sales |
| SaaS | Extract company size, user count, current software, and decision‑maker role (C‑level vs manager). BANT adapted to SaaS. | Product‑led growth |
| Payment Processing | Extract monthly volume, average ticket, industry type, and risk tolerance. Must avoid PCI data (never log card numbers). | Compliance |
| Executive Assistant | Not applicable – exec assistant does not automate qualification; they manually screen. | High trust, low volume |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep 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. |
| Necessary | For low‑confidence classifications (<0.6), system creates task for rep to review and qualify manually. | AI confidence <0.6. |
| Questionable | Fully 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:
| Element | Behavior |
|---|---|
| Tags added | manual:override_qualification, override_reason:<reason> |
| Stage change | Rep can move lead to QUALIFIED directly (bypassing automation) |
| Automation stopped | Any pending clarifying questions are cancelled; idempotency tag auto:qualification_processed added to prevent re‑processing |
| Logging | event_type = manual_override_qualification, original AI values, corrected values, rep name |
Code for override detection in the qualification workflow:
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 automation | When NOT to use |
|---|---|
| Volume >100 leads/month | Very low volume, manual qualification fine |
| Reps spend >1 hour/day on qualification | Qualification rarely needed |
| Need consistent BANT collection | Each lead requires nuanced conversation (e.g., high‑touch enterprise sales) |
| AI confidence can be monitored and model retrained | No ability to retrain or audit AI |
| Executive assistant – not suitable | N/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
Mermaid Diagram – 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 --> MStraightforward Implementation (Fast & Fragile)
Use case: Low volume, simple calendar link, no reminders or timezone handling.
Steps:
- Add a static Calendly link to the booking SMS/email.
- No confirmation message (lead assumes it’s booked).
- No reminders (lead may forget).
- No no‑show recovery.
Code (GHL workflow – send calendar link):
// 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)
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)
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):
// 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
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:
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
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
// 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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Booking requires insurance verification and consent forms before confirming. Confirmation must include HIPAA notice. Reminders must comply with TCPA (opt‑out). | Compliance |
| Construction | Book 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 Estate | Booking includes agent availability (multiple agents). Confirmation includes lockbox code or showing instructions. | Security |
| Home Improvement | As shown – simple, with reschedule and no‑show recovery. May include “bring photos of the area.” | Standard |
| SaaS | Not applicable (no appointments). Instead, book a demo call – same pattern. | N/A |
| Payment Processing | Book compliance review call. Must include disclosure that call may be recorded. | Legal |
| Executive Assistant | Booking link sent to assistant, not to executive. Assistant confirms before adding to executive’s calendar. | Trust, time protection |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can manually book an appointment on behalf of the lead (if lead prefers phone booking). | Lead calls or rep initiates. |
| Necessary | For no‑show recovery, rep must follow up at least once; automation only sends link. | No‑show detection. |
| Questionable | Fully 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):
| Element | Behavior |
|---|---|
| Tags added | manual:booking |
| Stage change | Same as automated booking (APPOINTMENT_BOOKED) |
| Automation stopped | The “booking invitation” workflow checks for auto:booking_invite_sent tag; if manual booking occurs before invitation, we add that tag to prevent duplicate invite. |
| Logging | event_type = manual_booking, rep_id |
Code for manual booking detection in the automated workflow:
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 automation | When NOT to use |
|---|---|
| >20 qualified leads/month | Very low volume |
| Reps waste time scheduling appointments | Appointments are rare or handled by dedicated scheduler |
| Customers prefer self‑service (younger demographics) | Older demographics prefer phone booking |
| Need to reduce no‑shows with reminders | No‑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
Mermaid Diagram – 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 --> MStraightforward Implementation (Fast & Fragile)
Use case: Low volume, simple email reminder only, no confirmation.
Steps:
- CRM sends a single email reminder 24 hours before appointment.
- No SMS, no confirmation, no escalation.
Code (GHL native reminder – email only):
# 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)
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)
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)
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)
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)
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Reminders must include HIPAA disclaimer and cannot contain appointment reason. Confirmation may require insurance verification. | Compliance |
| Construction | Include weather alert and site access instructions. Allow confirmation via phone call (older contractors). | Logistics |
| Real Estate | Include lockbox code (after confirmation) and agent contact. Confirmation required before sending code. | Security |
| Home Improvement | As shown – simple SMS/email with reschedule link. May include “bring photos.” | Standard |
| SaaS | Demo reminders: include calendar invite attachment, meeting link, and preparation tips. | Professionalism |
| Payment Processing | Reminders include compliance disclosure and list of required documents. Confirmation required for compliance. | Legal |
| Executive Assistant | Reminders sent to assistant, not executive. Assistant confirms on behalf. | Trust, time protection |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Lead can reply “HELP” to be connected to a human rep for rescheduling or questions. | Any reminder. |
| Necessary | For no‑show detection, rep must follow up at least once; automation only sends reschedule link. | No‑show. |
| Questionable | Automated 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:
| Element | Behavior |
|---|---|
| Tags added | manual:confirmation, confirmed_by_rep |
| Stage change | None (already in APPOINTMENT_BOOKED) |
| Automation stopped | Cancel pending reminder timers for that appointment. |
| Logging | event_type = manual_confirmation, rep_id, timestamp. |
Code to cancel reminders after manual confirmation:
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 automation | When NOT to use |
|---|---|
| Appointment volume >20/month | Very 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 logging | No consent tracking needed |
| Executive assistant – use email only | Executive 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
Mermaid Diagram – 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:
- At the end of the day, rep reviews which appointments were not completed.
- Rep calls or emails the lead to ask if they want to reschedule.
- No automation, no tracking of no‑show rate.
Code (none – purely manual):
// 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
// 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
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
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
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
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)
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | No‑show may incur a fee (automatically invoice). Reschedule link includes fee waiver if rescheduled within 24h. | Revenue protection, policy |
| Construction | No‑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 Estate | No‑show to a showing triggers agent notification and loss of priority for future showings. Reschedule requires agent approval. | Agent time protection |
| Home Improvement | As shown – simple reschedule link, one follow‑up, then reactivation. | Standard |
| SaaS | Not applicable (appointments are demos). Similar pattern: no‑show to demo → reschedule link → lost lead. | N/A |
| Payment Processing | No‑show to compliance call triggers automatic rescheduling with compliance officer. May affect underwriting timeline. | Legal |
| Executive Assistant | No‑show is not possible – assistant confirms executive availability. If executive misses, it’s a reschedule by assistant. | Trust |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can manually reschedule the lead without using the automated link (e.g., by phone). | Lead calls to reschedule. |
| Necessary | After 24h without reschedule, manager must review and decide whether to archive or attempt another contact. | Escalation. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:reschedule, manual:no_show_override |
| Stage change | Move from REACTIVATION back to APPOINTMENT_BOOKED |
| Automation stopped | Cancel pending reschedule timers and tasks. |
| Logging | event_type = manual_reschedule, rep_id, old appointment, new appointment. |
Code for manual override detection in the auto‑reschedule workflow:
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 automation | When NOT to use |
|---|---|
| Volume >20 appointments/month | Very 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
Mermaid Diagram – 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:
- Rep copies lead data from CRM into a document template (Word or Google Doc).
- Saves as PDF, attaches to email, sends manually.
- No tracking, no version control, no access link.
Code (Google Docs mail merge – script):
// 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
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)
<!-- 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
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
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
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
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
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Must include HIPAA notice, insurance codes, and disclaimer. Document must be stored in compliant cloud (e.g., AWS with BAA). | Compliance |
| Construction | Include AIA contract format, lien waiver, change order history. Document may need to be signed electronically. | Legal, industry standard |
| Real Estate | Include disclosure forms, MLS data, commission breakdown. Must be sent via secure portal (not just email). | Legal, security |
| Home Improvement | As shown – simple estimate with financing options. May include photo gallery of similar projects. | Standard |
| SaaS | Quote includes pricing tiers, feature comparison, SLA terms. Auto‑expiry after 30 days. | Sales process |
| Payment Processing | Include fee schedule, compliance disclosures, PCI attestation. Document must be retained for 7 years. | Regulatory |
| Executive Assistant | Not applicable – executive does not send mass estimates. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can review the generated document before sending (sandbox mode). | Estimate amount > $50k or first time using template. |
| Necessary | If template is missing required fields or data validation fails, rep must fill missing data manually. | Missing fields. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:estimate_created |
| Stage change | Rep manually sets stage to ESTIMATE_SENT |
| Automation stopped | The automated generation workflow checks for auto:estimate_processed tag; if present, it skips. |
| Logging | event_type = manual_estimate_generation, rep_id, document link. |
Override detection code:
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 automation | When NOT to use |
|---|---|
| Volume >50 estimates/month | Very low volume, manual fine |
| Estimates are formulaic (few variations) | Highly customized, unique per lead |
| Need tracking (opens, clicks) – see Pattern 9 | No tracking needed |
| Compliance requires audit trail of document versions | No compliance need |
| Executive assistant – not suitable | N/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
Mermaid Diagram – 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
function getTrackingPixelUrl(contactId, estimateId) {
return `https://track.yourdomain.com/pixel?contact=${contactId}&estimate=${estimateId}&type=open`;
}2. Generate wrapped links for each actionable link
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)
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
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
// 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
<!-- 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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Pixel must not collect PHI; use anonymous ID. Click tracking must not reveal condition. | HIPAA |
| Construction | Track clicks on “Change Order” and “Approve” links separately. Log who clicked (subcontractor vs owner). | Role‑based actions |
| Real Estate | Track disclosure acceptance and electronic signature clicks. Log IP addresses for legal audit. | Compliance |
| Home Improvement | As shown – standard tracking. | Standard |
| SaaS | Track trial activation link clicks; use UTM parameters to attribute signups. | Marketing attribution |
| Payment Processing | Track compliance document opens; store proof of disclosure. | Regulatory |
| Executive Assistant | Not applicable – executive rarely clicks tracking links; assistant does it. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can manually mark estimate as opened (if lead tells them). | Lead calls or emails. |
| Necessary | If open count high and no action, system creates task for rep to call. | After 3 opens with no decision. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:estimate_opened |
| Stage change | None |
| Automation stopped | Original tracking will still work; override just adds a log. |
| Logging | event_type = manual_mark_opened, rep_id. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Need to know if customer engaged with estimate | No need to track |
| Volume >100 estimates/month | Low 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 suitable | N/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
Mermaid Diagram – 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
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)
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)
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
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
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)
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Follow‑up must respect HIPAA; do not mention condition. Use neutral language like “your treatment plan.” | Compliance |
| Construction | Longer intervals (7 days instead of 72h) due to longer decision cycles. Include “change order” option. | Project complexity |
| Real Estate | Follow‑up includes “open house” or “final offer deadline” urgency. May include agent contact info. | Time sensitivity |
| Home Improvement | As shown – 1h, 24h, 48h, 72h sequence with financing option in urgency message. | Standard |
| SaaS | Trial follow‑up sequence: day 1 welcome, day 3 feature tip, day 7 upgrade, day 14 last chance. | Product‑led growth |
| Payment Processing | Follow‑up must include compliance reminders and rate lock expiration. | Legal |
| Executive Assistant | Not applicable – no estimate follow‑up. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can manually send a follow‑up outside the sequence (e.g., personalized note). | At any time. |
| Necessary | After 5 days in DECISION_PENDING with no response, manager must review before archiving. | Escalation step. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:followup_sent |
| Stage change | None |
| Automation stopped | The automated sequence continues; manual message does not cancel timers. (We could add a rule: if manual message = “accept”, cancel sequence.) |
| Logging | event_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 automation | When NOT to use |
|---|---|
| Volume >50 estimates/month | Very low volume, manual follow‑up fine |
| Close rate sensitive to follow‑up timing | Close rate unaffected |
| Need consistent outreach (avoid rep forgetting) | Reps are diligent |
| Customers expect digital communication | Customers prefer phone only |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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
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
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
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
// 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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Objection: insurance coverage → response includes pre‑auth link. Must comply with HIPAA. | Compliance |
| Construction | Objection: change order cost → auto‑send breakdown with line items. Include “approve” link. | Complex pricing |
| Real Estate | Objection: commission → send comparison chart. Objection: disclosure → send secure portal link. | Legal |
| Home Improvement | As shown – price leads to financing, trust to testimonials. | Standard |
| SaaS | Objection: feature gap → send roadmap link with date. Objection: price → send comparison with competitors. | Sales cycle |
| Payment Processing | Objection: hidden fees → send fee schedule. Objection: security → send PCI attestation. | Compliance |
| Executive Assistant | Not applicable – assistant handles objections manually. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can modify auto‑response before sending (if configured). | Low confidence (0.6–0.7). |
| Necessary | After 2 auto‑responses without resolution, human must call. | Attempt count >2. |
| Questionable | Auto‑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):
| Element | Behavior |
|---|---|
| Tags added | manual:objection_handled |
| Stage change | None, but rep may move to next stage. |
| Automation stopped | The automated objection handler checks for auto:objection_response_sent tag; if present, it skips. Or we check manual:objection_handled. |
| Logging | event_type = manual_objection_response, rep_id, resolution. |
Override detection code:
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 automation | When NOT to use |
|---|---|
| Volume >100 replies/month | Very low volume |
| Objections are predictable (price, trust, timing) | Highly complex, custom objections |
| Reps spend >2 hours/day on objection responses | Reps have capacity |
| Need consistent response quality | Responses are highly personalized |
| Executive assistant – not suitable | N/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
Mermaid Diagram – 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
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
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)
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
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Approval chain includes compliance officer and IRB for research. SLA often 48 hours. | Regulation |
| Construction | Change order approval: project manager → client → architect. May need electronic signature at each step. | Contractual |
| Real Estate | Offer approval: buyer's agent → seller's agent → seller. Multiple parties may approve simultaneously (not sequential). | Process |
| Home Improvement | Discount approval: rep → sales manager → owner for >20%. Simple. | Standard |
| SaaS | Custom pricing approval: sales → sales manager → finance for >30% discount. | Deal desk |
| Payment Processing | Underwriting approval: analyst → compliance officer → risk committee for high‑risk. SLA 5 days. | Regulation |
| Executive Assistant | Not applicable – executive approves directly. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Approver can delegate approval to another person via reply “delegate to X”. | Before SLA expires. |
| Necessary | If approver rejects, submitter can appeal to a higher authority (manual). | Rejection with reason. |
| Questionable | Fully 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):
| Element | Behavior |
|---|---|
| Tags added | manual:approval_override, override_by_admin |
| Stage change | Document moves directly to approved regardless of chain. |
| Automation stopped | Cancel all pending step timers, mark all pending steps as skipped. |
| Logging | event_type = manual_approval_override, override_by, original_chain, reason. |
Override detection code:
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 automation | When NOT to use |
|---|---|
| Approval volume >20/month | Very low volume |
| Multiple approvers required (2+) | Single approver, simple approval |
| Compliance requires audit trail | No compliance need |
| Approvers are frequently unavailable | Always available |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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
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
// 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)
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Must use HIPAA‑compliant e‑sign (DocuSign for Healthcare). Audit trail must include IP, consent, signed document hash. | Compliance |
| Construction | Use AIA contract templates with e‑signature fields. May require multiple signers (owner, contractor, architect). | Industry standard |
| Real Estate | Integration with MLS and title company. Signature must be witnessed or notarized for some documents. | Legal |
| Home Improvement | Simple contract with financing terms. Signature can be collected via PandaDoc with payment link embedded. | Standard |
| SaaS | Click‑to‑accept (not full signature) for terms of service. No e‑sign required for standard subscriptions. | Low friction |
| Payment Processing | Must include PCI attestation and disclosure acknowledgment. Audit trail required for 7 years. | Regulatory |
| Executive Assistant | Not applicable – executive signs manually via DocuSign, but assistant does not automate. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can manually send a signature link via email if automation fails. | After 24h no delivery. |
| Necessary | If customer declines signature, rep must call to understand reason and possibly send revised contract. | Signature declined. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:contract_uploaded |
| Stage change | Move to CONTRACT_SIGNED |
| Automation stopped | Cancel pending signature reminders, ignore webhooks for that envelope. |
| Logging | event_type = manual_contract_upload, rep_id, file_url. |
Override detection in webhook handler:
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 automation | When NOT to use |
|---|---|
| Volume >20 contracts/month | Very low volume, manual signature OK |
| Compliance requires audit trail | No compliance need |
| Multiple signers or remote customers | In‑person signing possible |
| Need reminders and tracking | Fine with manual follow‑up |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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 --> SStraightforward 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
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
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
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)
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
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Must use HIPAA‑compliant payment processor. Store minimal patient info in webhook logs. | Compliance |
| Construction | Progress payments: invoice percentage complete. Webhook triggers lien waiver generation. | Contractual |
| Real Estate | Earnest money deposit handling. Webhook notifies title company. | Legal |
| Home Improvement | As shown – deposit, progress, final payments. Financing integration often separate. | Standard |
| SaaS | Subscription recurring payments with dunning. Webhook handles subscription cancellation on payment failure. | Recurring revenue |
| Payment Processing | Risk scoring before capture. Webhook may trigger fraud review. | Risk management |
| Executive Assistant | Not applicable – executive does not process payments directly. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can manually mark payment as received via CRM button. | Customer pays by check or cash. |
| Necessary | After 3 payment failures, collections team must contact customer. | Max retries exhausted. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:payment_recorded |
| Stage change | Move to next stage depending on payment type. |
| Automation stopped | The webhook handler checks for manual:payment_recorded tag; if present, it ignores future webhooks for that payment. |
| Logging | event_type = manual_payment_entry, rep_id, amount, date. |
Override detection in webhook handler:
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 automation | When NOT to use |
|---|---|
| Volume >50 payments/month | Very low volume, manual reconciliation fine |
| Need idempotency to prevent double charge | Payment gateway provides idempotency natively? Still recommended |
| Multiple payment stages deposit, progress, final | Single payment only |
| Compliance requires audit trail of payment attempts | No compliance need |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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 --> RStraightforward 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
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
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)
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
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
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
// 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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Dunning for patient payment plans must follow FDCPA rules for debt collection. Escalation requires compliance officer review. | Legal |
| Construction | Progress payments rarely fail; handle with lien rights notices. | Contractual |
| Real Estate | HOA dues or rent collection: state‑specific notice periods before eviction. | Legal |
| Home Improvement | Financing payments – lender handles dunning, not installer. | Standard |
| SaaS | As shown – subscription dunning. High‑touch: offer downgrade option before cancellation. | Churn reduction |
| Payment Processing | Merchant account reserve triggers. Dunning may affect underwriting. | Risk |
| Executive Assistant | Not applicable – no recurring payments. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Collections rep can manually retry a payment or offer a discount. | After first failure. |
| Necessary | After 3 retries, escalation to collections team required. | Max retries exceeded. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:dunning_override, override_reason |
| Stage change | Move from PAYMENT_FAILED or REACTIVATION back to PAY_FULL. |
| Automation stopped | Cancel pending suspension jobs; ignore further dunning webhooks for that customer. |
| Logging | event_type = manual_dunning_override, rep_id, reason. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Recurring payments volume >50/month | Very low volume, manual follow‑up fine |
| High customer churn due to payment failures | Churn already low |
| Need to preserve revenue from accidental failures | Payments rarely fail |
| Compliance requires timely collection attempts | No compliance need |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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 --> PStraightforward 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
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
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)
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
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
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)
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Financing for medical procedures: CareCredit integration. Must comply with healthcare lending laws. | Compliance |
| Construction | Equipment financing for contractors. Lender may require lien waiver before funding. | Contractual |
| Real Estate | Bridge loans or hard money financing. Longer approval times, manual underwriting. | Complexity |
| Home Improvement | As shown – Enhancify or Wisetack integration. In‑house plan as fallback. | Standard |
| SaaS | Not applicable – annual prepay discounts instead of financing. | N/A |
| Payment Processing | Merchant cash advance or equipment financing. Requires business financials upload. | Risk |
| Executive Assistant | Not applicable – executive does not offer financing. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can manually submit a financing application on behalf of the customer (if customer prefers phone). | Customer calls. |
| Necessary | If financing denied and in‑house plan also declined, rep must negotiate alternative (discount, smaller scope). | Both options fail. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:financing_override, override_reason |
| Stage change | Move to DECISION_PENDING or CLOSED_WON. |
| Automation stopped | The webhook handler checks for manual:financing_override tag; if present, it ignores subsequent webhooks for that application. |
| Logging | event_type = manual_financing_override, rep_id. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Volume >20 financing requests/month | Very low volume, manual handling fine |
| Average ticket >$5k (financing critical) | Low ticket, financing rarely used |
| Lender provides webhook API | Lender requires manual portal access |
| Need to recover price objections | Price objections rare |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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 --> OStraightforward 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
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
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
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)
// 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
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | POs for medical supplies must include lot numbers and expiration dates. Compliance: track recalls. | Safety |
| Construction | POs for subcontractors require insurance certificates and lien waivers before sending. | Legal |
| Real Estate | POs for repairs to rental properties; vendor must provide W-9 before payment. | Accounting |
| Home Improvement | As shown – simple PO for materials. May integrate with supplier API (e.g., Home Depot Pro). | Standard |
| SaaS | Not applicable – no physical supplies. | N/A |
| Payment Processing | POs for hardware terminals; require serial number tracking. | Inventory |
| Executive Assistant | Not applicable – executive does not generate POs. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Purchasing manager can manually edit PO before sending. | Before generation. |
| Necessary | If vendor does not acknowledge after 48h, purchasing manager must call. | Escalation. |
| Questionable | Automated vendor routing for new vendors – requires human approval first. | First order with vendor. |
Override Behavior
If a rep manually creates a PO (bypassing automation):
| Element | Behavior |
|---|---|
| Tags added | manual:po_created |
| Stage change | None. |
| Automation stopped | The automated PO generation workflow checks for existing PO link; if present, it skips. |
| Logging | event_type = manual_po_creation, rep_id. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Volume >20 purchase orders/month | Very low volume, manual fine |
| Multiple vendors with regular items | Single vendor, simple process |
| Need to track acknowledgment and delays | No time sensitivity |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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 --> DStraightforward 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
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)
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)
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
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
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.)
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Track medical supplies (implants, devices) with lot numbers. Delay alerts trigger patient notification. | Safety, compliance |
| Construction | Track critical path materials (steel, concrete). Delay alerts automatically adjust subcontractor schedules. | Logistics |
| Real Estate | Track appliances or fixtures for rental renovations. Delay may affect tenant move‑in date. | Contractual |
| Home Improvement | As shown – delay alerts to customer and ops. | Standard |
| SaaS | Not applicable. | N/A |
| Payment Processing | Track hardware terminals; delay alerts to merchant. | Customer experience |
| Executive Assistant | Not applicable. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Ops can manually update tracking status if carrier API is down. | API error. |
| Necessary | Tracking exception (lost/damaged) requires purchasing manager to contact carrier. | Exception detected. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:delivery_complete |
| Stage change | Move to DELIVERY_COMPLETE. |
| Automation stopped | Cancel daily tracking jobs for that contact. |
| Logging | event_type = manual_delivery_complete, rep_id. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Volume >20 shipments/month with tracking | Very low volume, manual tracking fine |
| Delays cause significant customer dissatisfaction | Delays rare |
| Need to reschedule installation proactively | No downstream scheduling dependencies |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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 --> NStraightforward 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
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
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
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
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
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
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Schedule operating rooms, anesthesiologists, nurses. Must respect patient privacy. | Safety |
| Construction | Schedule subcontractors (electricians, plumbers). Requires sequencing (framing before drywall). | Dependencies |
| Real Estate | Schedule cleaners, handymen, photographers for rental turnover. | Logistics |
| Home Improvement | As shown – simple crew scheduling. | Standard |
| SaaS | Not applicable. | N/A |
| Payment Processing | Schedule technician for terminal installation. | Field service |
| Executive Assistant | Not applicable – executive schedules own meetings. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Ops can manually override resource assignment. | Before confirmation. |
| Necessary | If no resource available, ops manager must find alternative (overtime, subcontractor). | No availability. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:work_scheduled |
| Stage change | Move to WORK_SCHEDULED. |
| Automation stopped | Cancel pending auto‑selection timers. |
| Logging | event_type = manual_scheduling, rep_id. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Volume >20 jobs/month | Very low volume |
| Multiple resources with overlapping skills | Single dedicated crew |
| Need to balance workload | Workload trivial |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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
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
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)
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)
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
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
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
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Phases: pre‑op, surgery, recovery, follow‑up. Compliance: notify patient of each phase with instructions. | Safety |
| Construction | Phases: excavation, foundation, framing, MEP, drywall, finishing. Must coordinate inspections. | Legal, logistics |
| Real Estate | Phases for renovation: demo, rough, finish, cleanup. Notify landlord and tenant. | Coordination |
| Home Improvement | As shown – simple phase tracking. | Standard |
| SaaS | Not applicable. | N/A |
| Payment Processing | Phases: risk review, underwriting, approval, onboarding. | Compliance |
| Executive Assistant | Not applicable. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Project manager can manually mark a phase complete via CRM. | Crew forgot to report. |
| Necessary | If phase delay >2 days, PM must investigate and update schedule. | Delay exceeds threshold. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:phase_complete |
| Stage change | None (milestone status changes). |
| Automation stopped | The crew SMS reply handler checks for manual:phase_complete tag; if present, it ignores duplicate reports. |
| Logging | event_type = manual_phase_update, pm_id. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Projects with 3+ distinct phases | Single‑phase project (no need) |
| Need to keep customer informed of progress | Customer does not want updates |
| Multiple crews or subcontractors involved | Single crew handles all phases |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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 --> OStraightforward 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
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
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)
<!-- 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
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
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
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Sign‑off for treatment completion must comply with HIPAA. Signature must be witnessed or electronic with audit trail. | Compliance |
| Construction | Sign‑off may trigger lien waiver release. Must include certification that all subcontractors paid. | Legal |
| Real Estate | Tenant move‑out sign‑off includes security deposit reconciliation. | Contractual |
| Home Improvement | As shown – simple sign‑off with issue reporting. | Standard |
| SaaS | Not applicable. | N/A |
| Payment Processing | Merchant account sign‑off may require compliance officer approval. | Regulation |
| Executive Assistant | Not applicable. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Project manager can manually mark job as signed off via CRM (if customer signs paper). | Customer unavailable digitally. |
| Necessary | If customer reports issues, project manager must resolve before final payment. | Issue reported. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:signoff |
| Stage change | Move to CUSTOMER_SIGNED_OFF. |
| Automation stopped | Cancel pending sign‑off reminders. |
| Logging | event_type = manual_signoff, pm_id, reason. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Volume >20 jobs/month requiring sign‑off | Very low volume |
| Need digital audit trail for warranty or disputes | No compliance or warranty needs |
| Customers expect digital convenience | Customers prefer paper |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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
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)
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
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
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
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)
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Warranty for medical devices often requires patient consent and physician sign‑off. Not fully automatable. | Compliance |
| Construction | Warranty for workmanship (e.g., roof, foundation) may require periodic inspections. Reminders for annual check‑up. | Liability |
| Real Estate | Appliance warranties for rental properties; tenant receives copy, landlord tracks expiry. | Lease terms |
| Home Improvement | As shown – simple manufacturer warranty registration and customer delivery. | Standard |
| SaaS | Not applicable (software warranties are terms of service). | N/A |
| Payment Processing | Equipment warranties for terminals; includes maintenance plans. | Service contracts |
| Executive Assistant | Not applicable. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Service manager can manually register warranty if API fails. | Registration error. |
| Necessary | If warranty claim requires inspection, technician must visit customer before approval. | Claim filed. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:warranty_delivered |
| Stage change | None. |
| Automation stopped | The automated delivery workflow checks for existing warranty link; if present, it skips. |
| Logging | event_type = manual_warranty_delivery, rep_id. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Volume >20 jobs/month requiring warranty registration | Very low volume |
| Manufacturers offer API integration | Only paper‑based registration |
| Need to retain customers for future service (reminders) | No warranty renewal revenue |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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 --> KStraightforward 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
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
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
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
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)
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Cannot solicit reviews that include PHI. Review request must be generic, no mention of treatment. | HIPAA |
| Construction | Reviews on Google and Houzz. May ask for project photos. | Visual proof |
| Real Estate | Reviews on Zillow and Realtor.com. Must not incentivize reviews (fair housing laws). | Legal |
| Home Improvement | As shown – Google and Facebook reviews. Incentives allowed (e.g., $200 referral). | Standard |
| SaaS | Reviews on G2, Capterra. May offer extended trial for review. | Marketing |
| Payment Processing | Cannot solicit reviews that disclose merchant rates. | Compliance |
| Executive Assistant | Not applicable. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can manually request a review via phone. | Customer didn't respond to SMS. |
| Necessary | Negative review requires ops manager to respond within 24h. | Rating ≤3. |
| Questionable | Auto‑responding to reviews – some prefer human‑written responses for authenticity. | All reviews. |
Override Behavior
If a rep manually marks review as handled (bypassing automation):
| Element | Behavior |
|---|---|
| Tags added | manual:review_handled |
| Stage change | None. |
| Automation stopped | Cancel pending review reminders. |
| Logging | event_type = manual_review_handling, rep_id. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Volume >20 jobs/month | Very low volume, manual fine |
| Online reputation critical for acquisition | Reputation not important |
| Need to respond to negative reviews quickly | Low risk of negative reviews |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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 --> OStraightforward 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
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)
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
// 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
// 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)
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)
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)
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Cannot offer cash incentives for referrals (anti‑kickback). Offer charitable donation instead. | Legal |
| Construction | Referral reward may be a discount on future work or a gift card to home improvement store. | Practical |
| Real Estate | Referral fees must comply with RESPA. Offer nominal gift card, not percentage of commission. | Legal |
| Home Improvement | As shown – $200 off for both parties. | Standard |
| SaaS | Referral program offers free months of subscription. Tracks via unique promo codes. | Marketing |
| Payment Processing | Referral reward may be cash or reduced processing fees. Must comply with card brand rules. | Compliance |
| Executive Assistant | Not applicable. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Rep can manually issue a referral reward if automation fails. | Customer complains reward not received. |
| Necessary | If referral lead disputes that they were referred, manager must investigate. | Lead claims no referral. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:referral_credit |
| Stage change | None. |
| Automation stopped | The automation checks for existing referral_converted flag; if set, skips. |
| Logging | event_type = manual_referral_credit, rep_id, amount. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Volume >30 jobs/month | Very low volume, manual tracking fine |
| Customers are likely to refer (high satisfaction) | Low referral likelihood |
| Need to track attribution and reward automatically | No referral program |
| Executive assistant – not applicable | N/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
Mermaid Diagram – 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 --> NStraightforward 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
// 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
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)
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)
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
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
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
| Industry | Variant | Reason |
|---|---|---|
| Medical | Reactivation for annual check‑up reminders. Must comply with HIPAA; cannot mention specific conditions. | Compliance |
| Construction | Reactivation for past bids (e.g., after losing a bid). Offer includes updated pricing or value engineering. | Competitive |
| Real Estate | Reactivation for expired listings or past buyers. Offer includes market update report. | Relationship |
| Home Improvement | As shown – win‑back with discount or financing. | Standard |
| SaaS | Reactivation for cancelled users: offer a discount or feature upgrade. Track via usage data. | Churn reduction |
| Payment Processing | Reactivation for merchants who cancelled: offer rate review or new features. | Retention |
| Executive Assistant | Not applicable. | N/A |
Human Handoff
| Handoff type | Description | When triggered |
|---|---|---|
| Optional | Sales rep can manually revive a lost lead at any time. | Lead calls in. |
| Necessary | If lead replies with complex objection (not simple yes/no), rep must handle. | Unclear reply. |
| Questionable | Automated 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):
| Element | Behavior |
|---|---|
| Tags added | manual:reactivated |
| Stage change | Move to QUALIFIED. |
| Automation stopped | Cancel any pending reactivation timers. |
| Logging | event_type = manual_reactivation, rep_id. |
Decision Guide
| When to use this automation | When NOT to use |
|---|---|
| Volume >30 lost leads/month | Very low volume |
| Close rate on reactivated leads >5% | Low recovery rate |
| Need systematic win‑back program | No reactivation strategy |
| Executive assistant – not applicable | N/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)
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
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:
// 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):
<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):
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:
// 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.
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:
// 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):
// 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:
// 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
| Regulation | Applies to | Wrapper must enforce |
|---|---|---|
| TCPA | SMS, calls | Consent, opt‑out, time‑of‑day, logging |
| HIPAA | PHI (medical data) | Encryption, access controls, audit logs, BAAs, retention, breach notification |
| SOC2 | Service organizations | Security, availability, processing integrity, confidentiality, privacy |
| SOX | Publicly traded companies | Separation 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.
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
| Regulation | Core requirement | Automation impact |
|---|---|---|
| TCPA | Consent before SMS | Add consent check and opt‑out handler to every SMS automation |
| HIPAA | PHI protection | Encrypt data, limit access, log every action, sign BAAs |
| SOC2 | Security + availability | Add health checks, access controls, audit trails |
| SOX | Separation of duties | Require 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
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.
| Role | Function | Examples (2026) | Must have |
|---|---|---|---|
| Execution | State machine, CRM, workflows, timers | GoHighLevel, HubSpot Operations Hub, Pipedrive | Idempotency, stage changes, task creation |
| Orchestration | Connect tools, handle retries, route data | Make (Integromat), Zapier, n8n, custom webhooks | Error queues, conditional branching, dead‑letter queues |
| Intelligence | AI classification, extraction, scoring | OpenAI (GPT‑4o‑mini, GPT‑4o), Claude, Gemini, fine‑tuned local models | Confidence scores, fallback to rules |
| Logging | Immutable audit trail, metrics | Airtable, BigQuery, Snowflake, Datadog | Append‑only, timestamped, queryable |
| Reporting | Dashboards, visualization | Looker Studio, Tableau, Power BI, Metabase | Data 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:
| Feature | Make | Zapier | n8n |
|---|---|---|---|
| Retry with backoff | Yes | Limited | Yes (self‑hosted) |
| Dead‑letter queue | Via Airtable | No | Yes |
| Cost per operation | Lower | Higher | Free (self‑hosted) |
| Learning curve | Medium | Low | Higher |
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)
| Role | Tool | Monthly cost | Setup effort |
|---|---|---|---|
| Execution | GoHighLevel Agency Pro | $497 | Medium |
| Orchestration | Make Pro | $109 | Medium |
| Intelligence | OpenAI GPT‑4o‑mini | $20–$50 | Low |
| Logging | Airtable Pro | $20 | Low |
| Reporting | Looker Studio | $0 | Low |
Total: ~$650–$700/month for up to 10k events/day.
27.8 When to Deviate from the Default
| Scenario | Change |
|---|---|
| Enterprise (>10k leads/month) | Replace Airtable with BigQuery; add Tableau for dashboards. |
| HIPAA compliance | Add BAA with all vendors; use self‑hosted n8n instead of Make. |
| No budget for GHL | Use 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 case | Use no automation – manual is fine (Pattern 1 executive variant). |
27.9 Tool Decision Guide
When evaluating a new tool, ask:
- Which role does it fill? (If it tries to fill two, be skeptical.)
- Does it export data? (Lock‑in is expensive.)
- What is the SLA? (99.9% uptime required for execution and orchestration.)
- Does it have audit logs? (You need to know who changed what.)
- 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
| Role | Function | 2026 default | Failure if missing |
|---|---|---|---|
| Execution | State machine, workflows | GoHighLevel | Leads never move through pipeline |
| Orchestration | Cross‑tool glue, retries | Make | Broken automations, lost data |
| Intelligence | AI classification, extraction | OpenAI | Wrong responses, manual review overload |
| Logging | Immutable audit trail | Airtable | No debugging, compliance failures |
| Reporting | Dashboards, alerts | Looker Studio | Blind 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
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:
| Pattern | Problem it solves | Without it |
|---|---|---|
| Idempotency | Duplicate execution (same event processed twice) | Duplicate SMS, double payments, duplicated tasks |
| Retries | Transient failures (network timeout, API 5xx) | Lost events, incomplete workflows |
| Dead‑letter queues | Permanent failures (invalid data, API 404 after retries) | Silent data loss, no recovery path |
These three patterns work together. A robust automation:
- Is idempotent – running it twice produces the same result.
- Retries transient failures – with exponential backoff.
- 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_sentbefore 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:
| Method | How it works | Best for |
|---|---|---|
| Tag/flag check | Before action, check if a tag or custom field exists. After action, set it. | CRM workflows (GHL, HubSpot) |
| Idempotency key | Generate 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 constraint | Use a unique index on (event_type, contact_id, timestamp) to prevent duplicate inserts. | Logging tables |
Example – idempotency key in Make (using Data store):
// 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:
| Attempt | Delay (fixed) | Delay with jitter (±20%) |
|---|---|---|
| 1 | 0s (original) | 0s |
| 2 | 2s | 1.6–2.4s |
| 3 | 5s | 4–6s |
| 4 | 15s | 12–18s |
| 5 | 45s | 36–54s |
Implementation in Make (custom error handler):
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):
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:
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):
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
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 --> D28.6 Real‑World Examples
Example 1 – Lead capture webhook with all three patterns:
// 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 DLQExample 2 – Payment processing with idempotency key:
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:
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
| Metric | What to measure | Alert 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 size | Number of pending DLQ records | >10 for more than 1 hour |
| DLQ age | Oldest pending record | >24 hours |
Dashboard (Looker Studio):
Idempotency: 1,234 duplicates skipped (2.1%) – OK
Retry success: 87% – OK
DLQ pending: 3 records – oldest 2 hours – check28.8 Chapter Summary
| Pattern | Purpose | Implementation | Failure without it |
|---|---|---|---|
| Idempotency | Prevent duplicate actions | Tag check, idempotency key, unique constraint | Duplicate SMS, double payments, duplicated tasks |
| Retries | Recover from transient failures | Exponential backoff, max attempts | Lost events, incomplete workflows |
| Dead‑letter queue | Capture permanently failed events | Airtable or database table, reprocessing job | Silent 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
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:
| Layer | What it tests | When to run | Who runs it |
|---|---|---|---|
| Unit | A single action or function (e.g., idempotency check) | During development, on every code change | Developer |
| Integration | Interaction between two systems (e.g., webhook → CRM) | Before deployment to staging | Developer + QA |
| Chaos | Behavior under failure conditions (e.g., API timeout, rate limit) | Weekly in staging | Ops + QA |
| Synthetic health checks | End‑to‑end production functionality (e.g., form → SMS) | Every 15 minutes in production | Ops (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):
// 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:
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):
// 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):
// 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:
| Scenario | How to simulate | Expected behavior |
|---|---|---|
| OpenAI API down | Mock timeout or 500 | Rule‑based classification, log error, escalate after 3 attempts |
| Airtable rate limit | Send 10 writes in 1 second | Batching or queuing, fallback to Google Sheets |
| GHL webhook unreachable | Disable Make scenario | GHL retries 3x, then writes to DLQ, ops task created |
| SMS carrier failure | Mock SMS send failure | Fallback to email, create task for rep to call |
Chaos test automation (using Make or custom script):
// 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):
// 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):
| Check | Last run | Status | Latency |
|---|---|---|---|
| Lead capture | 2 min ago | ✅ | 1.2s |
| AI classification | 5 min ago | ✅ | 0.8s |
| Estimate tracking | 15 min ago | ❌ failed | – |
| Payment webhook | 1 hour ago | ✅ | 2.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 type | Unit | Integration | Chaos | Synthetic |
|---|---|---|---|---|
| Lead capture | Idempotency, dedupe | Webhook → CRM | Webhook timeout | Every 15 min |
| AI classification | Score calculation, fallback | OpenAI → CRM | API timeout, 429 | Every hour |
| Payment processing | Idempotency key | Stripe webhook → CRM | Duplicate webhook | Every hour |
| Estimate follow‑up | Timer logic | Follow‑up sequence | No response | Twice a day |
| Dunning | Retry count | Failed payment → task | Max retries exceeded | Daily |
| Referral program | Reward calculation | Referral link → lead | Expired link | Daily |
29.7 The Test Pyramid for Automations
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):
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‑destructiveSchedule chaos tests (cron):
// Run every Sunday at 2 AM
scheduleJob('0 2 * * 0', async () => {
await runChaosTests();
});29.9 Chapter Summary
| Test type | Purpose | Frequency | Failure response |
|---|---|---|---|
| Unit | Verify isolated logic | On every code change | Fix before merge |
| Integration | Verify system interactions | Before deployment | Block deployment |
| Chaos | Verify failure recovery | Weekly | Fix root cause, add test case |
| Synthetic | Monitor production health | Every 15–60 minutes | Critical 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
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:
- What does this automation do? (Purpose, trigger, actions)
- How do I monitor it? (Health checks, metrics, logs)
- What can go wrong? (Failure modes, error codes)
- 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:
# 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)
# 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
| Location | Who can access | Update frequency |
|---|---|---|
| Grimoire (Notion) – master copy | All team members | After every change |
| Slack pinned message – quick reference | Ops, support | After major changes |
| PDF export – printed emergency binder | On‑call engineer | Quarterly |
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:
# 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
| Frequency | Task | Owner |
|---|---|---|
| After every incident | Update runbook with root cause and recovery steps. | Incident commander |
| Monthly | Review runbooks for stale links, outdated API keys, changed escalation contacts. | Ops lead |
| Quarterly | Fire drill: follow runbook for a simulated failure. Update any unclear steps. | Ops + QA |
30.7 Chapter Summary
| Element | Purpose | Required for |
|---|---|---|
| Runbook | Step‑by‑step recovery for a specific automation | Every production automation |
| Health check | Automated synthetic test | Critical path automations (lead capture, payments) |
| Escalation contacts | Who to call when automation breaks | All automations |
| Change log | Track modifications for audit and debugging | All 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
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:
| Type | Definition | Automation’s role | Human’s role |
|---|---|---|---|
| Optional | Automation works 95%+, but human can override if they wish | Suggest, execute, log | Review, correct, approve |
| Necessary | Legal, compliance, trust, or high‑stakes judgment requires human action | Prepare, notify, escalate – never decide | Decide, approve, reject |
| Questionable | Automation could be built, but cost/effort > benefit, or human touch adds disproportionate value | Do not automate (or build a helper) | Own the action entirely |
31.2 The Universal Handoff Decision Flowchart
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.
| Pattern | Industry | Default handoff | Industry rule | Reason |
|---|---|---|---|---|
| P01 Lead capture | Medical | Fully automatable | ➕ Necessary for insurance verification | HIPAA requires human to verify coverage |
| P01 Lead capture | Executive assistant | Questionable | Keep manual | Trust and low volume |
| P04 AI classification | Medical | Optional | ➕ Necessary for diagnosis | Cannot auto‑classify without physician review |
| P04 AI classification | Executive assistant | Questionable | Keep manual | Assistant knows executive’s preferences |
| P11 Objection response | Home improvement | Optional | ✅ Fully automatable for price objection | Financing offer works well |
| P11 Objection response | Medical | Optional | ➕ Necessary for insurance objection | Auto‑send info, but human must verify |
| P12 Multi‑stage approval | Construction | Necessary | ✅ Fully automatable for change orders < $5k | Low risk, can auto‑approve |
| P12 Multi‑stage approval | Executive assistant | Necessary | Keep manual | Executive signs all approvals personally |
| P13 E‑signature | Real estate | Necessary | ✅ Auto‑trigger after human approves | Agent reviews contract, then auto‑send for signature |
| P15 Dunning | SaaS | Optional | ✅ Fully automatable for low‑value subscriptions (<$50/mo) | Churn risk low, automation recovers most |
| P15 Dunning | Medical | Optional | ➕ Necessary for payment plans > $10k | Requires collections team review |
| P21 Sign‑off | Construction | Optional | ➕ Necessary for projects > $100k | Legal liability requires in‑person walkthrough |
| P23 Review request | Home improvement | Optional | ✅ Fully automatable | No downside to automation |
| P23 Review request | Medical | Optional | ➕ 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 type | Tags added | Stage change | Automation stopped | Logging |
|---|---|---|---|---|
| Rep overrides AI classification | manual:override_classification | None (or to MANUAL_REVIEW) | Cancel pending auto‑responses | event_type = manual_override |
| Rep manually creates lead | manual:lead_creation, auto:welcome_sent | Set to CONTACTED | Intake workflow skips | event_type = manual_lead_creation |
| Rep manually approves contract | manual:approval_override | Move to APPROVED | Cancel approval timers | event_type = manual_approval |
| Rep manually marks no‑show as rescheduled | manual:reschedule, no_show_override | Move back to APPOINTMENT_BOOKED | Cancel no‑show escalation | event_type = manual_reschedule |
Code pattern for override detection (in every automation):
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
| Industry | Typical handoff bias | Example |
|---|---|---|
| Medical | Heavy on necessary handoff | Diagnosis, insurance verification, prescription approval |
| Construction | Mix: necessary for change orders >$5k, optional for sub‑$5k | Material orders can be automated, change orders need PM approval |
| Real Estate | Necessary for contract signing, optional for showing scheduling | Disclosures require human review, appointment booking can be automated |
| Home Improvement | Light on necessary; most automations optional or full | Price objection auto‑respond, financing auto‑approve |
| SaaS | Optional for most; necessary only for enterprise contracts | Trial signup fully automated, discount approval optional |
| Payment Processing | Necessary for fraud review, optional for recurring dunning | High‑risk transactions require human, low‑risk auto‑retry |
| Executive Assistant | Questionable for most; keep human | Calendar 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):
| Phase | AI accuracy | Volume | Handoff type | Reason |
|---|---|---|---|---|
| Month 1 | 70% | 50/month | Optional (human override) | Too many mistakes to auto‑act |
| Month 6 | 90% | 200/month | Fully automatable | Accuracy high, volume justifies |
| Month 12 | 95% | 500/month | Fully automatable with confidence threshold | Keep fallback for low confidence |
How to transition:
- Run both old (optional) and new (automatic) in parallel for 2 weeks.
- Compare error rates and customer satisfaction.
- If new is better, switch. Keep old as fallback.
31.9 Chapter Summary
| Tool | Purpose |
|---|---|
| Flowchart | Decide handoff type (optional/necessary/questionable) based on legal, accuracy, cost, volume, trust |
| Industry rules table | Override generic decisions for medical, construction, real estate, home improvement, SaaS, payments, executive |
| Risk/volume matrix | Quick visual for handoff type |
| One‑page checklist | Printable reference for designing new automations |
| Override behavior table | Standard 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
A.1 The 19 Pipeline Stages (Recap)
| # | Stage Name | Description |
|---|---|---|
| 1 | LEAD | Capture inbound inquiry |
| 2 | QUALIFY | Determine fit (budget, authority, need, timeline) |
| 3 | APPOINT (for estimate) | Schedule consultation or site visit |
| 4 | ESTIMATE | Deliver quote or scope of work |
| 5 | approve or reject est | Customer decides to accept, reject, or request changes |
| 6 | FU contract | Follow‑up on contract |
| 7 | contract | Negotiate and sign contract |
| 8 | initial pay | Deposit or down payment |
| 9 | FU pay | Follow‑up on payments |
| 10 | Order supplies | Procurement and purchase orders |
| 11 | monitor delivery | Track shipments, manage delays |
| 12 | schedule work/site | Assign crews, book installation dates |
| 13 | job in progress | Multi‑phase milestone tracking |
| 14 | job finished | Work completed |
| 15 | FU payments | Final payment collection |
| 16 | PAY full | Final payment received |
| 17 | FULFILL | Final delivery, sign‑off, warranty |
| 18 | poach for review | Request reviews and referrals |
| 19 | REACTIVATE | Win‑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.
| Pattern | Name | Stages (Affected) | Type |
|---|---|---|---|
| P01 | Capture inbound lead | 1 (LEAD) → 2 (QUALIFY) | Connects |
| P02 | Deduplicate and merge | 1 (LEAD) | Operates within |
| P03 | Round‑robin / territory assignment | 1 (LEAD) → 2 (QUALIFY) | Connects |
| P04 | AI classification / BANT | 2 (QUALIFY) → 3 (APPOINT) | Connects |
| P05 | Self‑service appointment booking | 3 (APPOINT) → 4 (ESTIMATE) | Connects |
| P06 | Multi‑channel reminders with confirmation | 3 (APPOINT) | Operates within |
| P07 | No‑show detection and auto‑reschedule | 3 (APPOINT) → 19 (REACTIVATE) | Connects |
| P08 | Document generation from template | 4 (ESTIMATE) → 5 (approve or reject) | Connects |
| P09 | Open/click tracking with webhooks | 4 (ESTIMATE) | Operates within |
| P10 | Timer‑based follow‑up sequence | 4 (ESTIMATE) → 5 (approve or reject) | Connects |
| P11 | Objection detection and auto‑response | 5 (approve or reject) → 6 (FU contract) | Connects |
| P12 | Multi‑stage approval workflow | 6 (FU contract) → 7 (contract) | Connects |
| P13 | E‑signature collection with audit trail | 7 (contract) → 8 (initial pay) | Connects |
| P14 | Payment processing with idempotent webhooks | 8 (initial pay) → 9 (FU pay) → 16 (PAY full) | Connects |
| P15 | Dunning management for recurring payments | 9 (FU pay) → 16 (PAY full) → 18 (poach for review) | Connects |
| P16 | Financing application & approval workflow | 5 (approve or reject) → 8 (initial pay) | Connects |
| P17 | Purchase order generation and vendor routing | 8 (initial pay) → 10 (Order supplies) | Connects |
| P18 | Shipment tracking with delay alerts | 10 (Order supplies) → 11 (monitor delivery) → 12 (schedule work/site) | Connects |
| P19 | Resource‑aware scheduling | 11 (monitor delivery) → 12 (schedule work/site) | Connects |
| P20 | Multi‑phase milestone tracking & notifications | 12 (schedule work/site) → 13 (job in progress) → 14 (job finished) | Connects |
| P21 | Digital sign‑off on completion | 14 (job finished) → 15 (FU payments) → 16 (PAY full) → 17 (FULFILL) | Connects |
| P22 | Warranty registration and document delivery | 17 (FULFILL) → 18 (poach for review) | Connects |
| P23 | Review request with reputation monitoring | 17 (FULFILL) → 18 (poach for review) | Connects |
| P24 | Referral program with unique links & reward tracking | 18 (poach for review) → 19 (REACTIVATE) → back to 1 (LEAD) | Connects (loop) |
| P25 | Reactivation 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.)
| Stage | Patterns |
|---|---|
| 1 – LEAD | P01, P02, P03, P24 (loop), P25 (loop) |
| 2 – QUALIFY | P01, P03, P04 |
| 3 – APPOINT | P04, P05, P06, P07 |
| 4 – ESTIMATE | P05, P08, P09, P10 |
| 5 – approve or reject est | P08, P10, P11, P16 |
| 6 – FU contract | P11, P12 |
| 7 – contract | P12, P13 |
| 8 – initial pay | P13, P14, P16, P17 |
| 9 – FU pay | P14, P15 |
| 10 – Order supplies | P17, P18 |
| 11 – monitor delivery | P18, P19 |
| 12 – schedule work/site | P18, P19, P20 |
| 13 – job in progress | P20 |
| 14 – job finished | P20, P21 |
| 15 – FU payments | P21 |
| 16 – PAY full | P14, P15, P21 |
| 17 – FULFILL | P21, P22, P23 |
| 18 – poach for review | P15, P22, P23, P24 |
| 19 – REACTIVATE | P07, P24, P25 |
A.4 How to Use This Matrix
| Scenario | Action |
|---|---|
| You are designing a new automation for a specific stage | Look up the stage in the reverse lookup table. See which patterns already exist – you may reuse or adapt them. |
| You are auditing a business process | Walk through stages 1–19. For each stage, check if the corresponding patterns are implemented. Missing patterns indicate automation gaps. |
| You are troubleshooting a failure | Identify 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
B.1 Idempotency Key Pattern
Problem: Prevent duplicate processing of the same event (e.g., duplicate webhook, user double‑click).
Pseudocode:
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):
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):
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:
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):
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:
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):
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:
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 errorReal code:
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:
if ai_confidence < 0.7 or ai_timeout:
intent = keyword_fallback(message)
else:
intent = ai_resultReal code:
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:
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):
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:
code = base64_encode(contact_id)[0:8]
if code already exists:
code = code + random_digitReal code:
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):
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):
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):
- Webhook module – receives event.
- Try‑catch wrapper (simulated by error handler route):
- Main path: call OpenAI, parse JSON, update CRM.
- Error path: on 5xx → sleep 2^retry seconds → re‑run module. On 4xx → go to fallback.
- Fallback router:
- Rule‑based classification.
- If fallback succeeds, continue.
- 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
C.1 Execution Role (CRM + Workflow Engine)
| Tool | Best for | Pricing (2026) | Key strengths | Weaknesses | Idempotency | Timers | Webhooks |
|---|---|---|---|---|---|---|---|
| GoHighLevel (GHL) | Home improvement, agencies, local services | $297–$497/mo (agency plan) | SMS native, workflows, calendars, round‑robin, custom fields | Limited logging, basic reporting | Via tags | Yes (wait steps) | Yes |
| HubSpot Operations Hub | B2B SaaS, enterprise | $800–$3,600/mo | Strong reporting, data sync, error handling | Expensive, no native SMS | Via custom objects | Yes (delays) | Yes |
| Salesforce | Large enterprise | $150–$300/user/mo | Unlimited customization, enterprise features | Complex, expensive, slow to change | Via Apex | Yes | Yes |
| Pipedrive | Small sales teams | $15–$99/user/mo | Simple, easy to use | Limited automation, no SMS | Via webhooks | Limited | Yes |
| SuiteCRM (self‑hosted) | Budget‑constrained, HIPAA | Free (hosting cost) | Full control, no per‑user fees | Requires dev resources, no native SMS | Via custom code | Via cron | Yes |
Recommendation: GHL for most SMBs. HubSpot for B2B SaaS with budget. SuiteCRM if you need self‑hosted compliance.
C.2 Orchestration Role (Workflow Glue)
| Tool | Best for | Pricing (2026) | Key strengths | Weaknesses | Retry | DLQ | Conditional branching |
|---|---|---|---|---|---|---|---|
| Make (Integromat) | High‑volume, complex branching | $9–$109/mo (10k–100k ops) | Visual builder, error handler, data stores, cheap | Steeper learning curve than Zapier | Yes (custom) | Via Airtable | Yes |
| Zapier | Simple, low‑volume | $20–$600/mo | Easiest to learn, huge app library | Expensive at scale, limited error handling | Yes (fixed) | No | Limited |
| n8n (self‑hosted) | Compliance, high‑volume | Free (self‑host) | Full control, no operation limits | Requires dev resources | Yes | Yes | Yes |
| Tray.io | Enterprise | Custom ($2k+/mo) | Advanced error handling, governance | Very expensive | Yes | Yes | Yes |
| Custom webhooks (Node.js) | Developer teams | Hosting cost | Full control, no third‑party risk | Maintenance burden | Implement yourself | Implement yourself | Yes |
Recommendation: Make for most SMBs. n8n for self‑hosted compliance. Zapier only for very simple, low‑volume needs.
C.3 Intelligence Role (AI)
| Tool | Best for | Pricing (2026) | Key strengths | Weaknesses | Fine‑tuning | Confidence scores | Fallback |
|---|---|---|---|---|---|---|---|
| OpenAI GPT‑4o‑mini | Classification, extraction | $0.15/1M input tokens | Cheap, fast, accurate | No local hosting | Yes | Yes | Rule‑based |
| OpenAI GPT‑4o | Complex generation, summarization | $2.50/1M input tokens | Highest quality | More expensive | Yes | Yes | Rule‑based |
| Claude 3.5 Sonnet | Long context, safety | $3/1M tokens | Large context window, safety features | More expensive, slower | Limited | Yes | Rule‑based |
| Llama 3 (self‑hosted) | High‑volume, offline compliance | Hosting cost | No API cost, full control | Requires GPU, lower accuracy | Yes | Yes | Built‑in |
| Gemini 1.5 Pro | Google ecosystem | $1.25/1M tokens | Long context, Google integration | Less fine‑tuning | Limited | Yes | Rule‑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)
| Tool | Best for | Pricing (2026) | Key strengths | Weaknesses | Append‑only | Queryable | Retention |
|---|---|---|---|---|---|---|---|
| Airtable | SMB (<500k rows) | $20–$45/mo | Easy to use, good UI, API | Row limit, rate limit (5 req/s) | Via permissions | Yes | 6–12 months |
| BigQuery | High‑volume (>10k events/day) | Free tier up to 1TB, then $5/TB | Scalable, fast, no row limit | Requires SQL knowledge | Via design | Yes | Unlimited |
| Google Sheets | Very low volume (<5k rows/mo) | Free | Simple, easy to share | Not append‑only, slow queries | No | Limited | Unlimited |
| Datadog | Real‑time ops logs | $0.10/GB ingested | Real‑time alerts, integrations | Expensive for long‑term storage | Yes | Yes | Configurable |
| AWS CloudWatch | AWS‑native stacks | $0.50/GB | Integrated with AWS | Complex to query | Yes | Yes | Configurable |
Recommendation: Airtable for SMBs. BigQuery for high volume. Google Sheets only for internal, non‑critical logs.
C.5 Reporting Role (Dashboards)
| Tool | Best for | Pricing (2026) | Key strengths | Weaknesses | Data freshness | Embedding | Alerts |
|---|---|---|---|---|---|---|---|
| Looker Studio | Free, Google ecosystem | $0 (free) | Free, easy sharing, connects to Sheets/BigQuery | Limited to 5 data sources per report | 15 min+ | Yes (iframe) | No (via Make) |
| Tableau | Enterprise, large data | $70/user/mo | Powerful visualizations, large data | Expensive, steep learning curve | Real‑time | Yes | Yes |
| Power BI | Microsoft shops | $10–$20/user/mo | Good integration with Excel, Azure | Windows‑centric | Real‑time | Yes | Yes |
| Metabase (self‑hosted) | Embedded analytics | Free (self‑host) | Open source, easy for non‑technical | Requires hosting | Real‑time | Yes | No |
| Superset (self‑hosted) | Large‑scale open source | Free (self‑host) | Powerful, SQL‑based | Complex setup | Real‑time | Yes | No |
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 case | Execution | Orchestration | Intelligence | Logging | Reporting |
|---|---|---|---|---|---|
| SMB, home improvement | GHL | Make | OpenAI GPT‑4o‑mini | Airtable | Looker Studio |
| B2B SaaS | HubSpot | Make | OpenAI GPT‑4o | BigQuery | Looker Studio / Power BI |
| Healthcare (HIPAA) | SuiteCRM (self‑host) | n8n (self‑host) | Llama 3 (self‑host) | BigQuery | Metabase |
| Enterprise | Salesforce | Tray.io | OpenAI GPT‑4o | Snowflake | Tableau |
| Executive assistant (low volume) | None (manual) | None | None | Google Sheets | None |
End of Appendix C
D.1 Should I Automate This Process?
Use this checklist when evaluating a new automation candidate.
| Question | Yes | No | Action |
|---|---|---|---|
| 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).
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:
| Condition | Handoff type |
|---|---|
| Legal/compliance requirement | Necessary |
| AI accuracy <80% + high risk | Necessary |
| AI accuracy 80–95% + medium risk | Optional (human override available) |
| AI accuracy >95% + low risk | Fully automatable |
| Volume <20/month + low risk | Questionable (keep manual) |
D.3 Production‑Grade vs Straightforward
| Factor | Straightforward (fast, fragile) | Production‑grade (logged, idempotent, recoverable) |
|---|---|---|
| Volume | <50 executions/day | >100 executions/day |
| Customer‑facing? | No (internal tools only) | Yes |
| Compliance required? | No | Yes (TCPA, HIPAA, SOC2) |
| Failure cost | <$100 per incident | >$1,000 per incident |
| Team time to build | Days | Weeks |
| Idempotency | No | Yes |
| Logging | None or console | Immutable audit trail |
| Retries | No | Exponential backoff |
| Dead‑letter queue | No | Yes |
| Health checks | No | Yes (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:
| Role | First choice | Second choice | When to choose second |
|---|---|---|---|
| Execution (CRM) | GoHighLevel | HubSpot | B2B SaaS, enterprise budget |
| Orchestration | Make | n8n (self‑host) | HIPAA, SOC2 compliance |
| Intelligence (AI) | OpenAI GPT‑4o‑mini | Llama 3 (self‑host) | Offline, on‑prem, high volume |
| Logging | Airtable | BigQuery | >500k rows/month |
| Reporting | Looker Studio | Metabase (self‑host) | Embedding, open source |
D.5 When to Use Each Automation Pattern
| Pattern | Use if... | Avoid if... |
|---|---|---|
| P01 – Lead capture | Volume >100 leads/month, multiple channels | Single channel, low volume |
| P02 – Deduplicate | Duplicate rate >5% | Duplicates rare, manual merge fine |
| P03 – Round‑robin | >3 reps, territories | Single rep |
| P04 – AI classification | >100 leads/month, consistent BANT needs | Highly nuanced, low volume |
| P05 – Self‑booking | Customers prefer self‑service, younger demo | Elderly customers, low volume |
| P06 – Reminders | No‑show rate >15% | No‑shows rare |
| P07 – No‑show recovery | Appointments scheduled, no‑show cost high | Low appointment volume |
| P08 – Document generation | >50 estimates/month, formulaic | Custom, one‑off estimates |
| P09 – Open/click tracking | Need to know if customer engaged | No tracking needed |
| P10 – Follow‑up sequence | Close rate sensitive to timing | Reps already diligent |
| P11 – Objection handling | >50 objections/month, predictable types | Highly varied, complex objections |
| P12 – Multi‑stage approval | >20 approvals/month, multiple approvers | Single approver |
| P13 – E‑signature | Remote customers, compliance needs | In‑person signing possible |
| P14 – Payment processing | Online payments accepted | Paper checks only |
| P15 – Dunning | Recurring billing >50 customers | One‑time payments |
| P16 – Financing | Average ticket >$10k, price objection common | Low ticket, financing rarely used |
| P17 – Purchase orders | >20 POs/month, multiple vendors | Single vendor, low volume |
| P18 – Shipment tracking | >20 shipments/month, delays costly | Local pickup, no tracking |
| P19 – Resource scheduling | Multiple crews, complex availability | Single crew, simple calendar |
| P20 – Milestone tracking | Projects with >3 phases | Single‑phase projects |
| P21 – Digital sign‑off | Need audit trail for completion | Paper sign‑off acceptable |
| P22 – Warranty registration | Manufacturers require registration | No warranty |
| P23 – Review requests | Volume >20 jobs/month | Low volume, poor reputation risk |
| P24 – Referral program | High customer satisfaction | Low satisfaction, no word‑of‑mouth |
| P25 – Reactivation | Lost leads >100/month | Lost leads rare |
D.6 Failure Handling Quick Reference
| Failure type | Detection | Recovery action |
|---|---|---|
| API timeout | Error log, health check | Retry with backoff (3–5 attempts) → fallback → DLQ |
| Missing data | Validation rule | Send clarifying question → create task for rep |
| Duplicate event | Idempotency check | Log skip, do not process |
| Human ignores task | Task overdue (SLA) | Remind (2 min) → reassign (5 min) → escalate (10 min) |
| Webhook lost | No event log after trigger | Retry 3x → DLQ → ops task |
| AI misclassification | Rep override rate >15% | Log override → retrain model monthly |
End of Appendix D
[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-oncalland 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_deliveryfails; 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_senttag. 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_classificationfails; 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_loggingfails; 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_airtablescenario 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_dlqto 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)
# 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
F.1 Handoff Types – Quick Reference
| Type | When to use | Automation role | Human role | Override tag |
|---|---|---|---|---|
| Optional | AI confidence 80–95%, medium risk | Suggest, auto‑execute | Review, override if wrong | manual:override_[pattern] |
| Necessary | Legal/compliance, high risk, >$1k error cost | Prepare, notify, escalate | Decide, approve, reject | manual:[action]_override |
| Questionable | Low volume (<20/mo), human adds trust value | Do not automate (build helper only) | Own the action | N/A |
F.2 Override Behavior – What Happens When a Human Steps In
| Element | Standard behavior | Example |
|---|---|---|
| Tags added | manual:override_[pattern], plus reason tag | manual:override_classification, override_reason:price_should_be_trust |
| Stage change | Usually none (or move to MANUAL_REVIEW stage if design includes it) | DECISION_PENDING → stays, but tag added |
| Automation stopped | Cancel pending timers, suppress further auto‑actions | Cancel decision timer, suppress second auto‑response |
| Logging | event_type = manual_override, original value, corrected value, human ID | event_type = manual_override_classification, original=intent:price, corrected=intent:trust |
| Downstream | May skip normal auto‑sequences | No automatic follow‑up after manual override |
Detection pattern in automation code:
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)
| Pattern | Automation name | Override tag | When to use |
|---|---|---|---|
| P01 – Lead capture | lead_capture | manual:lead_creation | Rep manually creates contact |
| P02 – Deduplicate | dedupe | manual:merge | Ops manually merges duplicates |
| P03 – Round‑robin | assignment | manual:reassign | Rep changes lead owner |
| P04 – AI classification | classification | manual:override_classification | Rep corrects AI intent |
| P05 – Self‑booking | booking | manual:booking | Rep books on behalf of customer |
| P06 – Reminders | reminders | manual:confirmation | Rep confirms appointment manually |
| P07 – No‑show | no_show | manual:reschedule | Rep reschedules no‑show lead |
| P08 – Document generation | document_gen | manual:estimate_created | Rep manually creates estimate |
| P09 – Tracking | tracking | manual:estimate_opened | Rep manually marks as opened |
| P10 – Follow‑up | follow_up | manual:followup_sent | Rep sends manual follow‑up |
| P11 – Objection | objection | manual:override_objection | Rep overrides AI‑sent response |
| P12 – Approval | approval | manual:approval_override | Admin approves without approver chain |
| P13 – E‑signature | esign | manual:contract_uploaded | Rep uploads signed PDF manually |
| P14 – Payment | payment | manual:payment_recorded | Rep marks payment as received |
| P15 – Dunning | dunning | manual:dunning_override | Rep reactivates subscription manually |
| P16 – Financing | financing | manual:financing_override | Rep approves financing outside lender |
| P17 – PO | purchase_order | manual:po_created | Rep creates PO manually |
| P18 – Shipment | shipment | manual:delivery_complete | Ops marks delivery as received |
| P19 – Scheduling | scheduling | manual:work_scheduled | Ops manually schedules job |
| P20 – Milestones | milestones | manual:phase_complete | PM manually marks phase done |
| P21 – Sign‑off | signoff | manual:signoff | PM marks signed off without customer |
| P22 – Warranty | warranty | manual:warranty_delivered | Rep manually sends warranty |
| P23 – Review | review | manual:review_handled | Ops marks negative review as resolved |
| P24 – Referral | referral | manual:referral_credit | Rep manually credits referral reward |
| P25 – Reactivation | reactivation | manual:reactivated | Rep revives lost lead manually |
F.4 Escalation Rules for Necessary Handoffs
| Pattern | Necessary handoff trigger | Escalation path | SLA |
|---|---|---|---|
| P11 – Objection | AI confidence <0.6 | Task for rep → after 4h, manager | 4h |
| P12 – Approval | No approver action within SLA | Reminder (2h) → reassign (4h) → VP (8h) | 4h (standard), 1h (urgent) |
| P13 – E‑signature | No signature after 48h | Reminder → rep call → after 7d, archive | 48h |
| P15 – Dunning | Payment failure after 3 retries | Collections team task → after 7d, suspension | 7d |
| P21 – Sign‑off | Customer reports issues | Project manager task → after 24h, escalate to ops director | 24h |
| P23 – Review | Rating ≤3 | Ops manager task → respond within 24h | 24h |
F.5 Industry‑Specific Handoff Overrides
| Industry | Pattern | Default | Override to | Reason |
|---|---|---|---|---|
| Medical | P01 Lead capture | Fully automatable | Necessary (insurance verification) | HIPAA |
| Medical | P04 AI classification | Optional | Necessary (diagnosis) | Physician review required |
| Medical | P23 Review request | Optional | Necessary (no PHI in review) | Compliance |
| Construction | P12 Approval | Necessary | Fully automatable for change orders <$5k | Low risk |
| Construction | P21 Sign‑off | Optional | Necessary for projects >$100k | Legal liability |
| Real Estate | P13 E‑signature | Necessary | Optional after agent approves | Agent reviews first |
| SaaS | P15 Dunning | Optional | Fully automatable for <$50/mo | Churn risk low |
| Executive | Most patterns | Automatable | Questionable (keep manual) | Trust, low volume |
F.6 Human Handoff Decision Flow (Printed)
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
| What you're seeing | Failure family | Go to | First check |
|---|---|---|---|
| Leads not appearing in CRM | Tech | P01 · Ch03 | Check webhook URL, last execution, and DLQ. |
| Duplicate contacts being created | Data | P02 | Check phone-first deduplication and idempotency tags. |
| Welcome SMS sending twice | Data | P01 · Ch28 | Check auto:welcome_sent and duplicate webhook delivery. |
| AI classifying with wrong intent | Logic | P04 · P11 | Check override rate and confidence threshold. |
| Follow-up SMS not sending after estimate | Tech / Logic | P10 | Check timer start and cancellation rules. |
| Leads stuck in a stage for days | Human | Ch03 | Check task completion rate and SLA escalation ladder. |
| Payment processed twice | Data / Tech | P14 · Ch28 | Check idempotency key, duplicate gateway webhook, and TTL. |
| Appointment not confirmed, no-show spike | Tech / Logic | P06 · P07 | Check reminder sequence, opt-out status, and confirmation webhook. |
| Contract sent but unsigned after 48h | Human | P13 | Check envelope status, reminders, and rep call task. |
| Health check failing but CRM looks fine | Tech | Ch29 | Run the health check manually and inspect the test event path. |
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.
8k chars · 12 headings
9k chars · 8 headings
11k chars · 10 headings
11k chars · 8 headings
9k chars · 10 headings
10k chars · 8 headings
11k chars · 9 headings
14k chars · 11 headings
7k chars · 29 headings
11k chars · 10 headings
13k chars · 10 headings
49k chars · 83 headings
14k chars · 17 headings
10k chars · 14 headings
11k chars · 11 headings
11k chars · 12 headings
10k chars · 11 headings
11k chars · 13 headings
12k chars · 14 headings
11k chars · 14 headings
9k chars · 13 headings
11k chars · 14 headings
10k chars · 14 headings
14k chars · 12 headings
12k chars · 15 headings
13k chars · 14 headings
12k chars · 14 headings
11k chars · 13 headings
12k chars · 15 headings
11k chars · 15 headings
12k chars · 13 headings
13k chars · 15 headings
12k chars · 15 headings
11k chars · 13 headings
10k chars · 14 headings
10k chars · 15 headings
8k chars · 13 headings