Skip to content
Last updated

Automate Contact Data Enrichment

Overview

This guide walks you through automating contact enrichment in Google Sheets using the Lusha Bulk Person API V2. You'll be able to send contact data in bulk to Lusha, retrieve detailed information, update your sheet seamlessly, and track the status of each enrichment request in real-time.

How it Works?

1. Create a New Google Sheet

Start by creating a new, blank Google Sheet.

You'll use this sheet to store and enrich contact data with the Lusha API. The required column headers will be added automatically after you complete the setup and refresh the sheet.

2. Add the Script to Google Sheet

Open the Script Editor:

Go to Extensions > Apps Script in your Google Sheets file.

Paste the Script:

In the script editor, paste the provided code below. If there's any existing code, you can delete it before pasting.

Save the Script:

Click the save icon or press Ctrl+S (Windows) / Cmd+S (Mac) to save the script.

// Function to create the custom menu when the spreadsheet opens
function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('Lusha Enrichment')
    .addItem('Enrich All Contacts', 'enrichAllContacts')
    .addItem('Enrich From Specific Row', 'enrichFromSpecificRow')
    .addToUi();
    
  // Set up just the status row and input headers - NOT the output headers
  setupInitialStructure();
}

// Function to set up the initial spreadsheet structure
function setupInitialStructure() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  
  // Set up status row (row 1)
  setupStatusRow(sheet);
  
  // Set up input headers (row 2)
  setupInputHeaders(sheet);
  
  // Format the sheet for better usability
  sheet.setFrozenRows(2);  // Freeze both header rows
}

// Function to set up the status row (row 1)
function setupStatusRow(sheet) {
  // Check if status row already exists
  const hasStatusLabel = sheet.getRange("A1").getValue() === "Enrichment Status";
  
  if (!hasStatusLabel) {
    // Set up simplified status area in row 1
    sheet.getRange("A1").setValue("Enrichment Status");
    sheet.getRange("A1").setFontWeight("bold");
    
    sheet.getRange("C1").setValue("Last Updated:");
    sheet.getRange("C1").setFontWeight("bold");
    
    // Move final stats to start from column G
    sheet.getRange("G1").setValue("Final Stats:");
    sheet.getRange("G1").setFontWeight("bold");
    
    // Set placeholder values
    sheet.getRange("B1").setValue("Not started");
    sheet.getRange("D1").setValue("-");
    
    // Set placeholder values for final stats starting at column H
    sheet.getRange("H1").setValue("Success: -");
    sheet.getRange("I1").setValue("No Data: -");
    sheet.getRange("J1").setValue("Failed: -");
    
    // Format the status row
    sheet.getRange("A1:J1").setBackground("#f3f3f3");
  }
}

// Function to set up input headers in row 2 (columns A-F)
function setupInputHeaders(sheet) {
  const inputHeaders = [
    "First Name (Input)", 
    "Last Name (Input)", 
    "Company Name (Input)", 
    "Company Domain (Input)", 
    "Email Address (Input)", 
    "LinkedIn URL (Input)"
  ];
  
  // Check if headers already exist in row 2
  const existingHeaders = sheet.getRange("A2:F2").getValues()[0];
  const hasHeaders = existingHeaders.some(header => header !== "");
  
  // If no headers exist, add them to row 2
  if (!hasHeaders) {
    sheet.getRange("A2:F2").setValues([inputHeaders]);
    sheet.getRange("A2:F2").setFontWeight("bold");
    sheet.getRange("A2:F2").setBackground("#f3f3f3");
    
    // Auto-resize columns for better visibility
    sheet.autoResizeColumns(1, 6);
  }
}

