** * Universal Agent → Google Sheets Webhook (Apps Script) * * Works for: Real Estate, Financial Services/Insurance, Accounting, Floral Shops — and more. * Drop-in endpoint that accepts JSON from voice/chat agents, normalizes it, * upserts into a "Records" sheet, and logs an audit trail in an "Events" sheet. * * Key features: * - Industry-agnostic schema with optional vertical-specific fields * - Smart upsert by callId / externalId / orderId / phone / email / crm_lead_id * - Action router: lead capture, qualification, quotes/invoices, appointments, orders, payments * - Schema evolution: adds missing columns automatically * - Timezone-aware ISO timestamps (configurable) * - Stores full raw payload + extra unmapped fields in JSON columns *//****************************** * CONFIG ******************************/const CONFIG = { RECORDS_SHEET: 'Records', EVENTS_SHEET: 'Events', TIMEZONE: 'America/Los_Angeles', // adjust if needed // Add any extra headers you want first-class in the sheet here (optional) EXTRA_HEADERS: [ // Example: 'utm_source', 'campaign' ],};// Master column order (you can extend safely; ensureHeaders() will create missing columns)const HEADERS = [ // Core 'ts', 'last_updated', 'env', 'source', 'channel', 'agent', 'agent_version', 'industry', 'service_type', 'stage', 'status', 'tags', // Keys 'callId', 'externalId', 'crm_lead_id', 'orderId', // Contact 'name', 'first_name', 'last_name', 'company', 'phone', 'email', 'email_valid', 'consent', // Address / location 'address_line1', 'address_line2', 'city', 'state', 'postal_code', 'country', // Appointment 'appointment_time', 'appointment_timezone', 'appointment_location', 'reschedule_reason', // Order / commerce 'order_items_json', 'order_total_cents', 'order_currency', 'payment_url', 'payment_status', 'fulfillment_status', 'delivery_date', // Quote / Invoice 'quote_id', 'quote_total_cents', 'invoice_id', 'invoice_total_cents', // Domain specifics (nullable) 'realestate_listing_id', 'realestate_address', 'realestate_preapproval_status', 'insurance_policy_type', 'insurance_policy_id', 'insurance_premium_cents', 'accounting_service', 'accounting_period_start', 'accounting_period_end', 'floral_occasion', 'floral_delivery_window', 'floral_card_message', // Free-form 'notes', 'raw_json', 'extra_fields_json'].concat(CONFIG.EXTRA_HEADERS);/****************************** * ENTRYPOINT ******************************/function doPost(e) { try { const payload = JSON.parse(e.postData?.contents || '{}'); const action = String(payload.action || 'append'); const rec = payload.record || payload.lead || {}; // backward compat: allow lead const ss = SpreadsheetApp.getActive(); const sh = ensureSheet(ss, CONFIG.RECORDS_SHEET, HEADERS); const ev = ensureSheet(ss, CONFIG.EVENTS_SHEET, ['ts','event','key','details']); const result = routeAction({ action, rec, payload, sh, ev }); return jsonOut({ ok: true, result }); } catch (err) { return jsonOut({ ok: false, error: String(err && err.stack || err) }, 200 /* Apps Script can't set status */); }}/****************************** * ROUTER ******************************/function routeAction({ action, rec, payload, sh, ev }) { const lock = LockService.getScriptLock(); lock.tryLock(30000); try { const norm = normalizeRecord(rec, payload); const now = new Date(); switch (action) { // Lead/contact lifecycle case 'append': case 'lead_captured': appendRecord(sh, norm, now); logEvent(ev, 'lead_captured', pickKey(norm), pickDetails(norm, ['name','phone','email','industry','service_type'])); break; case 'qualification_updated': case 'status_changed': case 'note_added': case 'tag_added': upsertSmart(sh, norm, now, action); logEvent(ev, action, pickKey(norm), pickDetails(norm, ['stage','status','notes','tags'])); break; // Appointments case 'appointment_scheduled': case 'appointment_confirmed': case 'appointment_cancelled': upsertSmart(sh, norm, now, action); logEvent(ev, action, pickKey(norm), pickDetails(norm, ['appointment_time','appointment_timezone','appointment_location','reschedule_reason'])); break; case 'reschedule_appointment': { const newTime = String(payload.new_time || norm.appointment_time || ''); if (!newTime) throw new Error('reschedule_appointment requires new_time or record.appointment_time'); const rowIndex = findRowIndexForSmartKey(sh, norm, action); if (rowIndex < 2) throw new Error('No matching record found to reschedule'); const map = headerMap(sh); sh.getRange(rowIndex, map['appointment_time']).setValue(newTime); if (norm.reschedule_reason) sh.getRange(rowIndex, map['reschedule_reason']).setValue(norm.reschedule_reason); sh.getRange(rowIndex, map['last_updated']).setValue(isoNow()); if (norm.notes) sh.getRange(rowIndex, map['notes']).setValue(norm.notes); logEvent(ev, 'reschedule_appointment', pickKey(norm), { new_time: newTime, reschedule_reason: norm.reschedule_reason || '' }); break; } // Quotes / invoices case 'quote_requested': case 'quote_sent': case 'invoice_sent': case 'invoice_paid': upsertSmart(sh, norm, now, action); logEvent(ev, action, pickKey(norm), pickDetails(norm, ['quote_id','quote_total_cents','invoice_id','invoice_total_cents'])); break; // Orders / payments / fulfillment case 'order_placed': case 'order_fulfilled': case 'order_cancelled': case 'payment_link_sent': case 'payment_confirmed': upsertSmart(sh, norm, now, action); logEvent(ev, action, pickKey(norm), pickDetails(norm, ['orderId','payment_url','payment_status','fulfillment_status','delivery_date'])); break; // Call/session case 'call_started': case 'call_ended': upsertSmart(sh, norm, now, action); logEvent(ev, action, pickKey(norm), pickDetails(norm, ['channel','agent','agent_version'])); break; // Forced keying strategies case 'upsert_by_callId': case 'upsert_by_externalId': case 'upsert_by_orderId': case 'upsert_by_phone': case 'upsert_by_email': case 'upsert_by_crm_id': case 'upsert_smart': upsertSmart(sh, norm, now, action); logEvent(ev, action, pickKey(norm), {}); break; default: throw new Error('Unknown action: ' + action); } return { action, key: pickKey(norm) }; } finally { lock.releaseLock(); }}/****************************** * CORE OPS ******************************/function appendRecord(sh, rec, now) { const map = headerMap(sh); const row = recordToRow(rec, map); row[map['ts']-1] = rec.ts || isoNow(); row[map['last_updated']-1] = isoNow(); sh.appendRow(row);}function upsertSmart(sh, rec, now, explicitMode) { const idx = findRowIndexForSmartKey(sh, rec, explicitMode); const map = headerMap(sh); const row = recordToRow(rec, map); row[map['last_updated']-1] = isoNow(); if (!row[map['ts']-1]) row[map['ts']-1] = isoNow(); if (idx >= 2) { sh.getRange(idx, 1, 1, HEADERS.length).setValues([row]); return idx; } else { sh.appendRow(row); return sh.getLastRow(); }}/****************************** * LOOKUP / MATCHING ******************************/function findRowIndexForSmartKey(sh, rec, explicitMode) { const values = sh.getDataRange().getValues(); if (values.length < 2) return -1; const headers = values[0]; const col = name => headers.indexOf(name); const iCallId = col('callId'); const iExternalId = col('externalId'); const iOrderId = col('orderId'); const iPhone = col('phone'); const iEmail = col('email'); const iCrmId = col('crm_lead_id'); const wantCall = /callId$/.test(explicitMode || '') || (!explicitMode && rec.callId); const wantExt = /externalId$/.test(explicitMode || '') || (!explicitMode && rec.externalId); const wantOrder = /orderId$/.test(explicitMode || '') || (!explicitMode && rec.orderId); const wantPhone = /phone$/.test(explicitMode || '') || (!explicitMode && rec.phone); const wantEmail = /email$/.test(explicitMode || '') || (!explicitMode && rec.email); const wantCrm = /(crm_id|crm_lead_id)$/.test(explicitMode || '') || (!explicitMode && rec.crm_lead_id); // Priority: callId → externalId → orderId → phone → email → crm_lead_id if (wantCall && rec.callId) { const r = values.findIndex((r, i) => i>0 && r[iCallId] === rec.callId); if (r !== -1) return r+1; } if (wantExt && rec.externalId) { const r = values.findIndex((r, i) => i>0 && r[iExternalId] === rec.externalId); if (r !== -1) return r+1; } if (wantOrder && rec.orderId) { const r = values.findIndex((r, i) => i>0 && r[iOrderId] === rec.orderId); if (r !== -1) return r+1; } if (wantPhone && rec.phone) { const r = values.findIndex((r, i) => i>0 && strip(String(r[iPhone])) === strip(rec.phone)); if (r !== -1) return r+1; } if (wantEmail && rec.email) { const r = values.findIndex((r, i) => i>0 && String(r[iEmail]).toLowerCase() === String(rec.email).toLowerCase()); if (r !== -1) return r+1; } if (wantCrm && rec.crm_lead_id) { const r = values.findIndex((r, i) => i>0 && String(r[iCrmId]) === String(rec.crm_lead_id)); if (r !== -1) return r+1; } return -1;}/****************************** * TRANSFORMS ******************************/function normalizeRecord(rec, payload) { const cleaned = Object.assign({}, rec); // Coerce/normalize cleaned.env = cleaned.env || payload.env || 'prod'; cleaned.email_valid = !!cleaned.email_valid; cleaned.consent = cleaned.consent === true || cleaned.consent === 'yes' ? 'yes' : (cleaned.consent === false ? 'no' : (cleaned.consent || '')); cleaned.tags = Array.isArray(cleaned.tags) ? cleaned.tags.join(',') : (cleaned.tags || ''); // Items JSON (commerce) if (!cleaned.order_items_json && cleaned.order_items) cleaned.order_items_json = JSON.stringify(cleaned.order_items); // Currency default if (cleaned.order_total_cents && !cleaned.order_currency) cleaned.order_currency = 'usd'; // Vertical hints (leave as-is if provided) // (No strict validation to remain universal.) // Notes safe if (typeof cleaned.notes !== 'string') cleaned.notes = JSON.stringify(cleaned.notes || ''); // Capture extra/unmapped fields cleaned.extra_fields_json = JSON.stringify(pickExtras(cleaned)); // Raw payload cleaned.raw_json = JSON.stringify(rec || {}); // Timestamp cleaned.ts = cleaned.ts || isoNow(); return cleaned;}function recordToRow(rec, map) { const row = new Array(HEADERS.length).fill(''); HEADERS.forEach((h, i) => { if (h in rec) row[i] = rec[h]; }); // Common fallbacks row[map['source']-1] = row[map['source']-1] || rec.source || ''; row[map['agent']-1] = row[map['agent']-1] || rec.agent || ''; row[map['name']-1] = row[map['name']-1] || rec.name || ''; row[map['phone']-1] = row[map['phone']-1] || rec.phone || ''; row[map['email']-1] = row[map['email']-1] || rec.email || ''; row[map['industry']-1] = row[map['industry']-1] || rec.industry || ''; row[map['service_type']-1] = row[map['service_type']-1] || rec.service_type || ''; return row;}/****************************** * SHEET UTIL ******************************/function ensureSheet(ss, name, headerRow) { const sh = ss.getSheetByName(name) || ss.insertSheet(name); if (sh.getLastRow() === 0) sh.appendRow(headerRow); else ensureHeaders(sh, headerRow); return sh;}function ensureHeaders(sh, headerRow) { const existing = sh.getRange(1,1,1,sh.getLastColumn()).getValues()[0]; const missing = headerRow.filter(h => existing.indexOf(h) === -1); if (missing.length) { sh.insertColumnsAfter(existing.length, missing.length); sh.getRange(1, existing.length+1, 1, missing.length).setValues([missing]); }}function headerMap(sh) { const headers = sh.getRange(1,1,1,sh.getLastColumn()).getValues()[0]; const map = {}; headers.forEach((h, i) => map[h] = i+1); return map;}/****************************** * EVENTS LOG ******************************/function logEvent(ev, event, key, details) { ev.appendRow([isoNow(), event, JSON.stringify(key || {}), JSON.stringify(details || {})]);}/****************************** * HELPERS ******************************/function isoNow() { return Utilities.formatDate(new Date(), CONFIG.TIMEZONE, "yyyy-MM-dd'T'HH:mm:ssXXX");}function strip(s) { return String(s || '').replace(/\D/g, ''); }function jsonOut(obj, code) { const out = ContentService.createTextOutput(JSON.stringify(obj)); out.setMimeType(ContentService.MimeType.JSON); return out;}function pickKey(rec) { return { callId: rec.callId || '', externalId: rec.externalId || '', orderId: rec.orderId || '', phone: rec.phone || '', email: rec.email || '', crm_lead_id: rec.crm_lead_id || '' };}function pickDetails(rec, fields) { const o = {}; fields.forEach(f => { if (rec[f] !== undefined) o[f] = rec[f]; }); return o;}function pickExtras(rec) { const known = new Set(HEADERS); const extras = {}; Object.keys(rec).forEach(k => { if (!known.has(k)) extras[k] = rec[k]; }); return extras;}