AutomationFlowsGeneral › Route Law Firm Review Requests with Twilio Sms, Google Reviews and Email

Route Law Firm Review Requests with Twilio Sms, Google Reviews and Email

ByDele Tosh @ Protomated @deletosh on n8n.io

This workflow uses webhooks, Twilio SMS, SMTP email, and a Bar-Compliance Guardrail sub-workflow to request client ratings 48 hours after matters close. It routes replies so 4–5 star clients get a Google Reviews link, while 1–3 star clients receive a private feedback form and…

Webhook trigger★★★★★ complexity34 nodesTwilioEmail Send
General Trigger: Webhook Nodes: 34 Complexity: ★★★★★ Added:

This workflow corresponds to n8n.io template #17333 — we link there as the canonical source.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "name": "Review Request Engine",
  "tags": [],
  "nodes": [
    {
      "id": "996340ab-ed84-4409-935e-dd03a2411d76",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -608,
        -192
      ],
      "parameters": {
        "width": 480,
        "height": 896,
        "content": "## Review Request Engine\n\n### How it works\n\n1. The workflow triggers when a legal matter is closed and parses the matter details.\n2. It verifies the closure validity and prepares a review request to be sent after a 48-hour delay.\n3. Upon delay completion, it checks compliance and sends a rating request SMS if approved.\n4. The workflow also handles incoming review replies, validating ratings and branching based on client satisfaction.\n5. Happy clients are sent a Google review link if authorized, while unhappy clients receive a feedback form SMS; firms are notified accordingly.\n6. Throughout, non-compliant or opted-out cases are gracefully skipped with no operation nodes.\n\n### Setup steps\n\n- [ ] Configure webhook URLs in the external systems to trigger the 'When Matter Closed' and 'When Review Reply Received' webhook nodes.\n- [ ] Set up n8n Variables for firm details that the 'Prepare Review Request' and 'Parse Client Reply' nodes reference.\n- [ ] Ensure the Twilio credentials are configured in n8n for sending SMS messages.\n- [ ] Configure email credentials in n8n for the 'Inform Firm Request Sent' and 'Inform Firm of Unhappy Client' email nodes.\n- [ ] Verify the sub-workflows used for compliance checks are imported and active in the n8n instance.\n\n### Customization\n\nAdjust timing in the 'Wait 48 Hours' node as needed to control the delay before sending review requests. Update compliance sub-workflows and message contents to fit firm policies."
      },
      "typeVersion": 1
    },
    {
      "id": "f218a63d-df64-49ce-840b-761917b2cb3b",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -48,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 864,
        "height": 448,
        "content": "## Matter closure trigger and validation\n\nInitial webhook trigger on matter closure, parsing of matter details, validation of closure, and preparation for review request setup."
      },
      "typeVersion": 1
    },
    {
      "id": "2cc0d265-2c72-4525-87af-43bd9635a2cd",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        848,
        -192
      ],
      "parameters": {
        "color": 7,
        "width": 240,
        "height": 352,
        "content": "## Delay before review request\n\nWait node enforcing a 48-hour delay before proceeding with review requests."
      },
      "typeVersion": 1
    },
    {
      "id": "af8ad042-2a22-4e47-9761-2bc2495f0be4",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1120,
        -128
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 448,
        "content": "## Compliance and approval for rating request\n\nSub-workflow to check review request compliance, and conditional branch to approve or skip sending rating request SMS."
      },
      "typeVersion": 1
    },
    {
      "id": "8d475bbb-340f-4c8e-a6f6-f9a42def3e34",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1792,
        -160
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Send rating request SMS and notify firm\n\nSending the rating request SMS via Twilio and notifying the firm by email when the request is sent."
      },
      "typeVersion": 1
    },
    {
      "id": "9fe461de-9444-4a76-8b93-7950f3f3e1e1",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -48,
        352
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 304,
        "content": "## Review reply trigger and parsing\n\nWebhook to receive review replies from clients and parsing of the reply data for processing."
      },
      "typeVersion": 1
    },
    {
      "id": "0b772ee7-06fc-4ba2-86e0-c7dcc583f39e",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        400,
        352
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 480,
        "content": "## Validate rating and satisfaction check\n\nConditionally checks if the review reply is a valid rating, and determines client satisfaction to branch further."
      },
      "typeVersion": 1
    },
    {
      "id": "a17589a6-f194-4147-9db6-2dd5333f27df",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        848,
        352
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 416,
        "content": "## Happy client review handling\n\nSub-workflow for compliance check, approval branch, sending Google review link SMS, and skipping opted-out recipients with 4-5 star ratings."
      },
      "typeVersion": 1
    },
    {
      "id": "5491533e-e8f3-4fdc-a3f6-a6d1159214e5",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        848,
        800
      ],
      "parameters": {
        "color": 7,
        "width": 864,
        "height": 432,
        "content": "## Unhappy client feedback handling\n\nSub-workflow for compliance check, approval branch, sending feedback form SMS, and skipping opted-out recipients with 1-3 star ratings, along with firm notification."
      },
      "typeVersion": 1
    },
    {
      "id": "trigger_a",
      "name": "When Matter Closure Requested",
      "type": "n8n-nodes-base.webhook",
      "notes": "Receives the matter-close event from your practice management system.\n\nCopy the Production URL and paste it into your practice management system's webhook settings. Configure it to fire on 'matter closed' or 'matter updated to Closed status'.\n\nClio: Settings \u2192 Webhooks \u2192 New Webhook \u2192 Topic: Matter \u2192 paste this URL \u2192 subscribe to 'matter.updated' events. The Read Matter Details step detects status = 'Closed' automatically and ignores all other updates.\n\nGeneric: POST JSON with 'matter_status: closed' (or 'event_type: matter.closed'), 'client_phone', 'client_name', and 'matter_title'. See deploy guide for full field list.\n\nRequired fields:\n  matter_id \u2014 your internal matter reference\n  matter_title \u2014 the case name for the firm notification\n  client_name \u2014 used to personalise the SMS\n  client_phone \u2014 the number to text (required; without it the event is skipped)\n  matter_status or status \u2014 must equal 'closed' or 'Closed'",
      "position": [
        0,
        0
      ],
      "parameters": {
        "path": "review-request",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 2.1
    },
    {
      "id": "normalize_a",
      "name": "Parse Matter Closure Details",
      "type": "n8n-nodes-base.code",
      "notes": "Reads the incoming webhook and extracts what the workflow needs: client name, phone, email, matter title, and whether this is a genuine closure.\n\nWorks with both Clio and generic practice management systems \u2014 detects the format automatically.\n\nValidity check: proceeds only when (1) matter status is 'Closed' and (2) a client phone number is present. Clio fires 'matter.updated' on every status change \u2014 this step filters to final closure only so no premature review requests are sent to active clients.\n\nOutput: matter_id, matter_title, client_name, client_phone, client_email, closed_at, source, is_valid_closure, received_at.",
      "position": [
        224,
        0
      ],
      "parameters": {
        "jsCode": "// Handles both Clio webhooks and generic matter-close payloads.\n// Sets is_valid_closure = true only when the event signals a genuine matter\n// closure and the client has a phone number to receive the review request.\n\nconst raw  = $input.first().json;\nconst body = raw.body || raw;\n\nlet normalized;\n\n// Clio webhook shape: { topic: 'matter', payload: { status: 'Closed', client: { ... } } }\nif (body.topic === 'matter' || (body.payload && body.payload.status !== undefined)) {\n  const p      = body.payload || body;\n  const status = (p.status || '').toLowerCase();\n  const client = p.client || {};\n  const phones = client.phone_numbers || [];\n\n  normalized = {\n    matter_id:    String(p.id || p.matter_id || ''),\n    matter_title: p.display_number || p.description || p.name || 'Matter',\n    client_name:  client.name || p.contact_name || '',\n    client_phone: (phones[0] && phones[0].number) || p.client_phone || '',\n    client_email: client.primary_email_address || p.client_email || '',\n    closed_at:    p.closed_at || new Date().toISOString(),\n    source:       'clio',\n    is_valid_closure: status === 'closed'\n      && !!((phones[0] && phones[0].number) || p.client_phone),\n  };\n} else {\n  // Generic webhook\n  const status    = (body.matter_status || body.status || '').toLowerCase();\n  const eventType = (body.event_type   || body.event  || '').toLowerCase();\n\n  normalized = {\n    matter_id:    String(body.matter_id || body.id || ''),\n    matter_title: body.matter_title || body.case_name || body.name || 'Matter',\n    client_name:  body.client_name  || body.contact_name || '',\n    client_phone: body.client_phone || body.phone        || '',\n    client_email: body.client_email || body.email        || '',\n    closed_at:    body.closed_at || new Date().toISOString(),\n    source:       'generic',\n    is_valid_closure: (status === 'closed' || /clos/i.test(eventType))\n      && !!(body.client_phone || body.phone),\n  };\n}\n\nreturn [{ json: { ...normalized, received_at: new Date().toISOString() } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "is_closure",
      "name": "If Matter Closure Valid",
      "type": "n8n-nodes-base.if",
      "notes": "Decides whether to proceed \u2014 only genuine matter closures with a client phone number trigger the review request.\n\nClio fires 'matter.updated' on any status change, not just closure. Without this guard, a matter moved from Pending to Open would trigger a review request for an active client.\n\nYes (matter Closed + client phone present) \u2192 proceed to the 48-hour wait.\nNo (non-closure update, or no phone number) \u2192 route to Skip.",
      "onError": "continueErrorOutput",
      "position": [
        448,
        0
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c_closure",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.is_valid_closure }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "skip_not_closure",
      "name": "Skip Invalid Matter Closure",
      "type": "n8n-nodes-base.noOp",
      "notes": "Safely ends the workflow for non-closure matter updates and events missing a client phone number. No messages sent. Visible in execution history for confirmation.",
      "position": [
        672,
        150
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "config_a",
      "name": "Build Review Request Data",
      "type": "n8n-nodes-base.code",
      "notes": "Prepares the review request message and calculates the 48-hour send time.\n\nReads from n8n Variables (project \u2192 Variables tab):\n  FIRM_NAME \u2014 your law firm name as it appears in texts\n  FIRM_EMAIL \u2014 intake email (firm notifications go here)\n  FIRM_FROM_EMAIL \u2014 notification sender address; must be authorised by your SMTP account\n  FIRM_TWILIO_NUMBER \u2014 your Twilio number in +1XXXXXXXXXX format (the same number clients text back)\n  FIRM_GOOGLE_REVIEW_URL \u2014 Google Business Profile \u2192 Home \u2192 'Get more reviews' \u2192 copy the short URL\n  FIRM_FEEDBACK_URL \u2014 a Google Form, Typeform, or your website contact page\n\nPath B (Read Client Reply) reads the same variables \u2014 set once in project \u2192 Variables tab, used by both paths.\n\nThe 48-hour wait is calculated from when this node runs (when the matter-close event arrives).",
      "position": [
        672,
        0
      ],
      "parameters": {
        "jsCode": "// Reads firm details from n8n Variables (project \u2192 Variables tab).\n// Set: FIRM_NAME, FIRM_EMAIL, FIRM_FROM_EMAIL, FIRM_TWILIO_NUMBER, FIRM_GOOGLE_REVIEW_URL, FIRM_FEEDBACK_URL\nconst FIRM_NAME            = $vars.FIRM_NAME;\nconst FIRM_EMAIL           = $vars.FIRM_EMAIL;              // firm-side notifications\nconst FROM_EMAIL           = $vars.FIRM_FROM_EMAIL;         // must be authorised by your SMTP account\nconst FIRM_TWILIO_NUMBER   = $vars.FIRM_TWILIO_NUMBER;      // your Twilio sending number (E.164)\nconst GOOGLE_REVIEW_URL    = $vars.FIRM_GOOGLE_REVIEW_URL;  // Google Business Profile \u2192 Get more reviews\nconst PRIVATE_FEEDBACK_URL = $vars.FIRM_FEEDBACK_URL;       // your internal feedback form\n\nconst item    = $input.first().json;\nconst now     = new Date();\n\n// 48-hour sweet spot \u2014 client remembers the outcome but acute stress has faded.\nconst MS_48H  = 48 * 60 * 60 * 1000;\nconst send_at = new Date(now.getTime() + MS_48H).toISOString();\n\nconst firstName = (item.client_name || '').split(' ')[0] || '';\nconst greeting  = firstName ? `Hi ${firstName}` : 'Hi there';\n\nconst ratingSms = `${greeting}, it was our honor to serve you at ${FIRM_NAME}. How was your experience? Reply 1\u20135 (1 = poor, 5 = excellent). Reply STOP to opt out.`;\n\nreturn [{ json: {\n  ...item,\n  firm_name:            FIRM_NAME,\n  firm_email:           FIRM_EMAIL,\n  from_email:           FROM_EMAIL,\n  firm_twilio_number:   FIRM_TWILIO_NUMBER,\n  google_review_url:    GOOGLE_REVIEW_URL,\n  private_feedback_url: PRIVATE_FEEDBACK_URL,\n  send_at,\n  rating_sms:           ratingSms,\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "wait_48h",
      "name": "Wait 48 Hours Before Request",
      "type": "n8n-nodes-base.wait",
      "notes": "Pauses the workflow for 48 hours \u2014 the research-backed sweet spot for review requests in professional services. Clients who receive a request too soon feel pressured; too late and they've forgotten the details. 48 hours is the window when the outcome is fresh but the stress of the legal matter has faded.\n\nThe execution sits suspended in your n8n database during this wait \u2014 no server resources consumed.\n\nSelf-hosted n8n: must use Postgres as the database (not SQLite) for long waits to survive a restart. n8n Cloud handles this automatically.",
      "position": [
        896,
        0
      ],
      "parameters": {
        "resume": "specificTime",
        "dateTime": "={{ $('Build Review Request Data').item.json.send_at }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "guardrail_a",
      "name": "Execute Review Compliance Check",
      "type": "n8n-nodes-base.executeWorkflow",
      "notes": "Calls the Bar-Compliance Guardrail before sending the review request SMS.\n\nChecks whether the client has opted out. If not, attaches the TCPA opt-out instruction (already present in rating_sms \u2014 guardrail detects this and skips duplication). Logs the attempt to your compliance audit sheet regardless of outcome.\n\nThe guardrail workflow ID is read from the GUARDRAIL_WORKFLOW_ID n8n Variable (project \u2192 Variables tab). Set it to the numeric ID from the Bar-Compliance Guardrail workflow URL: .../workflow/WORKFLOW_ID.\n\nReturns: { approved, message_out, recipient, suppression_reason } plus all upstream fields.",
      "position": [
        1168,
        0
      ],
      "parameters": {
        "options": {},
        "workflowId": {
          "__rl": true,
          "mode": "expression",
          "value": "={{ $vars.GUARDRAIL_WORKFLOW_ID }}"
        },
        "workflowInputs": {
          "value": {
            "channel": "sms",
            "message": "={{ $('Build Review Request Data').item.json.rating_sms }}",
            "matter_id": "={{ $('Build Review Request Data').item.json.matter_id }}",
            "recipient": "={{ $('Build Review Request Data').item.json.client_phone }}",
            "template_id": "NTC-17"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "approved_a",
      "name": "If Approved for Rating Request",
      "type": "n8n-nodes-base.if",
      "notes": "Routes based on the compliance guardrail's decision.\n\nYes (approved: true) \u2192 sends the rating request SMS to the client.\nNo (approved: false) \u2192 client is on the opt-out list; message suppressed. The guardrail has already logged the suppression to your compliance sheet.",
      "onError": "continueErrorOutput",
      "position": [
        1392,
        0
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c_approved_a",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.approved }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "skip_opted_out_a",
      "name": "Skip Opted-Out Contact Rating",
      "type": "n8n-nodes-base.noOp",
      "notes": "Client is on the opt-out list \u2014 no review request sent. The Bar-Compliance Guardrail has logged the suppression to your audit sheet. No further action needed.",
      "position": [
        1616,
        144
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "send_rating_sms",
      "name": "Send SMS Rating Request",
      "type": "n8n-nodes-base.twilio",
      "notes": "Sends the review request text to the client 48 hours after matter close.\n\nMessage example: 'Hi Jane, it was our honor to serve you at Smith Law Firm. How was your experience? Reply 1\u20135 (1 = poor, 5 = excellent). Reply STOP to opt out.'\n\nThe message comes from the compliance guardrail (message_out) \u2014 the opt-out instruction is already in the original text; the guardrail verified it and logged the send.\n\nCredential to wire: project \u2192 Credentials tab \u2192 New \u2192 search Twilio \u2192 enter Account SID and Auth Token from console.twilio.com \u2192 save as 'Twilio account'.\n\nWhen the client replies, Twilio fires the inbound message webhook to the Path B URL, which routes their rating. If Twilio returns an error here, the workflow continues to the firm notification so your team is still informed and can follow up manually.",
      "onError": "continueRegularOutput",
      "position": [
        1840,
        0
      ],
      "parameters": {
        "to": "={{ $json.recipient }}",
        "from": "={{ $('Build Review Request Data').item.json.firm_twilio_number }}",
        "message": "={{ $json.message_out }}",
        "operation": "send"
      },
      "credentials": {
        "twilioApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "notify_firm_sent",
      "name": "Notify Firm Review Sent",
      "type": "n8n-nodes-base.emailSend",
      "notes": "Emails your firm a confirmation that the review request was sent, with the matter details and a reminder of how reply routing works.\n\nInformational only \u2014 no action needed unless a feedback alert follows.\n\nCredential: project \u2192 Credentials tab \u2192 New \u2192 search SMTP \u2192 enter your email provider's settings \u2192 save as 'Email account (SMTP)'. Supports Gmail, Outlook 365, Zoho Mail, and any SMTP provider.\n\nAll values (client name, matter title, etc.) are read from the Set Up Review Request node \u2014 not the immediate input \u2014 because the Twilio response from the previous step has a different data shape than the matter details.",
      "position": [
        2064,
        0
      ],
      "parameters": {
        "text": "=A review request has been sent to {{ $('Build Review Request Data').item.json.client_name || 'your client' }} at {{ $('Build Review Request Data').item.json.client_phone }}.\n\nMatter: {{ $('Build Review Request Data').item.json.matter_title }}\nMatter ID: {{ $('Build Review Request Data').item.json.matter_id }}\nSent at: {{ $now }}\n\nWhen the client replies:\n  4\u20135 stars: they receive your Google Reviews link automatically\n  1\u20133 stars: they receive your private feedback form and you get an alert email\n\nNo action needed unless you receive a feedback alert.",
        "subject": "=Review request sent \u2014 {{ $('Build Review Request Data').item.json.matter_title }}",
        "toEmail": "={{ $('Build Review Request Data').item.json.firm_email }}",
        "emailType": "text",
        "fromEmail": "={{ $('Build Review Request Data').item.json.from_email }}"
      },
      "credentials": {
        "smtp": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    },
    {
      "id": "trigger_b",
      "name": "When Review Reply Received Webhook",
      "type": "n8n-nodes-base.webhook",
      "notes": "Receives the client's inbound reply SMS from Twilio.\n\nCopy the Production URL from this node and paste it into your Twilio number's messaging webhook:\n  Twilio Console \u2192 Phone Numbers \u2192 Manage \u2192 Active Numbers \u2192 click your number \u2192 Messaging Configuration \u2192 'When a message comes in' \u2192 Webhook \u2192 paste this URL \u2192 HTTP POST \u2192 Save\n\nThis tells Twilio to POST every inbound SMS to this workflow for rating-based routing. The Valid rating? guard handles non-rating messages (opt-outs, questions, unrelated texts) gracefully.\n\nImportant: this replaces any existing incoming-message webhook on that Twilio number. If you already route inbound SMS elsewhere, coordinate with your existing setup before saving.",
      "position": [
        0,
        496
      ],
      "parameters": {
        "path": "review-reply",
        "options": {},
        "httpMethod": "POST"
      },
      "typeVersion": 2.1
    },
    {
      "id": "parse_reply",
      "name": "Parse Client Review Reply",
      "type": "n8n-nodes-base.code",
      "notes": "Parses the client's SMS reply and prepares the routing decision and response messages.\n\nReads from n8n Variables (project \u2192 Variables tab) \u2014 the same variables set for Path A (Set Up Review Request). No separate configuration needed here.\n\nRating logic:\n  4 or 5 \u2192 happy path \u2192 client receives your Google Reviews link\n  1, 2, or 3 \u2192 unhappy path \u2192 client receives private feedback form + firm gets an alert\n\nThe parser is tolerant of extra text: '5 stars!' and '5 very happy' both read as 5. Only the leading digit is used.\n\nOpt-out handling: STOP, UNSUBSCRIBE, CANCEL, END, QUIT are filtered here \u2014 Twilio also handles STOP independently by suppressing future texts to that number.\n\nOutput: from_number, rating, is_happy, is_valid_rating, all firm config, and pre-built SMS text for each routing path.",
      "position": [
        224,
        496
      ],
      "parameters": {
        "jsCode": "// Reads firm details from n8n Variables (project \u2192 Variables tab) \u2014 must match Set Up Review Request (Path A).\nconst FIRM_NAME            = $vars.FIRM_NAME;\nconst FIRM_EMAIL           = $vars.FIRM_EMAIL;\nconst FROM_EMAIL           = $vars.FIRM_FROM_EMAIL;\nconst FIRM_TWILIO_NUMBER   = $vars.FIRM_TWILIO_NUMBER;\nconst GOOGLE_REVIEW_URL    = $vars.FIRM_GOOGLE_REVIEW_URL;\nconst PRIVATE_FEEDBACK_URL = $vars.FIRM_FEEDBACK_URL;\n\n// Guard: fail loudly if variables are not set.\nif (!GOOGLE_REVIEW_URL) {\n  throw new Error('FIRM_GOOGLE_REVIEW_URL n8n Variable is not set. Go to project \u2192 Variables tab and add it.');\n}\nif (!PRIVATE_FEEDBACK_URL) {\n  throw new Error('FIRM_FEEDBACK_URL n8n Variable is not set. Go to project \u2192 Variables tab and add it.');\n}\n\n// Parses the inbound Twilio SMS reply.\n// Twilio POSTs form-encoded fields: From (client number), Body (reply text).\nconst raw  = $input.first().json;\nconst body = raw.body || raw;\n\nconst fromNumber = (body.From || '').trim();\nconst rawReply   = (body.Body || '').trim();\n\n// Extract leading digit 1\u20135; tolerant of extra text like '5 great!'\nconst match  = rawReply.match(/^([1-5])/);\nconst rating = match ? parseInt(match[1]) : 0;\n\n// Filter opt-out keywords \u2014 Twilio also handles STOP automatically\nconst isOptOut      = /^(STOP|UNSUBSCRIBE|CANCEL|END|QUIT)/i.test(rawReply);\nconst isValidRating = !isOptOut && rating >= 1 && rating <= 5;\nconst isHappy       = rating >= 4;\n\nconst googleSms   = `Thank you! We're so glad you had a great experience at ${FIRM_NAME}. Would you mind leaving a quick Google review? It truly helps: ${GOOGLE_REVIEW_URL}`;\nconst feedbackSms = `Thank you for letting us know. We take every concern seriously and want to make this right. Please share your feedback privately here: ${PRIVATE_FEEDBACK_URL}`;\n\nreturn [{ json: {\n  from_number:          fromNumber,\n  raw_reply:            rawReply,\n  rating,\n  is_happy:             isHappy,\n  is_valid_rating:      isValidRating,\n  firm_name:            FIRM_NAME,\n  firm_email:           FIRM_EMAIL,\n  from_email:           FROM_EMAIL,\n  firm_twilio_number:   FIRM_TWILIO_NUMBER,\n  google_review_url:    GOOGLE_REVIEW_URL,\n  private_feedback_url: PRIVATE_FEEDBACK_URL,\n  google_sms:           googleSms,\n  feedback_sms:         feedbackSms,\n  received_at:          new Date().toISOString(),\n} }];"
      },
      "typeVersion": 2
    },
    {
      "id": "is_valid_rating",
      "name": "If Client Rating Valid",
      "type": "n8n-nodes-base.if",
      "notes": "Checks whether the client's reply is a valid 1\u20135 rating before routing.\n\nTwilio fires this webhook for ALL inbound messages to your number \u2014 including opt-out replies (STOP), questions, and unrelated texts. Without this guard, a non-rating message would incorrectly trigger a Google Reviews link or a feedback alert.\n\nYes (1\u20135, not an opt-out) \u2192 proceed to happy/unhappy routing.\nNo (opt-out keyword, non-numeric, or unrelated text) \u2192 route to Skip.",
      "onError": "continueErrorOutput",
      "position": [
        448,
        512
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c_valid_rating",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.is_valid_rating }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "skip_not_rating",
      "name": "Skip Non-Rating Reply",
      "type": "n8n-nodes-base.noOp",
      "notes": "Safely ends the workflow for non-rating replies (opt-outs, questions, unrelated texts). No Google Reviews link or feedback form sent. Visible in execution history for auditing.",
      "position": [
        672,
        672
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "is_happy",
      "name": "If Client Is Satisfied",
      "type": "n8n-nodes-base.if",
      "notes": "Routes the client based on their rating \u2014 this is the rating gate that protects your Google profile.\n\n4 or 5 stars (Yes) \u2192 happy path \u2192 compliance check \u2192 sends Google Reviews link. Only clients with a genuinely positive experience reach your public Google profile.\n\n1, 2, or 3 stars (No) \u2192 unhappy path \u2192 compliance check \u2192 sends private feedback form + alerts your firm. Concerned clients go to a private channel where you can address the issue before it becomes a public negative review.",
      "onError": "continueErrorOutput",
      "position": [
        672,
        512
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c_happy",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.is_happy }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "guardrail_b_happy",
      "name": "Execute Google Link Compliance",
      "type": "n8n-nodes-base.executeWorkflow",
      "notes": "Calls the Bar-Compliance Guardrail before sending the Google Reviews link.\n\nChecks whether the client has opted out. If not, attaches 'Reply STOP to opt out.' to the Google Reviews message and logs the send to your audit sheet.\n\nThe guardrail workflow ID is read from the GUARDRAIL_WORKFLOW_ID n8n Variable (project \u2192 Variables tab).",
      "position": [
        896,
        608
      ],
      "parameters": {
        "options": {},
        "workflowId": {
          "__rl": true,
          "mode": "expression",
          "value": "={{ $vars.GUARDRAIL_WORKFLOW_ID }}"
        },
        "workflowInputs": {
          "value": {
            "channel": "sms",
            "message": "={{ $json.google_sms }}",
            "matter_id": "",
            "recipient": "={{ $json.from_number }}",
            "template_id": "NTC-17"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "approved_b_happy",
      "name": "If Approved for Google Link",
      "type": "n8n-nodes-base.if",
      "notes": "Routes based on the compliance guardrail's decision for the 4\u20135 star happy path.\n\nYes (approved) \u2192 sends Google Reviews link.\nNo (opted out) \u2192 suppressed; guardrail has logged the suppression.",
      "onError": "continueErrorOutput",
      "position": [
        1120,
        608
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c_approved_b_happy",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.approved }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "skip_opted_out_b_happy",
      "name": "Skip Opted-Out Google Link",
      "type": "n8n-nodes-base.noOp",
      "notes": "Client rated 4\u20135 stars but is on the opt-out list \u2014 Google Reviews link not sent. The Bar-Compliance Guardrail has logged the suppression to your audit sheet.",
      "position": [
        1344,
        496
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "send_google_link",
      "name": "Send SMS Google Review Link",
      "type": "n8n-nodes-base.twilio",
      "notes": "Sends the Google Reviews link to clients who rated 4 or 5 stars.\n\nMessage (with STOP instruction appended by the compliance guardrail): 'Thank you! We're so glad you had a great experience at Smith Law Firm. Would you mind leaving a quick Google review? It truly helps: [link]\\n\\nReply STOP to opt out.'\n\nThis is the rating gate: only clients with a positive experience reach this step and your public Google profile.",
      "onError": "continueRegularOutput",
      "position": [
        1344,
        608
      ],
      "parameters": {
        "to": "={{ $json.recipient }}",
        "from": "={{ $('Parse Client Review Reply').item.json.firm_twilio_number }}",
        "message": "={{ $json.message_out }}",
        "operation": "send"
      },
      "credentials": {
        "twilioApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "guardrail_b_unhappy",
      "name": "Execute Feedback Form Compliance",
      "type": "n8n-nodes-base.executeWorkflow",
      "notes": "Calls the Bar-Compliance Guardrail before sending the private feedback form link.\n\nChecks whether the client has opted out. If not, attaches 'Reply STOP to opt out.' to the feedback form message and logs the send to your audit sheet.\n\nThe guardrail workflow ID is read from the GUARDRAIL_WORKFLOW_ID n8n Variable (project \u2192 Variables tab).",
      "position": [
        896,
        960
      ],
      "parameters": {
        "options": {},
        "workflowId": {
          "__rl": true,
          "mode": "expression",
          "value": "={{ $vars.GUARDRAIL_WORKFLOW_ID }}"
        },
        "workflowInputs": {
          "value": {
            "channel": "sms",
            "message": "={{ $json.feedback_sms }}",
            "matter_id": "",
            "recipient": "={{ $json.from_number }}",
            "template_id": "NTC-17"
          },
          "schema": [],
          "mappingMode": "defineBelow",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "approved_b_unhappy",
      "name": "If Approved for Feedback Form",
      "type": "n8n-nodes-base.if",
      "notes": "Routes based on the compliance guardrail's decision for the 1\u20133 star unhappy path.\n\nYes (approved) \u2192 sends private feedback form link + alerts your firm.\nNo (opted out) \u2192 suppressed; guardrail has logged the suppression. No feedback link sent and no firm alert triggered.",
      "onError": "continueErrorOutput",
      "position": [
        1120,
        960
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c_approved_b_unhappy",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.approved }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "skip_opted_out_b_unhappy",
      "name": "Skip Opted-Out Feedback Form",
      "type": "n8n-nodes-base.noOp",
      "notes": "Client rated 1\u20133 stars but is on the opt-out list \u2014 private feedback form not sent and firm alert suppressed. The Bar-Compliance Guardrail has logged the suppression to your audit sheet.",
      "position": [
        1344,
        1088
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "send_feedback_link",
      "name": "Send SMS Feedback Form Link",
      "type": "n8n-nodes-base.twilio",
      "notes": "Sends the private feedback form link to clients who rated 1, 2, or 3 stars.\n\nMessage (with STOP instruction appended by the compliance guardrail): 'Thank you for letting us know. We take every concern seriously and want to make this right. Please share your feedback privately here: [link]\\n\\nReply STOP to opt out.'\n\nThe private path keeps concerns off your public Google profile and gives your team a chance to address the issue \u2014 often converting an unhappy client into a loyal one. The next step alerts your firm immediately.",
      "onError": "continueRegularOutput",
      "position": [
        1344,
        944
      ],
      "parameters": {
        "to": "={{ $json.recipient }}",
        "from": "={{ $('Parse Client Review Reply').item.json.firm_twilio_number }}",
        "message": "={{ $json.message_out }}",
        "operation": "send"
      },
      "credentials": {
        "twilioApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "alert_firm",
      "name": "Notify Firm Unhappy Client Feedback",
      "type": "n8n-nodes-base.emailSend",
      "notes": "Emails your firm immediately when a client rates 1\u20133 stars.\n\nThe alert includes: the client's phone number (so you can call them directly), their rating, their raw reply text, and the private feedback form URL you sent them.\n\nThe 24-hour follow-up recommendation is intentional \u2014 research shows that firms who address a complaint directly within 24 hours resolve the concern the majority of the time and virtually eliminate the risk of a public negative review.\n\nUses the same 'Email account (SMTP)' credential as Path A.",
      "position": [
        1568,
        944
      ],
      "parameters": {
        "text": "=A client responded to your review request with a {{ $('Parse Client Review Reply').item.json.rating }}/5 rating.\n\nTheir phone number: {{ $('Parse Client Review Reply').item.json.from_number }}\nTheir reply: \"{{ $('Parse Client Review Reply').item.json.raw_reply }}\"\nReceived: {{ $('Parse Client Review Reply').item.json.received_at }}\n\nThey have been sent your private feedback form:\n{{ $('Parse Client Review Reply').item.json.private_feedback_url }}\n\nRecommendation: follow up personally within 24 hours. A direct call often resolves the concern and prevents a public negative review.",
        "subject": "=Client feedback alert \u2014 {{ $('Parse Client Review Reply').item.json.rating }}/5 rating from {{ $('Parse Client Review Reply').item.json.from_number }}",
        "toEmail": "={{ $('Parse Client Review Reply').item.json.firm_email }}",
        "emailType": "text",
        "fromEmail": "={{ $('Parse Client Review Reply').item.json.from_email }}"
      },
      "credentials": {
        "smtp": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2
    }
  ],
  "active": false,
  "settings": {
    "availableInMCP": false,
    "executionOrder": "v1",
    "executionTimeout": -1,
    "saveManualExecutions": true,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all"
  },
  "nodeGroups": [],
  "staticData": null,
  "connections": {
    "If Client Is Satisfied": {
      "main": [
        [
          {
            "node": "Execute Google Link Compliance",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Execute Feedback Form Compliance",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Client Rating Valid": {
      "main": [
        [
          {
            "node": "If Client Is Satisfied",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip Non-Rating Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Matter Closure Valid": {
      "main": [
        [
          {
            "node": "Build Review Request Data",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip Invalid Matter Closure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send SMS Rating Request": {
      "main": [
        [
          {
            "node": "Notify Firm Review Sent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Review Request Data": {
      "main": [
        [
          {
            "node": "Wait 48 Hours Before Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Client Review Reply": {
      "main": [
        [
          {
            "node": "If Client Rating Valid",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Approved for Google Link": {
      "main": [
        [
          {
            "node": "Send SMS Google Review Link",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip Opted-Out Google Link",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send SMS Feedback Form Link": {
      "main": [
        [
          {
            "node": "Notify Firm Unhappy Client Feedback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Matter Closure Details": {
      "main": [
        [
          {
            "node": "If Matter Closure Valid",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 48 Hours Before Request": {
      "main": [
        [
          {
            "node": "Execute Review Compliance Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Approved for Feedback Form": {
      "main": [
        [
          {
            "node": "Send SMS Feedback Form Link",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip Opted-Out Feedback Form",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Matter Closure Requested": {
      "main": [
        [
          {
            "node": "Parse Matter Closure Details",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute Google Link Compliance": {
      "main": [
        [
          {
            "node": "If Approved for Google Link",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Approved for Rating Request": {
      "main": [
        [
          {
            "node": "Send SMS Rating Request",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Skip Opted-Out Contact Rating",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute Review Compliance Check": {
      "main": [
        [
          {
            "node": "If Approved for Rating Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute Feedback Form Compliance": {
      "main": [
        [
          {
            "node": "If Approved for Feedback Form",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When Review Reply Received Webhook": {
      "main": [
        [
          {
            "node": "Parse Client Review Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "description": "Sends an SMS review request 48 hours after matter close \u2014 the sweet spot when clients remember the outcome but stress has faded. Clients who rate 4\u20135 stars receive a Google Reviews link; clients who rate 1\u20133 receive a private feedback form and trigger an internal alert. Replaces Clio Reputation and Birdeye ($49\u2013$250+/mo). Two webhook triggers: matter-close (Path A) and client reply routing (Path B). Opt-out checking and TCPA disclaimer are handled by the Bar-Compliance Guardrail sub-workflow before every client message."
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow uses webhooks, Twilio SMS, SMTP email, and a Bar-Compliance Guardrail sub-workflow to request client ratings 48 hours after matters close. It routes replies so 4–5 star clients get a Google Reviews link, while 1–3 star clients receive a private feedback form and…

Source: https://n8n.io/workflows/17333/ — original creator credit. Request a take-down →

More General workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

General

This workflow receives new consult bookings via webhook (Calendly v2 or a generic scheduling tool), generates timed confirmation and reminder messages, runs each SMS through a separate compliance work

Twilio, Email Send
General

This workflow receives missed-call webhooks from Twilio or CallRail, normalizes call details, and instantly sends a compliant text-back via Twilio when allowed, then emails your firm a delivery-status

Twilio, Email Send
General

This workflow automates flight price comparison across multiple booking platforms (Kayak, Skyscanner, Expedia, Google Flights). It accepts natural language queries, extracts flight details using NLP,

Ssh, Email Send
General

Automate the tracking of customer subscription expiry dates, create renewal tasks in ClickUp, and dispatch friendly email reminders before the due date. The workflow listens for incoming subscription

Email Send
General

[NooviChat] Onboarding Pós-Pagamento (Exp 6). Uses emailSend. Webhook trigger; 11 nodes.

Email Send