// Function to set up output headers in row 2 (starting at column G)
function setupOutputHeaders(sheet) {
  const outputHeaders = [
    "Status", "Is Credit Charged", "Person ID", "First Name", "Last Name", "Full Name", 
    "Email 1", "Email Type 1", "Email Confidence 1", 
    "Email 2", "Email Type 2", "Email Confidence 2",
    "Phone Number 1", "Phone Type 1", "Do Not Call 1", 
    "Phone Number 2", "Phone Type 2", "Do Not Call 2",
    "Contact Tags", 
    "Contact Location Country", "Contact Location Country ISO2", 
    "Contact Location Continent", "Contact Location City", 
    "Contact Location State", "Contact Location State Code",
    "Contact Location Coordinates (Latitude)", "Contact Location Coordinates (Longitude)", 
    "Is EU Contact", "Job Title", "Job Start Date", "Job Departments", 
    "Seniority Level", "LinkedIn Profile", 
    "Company Name", "Company Description", "Company Homepage",
    "Company Location", "Company Location City", "Company Location State", 
    "Company Location State Code", "Company Location Country", 
    "Company Location Country ISO2", "Company Location Continent", 
    "Company Location Coordinates (Latitude)", "Company Location Coordinates (Longitude)", 
    "Company Size Min", "Company Size Max", 
    "Revenue Range Min", "Revenue Range Max", 
    "Company Logo URL", "Company LinkedIn", "Company Crunchbase", 
    "Technologies", "Funding Rounds (String)", "Total Funding Rounds", 
    "Total Funding Amount", "Is IPO", "Last Funding Type", 
    "Last Funding Amount", "Last Funding Date", 
    "Intent Topics (String)", "Specialities", 
    "Previous Job Title", "Previous Company Name", "Previous Company Domain"
  ];
  
  // Check if headers already exist and are correct
  const existingHeaders = sheet.getRange(2, 7, 1, outputHeaders.length).getValues()[0];
  if (existingHeaders.some((header, i) => header !== outputHeaders[i])) {
    sheet.getRange(2, 7, 1, outputHeaders.length).setValues([outputHeaders]);
    sheet.getRange(2, 7, 1, outputHeaders.length).setFontWeight("bold");
    sheet.getRange(2, 7, 1, outputHeaders.length).setBackground("#f3f3f3");
  }
}

// Function to update status in the first row
function updateStatusInTopRow(message, processed, total) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  
  // Ensure status row exists
  setupStatusRow(sheet);
  
  // Update only status and timestamp - no progress or processed counts
  sheet.getRange("B1").setValue(message);
  
  // Format date and time with both date and time
  sheet.getRange("D1").setValue(Utilities.formatDate(
    new Date(), 
    Session.getScriptTimeZone(), 
    "yyyy-MM-dd HH:mm:ss"
  ));
  
  // Set status background
  if (message.includes("Complete")) {
    sheet.getRange("B1").setBackground("#d9ead3"); // Green for complete
  } else if (message.includes("Error")) {
    sheet.getRange("B1").setBackground("#f4cccc"); // Red for error
  } else {
    sheet.getRange("B1").setBackground("#fff2cc"); // Yellow for in progress
  }
}

// Function to update final statistics (now starting at column H)
function updateFinalStats(success, nodata, failed) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  
  // Set final statistics in row 1, starting at column H
  sheet.getRange("H1").setValue("Success: " + success);
  sheet.getRange("I1").setValue("No Data: " + nodata);
  sheet.getRange("J1").setValue("Failed: " + failed);
  
  // Format stats
  sheet.getRange("H1:J1").setBackground("#e8f4fe");
}

// Trigger to enrich all contacts
function enrichAllContacts() {
  startEnrichment(true);
}

// Function to enrich from a specific row
function enrichFromSpecificRow() {
  const ui = SpreadsheetApp.getUi();
  const response = ui.prompt(
    'Enrich From Specific Row',
    'Enter the row number to start enrichment from:',
    ui.ButtonSet.OK_CANCEL
  );
  
  // Check if the user clicked "OK"
  if (response.getSelectedButton() == ui.Button.OK) {
    const rowNumber = parseInt(response.getResponseText());
    
    // Validate that it's a valid row number (must be >= 3 now that headers are in row 2)
    if (isNaN(rowNumber) || rowNumber < 3) {
      ui.alert('Invalid row number. Please enter a number greater than or equal to 3.');
      return;
    }
    
    // Call the enrichment function with the specific row number
    startEnrichment(true, rowNumber);
  }
}

// Main function to start enrichment process
function startEnrichment(processAll, customStartRow) {
  // Clear any previous enrichment state to ensure a fresh start
  PropertiesService.getScriptProperties().deleteProperty('enrichment_state');
  
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  
  // Set up the output headers now (only when enrichment is actually starting)
  setupOutputHeaders(sheet);
  
  // Get sheet and calculate total rows to process
  const lastRow = sheet.getLastRow();
  const startRow = customStartRow || 3; // Start from row 3 by default (after headers)
  const totalRowsToProcess = lastRow < 3 ? 0 : (lastRow - startRow + 1);
  
  // Initialize the status area
  updateStatusInTopRow("In progress: Starting enrichment process...", 0, totalRowsToProcess);
  
  // Create and store enrichment state
  const state = {
    processAll: processAll,
    startRow: startRow,
    chunkSize: 1000,  // Process data in chunks of 1000 rows at a time
    batchSize: 100,   // Lusha API batch size
    totalRowsToProcess: totalRowsToProcess, // Store total rows for progress tracking
    stats: {
      processed: 0,
      success: 0,
      nodata: 0,
      failed: 0,
      batches: 0
    }
  };
  
  // Save state
  saveState(state);
  
  // Display toast notification to inform user
  SpreadsheetApp.getActiveSpreadsheet().toast(
    "Enrichment process has started. You can continue working while the process runs in the background. Check the status in row 1 for updates.",
    "Process Started",
    10 // Show for 10 seconds
  );
  
  // If no data to process, complete immediately
  if (totalRowsToProcess <= 0) {
    updateStatusInTopRow("Complete: No data to process", 0, 0);
    updateFinalStats(0, 0, 0);
    return;
  }
  
  // Start the first chunk processing
  processNextChunk();
}

