AutomationFlowsEmail & Gmail › Freight Forwarding - Quote Automation

Freight Forwarding - Quote Automation

Freight Forwarding - Quote Automation. Uses googleSheets, lineNotify, gmail, line. Webhook trigger; 17 nodes.

Webhook trigger★★★★☆ complexity17 nodesGoogle SheetsLine NotifyGmailLine
Email & Gmail Trigger: Webhook Nodes: 17 Complexity: ★★★★☆ Added:

This workflow follows the Gmail → Google Sheets recipe pattern — see all workflows that pair these two integrations.

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": "Freight Forwarding - Quote Automation",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "freight-quote-request",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "node-webhook-trigger",
      "name": "Webhook - Quote Request",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [
        240,
        300
      ],
      "notes": "Receives data from Google Form or LINE OA. Configure Google Form to POST to this webhook URL via Apps Script, or connect via Make.com intermediary."
    },
    {
      "parameters": {
        "jsCode": "// Normalize and validate incoming quote request data\nconst body = $input.first().json.body || $input.first().json;\n\nconst origin = (body.origin || body['\u0e15\u0e49\u0e19\u0e17\u0e32\u0e07'] || '').toString().trim().toUpperCase();\nconst destination = (body.destination || body['\u0e1b\u0e25\u0e32\u0e22\u0e17\u0e32\u0e07'] || '').toString().trim().toUpperCase();\nconst weightKg = parseFloat(body.weight_kg || body['\u0e19\u0e49\u0e33\u0e2b\u0e19\u0e31\u0e01'] || 0);\nconst lengthCm = parseFloat(body.length_cm || body['\u0e04\u0e27\u0e32\u0e21\u0e22\u0e32\u0e27'] || 0);\nconst widthCm = parseFloat(body.width_cm || body['\u0e04\u0e27\u0e32\u0e21\u0e01\u0e27\u0e49\u0e32\u0e07'] || 0);\nconst heightCm = parseFloat(body.height_cm || body['\u0e04\u0e27\u0e32\u0e21\u0e2a\u0e39\u0e07'] || 0);\nconst shipmentType = (body.shipment_type || body['\u0e1b\u0e23\u0e30\u0e40\u0e20\u0e17\u0e01\u0e32\u0e23\u0e02\u0e19\u0e2a\u0e48\u0e07'] || 'Air').toString().trim();\nconst customerName = (body.customer_name || body['\u0e0a\u0e37\u0e48\u0e2d\u0e25\u0e39\u0e01\u0e04\u0e49\u0e32'] || '').toString().trim();\nconst contact = (body.contact || body['\u0e15\u0e34\u0e14\u0e15\u0e48\u0e2d'] || '').toString().trim();\nconst commodity = (body.commodity || body['\u0e1b\u0e23\u0e30\u0e40\u0e20\u0e17\u0e2a\u0e34\u0e19\u0e04\u0e49\u0e32'] || '').toString().trim();\nconst notes = (body.notes || body['\u0e2b\u0e21\u0e32\u0e22\u0e40\u0e2b\u0e15\u0e38'] || '').toString().trim();\n\n// Calculate CBM\nconst cbm = parseFloat(((lengthCm * widthCm * heightCm) / 1000000).toFixed(4));\n\n// Calculate Chargeable Weight\nlet chargeableWeight = weightKg;\nif (shipmentType.toLowerCase().includes('air')) {\n  chargeableWeight = Math.max(weightKg, cbm * 167);\n} else if (shipmentType.toLowerCase().includes('lcl')) {\n  chargeableWeight = Math.max(weightKg / 1000, cbm);\n}\nchargeableWeight = parseFloat(chargeableWeight.toFixed(2));\n\n// Generate Quote ID\nconst now = new Date();\nconst dateStr = now.toISOString().slice(0,10).replace(/-/g,'');\nconst randomSuffix = Math.floor(1000 + Math.random() * 9000);\nconst quoteId = `QT-${dateStr}-${randomSuffix}`;\n\n// Route key for Rate Table lookup\nconst routeKey = `${origin}-${destination}`;\n\nreturn [{\n  json: {\n    quoteId,\n    dateReceived: now.toISOString(),\n    customerName,\n    contact,\n    origin,\n    destination,\n    routeKey,\n    shipmentType,\n    commodity,\n    weightKg,\n    lengthCm,\n    widthCm,\n    heightCm,\n    cbm,\n    chargeableWeight,\n    notes\n  }\n}];\n"
      },
      "id": "node-normalize-data",
      "name": "Normalize & Calculate CBM",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        300
      ],
      "notes": "Validates input, calculates CBM and chargeable weight, generates Quote ID."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "={{ $env.GOOGLE_SHEET_ID }}",
          "mode": "expression"
        },
        "sheetName": {
          "__rl": true,
          "value": "RateTable",
          "mode": "name"
        },
        "options": {}
      },
      "id": "node-get-rate-table",
      "name": "Get Rate Table",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        680,
        300
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "notes": "Replace YOUR_GOOGLE_SHEET_ID with your actual Google Sheets ID. Sheet name must be 'RateTable'."
    },
    {
      "parameters": {
        "jsCode": "// Look up rate for the route and calculate total price\nconst quoteData = $('Normalize & Calculate CBM').first().json;\nconst rateRows = $('Get Rate Table').all().map(r => r.json);\n\n// Read USD/THB exchange rate from environment variable (REQUIRED \u2014 do not hardcode)\nconst usdThb = parseFloat($env.USD_THB || '0');\nif (usdThb <= 0) {\n  return [{\n    json: {\n      ...quoteData,\n      error: 'USD_THB rate not configured. Set USD_THB environment variable.',\n      requiresManualQuote: true,\n      freightCharge: 0,\n      docFee: 0,\n      otherCharges: 0,\n      totalPrice: 0,\n      quoteStatus: 'DRAFT \u2014 Pending Internal Approval'\n    }\n  }];\n}\n\n// Read quote validity days from environment variable\nconst validityDays = parseInt($env.QUOTE_VALIDITY_DAYS || '30');\n\nconst route = quoteData.routeKey;\nconst shipmentType = quoteData.shipmentType.toLowerCase();\n\n// Find matching rate row\nconst rateRow = rateRows.find(r => {\n  const rowRoute = (r['Route'] || '').toString().trim().toUpperCase();\n  return rowRoute === route;\n});\n\nif (!rateRow) {\n  return [{\n    json: {\n      ...quoteData,\n      error: `No rate found for route: ${route}`,\n      requiresManualQuote: true,\n      freightCharge: 0,\n      docFee: 0,\n      otherCharges: 0,\n      totalPrice: 0,\n      quoteStatus: 'DRAFT \u2014 Pending Internal Approval'\n    }\n  }];\n}\n\n\n// Block unverified rates \u2014 Rate Verified must be YES before any calculation\n// Case-insensitive check: 'YES', 'yes', 'Yes' all accepted; anything else \u2192 blocked\nconst rateVerified = (rateRow['Rate Verified'] || '').toString().trim().toUpperCase();\nif (rateVerified !== 'YES') {\n  return [{\n    json: {\n      ...quoteData,\n      error: `Rate for route ${route} is not verified (Rate Verified = ${rateRow['Rate Verified'] || 'NO'}). Manual quote required.`,\n      requiresManualQuote: true,\n      logReason: 'RATE_NOT_VERIFIED',\n      freightCharge: 0,\n      docFee: 0,\n      otherCharges: 0,\n      totalPrice: 0,\n      quoteStatus: 'DRAFT \u2014 Pending Internal Approval'\n    }\n  }];\n}\n\n// Land shipments: no verified rate in table \u2014 always require manual quote\nif (shipmentType.includes('land')) {\n  return [{\n    json: {\n      ...quoteData,\n      error: 'Land shipment: no verified rate available. Requires manual quote.',\n      requiresManualQuote: true,\n      freightCharge: 0,\n      docFee: 0,\n      otherCharges: 0,\n      totalPrice: 0,\n      quoteStatus: 'DRAFT \u2014 Pending Internal Approval'\n    }\n  }];\n}\n\n// Calculate freight charge based on shipment type\nlet freightCharge = 0;\nconst chargeableWeight = quoteData.chargeableWeight;\nconst cbm = quoteData.cbm;\n\nif (shipmentType.includes('air')) {\n  const airRate = parseFloat(rateRow['Air Rate (THB/kg)'] || 0);\n  freightCharge = chargeableWeight * airRate;\n} else if (shipmentType.includes('fcl') && shipmentType.includes('20')) {\n  freightCharge = parseFloat(rateRow['Sea FCL 20ft (USD)'] || 0) * usdThb;\n} else if (shipmentType.includes('fcl') && shipmentType.includes('40')) {\n  freightCharge = parseFloat(rateRow['Sea FCL 40ft (USD)'] || 0) * usdThb;\n} else if (shipmentType.includes('lcl')) {\n  const lclRate = parseFloat(rateRow['Sea LCL (USD/CBM)'] || 0);\n  freightCharge = Math.max(cbm, chargeableWeight) * lclRate * usdThb;\n} else {\n  return [{\n    json: {\n      ...quoteData,\n      error: `Unknown shipment type: ${quoteData.shipmentType}. Requires manual quote.`,\n      requiresManualQuote: true,\n      freightCharge: 0,\n      docFee: 0,\n      otherCharges: 0,\n      totalPrice: 0,\n      quoteStatus: 'DRAFT \u2014 Pending Internal Approval'\n    }\n  }];\n}\n\n// Apply only numeric verified surcharges from rate table columns\nlet surchargeTotal = 0;\nconst fuelSurcharge = parseFloat(rateRow['Fuel Surcharge (USD/CBM)'] || '0');\nconst baf = parseFloat(rateRow['BAF (USD/CBM)'] || '0');\nconst otherSurcharge = parseFloat(rateRow['Other Surcharge (USD)'] || '0');\n\nif (fuelSurcharge > 0) surchargeTotal += fuelSurcharge * cbm * usdThb;\nif (baf > 0) surchargeTotal += baf * cbm * usdThb;\nif (otherSurcharge > 0) surchargeTotal += otherSurcharge * usdThb;\n\nconst docFee = parseFloat($env.DOC_FEE || '500');\nconst minCharge = parseFloat(rateRow['Min Charge (THB)'] || 500);\nconst transitDays = shipmentType.includes('air')\n  ? rateRow['Transit Air (days)']\n  : rateRow['Transit Sea (days)'];\n\nfreightCharge = Math.max(freightCharge, minCharge);\nconst totalPrice = Math.ceil(freightCharge + surchargeTotal + docFee);\n\nconst validUntil = new Date();\nvalidUntil.setDate(validUntil.getDate() + validityDays);\n\nreturn [{\n  json: {\n    ...quoteData,\n    requiresManualQuote: false,\n    airRatePerKg: rateRow['Air Rate (THB/kg)'],\n    transitDays,\n    freightCharge: Math.ceil(freightCharge),\n    docFee,\n    otherCharges: Math.ceil(surchargeTotal),\n    totalPrice,\n    validUntil: validUntil.toISOString().slice(0, 10),\n    surchargeNotes: rateRow['Surcharge Notes'] || '',\n    quoteStatus: 'DRAFT \u2014 Pending Internal Approval'\n  }\n}];\n"
      },
      "id": "node-calculate-price",
      "name": "Calculate Price",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        900,
        300
      ],
      "notes": "Looks up rate from RateTable and calculates total quoted price. USD_THB is read from $env.USD_THB (required). Land shipments always set requiresManualQuote=true. Surcharges applied only from numeric verified columns."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "={{ $env.GOOGLE_SHEET_ID }}",
          "mode": "expression"
        },
        "sheetName": {
          "__rl": true,
          "value": "QuoteLog",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Quote ID": "={{ $json.quoteId }}",
            "Date Received": "={{ $json.dateReceived }}",
            "Customer Name": "={{ $json.customerName }}",
            "Contact (LINE/Email)": "={{ $json.contact }}",
            "Origin": "={{ $json.origin }}",
            "Destination": "={{ $json.destination }}",
            "Shipment Type": "={{ $json.shipmentType }}",
            "Weight (kg)": "={{ $json.weightKg }}",
            "L (cm)": "={{ $json.lengthCm }}",
            "W (cm)": "={{ $json.widthCm }}",
            "H (cm)": "={{ $json.heightCm }}",
            "CBM": "={{ $json.cbm }}",
            "Chargeable Weight": "={{ $json.chargeableWeight }}",
            "Base Rate": "={{ $json.freightCharge }}",
            "Surcharge": "={{ $json.otherCharges }}",
            "Total Quoted Price (THB)": "={{ $json.totalPrice }}",
            "Currency": "THB",
            "Approval Status": "Pending",
            "Notes": "={{ $json.notes }}",
            "Quote Status": "={{ $json.quoteStatus || \"DRAFT \u2014 Pending Internal Approval\" }}"
          }
        },
        "options": {}
      },
      "id": "node-log-to-sheet",
      "name": "Log to Quote Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        1120,
        300
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "notes": "Appends new DRAFT row to QuoteLog sheet. Quote Status is always DRAFT at this stage."
    },
    {
      "parameters": {
        "jsCode": "// Build approval notification message\nconst d = $input.first().json;\nconst status = d.requiresManualQuote ? '\u26a0\ufe0f \u0e15\u0e49\u0e2d\u0e07\u0e04\u0e33\u0e19\u0e27\u0e13\u0e23\u0e32\u0e04\u0e32\u0e40\u0e2d\u0e07' : '\u2705 \u0e04\u0e33\u0e19\u0e27\u0e13\u0e2d\u0e31\u0e15\u0e42\u0e19\u0e21\u0e31\u0e15\u0e34';\n\nconst message = [\n  '\ud83d\udea2 [FREIGHT QUOTE REQUEST \u2014 DRAFT]',\n  `\ud83d\udccb Quote No: ${d.quoteId}`,\n  `\ud83d\udc64 \u0e25\u0e39\u0e01\u0e04\u0e49\u0e32: ${d.customerName}`,\n  `\ud83d\udcde \u0e15\u0e34\u0e14\u0e15\u0e48\u0e2d: ${d.contact}`,\n  `\ud83d\udce6 \u0e40\u0e2a\u0e49\u0e19\u0e17\u0e32\u0e07: ${d.origin} \u2192 ${d.destination}`,\n  `\ud83d\ude80 \u0e1b\u0e23\u0e30\u0e40\u0e20\u0e17: ${d.shipmentType}`,\n  `\u2696\ufe0f  \u0e19\u0e49\u0e33\u0e2b\u0e19\u0e31\u0e01: ${d.weightKg} kg | CBM: ${d.cbm}`,\n  `\ud83d\udcb0 \u0e23\u0e32\u0e04\u0e32\u0e17\u0e35\u0e48\u0e41\u0e19\u0e30\u0e19\u0e33: ${d.totalPrice?.toLocaleString() || 'N/A'} THB`,\n  `\ud83d\udcc5 Transit: ${d.transitDays || 'N/A'}`,\n  status,\n  '',\n  '\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501',\n  '\u26a0\ufe0f \u0e01\u0e23\u0e38\u0e13\u0e32\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e41\u0e25\u0e30\u0e2d\u0e19\u0e38\u0e21\u0e31\u0e15\u0e34\u0e01\u0e48\u0e2d\u0e19\u0e2a\u0e48\u0e07\u0e43\u0e2b\u0e49\u0e25\u0e39\u0e01\u0e04\u0e49\u0e32',\n  `\ud83d\udc49 \u0e40\u0e1b\u0e34\u0e14 Google Sheets: https://docs.google.com/spreadsheets/d/${$env.GOOGLE_SHEET_ID || \"CONFIGURE_GOOGLE_SHEET_ID\"}`,\n  `\u0e2b\u0e32 Quote ID: ${d.quoteId} \u2192 \u0e40\u0e1b\u0e25\u0e35\u0e48\u0e22\u0e19 Status \u0e40\u0e1b\u0e47\u0e19 Approved`\n].join('\\n');\n\nreturn [{ json: { ...d, notificationMessage: message } }];\n"
      },
      "id": "node-build-notification",
      "name": "Build Approval Notification",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1340,
        300
      ],
      "notes": "Formats the LINE Notify / Email approval message."
    },
    {
      "parameters": {
        "token": "={{ $env.LINE_NOTIFY_TOKEN }}",
        "message": "={{ $json.notificationMessage }}"
      },
      "id": "node-line-notify",
      "name": "LINE Notify - Human Approval",
      "type": "n8n-nodes-base.lineNotify",
      "typeVersion": 1,
      "position": [
        1560,
        200
      ],
      "credentials": {
        "lineNotifyOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "notes": "Sends approval notification to LINE Notify. Get token at notify-bot.line.me/my. Replace YOUR_LINE_NOTIFY_TOKEN."
    },
    {
      "parameters": {
        "fromEmail": "={{ $env.SALES_EMAIL }}",
        "toEmail": "={{ $env.MANAGER_EMAIL }}",
        "subject": "=[APPROVAL REQUIRED] Freight Quote {{ $json.quoteId }}",
        "emailType": "text",
        "message": "={{ $json.notificationMessage }}"
      },
      "id": "node-email-notify",
      "name": "Email - Human Approval Backup",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2,
      "position": [
        1560,
        420
      ],
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      },
      "notes": "Backup email notification. Set SALES_EMAIL and MANAGER_EMAIL in n8n environment variables."
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "freight-quote-approved",
        "responseMode": "onReceived",
        "options": {}
      },
      "id": "node-approval-webhook",
      "name": "Webhook - Quote Approved",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 1,
      "position": [
        240,
        600
      ],
      "notes": "Triggered when a quote is approved. Can be called manually from Google Sheets Apps Script, or from a simple approval web form. POST body: { quoteId, approvedBy, customerContact, totalPrice, customerName }"
    },
    {
      "parameters": {
        "jsCode": "// Build customer-facing quote message\nconst d = $input.first().json.body || $input.first().json;\n\nconst message = [\n  '\ud83d\udea2 \u0e43\u0e1a\u0e40\u0e2a\u0e19\u0e2d\u0e23\u0e32\u0e04\u0e32\u0e02\u0e19\u0e2a\u0e48\u0e07\u0e2a\u0e34\u0e19\u0e04\u0e49\u0e32',\n  `\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,\n  `\ud83d\udccb \u0e40\u0e25\u0e02\u0e17\u0e35\u0e48: ${d.quoteId}`,\n  `\ud83d\udc64 \u0e40\u0e23\u0e35\u0e22\u0e19: ${d.customerName}`,\n  `\ud83d\udce6 \u0e40\u0e2a\u0e49\u0e19\u0e17\u0e32\u0e07: ${d.origin || ''} \u2192 ${d.destination || ''}`,\n  `\ud83d\ude80 \u0e1b\u0e23\u0e30\u0e40\u0e20\u0e17: ${d.shipmentType || ''}`,\n  `\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,\n  `\ud83d\udcb0 \u0e23\u0e32\u0e04\u0e32\u0e23\u0e27\u0e21: ${parseFloat(d.totalPrice || 0).toLocaleString()} THB`,\n  `\ud83d\udcc5 \u0e23\u0e30\u0e22\u0e30\u0e40\u0e27\u0e25\u0e32: ${d.transitDays || '\u0e15\u0e34\u0e14\u0e15\u0e48\u0e2d\u0e2a\u0e2d\u0e1a\u0e16\u0e32\u0e21'} \u0e27\u0e31\u0e19`,\n  `\u23f0 \u0e23\u0e32\u0e04\u0e32\u0e21\u0e35\u0e1c\u0e25\u0e16\u0e36\u0e07: ${d.validUntil || '30 \u0e27\u0e31\u0e19'}`,\n  `\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,\n  '\u2705 \u0e2b\u0e32\u0e01\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e22\u0e37\u0e19\u0e22\u0e31\u0e19\u0e01\u0e32\u0e23\u0e08\u0e2d\u0e07',\n  '\u0e01\u0e23\u0e38\u0e13\u0e32\u0e15\u0e2d\u0e1a\u0e01\u0e25\u0e31\u0e1a\u0e02\u0e49\u0e2d\u0e04\u0e27\u0e32\u0e21\u0e19\u0e35\u0e49\u0e2b\u0e23\u0e37\u0e2d\u0e42\u0e17\u0e23 [\u0e40\u0e1a\u0e2d\u0e23\u0e4c\u0e15\u0e34\u0e14\u0e15\u0e48\u0e2d]',\n  '',\n  '\u0e02\u0e2d\u0e1a\u0e04\u0e38\u0e13\u0e17\u0e35\u0e48\u0e44\u0e27\u0e49\u0e27\u0e32\u0e07\u0e43\u0e08\u0e43\u0e19\u0e1a\u0e23\u0e34\u0e01\u0e32\u0e23\u0e02\u0e2d\u0e07\u0e40\u0e23\u0e32 \ud83d\ude4f'\n].join('\\n');\n\nreturn [{ json: { ...d, customerMessage: message } }];\n"
      },
      "id": "node-build-customer-message",
      "name": "Build Customer Quote Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        460,
        600
      ],
      "notes": "Formats the customer-facing quotation message after approval."
    },
    {
      "parameters": {
        "authentication": "oAuth2",
        "resource": "message",
        "operation": "send",
        "to": "={{ $json.customerLineId }}",
        "type": "text",
        "text": "={{ $json.customerMessage }}"
      },
      "id": "node-send-to-customer",
      "name": "Send Quote to Customer (LINE)",
      "type": "n8n-nodes-base.line",
      "typeVersion": 1,
      "position": [
        680,
        600
      ],
      "credentials": {
        "lineOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "notes": "Sends approved quote to customer via LINE Messaging API. Requires LINE Channel Access Token."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "={{ $env.GOOGLE_SHEET_ID }}",
          "mode": "expression"
        },
        "sheetName": {
          "__rl": true,
          "value": "QuoteLog",
          "mode": "name"
        },
        "filtersUI": {
          "values": [
            {
              "lookupColumn": "Quote ID",
              "lookupValue": "={{ $json.quoteId }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Approval Status": "Approved",
            "Approved By": "={{ $json.approvedBy }}",
            "Approval Date": "={{ new Date().toISOString() }}",
            "Sent Date": "={{ new Date().toISOString() }}",
            "Follow-up Date": "={{ (() => { const d = new Date(); d.setDate(d.getDate() + 3); return d.toISOString().slice(0,10); })() }}"
          }
        },
        "options": {}
      },
      "id": "node-update-sheet-approved",
      "name": "Update Sheet - Approved & Sent",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        900,
        600
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "notes": "Updates the quote row in Google Sheets to Approved and records sent date."
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 24
            }
          ]
        }
      },
      "id": "node-followup-trigger",
      "name": "Daily Follow-up Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [
        240,
        900
      ],
      "notes": "Runs every 24 hours to check for quotes that need follow-up."
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "={{ $env.GOOGLE_SHEET_ID }}",
          "mode": "expression"
        },
        "sheetName": {
          "__rl": true,
          "value": "QuoteLog",
          "mode": "name"
        },
        "options": {}
      },
      "id": "node-get-pending-followups",
      "name": "Get Pending Follow-ups",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4,
      "position": [
        460,
        900
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "notes": "Reads all rows from QuoteLog."
    },
    {
      "parameters": {
        "jsCode": "// Filter rows that need follow-up today\nconst today = new Date().toISOString().slice(0, 10);\nconst rows = $input.all().map(r => r.json);\n\nconst dueFollowUps = rows.filter(row => {\n  const followUpDate = (row['Follow-up Date'] || '').toString().slice(0, 10);\n  const sentStatus = (row['Sent Status'] || '').toString();\n  const bookingStatus = (row['Booking Status'] || '').toString();\n  return followUpDate === today\n    && sentStatus === 'Sent'\n    && !['Booking Confirmed', 'Declined'].includes(bookingStatus);\n});\n\nreturn dueFollowUps.map(row => ({ json: row }));\n"
      },
      "id": "node-filter-followups",
      "name": "Filter Due Follow-ups",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        680,
        900
      ],
      "notes": "Filters quotes where today is the follow-up date and no response yet."
    },
    {
      "parameters": {
        "token": "={{ $env.LINE_NOTIFY_TOKEN }}",
        "message": "=\ud83d\udcec Follow-up \u0e17\u0e35\u0e48\u0e15\u0e49\u0e2d\u0e07\u0e17\u0e33\u0e27\u0e31\u0e19\u0e19\u0e35\u0e49:\nQuote {{ $json['Quote ID'] }} | {{ $json['Customer Name'] }}\n\u0e40\u0e2a\u0e49\u0e19\u0e17\u0e32\u0e07: {{ $json['Origin'] }} \u2192 {{ $json['Destination'] }}\n\u0e23\u0e32\u0e04\u0e32: {{ $json['Total Quoted Price (THB)'] }} THB\n\u0e15\u0e34\u0e14\u0e15\u0e48\u0e2d: {{ $json['Contact (LINE/Email)'] }}"
      },
      "id": "node-followup-notify",
      "name": "Follow-up Reminder Notify",
      "type": "n8n-nodes-base.lineNotify",
      "typeVersion": 1,
      "position": [
        900,
        900
      ],
      "credentials": {
        "lineNotifyOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "notes": "Sends daily follow-up reminders to the team via LINE Notify."
    },
    {
      "parameters": {
        "jsCode": "// Validate APPROVAL_SECRET_TOKEN before allowing customer-facing quote to be sent\nconst body = $input.first().json.body || $input.first().json;\nconst headers = $input.first().json.headers || {};\n\n// Accept token from HTTP header 'x-approval-token' OR body field 'approvalToken'\nconst providedToken = (headers['x-approval-token'] || body.approvalToken || '').toString().trim();\nconst expectedToken = ($env.APPROVAL_SECRET_TOKEN || '').toString().trim();\n\nif (!expectedToken) {\n  throw new Error(\n    'APPROVAL_SECRET_TOKEN env var is not configured. '\n    + 'Cannot process approval requests. Configure the variable in n8n Settings \u2192 Variables.'\n  );\n}\n\nif (!providedToken || providedToken !== expectedToken) {\n  throw new Error(\n    '401 Unauthorized: Invalid or missing approval token. '\n    + 'Request rejected \u2014 customer quote NOT sent. '\n    + 'Provide the correct token in header \\'x-approval-token\\' or body field \\'approvalToken\\'.'\n  );\n}\n\n// Token valid \u2014 pass through to Build Customer Quote Message\nreturn [{ json: $input.first().json }];\n"
      },
      "id": "node-validate-approval-token",
      "name": "Validate Approval Token",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        350,
        600
      ],
      "notes": "Security gate: validates APPROVAL_SECRET_TOKEN before allowing customer quote to be sent. Accepts token via HTTP header 'x-approval-token' OR body field 'approvalToken'. Throws error (halts execution) if token is missing, empty, or does not match $env.APPROVAL_SECRET_TOKEN. NEVER commit a real token value \u2014 set APPROVAL_SECRET_TOKEN in n8n Settings \u2192 Variables."
    }
  ],
  "connections": {
    "Webhook - Quote Request": {
      "main": [
        [
          {
            "node": "Normalize & Calculate CBM",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize & Calculate CBM": {
      "main": [
        [
          {
            "node": "Get Rate Table",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Rate Table": {
      "main": [
        [
          {
            "node": "Calculate Price",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Calculate Price": {
      "main": [
        [
          {
            "node": "Log to Quote Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log to Quote Sheet": {
      "main": [
        [
          {
            "node": "Build Approval Notification",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Approval Notification": {
      "main": [
        [
          {
            "node": "LINE Notify - Human Approval",
            "type": "main",
            "index": 0
          },
          {
            "node": "Email - Human Approval Backup",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook - Quote Approved": {
      "main": [
        [
          {
            "node": "Validate Approval Token",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Customer Quote Message": {
      "main": [
        [
          {
            "node": "Send Quote to Customer (LINE)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Quote to Customer (LINE)": {
      "main": [
        [
          {
            "node": "Update Sheet - Approved & Sent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Daily Follow-up Trigger": {
      "main": [
        [
          {
            "node": "Get Pending Follow-ups",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Pending Follow-ups": {
      "main": [
        [
          {
            "node": "Filter Due Follow-ups",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Due Follow-ups": {
      "main": [
        [
          {
            "node": "Follow-up Reminder Notify",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Approval Token": {
      "main": [
        [
          {
            "node": "Build Customer Quote Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "description": "Freight Forwarding Quotation Automation \u2014 PILOT V1 (Draft-Only Supervised Mode). Receives quote requests, calculates prices from Rate Table using USD_THB from $env.USD_THB, logs DRAFT quotes to Google Sheets, notifies team for HUMAN APPROVAL via LINE + Email. No customer-facing message is sent automatically. Land shipments and unknown routes always set requiresManualQuote=true.",
    "version": "1.1.0-pilot",
    "setupInstructions": [
      "1. Set GOOGLE_SHEET_ID in n8n environment variables (Settings \u2192 Variables)",
      "2. Set USD_THB in n8n environment variables \u2014 current exchange rate, e.g. 35.50 (REQUIRED)",
      "3. Set QUOTE_VALIDITY_DAYS in n8n environment variables, e.g. 30",
      "4. Set SALES_EMAIL in n8n environment variables (sender email)",
      "5. Set MANAGER_EMAIL in n8n environment variables (approval recipient)",
      "6. Set LINE_NOTIFY_TOKEN in n8n environment variables (from notify-bot.line.me/my)",
      "7. Configure Google Sheets credentials (Service Account or OAuth2) in n8n Credentials",
      "8. Configure Gmail credentials in n8n Credentials",
      "9. Configure LINE Notify credentials in n8n Credentials",
      "10. Configure LINE Messaging API credentials in n8n Credentials (for post-approval send to customer)",
      "11. Fill RateTable in Google Sheets with verified live rates before activating",
      "12. Activate workflow \u2014 all generated quotes are DRAFT status until human approval webhook is called"
    ],
    "pilotMode": "draft-only-supervised"
  }
}

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

Freight Forwarding - Quote Automation. Uses googleSheets, lineNotify, gmail, line. Webhook trigger; 17 nodes.

Source: https://github.com/natbkgift/multi-business/blob/1c0d68c6320443ab97119ee3d13fa2cd1331b5ee/freight/n8n-workflow.json — original creator credit. Request a take-down →

More Email & Gmail workflows → · Browse all categories →

Related workflows

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

Email & Gmail

Automate WhatsApp communication for recruitment agencies with an interactive, structured customer experience. This workflow handles pricing inquiries, request submissions, tracking, complaints, and hu

HTTP Request, Google Sheets, Gmail +1
Email & Gmail

Code. Uses googleSheets, gmail, supabase, stickyNote. Webhook trigger; 51 nodes.

Google Sheets, Gmail, Supabase +1
Email & Gmail

This template turns Podium's conversation inbox into a full sales CRM with a custom funnel, AI message classification, automated drip follow-ups, daily admin reports, and a live Kanban dashboard. Six

HTTP Request, Google Sheets, Gmail
Email & Gmail

Ticketing Backend automates registration, QR-ticket generation, email delivery, and check-in validation using Google Sheets, Gmail, and a webhook scanner — reducing manual ticket prep from ~3 hours to

Google Sheets, HTTP Request, Gmail
Email & Gmail

&gt; n8n + Gmail + Google Sheets + Typeform

Gmail, Google Sheets