// Function to save current state to script properties
function saveState(state) {
  PropertiesService.getScriptProperties().setProperty(
    'enrichment_state', 
    JSON.stringify(state)
  );
}

// Function to retrieve state from script properties
function getState() {
  const stateJson = PropertiesService.getScriptProperties().getProperty('enrichment_state');
  return stateJson ? JSON.parse(stateJson) : null;
}

// Function to process the next chunk of data
function processNextChunk() {
  const state = getState();
  if (!state) return;
  
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  
  // DEBUG: Log what we're doing
  Logger.log("Processing next chunk...");
  Logger.log("Current processed count: " + state.stats.processed);
  
  // IMPORTANT: Get the last row with any data
  const lastRow = sheet.getLastRow();
  Logger.log("Last row with any data: " + lastRow);
  
  // Check if we've processed all rows
  if (state.currentChunkStart && state.currentChunkStart > lastRow) {
    Logger.log("All rows processed. Current chunk start: " + state.currentChunkStart + ", Last row: " + lastRow);
    completeProcessing(state);
    return;
  }
  
  // Determine the current chunk boundaries
  const chunkStart = state.currentChunkStart || state.startRow;
  const chunkEnd = Math.min(chunkStart + state.chunkSize - 1, lastRow);
  
  Logger.log("Processing chunk from row " + chunkStart + " to " + chunkEnd);
  
  // Update status
  updateStatusInTopRow(
    `In progress: Processing rows ${chunkStart} to ${chunkEnd}`,
    state.stats.processed,
    state.totalRowsToProcess
  );
  
  // Process this chunk
  processChunk(chunkStart, chunkEnd);
  
  // Update state for next chunk
  state.currentChunkStart = chunkEnd + 1;
  saveState(state);
  
  // Log the updated state
  Logger.log("After processing chunk, processed count is now: " + state.stats.processed);
  
  // Schedule the next chunk processing
  if (chunkEnd < lastRow) {
    processNextChunk();
  } else {
    completeProcessing(state);
  }
}

// Function to process a chunk of data
function processChunk(startRow, endRow) {
  const state = getState();
  if (!state) return;
  
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  
  // Get the API key
  const API_KEY = PropertiesService.getScriptProperties().getProperty('api_key');
  if (!API_KEY) {
    throw new Error("API key not found in Script Properties. Please set it under Project Settings with the key 'api_key'.");
  }
  
  // Get data for this chunk
  const dataRange = sheet.getRange(startRow, 1, endRow - startRow + 1, 6);
  const data = dataRange.getValues();
  
  // DEBUG: Log the data we're attempting to process
  Logger.log("Processing rows from " + startRow + " to " + endRow);
  Logger.log("Number of rows in data: " + data.length);
  
  // Get statuses if needed
  let statuses = [];
  if (!state.processAll) {
    statuses = sheet.getRange(startRow, 7, endRow - startRow + 1, 1).getValues().flat();
    Logger.log("Not processing all - number of status values: " + statuses.length);
  } else {
    Logger.log("Processing all rows regardless of status");
  }
  
  // Collect valid contacts for this chunk
  const validContacts = [];
  data.forEach((row, index) => {
    const rowIndex = startRow + index;
    const [firstName, lastName, companyName, companyDomain, emailAddress, linkedinUrl] = row;
    
    // Skip if already successful and not processing all
    if (!state.processAll && statuses[index] === "Success") {
      Logger.log("Skipping row " + rowIndex + " because status is Success");
      return;
    }
    
    // Check if this has data to process - be more lenient
    if (linkedinUrl || emailAddress || firstName || lastName || companyName || companyDomain) {
      Logger.log("Row " + rowIndex + " is valid and will be processed");
      validContacts.push({
        rowIndex: rowIndex,
        data: row
      });
    } else {
      // Mark as invalid immediately
      Logger.log("Row " + rowIndex + " has no valid data");
      sheet.getRange(rowIndex, 7).setValue("Failed: Missing required fields");
      sheet.getRange(rowIndex, 7).setFontColor("#8B0000");
      state.stats.failed++;
    }
  });
  
  Logger.log("Valid contacts found: " + validContacts.length);
  
  // Process contacts in API batches
  for (let i = 0; i < validContacts.length; i += state.batchSize) {
    const batchContacts = validContacts.slice(i, i + state.batchSize);
    Logger.log("Processing batch of " + batchContacts.length + " contacts");
    
    // Update processed count before sending to API
    state.stats.processed += batchContacts.length;
    state.stats.batches++;
    
    // Update status with simplified message
    updateStatusInTopRow(
      `In progress: Processing batch ${state.stats.batches}`,
      state.stats.processed,
      state.totalRowsToProcess
    );
    
    // Process the batch
    processBatch(batchContacts, API_KEY);
    
    // Save state after each batch
    saveState(state);
  }
  
  // Save state after all batches in this chunk
  saveState(state);
  
  // Log final progress after this chunk
  const percentComplete = (state.stats.processed / state.totalRowsToProcess) * 100;
  Logger.log("After all batches, progress is: " + state.stats.processed + 
            " / " + state.totalRowsToProcess + " = " + percentComplete + "%");
}

// Function to process a batch of contacts
function processBatch(contacts, apiKey) {
  if (contacts.length === 0) return;
  
  const state = getState();
  if (!state) return;
  
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const url = 'https://api.lusha.com/v2/person';
  const batchData = [];
  const rowMap = {};
  
  // Prepare the contacts for the API request
  contacts.forEach(contact => {
    const [firstName, lastName, companyName, companyDomain, emailAddress, linkedinUrl] = contact.data;
    
    const fullName = [firstName, lastName].filter(Boolean).join(" ");
    const companies = (companyName || companyDomain) 
      ? [{ name: companyName || "", domain: companyDomain || "", isCurrent: true }]
      : [];
    
    let contactPayload = {
      contactId: String(contact.rowIndex),
      linkedinUrl: linkedinUrl || "",
      email: emailAddress || "",
      companies: companies
    };
    
    if (!(firstName || lastName) && emailAddress) {
      delete contactPayload.fullName;
    } else {
      contactPayload.fullName = fullName || "";
    }
    
    batchData.push(contactPayload);
    rowMap[contact.rowIndex] = contact.rowIndex;
  });
  
  // Make the API request
  const requestPayload = { contacts: batchData };
  const options = {
    method: 'POST',
    contentType: 'application/json',
    headers: { 'api_key': apiKey },
    muteHttpExceptions: true,
    payload: JSON.stringify(requestPayload)
  };
  
  try {
    const response = UrlFetchApp.fetch(url, options);
    const statusCode = response.getResponseCode();
    const responseText = response.getContentText();
    
    // Error handling for HTTP status codes
    if (statusCode < 200 || statusCode >= 300) {
      let errorMessage = `API Error (HTTP ${statusCode}): `;
      
      try {
        // Try to parse the error response as JSON
        const errorResponse = JSON.parse(responseText);
        
        // Extract detailed error information if available
        if (errorResponse.error) {
          errorMessage += errorResponse.error.message || "Unknown error";
          if (errorResponse.error.code) {
            errorMessage += ` (Code: ${errorResponse.error.code})`;
          }
          if (errorResponse.error.details) {
            errorMessage += ` - ${errorResponse.error.details}`;
          }
        } else if (errorResponse.message) {
          errorMessage += errorResponse.message;
        } else {
          errorMessage += "Unknown API error";
        }
      } catch (parseError) {
        // If response is not valid JSON, use the raw response text
        errorMessage += responseText || "No error details available";
      }
      
      // Log the detailed error
      Logger.log(`Lusha API error: ${errorMessage}`);
      
      // Mark all contacts in this batch as failed
      contacts.forEach(contact => {
        const rowIndex = contact.rowIndex;
        sheet.getRange(rowIndex, 7).setValue(errorMessage);
        sheet.getRange(rowIndex, 7).setFontColor("#8B0000");
        state.stats.failed++;
      });
      
      // Update status with error
      updateStatusInTopRow(
        `Error: ${errorMessage}`,
        state.stats.processed,
        state.totalRowsToProcess
      );
      
      saveState(state);
      return;
    }
    
    // If we get here, the status code was in the 200 range
    // Parse the response data
    let responseData;
    try {
      responseData = JSON.parse(responseText);
    } catch (parseError) {
      const errorMessage = `Failed to parse API response: ${parseError.message}`;
      Logger.log(errorMessage);
      Logger.log(`Response that failed to parse: ${responseText.substring(0, 500)}...`);
      
      // Mark all contacts as failed
      contacts.forEach(contact => {
        const rowIndex = contact.rowIndex;
        sheet.getRange(rowIndex, 7).setValue(`Failed: ${errorMessage}`);
        sheet.getRange(rowIndex, 7).setFontColor("#8B0000");
        state.stats.failed++;
      });
      
      saveState(state);
      return;
    }
    
    // Prepare data for batch updates to the sheet
    const updateData = [];
    const statusUpdates = [];
    
    // Process each contact in the response
    batchData.forEach(contact => {
      const rowIndex = parseInt(contact.contactId);
      const contactResponse = responseData.contacts[contact.contactId];
      
      if (!contactResponse) {
        // Missing contact in response
        const errorMsg = "Contact missing from API response";
        const emptyRow = Array(66).fill(""); // Updated to 66 columns (added 1 for Job Start Date)
        emptyRow[0] = `Failed: ${errorMsg}`;
        
        updateData.push({
          row: rowIndex,
          data: emptyRow
        });
        
        statusUpdates.push({
          row: rowIndex,
          color: "#8B0000" // Red
        });
        
        state.stats.failed++;
        return;
      }
      
      if (statusCode === 201 && contactResponse && !contactResponse.error) {
        // Success case
        const contactData = contactResponse.data || {};
        const companyData = responseData.companies[contactData.companyId] || {};
        
        // Fix company description: trim, normalize spaces, and remove line breaks
        let companyDescription = companyData.description || "";
        companyDescription = companyDescription.trim().replace(/\s+/g, ' ').replace(/[\r\n]+/g, ' ');
        
        // Prepare row data - Convert booleans to strings to avoid issues with setValues
        const rowData = [
          "Success", // Status
          contactResponse.isCreditCharged ? "TRUE" : "FALSE", // Convert boolean to string "TRUE"/"FALSE"
          contactData.personId || "", 
          contactData.firstName || "", 
          contactData.lastName || "", 
          contactData.fullName || "",
          contactData.emailAddresses?.[0]?.email || "", 
          contactData.emailAddresses?.[0]?.emailType || "", 
          contactData.emailAddresses?.[0]?.emailConfidence || "",
          contactData.emailAddresses?.[1]?.email || "", 
          contactData.emailAddresses?.[1]?.emailType || "", 
          contactData.emailAddresses?.[1]?.emailConfidence || "",
          contactData.phoneNumbers?.[0]?.number || "", 
          contactData.phoneNumbers?.[0]?.phoneType || "", 
          contactData.phoneNumbers?.[0]?.doNotCall ? "TRUE" : "FALSE", // Convert boolean
          contactData.phoneNumbers?.[1]?.number || "", 
          contactData.phoneNumbers?.[1]?.phoneType || "", 
          contactData.phoneNumbers?.[1]?.doNotCall ? "TRUE" : "FALSE", // Convert boolean
          contactData.contactTags?.map(tag => tag.name).join(", ") || "",
          contactData.location?.country || "", 
          contactData.location?.country_iso2 || "", 
          contactData.location?.continent || "",
          contactData.location?.city || "", 
          contactData.location?.state || "", 
          contactData.location?.state_code || "",
          contactData.location?.location_coordinates?.[1] || "", 
          contactData.location?.location_coordinates?.[0] || "",
          contactData.location?.is_eu_contact ? "TRUE" : "FALSE", // Convert boolean
          contactData.jobTitle?.title || "", 
          contactData.jobStartDate || "", // *** ADDED JOB START DATE HERE ***
          contactData.jobTitle?.departments?.join(", ") || "",
          contactData.jobTitle?.seniority || "", 
          contactData.socialLinks?.linkedin || "",
          companyData.name || "", 
          companyDescription, 
          companyData.domains?.homepage || "",
          companyData.location?.rawLocation || "", 
          companyData.location?.city || "",
          companyData.location?.state || "", 
          companyData.location?.stateCode || "",
          companyData.location?.country || "", 
          companyData.location?.countryIso2 || "",
          companyData.location?.continent || "",
          companyData.location?.locationCoordinates?.[1] || "", 
          companyData.location?.locationCoordinates?.[0] || "",
          companyData.companySize?.[0] || "", 
          companyData.companySize?.[1] || "",
          companyData.revenueRange?.[0] || "", 
          companyData.revenueRange?.[1] || "",
          companyData.logoUrl || "", 
          companyData.social?.linkedin || "", 
          companyData.social?.crunchbase || "",
          companyData.technologies?.map(tech => tech.name).join(", ") || "",
          companyData.funding?.rounds?.map(f => `${f.roundType} (${f.roundAmount} ${f.currency}, ${f.roundDate})`).join(", ") || "",
          companyData.funding?.totalRounds || "", 
          companyData.funding?.totalRoundsAmount || "",
          companyData.funding?.isIpo ? "TRUE" : "FALSE", // Convert boolean
          companyData.funding?.lastRoundType || "",
          companyData.funding?.lastRoundAmount || "", 
          companyData.funding?.lastRoundDate || "",
          companyData.intent?.detectedTopics?.map(i => `${i.topicName} (Score: ${i.metadata.topicScore}, Trend: ${i.metadata.topicTrend})`).join(", ") || "",
          companyData.specialities?.join(", ") || "",
          contactData.previousJob?.jobTitle?.title || "", 
          contactData.previousJob?.company?.name || "", 
          contactData.previousJob?.company?.domain || ""
        ];
        
        updateData.push({
          row: rowIndex,
          data: rowData
        });
        
        statusUpdates.push({
          row: rowIndex,
          color: "#006400" // Green
        });
        
        state.stats.success++;
      } else {
        // Error case - Provide detailed error information
        let errorMsg;
        
        if (contactResponse.error) {
          // Special case for "Could not find requested data" error - don't format as an error
          if (contactResponse.error.message === "Could not find requested data" || 
              contactResponse.error.code === "DATA_NOT_FOUND") {
            errorMsg = "Could not find requested data";
          } else {
            // For other errors, include detailed information
            errorMsg = "Error: ";
            
            // Include error code if available
            if (contactResponse.error.code) {
              errorMsg += `[${contactResponse.error.code}] `;
            }
            
            // Include error message
            errorMsg += contactResponse.error.message || "Unknown error";
            
            // Include details if available
            if (contactResponse.error.details) {
              errorMsg += ` - ${contactResponse.error.details}`;
            }
            
            // Check for rate limiting
            if (contactResponse.error.code === "RATE_LIMIT_EXCEEDED") {
              errorMsg += " (Consider slowing down the requests)";
            }
            
            // Check for credit issues
            if (contactResponse.error.code === "INSUFFICIENT_CREDITS") {
              errorMsg += " (Please check your account credits)";
            }
          }
        } else {
          errorMsg = "Failed: Unknown API error";
        }
        
        const emptyRow = Array(66).fill(""); // Updated to 66 columns (added 1 for Job Start Date)
        emptyRow[0] = errorMsg; // Set status message
        
        updateData.push({
          row: rowIndex,
          data: emptyRow
        });
        
        // Check if this is a "no data" message - more specific check
        if (contactResponse?.error?.message === "Could not find requested data" || 
            contactResponse?.error?.code === "DATA_NOT_FOUND") {
          statusUpdates.push({
            row: rowIndex, 
            color: "#FF8C00" // Orange
          });
          state.stats.nodata++;
        } else {
          statusUpdates.push({
            row: rowIndex,
            color: "#8B0000" // Red
          });
          state.stats.failed++;
        }
      }
    });
    
    // Batch update the sheet
    batchUpdateSheet(sheet, updateData, statusUpdates);
    
    // Save the updated state
    saveState(state);
    
  } catch (error) {
    // Handle connection errors and other exceptions
    const errorMessage = `API Connection Error: ${error.message}`;
    Logger.log(errorMessage);
    
    contacts.forEach(contact => {
      const rowIndex = contact.rowIndex;
      sheet.getRange(rowIndex, 7).setValue(`Failed: ${errorMessage}`);
      sheet.getRange(rowIndex, 7).setFontColor("#8B0000");
      state.stats.failed++;
    });
    
    // Update status with error
    updateStatusInTopRow(
      `Error: ${errorMessage}`,
      state.stats.processed,
      state.totalRowsToProcess
    );
    
    saveState(state);
  }
}

// Updated batch update function with improved text wrapping handling
function batchUpdateSheet(sheet, rowUpdates, statusUpdates) {
  // Group updates by adjacent rows to minimize API calls
  const rowGroups = {};
  
  rowUpdates.forEach(update => {
    if (!rowGroups[update.row]) {
      rowGroups[update.row] = update.data;
    }
  });
  
  // Update data in batches
  const batchSize = 20; // Number of rows to update at once
  const rowIndices = Object.keys(rowGroups).map(Number).sort((a, b) => a - b);
  
  for (let i = 0; i < rowIndices.length; i += batchSize) {
    const batch = rowIndices.slice(i, i + batchSize);
    
    batch.forEach(rowIndex => {
      try {
        // Make sure the data array length matches the sheet's expected width
        // If the output headers have 66 columns, ensure our data has exactly 66 columns
        const data = rowGroups[rowIndex];
        
        // Verify the row index is valid
        if (rowIndex < 1) {
          Logger.log(`Skipping invalid row index: ${rowIndex}`);
          return;
        }
        
        // Ensure data array is of expected length (updated to 66)
        while (data.length > 66) {
          data.pop(); // Trim extra columns
        }
        while (data.length < 66) {
          data.push(""); // Add missing columns
        }
        
        // Update the row data
        sheet.getRange(rowIndex, 7, 1, data.length).setValues([data]);
      } catch (error) {
        Logger.log(`Error updating row ${rowIndex}: ${error.message}`);
        // Set a simple error message for this row
        try {
          sheet.getRange(rowIndex, 7).setValue(`Failed: Error updating row (${error.message})`);
        } catch (e) {
          Logger.log(`Unable to set error message: ${e.message}`);
        }
      }
    });
  }
  
  // Update status colors
  statusUpdates.forEach(update => {
    try {
      sheet.getRange(update.row, 7).setFontColor(update.color);
    } catch (error) {
      Logger.log(`Error setting color for row ${update.row}: ${error.message}`);
    }
  });
  
  // Set text wrapping for company description column for all updated rows
  if (rowIndices.length > 0) {
    try {
      const startRow = Math.min(...rowIndices);
      const endRow = Math.max(...rowIndices);
      
      // Company Description is typically column "AN" which should be 35 columns after G (updated due to new column)
      // Let's determine its position from the headers to be safe
      const headers = sheet.getRange(2, 7, 1, 66).getValues()[0]; // Updated to 66
      let companyDescIndex = headers.findIndex(header => header === "Company Description");
      
      if (companyDescIndex === -1) {
        // If we can't find it, use a default position (35 columns after G)
        companyDescIndex = 35; // Updated from 34 to 35
      }
      
      const companyDescColumn = 7 + companyDescIndex;
      
      // Apply proper wrapping to company description column
      sheet.getRange(startRow, companyDescColumn, endRow - startRow + 1, 1)
           .setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP);
      
      // Set column width to ensure adequate display space
      sheet.setColumnWidth(companyDescColumn, 300); // 300 pixels should be enough for most descriptions
      
      // Make sure row height adjusts automatically - but only if there aren't too many rows
      // to avoid script timeout
      if (endRow - startRow < 100) {
        for (let row = startRow; row <= endRow; row++) {
          sheet.setRowHeight(row, -1); // -1 means automatic height based on content
        }
      }
    } catch (error) {
      Logger.log(`Error applying text wrapping: ${error.message}`);
    }
  }
}

// Function to complete the processing with improved text wrapping
function completeProcessing(state) {
  // Get accurate final statistics from the sheet
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  
  // Get all status values from column G (excluding header rows)
  let finalStats = {
    processed: state.stats.processed,
    success: 0,
    nodata: 0,
    failed: 0,
    batches: state.stats.batches
  };
  
  // Only count status values if there are rows with data
  if (sheet.getLastRow() > 2) { // > 2 because row 1-2 are headers
    try {
      const statusRange = sheet.getRange(3, 7, sheet.getLastRow() - 2, 1);
      const statusValues = statusRange.getValues().flat();
      
      // Count each type of status
      statusValues.forEach(status => {
        if (status === "Success") {
          finalStats.success++;
        } else if (status === "Could not find requested data") {
          finalStats.nodata++;
        } else if (status && status !== "") {
          finalStats.failed++;
        }
      });
      
      Logger.log("Final stats from sheet - Success: " + finalStats.success + 
                ", No data: " + finalStats.nodata + 
                ", Failed: " + finalStats.failed);
      
      // Find the Company Description column
      const headers = sheet.getRange(2, 7, 1, 65).getValues()[0];
      let companyDescIndex = headers.findIndex(header => header === "Company Description");
      
      if (companyDescIndex === -1) {
        // If we can't find it, use a default position (34 columns after G)
        companyDescIndex = 34;
      }
      
      const companyDescColumn = 7 + companyDescIndex;
      
// Apply proper wrapping to all Company Description cells
      if (sheet.getLastRow() >= 3) {
        // Get the range for all company descriptions
        const descRange = sheet.getRange(3, companyDescColumn, sheet.getLastRow() - 2, 1);
        
        // Apply wrap strategy
        descRange.setWrapStrategy(SpreadsheetApp.WrapStrategy.WRAP);
        
        // Set column width to ensure adequate display space
        sheet.setColumnWidth(companyDescColumn, 300); // 300 pixels is usually sufficient
        
        // Reset all row heights to automatic - but limit to avoid timeout
        const maxRowsToAdjust = 500;
        const rowsToAdjust = Math.min(sheet.getLastRow() - 2, maxRowsToAdjust);
        
        for (let row = 3; row <= 3 + rowsToAdjust - 1; row++) {
          try {
            sheet.setRowHeight(row, -1); // -1 means automatic row height
          } catch (err) {
            Logger.log(`Error setting row height for row ${row}: ${err.message}`);
          }
        }
        
        if (sheet.getLastRow() - 2 > maxRowsToAdjust) {
          Logger.log(`Only adjusted row heights for first ${maxRowsToAdjust} rows to avoid timeout.`);
        }
      }
      
    } catch (e) {
      // Ignore formatting errors
      Logger.log('Error setting text wrapping: ' + e);
    }
  }
  
  // Update final status with simplified message
  updateStatusInTopRow(
    `Complete!`,
    state.totalRowsToProcess,
    state.totalRowsToProcess
  );
  
  // Update final stats
  updateFinalStats(finalStats.success, finalStats.nodata, finalStats.failed);
  
  // Show toast notification to inform user
  SpreadsheetApp.getActiveSpreadsheet().toast(
    `Enrichment process complete! Successfully enriched: ${finalStats.success}, No data found: ${finalStats.nodata}, Failed: ${finalStats.failed}`,
    "Process Complete",
    10 // Show for 10 seconds
  );
  
  // Clean up state
  PropertiesService.getScriptProperties().deleteProperty('enrichment_state');
}

// Helper function to find the last row with actual data
function getLastRowWithData(sheet, numColumns) {
  const data = sheet.getDataRange().getValues();
  for (let i = data.length - 1; i >= 0; i--) {
    for (let j = 0; j < numColumns; j++) {
      if (data[i][j] !== '') {
        return i + 1; // +1 because array is 0-indexed
      }
    }
  }
  return 1; // Return 1 if no data found (just the header row)
}

// Add a utility function for better debugging API errors
function debugApiErrors(apiResponse) {
  try {
    const statusCode = apiResponse.getResponseCode();
    const responseText = apiResponse.getContentText();
    
    Logger.log(`API Response Code: ${statusCode}`);
    
    // Log headers
    const allHeaders = apiResponse.getAllHeaders();
    Logger.log(`API Headers: ${JSON.stringify(allHeaders)}`);
    
    // Parse and log response body if it's JSON
    try {
      const responseData = JSON.parse(responseText);
      Logger.log(`API Response Body: ${JSON.stringify(responseData, null, 2).substring(0, 500)}...`);
      
      // Log specific error information if available
      if (responseData.error) {
        Logger.log(`Error Code: ${responseData.error.code || 'N/A'}`);
        Logger.log(`Error Message: ${responseData.error.message || 'N/A'}`);
        Logger.log(`Error Details: ${responseData.error.details || 'N/A'}`);
      }
    } catch (e) {
      // Not JSON, log as text
      Logger.log(`API Response (not JSON): ${responseText.substring(0, 500)}...`);
    }
    
    return {
      statusCode,
      responseText,
      headers: allHeaders
    };
  } catch (error) {
    Logger.log(`Error debugging API response: ${error.message}`);
    return null;
  }
}

3. Add Your API Key

I. Go to Project Settings (gear icon in the script editor)

II. Set Script Properties: Under Script Properties, add a new property with:

  • Key: api_key
  • Value: your actual Lusha API key

This keeps your API key secure and out of the main code.

4. Refresh the Spreadsheet

After saving the script and adding your API key, refresh the Google Sheets page.

You'll now see a new menu option: Lusha Enrichment, and your sheet will automatically populate the required headers in Columns A–F:

  • Column A: First Name
  • Column B: Last Name
  • Column C: Company Name
  • Column D: Company Domain
  • Column E: Email Address
  • Column F: LinkedIn URL

This layout is used by the script to send enrichment requests.

Minimum Input Requirements

Each row should include at least one of the following valid combinations:

  • Full Name (First + Last) and Company Name or Domain
  • Email Address
  • LinkedIn URL

This ensures compatibility with Lusha's API for enrichment.

Use the Script

The Lusha Enrichment menu provides the following options:

  • Enrich All Contacts: Enriches every row with data
  • Enrich From a Specific Row: This lets you specify a starting row number and enriches all rows from that point onward

Review and Analyze Results

Once enrichment is complete, your sheet will update with the following:

  • Column G (Status): "Success" or a specific error message
  • Columns H onward: Additional data such as job title, location, company size, and more

Troubleshooting

If you encounter issues, here are some troubleshooting tips:

  • Error Messages: Check the Status column for specific error messages indicating what went wrong (e.g., invalid API key, missing required data)
  • Invalid JSON Response: Ensure your API key is correctly entered under Project Settings and that the data in Columns A to F matches the expected structure

Automate with Triggers (Optional)

To keep your contact data continuously updated, you can set up a time-driven trigger in Google Apps Script:

  1. Set Up Triggers: In the Apps Script editor, go to Triggers (clock icon in the left sidebar)
  2. Choose Time-Driven: Set up a time-driven trigger to run the script on a recurring basis (e.g., daily or weekly)

This automation keeps your contact data up-to-date without requiring manual intervention.

Additional Resources

If you have any questions on the above feel free to reach out the support team:

• Via live chat from the Lusha website

• Your Lusha Dashboard

• Via email: support@lusha.com