12. - CRM Integration & Salesforce

Screen Pop: Architecture & Implementation

Overview

Screen pop is the automatic display of a customer record when they call. Agent's desktop automatically looks up the caller by phone number and displays their Salesforce record.

Benefits:


How It Works (The Flow)

Customer calls (ANI: +15551234567)
    ↓
Architect Flow receives call
    ↓
Data Action: Look up contact by phone
    in Salesforce API
    ↓
Salesforce returns: Contact ID + Account info
    ↓
Flow sets Interaction Attributes:
  - contact_id = "003xx000003SG"
  - account_id = "001xx000002Edc"
    ↓
Flow transfers to Support Queue
    ↓
Agent receives call
    ↓
Agent's desktop extension
    reads Interaction Attributes
    ↓
Desktop makes API call to Salesforce:
  GET /sobjects/Contact/003xx000003SG
    ↓
Browser pops Salesforce record in new window
    ↓
Agent sees: Name, account, cases, history
    ↓
Agent handles call with context

Architecture Components

1. Call Entry Point (Architect Flow)

The flow that receives inbound calls:

START
  ↓
Play: "Thank you for calling..."
  ↓
Data Action: lookup-contact-by-phone
  Input: ${interaction.caller.phoneNumber}
  Output: ${contact.id}, ${account.id}
  ↓
Decision: Contact found?
  YES → Set attributes → Transfer
  NO → Collect account number → Transfer
  ↓
Transfer to Queue
  (attributes go with call)
  ↓
END

2. Data Action (API Call)

{
  "name": "lookup-contact-by-phone",
  "method": "POST",
  "url": "https://your-instance.salesforce.com/services/apexrest/contact-lookup",
  "inputContract": {
    "phoneNumber": {
      "type": "string",
      "required": true
    }
  },
  "outputContract": {
    "success": { "type": "boolean" },
    "contactId": { "type": "string" },
    "firstName": { "type": "string" },
    "lastName": { "type": "string" },
    "accountId": { "type": "string" },
    "accountName": { "type": "string" },
    "tier": { "type": "string" }
  }
}

3. Interaction Attributes

Data attached to the call that agent's desktop can access:

{
  "contact_id": "003xx000003SG",
  "contact_name": "John Doe",
  "account_id": "001xx000002Edc",
  "account_name": "Acme Corp",
  "customer_tier": "Premium",
  "last_contact": "2026-03-10T14:30:00Z"
}

4. Agent Desktop Extension

JavaScript in the Genesys Workspace (desktop app or web):

// Listen for incoming interaction
genesysClient.on('interaction.incoming', async (interaction) => {
  // Get attributes set by flow
  const contactId = interaction.attributes?.contact_id;
  
  if (contactId) {
    // Pop Salesforce record
    const recordUrl = `https://your-instance.salesforce.com/${contactId}`;
    window.open(recordUrl, 'salesforce');
  }
});

Step-by-Step Implementation

Step 1: Create Salesforce Apex Endpoint

// ContactLookupController.cls

@RestResource(urlMapping='/contact-lookup')
global class ContactLookupController {
  @HttpPost
  global static LookupResponse lookup(String phoneNumber) {
    LookupResponse response = new LookupResponse();
    
    try {
      // Search for contact by phone
      List<Contact> contacts = [
        SELECT Id, FirstName, LastName, Email,
               AccountId, Account.Name, 
               Custom_Tier__c
        FROM Contact
        WHERE Phone = :phoneNumber
           OR MobilePhone = :phoneNumber
        LIMIT 1
      ];
      
      if (contacts.isEmpty()) {
        response.success = false;
        return response;
      }
      
      Contact contact = contacts[0];
      response.success = true;
      response.contactId = contact.Id;
      response.firstName = contact.FirstName;
      response.lastName = contact.LastName;
      response.accountId = contact.AccountId;
      response.accountName = contact.Account?.Name;
      response.tier = contact.Custom_Tier__c;
      
    } catch (Exception e) {
      response.success = false;
      response.error = e.getMessage();
    }
    
    return response;
  }
  
  global class LookupResponse {
    public Boolean success;
    public String contactId;
    public String firstName;
    public String lastName;
    public String accountId;
    public String accountName;
    public String tier;
    public String error;
  }
}

Step 2: Create Architect Flow

In Genesys Architect → Create Inbound Call Flow:

Flow Steps:

1. START
   ↓
2. Play Audio: "Thank you for calling..."
   ↓
3. Data Action: lookup-contact-by-phone
   Input: ${interaction.caller.phoneNumber}
   ↓
4. Decision: ${dataAction.result.success}?
   
   IF YES:
   └─ Set Agent Variables:
      - contact_id = ${dataAction.result.contactId}
      - contact_name = ${dataAction.result.firstName} ${dataAction.result.lastName}
      - account_id = ${dataAction.result.accountId}
      - account_name = ${dataAction.result.accountName}
      - customer_tier = ${dataAction.result.tier}
   
   IF NO:
   └─ Play Audio: "Please hold while we locate your account..."
   
   ↓
5. Transfer to Queue: Support Queue
   ↓
6. DISCONNECT

Step 3: Create Desktop Extension

In Genesys CloudIntegrationsCustom AppsDesktop App Extensions:

// manifest.json
{
  "version": "1.0",
  "name": "Salesforce Screen Pop",
  "description": "Pops Salesforce contact on inbound call"
}

// index.html
<!DOCTYPE html>
<html>
<head>
  <script src="https://sdk.mypurecloud.com/v131/platform.min.js"></script>
</head>
<body>
  <script>
    const client = require('purecloud-platform-client-v2');

    // Initialize
    const platformClient = client.ApiClient.instance;
    platformClient.setEnvironment('mypurecloud.com');

    // Listen for incoming interaction
    const notificationService = platformClient.createNotificationService();

    notificationService.subscribe(
      'v2.conversations.{id}',
      async (event) => {
        const interaction = event.eventBody;
        
        // Check if attributes set by flow
        if (interaction.attributes?.contact_id) {
          const contactId = interaction.attributes.contact_id;
          const accountId = interaction.attributes.account_id;
          const contactName = interaction.attributes.contact_name;
          
          // Build Salesforce URL
          const baseUrl = 'https://your-instance.salesforce.com';
          const recordUrl = `${baseUrl}/${contactId}`;
          
          // Pop window
          window.open(recordUrl, 'salesforce_record', 
            'width=1200,height=800,resizable=yes');
          
          console.log(`Screen pop: ${contactName} (${accountId})`);
        }
      }
    );
  </script>
</body>
</html>

Handling Edge Cases

Multiple Matches

Problem: Phone number found 3 contacts (different names, same company)

Solution: Let agent pick

Flow: Decision - ${dataAction.result.matches.length} > 1?
  
  IF YES:
  └─ Play: "Multiple accounts found. Press..."
  └─ Collect Input:
      "Press 1 for John Doe"
      "Press 2 for Jane Doe"
      "Press 3 for John Smith"
  └─ Set contact_id = ${dataAction.result.matches[input]}.id
  
  IF NO:
  └─ Continue normally

Contact Not Found

Problem: Phone number not in Salesforce

Solution: Offer fallback

Flow: Decision - ${dataAction.result.success}?
  
  IF NO:
  └─ Play: "Account not found. Please enter your account number..."
  └─ Collect Input: Account Number
  └─ Data Action: lookup-by-account-number
  └─ Set attributes if found
  └─ Transfer without pop if not found

Slow Lookup (Timeout)

Problem: Salesforce API slow, lookup takes 5+ seconds

Solution: Don't block the call

Flow: Data Action: lookup-contact-by-phone
  Timeout: 3 seconds
  
  Decision: Action succeeded?
    
    IF YES (within 3 sec):
    └─ Set attributes → Transfer
    
    IF NO (timed out):
    └─ Play: "Connecting you now..."
    └─ Transfer WITHOUT attributes
    └─ (Agent can manual search)

Salesforce Field Mapping

What agent sees after screen pop:

Data Point Source Salesforce Record
Contact Name Flow attribute Contact.Name
Email API response Contact.Email
Phone API response Contact.Phone
Account Flow attribute Account.Name
Tier/Priority Flow attribute Contact.Custom_Tier__c
Recent Cases (automatic in SF) Related Cases
Contact History (automatic in SF) Activity Timeline

Performance Optimization

Problem: Lookup taking 2+ seconds

Causes:

Solutions:

  1. Add Index in Salesforce

    -- Make phone lookups fast
    CREATE INDEX idx_contact_phone 
    ON Contact(Phone, MobilePhone);
    
  2. Cache recent lookups

    class ScreenPopCache {
      constructor(ttlSeconds = 3600) {
        this.cache = new Map();
        this.ttl = ttlSeconds;
      }
    
      async lookup(phone) {
        const cached = this.cache.get(phone);
        if (cached && Date.now() - cached.timestamp < this.ttl * 1000) {
          return cached.data;
        }
    
        const result = await callSalesforceAPI(phone);
        this.cache.set(phone, { data: result, timestamp: Date.now() });
        return result;
      }
    }
    
  3. Use webhook instead of polling

    • Salesforce creates contact → webhook updates Genesys
    • (More advanced, requires webhook setup)

Testing Screen Pop

Manual Test

  1. Configure test flow in Architect
  2. Set up desktop extension locally
  3. Make test call to your Genesys DID
  4. Verify:
    • ✓ Architect flow receives call
    • ✓ Data Action executes (check execution history)
    • ✓ Salesforce API returns contact
    • ✓ Flow sets interaction attributes
    • ✓ Desktop receives attributes
    • ✓ Window pops Salesforce record

Automated Test

// test-screen-pop.js

async function testScreenPop() {
  // 1. Test Salesforce lookup
  const contact = await lookupContactByPhone('+15551234567');
  console.assert(contact.id, 'Contact not found');

  // 2. Test Genesys Data Action
  const result = await callDataAction('lookup-contact-by-phone', {
    phoneNumber: '+15551234567'
  });
  console.assert(result.success, 'Data Action failed');

  // 3. Test flow
  const call = await makeTestCall('+15551234567');
  const attributes = call.attributes;
  console.assert(attributes.contact_id, 'No contact_id in attributes');

  console.log('✓ Screen pop test passed');
}

Troubleshooting

Problem: Desktop doesn't pop window

Check:

Debug:

// Add logging to desktop extension
genesysClient.on('interaction.incoming', (interaction) => {
  console.log('Attributes:', interaction.attributes);
  console.log('Contact ID:', interaction.attributes?.contact_id);
});

Problem: Lookup returns "Contact not found" for valid phone

Check:

Debug:

// Test in Salesforce Developer Console
List<Contact> contacts = [
  SELECT Id FROM Contact 
  WHERE Phone = '+15551234567'
];
System.debug(contacts.size() + ' contacts found');

Problem: Performance is slow

Check:

Optimize:


Production Deployment Checklist


Contact Sync Patterns

Overview

Contact sync keeps Genesys and your CRM (Salesforce, Dynamics, etc.) in sync. This guide covers two approaches:

  1. One-way sync (CRM → Genesys, simpler, recommended first)
  2. Bi-directional sync (both ways, complex, conflict resolution needed)

Strategy 1: One-Way Sync (CRM → Genesys)

Concept

Salesforce is the master. Genesys is a read-only mirror.

Salesforce (Master)
    ↓ (one direction)
Genesys (Mirror)

If contact changes in SF → update Genesys
If contact changes in Genesys → ignore (no write-back)

Pros

Cons

Sync Flow

Every 30 minutes:
  ↓
1. Query SF for changes:
   "SELECT * FROM Contact WHERE LastModifiedDate >= 30 min ago"
  ↓
2. For each contact:
   a. Check if exists in Genesys (by email, phone, external ID)
   b. If exists → PATCH (update only changed fields)
   c. If not exists → POST (create new)
  ↓
3. Log results (created, updated, skipped, errors)
  ↓
4. Alert if failures

Implementation

// sync-one-way.js

async function syncSalesforceToGenesys() {
  // 1. Fetch modified contacts from Salesforce
  const sfContacts = await querySalesforce(`
    SELECT Id, FirstName, LastName, Email, Phone
    FROM Contact
    WHERE LastModifiedDate >= LAST_N_MINUTES:30
  `);

  // 2. Get existing Genesys contacts
  const genesysContacts = await fetchGenesysContacts();
  const emailIndex = indexBy(genesysContacts, 'email');

  // 3. Process each SF contact
  let created = 0, updated = 0;
  
  for (const sfContact of sfContacts) {
    const genesysContact = emailIndex.get(sfContact.Email);

    if (genesysContact) {
      // Update existing
      await patchContact(genesysContact.id, {
        firstName: sfContact.FirstName,
        lastName: sfContact.LastName,
        phoneNumbers: [{ number: sfContact.Phone, type: 'WORK' }]
      });
      updated++;
    } else {
      // Create new
      await postContact({
        firstName: sfContact.FirstName,
        lastName: sfContact.LastName,
        email: sfContact.Email,
        phoneNumbers: [{ number: sfContact.Phone, type: 'WORK' }],
        externalId: sfContact.Id  // Link back
      });
      created++;
    }
  }

  console.log(`Created: ${created}, Updated: ${updated}`);
}

When to Use


Strategy 2: Bi-Directional Sync (Both Ways)

Concept

Both Salesforce AND Genesys can be updated. Sync runs both directions:

Salesforce ←→ Genesys

If SF updated → update Genesys
If Genesys updated → update Salesforce
If both updated at same time → conflict!

Pros

Cons

Conflict Resolution Strategies

Strategy A: Last-Write-Wins

Simple but risky:

Contact updated in both systems at same time:
  SF:     phone changed to 555-1111
  Genesys: phone changed to 555-2222

Winner: Whoever was modified LAST
Loser: Other change is overwritten

Problem: Unpredictable. User's change might be lost.

Strategy B: Field-Level Priority

Different fields have different sources:

contact_name:   SF always wins (SF is master for names)
phone_number:   Last-write-wins (allow edits in both)
tags:           Genesys always wins (agent tags)

Strategy C: Timestamp-Based with Locks

More robust:

1. Read contact with timestamp from both systems
2. Check: Who modified last?
   SF time > Genesys time → SF wins
   Genesys time > SF time → Genesys wins
3. Write winner's data to loser's system
4. Log conflict for human review

Implementation

// sync-bidirectional.js

async function syncBidirectional() {
  // 1. Fetch from both systems
  const sfContacts = await querySalesforce(`
    SELECT Id, FirstName, LastName, Email, LastModifiedDate
    FROM Contact
  `);

  const genesysContacts = await fetchGenesysContacts();

  // 2. Index for matching
  const emailIndex = new Map();
  for (const contact of [...sfContacts, ...genesysContacts]) {
    if (!emailIndex.has(contact.email)) {
      emailIndex.set(contact.email, []);
    }
    emailIndex.get(contact.email).push(contact);
  }

  // 3. Detect conflicts and sync
  let conflicts = [];

  for (const [email, records] of emailIndex) {
    if (records.length === 1) {
      // Only in one system, create in other
      await syncOneWay(records[0]);
    } else if (records.length === 2) {
      // In both systems, detect conflict
      const sf = records.find(r => r.system === 'salesforce');
      const gz = records.find(r => r.system === 'genesys');

      const winner = resolveConflict(sf, gz);

      if (winner === sf) {
        // SF wins, update Genesys
        await updateGenesys(gz.id, sf.data);
      } else {
        // Genesys wins, update SF
        await updateSalesforce(sf.id, gz.data);
      }

      // Log for audit
      if (sf.lastModifiedDate !== gz.dateModified) {
        conflicts.push({ email, sf, gz, winner });
      }
    }
  }

  // 4. Alert on conflicts
  if (conflicts.length > 0) {
    await alertConflicts(conflicts);
  }
}

function resolveConflict(sfContact, gzContact) {
  // Last-write-wins
  const sfTime = new Date(sfContact.LastModifiedDate);
  const gzTime = new Date(gzContact.dateModified);

  if (sfTime > gzTime) {
    console.log(`Conflict: SF wins for ${sfContact.Email}`);
    return sfContact;
  } else {
    console.log(`Conflict: Genesys wins for ${sfContact.Email}`);
    return gzContact;
  }
}

Conflict Resolution Workflow

Conflict Detected
    ↓
Log both versions (SF and Genesys)
    ↓
Apply resolution strategy
    ├─ Last-write-wins?
    ├─ Field-level priority?
    └─ Manual approval?
    ↓
Update loser system
    ↓
Send alert to admin
    ↓
Update audit log

Data Deduplication

Problem: Same person, multiple records

Salesforce:
  Record 1: john.doe@example.com
  Record 2: JDoe@example.com (same person)

Genesys would create 2 records!

Solution: Match on Multiple Fields

function findMatchingContact(sfContact, genesysContacts) {
  // Try email (best match)
  let match = genesysContacts.find(
    gc => gc.email?.toLowerCase() === sfContact.Email.toLowerCase()
  );
  if (match) return match;

  // Try phone (second best)
  match = genesysContacts.find(
    gc => gc.phoneNumbers?.[0]?.number === sfContact.Phone
  );
  if (match) return match;

  // Try first + last name (risky, could be duplicate)
  match = genesysContacts.find(
    gc => gc.firstName === sfContact.FirstName &&
          gc.lastName === sfContact.LastName &&
          gc.externalOrganization?.id === sfContact.AccountId
  );
  if (match) return match;

  // Not found
  return null;
}

Best Practice: External IDs

Salesforce Contact:
  ID: 003xx000003SG
  Email: john@example.com

Genesys Contact:
  ID: contact-123
  externalId: "003xx000003SG"  ← Link back
  email: john@example.com

On next sync, use externalId to find exact match.


Field Mapping Reference

Salesforce Genesys Sync Direction Conflicts
Contact.Id externalId SF → GZ SF (master)
Contact.FirstName firstName Both SF (master)
Contact.LastName lastName Both SF (master)
Contact.Email email Both Last-write
Contact.Phone phoneNumbers[0] Both Last-write
Contact.AccountId org.externalId SF → GZ SF
Contact.LeadScore N/A SF → GZ N/A
Contact.Tags (custom) attributes.tags Both GZ wins

Sync Frequency Trade-offs

Frequency Pros Cons Use Case
Every 5 min Real-time High API load, cost Critical data
Every 15 min Near real-time Medium load Normal ops
Every 30 min Balanced Slight delay Standard
Hourly Low cost 1hr delay Low priority
Daily Very low cost 24hr delay Batch jobs

Recommendation: Start with 30 minutes, adjust based on needs.


Monitoring Sync Health

class SyncMonitor {
  constructor() {
    this.history = [];
  }

  recordSync(result) {
    this.history.push({
      timestamp: new Date(),
      created: result.created,
      updated: result.updated,
      errors: result.errors,
      duration: result.duration,
      conflicts: result.conflicts
    });

    // Alert if too many errors
    if (result.errors.length > 10) {
      this.alertHighErrorRate(result);
    }

    // Alert if sync slow
    if (result.duration > 5 * 60 * 1000) { // 5 minutes
      this.alertSlowSync(result);
    }
  }

  getHealthScore() {
    const recent = this.history.slice(-10); // Last 10 syncs
    const avgErrors = recent.reduce((sum, h) => sum + h.errors.length, 0) / recent.length;
    const avgDuration = recent.reduce((sum, h) => sum + h.duration, 0) / recent.length;

    const score = 100 - (avgErrors * 5) - (avgDuration / 1000); // Deduct for errors and slow
    return Math.max(0, score);
  }
}

When to Use Which Strategy

One-Way Sync (Recommended First)

Use if:

Bi-Directional Sync

Use if:


Migration Path

Phase 1: One-Way

Phase 2: Expand

Phase 3: Bi-Directional

Phase 4: Optimize


Activity Logging & Webhooks

Overview

Activity logging means capturing what happened during a call and writing it back to your CRM. After the call ends, Genesys sends details to Salesforce so agents can track customer interactions.

Benefit: Single source of truth. All customer interactions visible in CRM.


The Flow

Call Happens
    ↓
Agent handles call (duration, queue, notes)
    ↓
Call ends
    ↓
Genesys webhook: conversation.ended
    ↓
Your backend receives webhook
    ↓
Fetch conversation details:
  - Caller phone
  - Duration
  - Agent name
  - Recording URL
  - Start/end time
    ↓
Find matching Salesforce contact (by phone)
    ↓
Create Salesforce Task:
  Subject: "Call with John Doe"
  Description: "Duration: 10 min..."
  Recording: [link]
  WhoId: Contact ID
  WhatId: Account ID
    ↓
Update Contact:
  LastActivityDate = today
  Last_Call_Date = today
    ↓
Success! Call logged in CRM

Setup: Genesys Webhook

In Genesys Admin

  1. Go to IntegrationsWebhooks
  2. Click Add Webhook
  3. Configure:
    • Event: conversation.ended
    • URL: https://your-server.com/webhook/call-ended
    • Payload: Send call details
    • Retry: 3 times if fails

Webhook Payload (What Genesys Sends)

{
  "conversationId": "conversation-111",
  "participantId": "user-agent-1",
  "conversationType": "phone",
  "callerId": "+15551234567",
  "calleeId": "+18001234567",
  "direction": "INBOUND",
  "startTime": "2026-03-14T10:00:00Z",
  "endTime": "2026-03-14T10:10:30Z",
  "durationSeconds": 630,
  "recordingId": "recording-222",
  "agentId": "user-agent-1",
  "agentName": "Agent Smith",
  "queueId": "queue-456",
  "queueName": "Support Queue",
  "interactionId": "interaction-123",
  "attributes": {
    "contact_id": "003xx000003SG",
    "account_id": "001xx000002Edc",
    "customer_tier": "Premium"
  }
}

Implementation: Webhook Handler

Node.js Server

const express = require('express');
const axios = require('axios');
require('dotenv').config();

const app = express();
app.use(express.json());

/**
 * Webhook endpoint - Genesys calls this when call ends
 */
app.post('/webhook/call-ended', async (req, res) => {
  const {
    conversationId,
    callerId,
    durationSeconds,
    recordingId,
    agentName,
    queueName,
    startTime,
    endTime,
    attributes
  } = req.body;

  console.log(`📞 Call ended: ${conversationId}, Duration: ${durationSeconds}s`);

  try {
    // 1. Find matching Salesforce contact
    const contact = await findSalesforceContactByPhone(callerId);

    if (!contact) {
      console.warn(`⚠️ Contact not found for ${callerId}`);
      return res.status(200).json({ message: 'Contact not found' });
    }

    // 2. Get recording URL
    let recordingUrl = null;
    if (recordingId) {
      recordingUrl = await getRecordingUrl(recordingId);
    }

    // 3. Create Task in Salesforce
    const task = {
      Subject: `Call with ${contact.Name}`,
      Description: `
Inbound Call from ${callerId}
Duration: ${Math.floor(durationSeconds / 60)} minutes
Agent: ${agentName}
Queue: ${queueName}
Start: ${startTime}
End: ${endTime}
${recordingUrl ? `Recording: ${recordingUrl}` : ''}
      `.trim(),
      WhoId: contact.Id,          // Contact ID
      WhatId: contact.AccountId,  // Account ID
      ActivityDate: new Date().toISOString().split('T')[0],
      CallDurationInSeconds: durationSeconds,
      CallType: 'Inbound',
      Status: 'Completed',
      Type: 'Call'
    };

    const taskResult = await createSalesforceTask(task);
    console.log(`✅ Task created: ${taskResult.id}`);

    // 4. Update Contact's LastActivityDate
    await updateSalesforceContact(contact.Id, {
      LastActivityDate: new Date().toISOString().split('T')[0],
      Last_Call_Date__c: new Date().toISOString(),
      Last_Call_Duration__c: durationSeconds
    });

    res.status(200).json({ taskId: taskResult.id });

  } catch (error) {
    console.error('❌ Failed to log activity:', error.message);
    res.status(500).json({ error: error.message });
  }
});

/**
 * Find Salesforce contact by phone number
 */
async function findSalesforceContactByPhone(phoneNumber) {
  const query = `
    SELECT Id, Name, AccountId, Email
    FROM Contact
    WHERE Phone = '${phoneNumber}'
       OR MobilePhone = '${phoneNumber}'
    LIMIT 1
  `;

  const response = await axios.get(
    `${process.env.SALESFORCE_INSTANCE}/services/data/v57.0/query`,
    {
      params: { q: query },
      headers: {
        'Authorization': `Bearer ${process.env.SALESFORCE_TOKEN}`,
        'Content-Type': 'application/json'
      }
    }
  );

  return response.data.records[0] || null;
}

/**
 * Get Genesys recording URL
 */
async function getRecordingUrl(recordingId) {
  const response = await axios.get(
    `https://api.mypurecloud.com/api/v2/recordings/${recordingId}/media`,
    {
      headers: {
        'Authorization': `Bearer ${process.env.GENESYS_TOKEN}`
      },
      maxRedirects: 0,
      validateStatus: status => status === 307  // Expect redirect
    }
  );

  // Returns presigned S3 URL in Location header
  return response.headers.location || null;
}

/**
 * Create Salesforce Task
 */
async function createSalesforceTask(taskData) {
  const response = await axios.post(
    `${process.env.SALESFORCE_INSTANCE}/services/data/v57.0/sobjects/Task`,
    taskData,
    {
      headers: {
        'Authorization': `Bearer ${process.env.SALESFORCE_TOKEN}`,
        'Content-Type': 'application/json'
      }
    }
  );

  return response.data;
}

/**
 * Update Salesforce Contact
 */
async function updateSalesforceContact(contactId, updateData) {
  const response = await axios.patch(
    `${process.env.SALESFORCE_INSTANCE}/services/data/v57.0/sobjects/Contact/${contactId}`,
    updateData,
    {
      headers: {
        'Authorization': `Bearer ${process.env.SALESFORCE_TOKEN}`,
        'Content-Type': 'application/json'
      }
    }
  );

  return response.data;
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Webhook server running on port ${PORT}`);
});

Data Mapping

Call → Salesforce Task

Genesys Data Salesforce Field Mapping
callerId (phone) Task subject "Call with {contact.Name}"
durationSeconds CallDurationInSeconds Direct
startTime Task description Include timestamp
endTime Task description Include timestamp
recordingId Task description Link to recording
agentName Task description "Agent: {name}"
queueName Task description "Queue: {name}"
Contact ID (from lookup) WhoId Links to contact
Account ID (from lookup) WhatId Links to account
Current date ActivityDate Today's date

Advanced: Logging with Custom Fields

Salesforce custom fields capture more data:

// Salesforce custom fields setup
const task = {
  // Standard fields
  Subject: `Call with ${contact.Name}`,
  WhoId: contact.Id,
  WhatId: contact.AccountId,
  
  // Custom fields
  Call_Type__c: 'Inbound',
  Call_Queue__c: queueName,
  Call_Agent__c: agentName,
  Call_Duration_Seconds__c: durationSeconds,
  Call_Recording_URL__c: recordingUrl,
  Call_Channel__c: 'Phone',
  Customer_Tier__c: attributes?.customer_tier,
  Was_Transferred__c: false,
  Call_Outcome__c: 'Completed',
  Agent_Notes__c: attributes?.notes
};

// Custom field names end with __c (Salesforce convention)

Error Handling

Problem: Contact Not Found

// Option 1: Skip (don't log if no contact)
if (!contact) {
  console.warn(`No contact for ${callerId}`);
  return res.status(200).json({ message: 'Contact not found' });
}

// Option 2: Create contact
if (!contact) {
  contact = await createSalesforceContact({
    LastName: callerId,  // Use phone as fallback
    Phone: callerId
  });
}

// Option 3: Log to generic "unknown caller" account
if (!contact) {
  contact = { Id: UNKNOWN_CALLER_ACCOUNT };
}

Problem: Recording URL Timeout

async function getRecordingUrlSafe(recordingId, timeoutMs = 3000) {
  try {
    const response = await axios.get(
      `https://api.mypurecloud.com/api/v2/recordings/${recordingId}/media`,
      {
        headers: { 'Authorization': `Bearer ${token}` },
        timeout: timeoutMs
      }
    );
    return response.headers.location || null;
  } catch (error) {
    if (error.code === 'ECONNABORTED') {
      console.warn('Recording URL timeout');
      return null;  // Don't block task creation
    }
    throw error;
  }
}

Problem: Task Creation Fails

async function createTaskWithRetry(task, maxAttempts = 3) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const result = await createSalesforceTask(task);
      console.log(`✅ Task created: ${result.id}`);
      return result;
    } catch (error) {
      if (attempt === maxAttempts) {
        console.error(`❌ Failed after ${maxAttempts} attempts`);
        throw error;
      }
      
      const delayMs = 1000 * Math.pow(2, attempt - 1); // Exponential backoff
      console.warn(`Retry in ${delayMs}ms...`);
      await sleep(delayMs);
    }
  }
}

Webhook Security

Verify Genesys Signature

Genesys signs webhooks so you can verify they're legitimate:

const crypto = require('crypto');

/**
 * Verify Genesys webhook signature
 */
function verifyWebhookSignature(request, secret) {
  const signature = request.headers['x-genesys-webhook-signature'];
  const timestamp = request.headers['x-genesys-webhook-timestamp'];
  
  if (!signature || !timestamp) {
    throw new Error('Missing signature or timestamp header');
  }

  // Reconstruct the signed string
  const body = request.rawBody; // Must be raw, not parsed
  const signedString = `${timestamp}.${body}`;

  // Calculate HMAC
  const hash = crypto
    .createHmac('sha256', secret)
    .update(signedString)
    .digest('base64');

  // Verify
  if (hash !== signature) {
    throw new Error('Webhook signature invalid');
  }

  return true;
}

// Middleware to verify all webhooks
app.use((req, res, next) => {
  req.rawBody = req.body;  // Keep raw body
  next();
});

app.post('/webhook/call-ended', (req, res, next) => {
  try {
    verifyWebhookSignature(req, process.env.GENESYS_WEBHOOK_SECRET);
    next();
  } catch (error) {
    console.error('❌ Webhook signature verification failed:', error.message);
    return res.status(401).json({ error: 'Unauthorized' });
  }
});

Monitoring & Alerts

class ActivityLoggingMonitor {
  constructor() {
    this.stats = {
      totalCalls: 0,
      logsCreated: 0,
      logsFailed: 0,
      contactsNotFound: 0,
      avgProcessingTime: 0
    };
  }

  recordSuccess(processingTimeMs) {
    this.stats.totalCalls++;
    this.stats.logsCreated++;
    this.updateAvgTime(processingTimeMs);
  }

  recordFailure(reason) {
    this.stats.totalCalls++;
    this.stats.logsFailed++;
    
    if (reason === 'contact_not_found') {
      this.stats.contactsNotFound++;
    }

    // Alert if too many failures
    const failureRate = this.stats.logsFailed / this.stats.totalCalls;
    if (failureRate > 0.05) { // > 5%
      this.alertHighFailureRate(failureRate);
    }
  }

  updateAvgTime(newTime) {
    const count = this.stats.logsCreated;
    this.stats.avgProcessingTime = 
      (this.stats.avgProcessingTime * (count - 1) + newTime) / count;
  }

  reportHealth() {
    return `
Activity Logging Health:
  Total calls: ${this.stats.totalCalls}
  Logged: ${this.stats.logsCreated}
  Failed: ${this.stats.logsFailed}
  Not found: ${this.stats.contactsNotFound}
  Avg time: ${this.stats.avgProcessingTime.toFixed(0)}ms
  Success rate: ${((this.stats.logsCreated / this.stats.totalCalls) * 100).toFixed(1)}%
    `;
  }
}

Production Checklist


Bi-Directional Sync with Conflict Resolution

Overview

Bi-directional sync means changes flow both ways: Salesforce → Genesys AND Genesys → Salesforce. This is more complex than one-way sync because conflicts can occur when both systems are updated simultaneously.

Conflict Example:

Same contact updated at same time:
  Salesforce: phone changed to +15551111111 at 10:00:05
  Genesys: phone changed to +15552222222 at 10:00:06

Question: Which phone number wins?

Conflict Resolution Strategies

Strategy 1: Last-Write-Wins (Simplest)

The system modified MOST RECENTLY wins:

function resolveConflict(sfContact, gzContact) {
  const sfTime = new Date(sfContact.LastModifiedDate).getTime();
  const gzTime = new Date(gzContact.dateModified).getTime();

  if (sfTime > gzTime) {
    console.log(`Conflict: SF wins (${sfTime} > ${gzTime})`);
    return { winner: 'salesforce', action: 'update_genesys' };
  } else {
    console.log(`Conflict: Genesys wins (${gzTime} >= ${sfTime})`);
    return { winner: 'genesys', action: 'update_salesforce' };
  }
}

Pros:

Cons:


Strategy 2: Field-Level Priority

Different fields have different masters:

const fieldPriority = {
  // Names are always SF (master data)
  'firstName': 'salesforce',
  'lastName': 'salesforce',

  // Phone can be updated in either (last-write-wins)
  'phoneNumbers': 'last_write',
  'email': 'last_write',

  // Tags are Genesys (agent annotations)
  'tags': 'genesys',
  'notes': 'genesys',

  // Organization always SF
  'organization': 'salesforce'
};

function applyFieldLevelResolution(sfContact, gzContact) {
  const merged = {};

  for (const field in fieldPriority) {
    const priority = fieldPriority[field];

    if (priority === 'salesforce') {
      // Always use SF value
      merged[field] = sfContact[field];
    } else if (priority === 'genesys') {
      // Always use Genesys value
      merged[field] = gzContact[field];
    } else if (priority === 'last_write') {
      // Use whoever was modified last
      const sfTime = sfContact.LastModifiedDate || 0;
      const gzTime = gzContact.dateModified || 0;
      merged[field] = new Date(sfTime) > new Date(gzTime) 
        ? sfContact[field] 
        : gzContact[field];
    }
  }

  return merged;
}

Pros:

Cons:


Strategy 3: Timestamp-Based with Locking

Most robust: lock during sync, use timestamps, log everything:

async function syncWithLocking(sfContact, gzContact) {
  // 1. Lock both records (prevent other changes during sync)
  await lockSalesforceRecord(sfContact.Id);
  await lockGenesysRecord(gzContact.id);

  try {
    // 2. Detect if either was modified since we last synced
    const lastSyncTime = new Date(sfContact.Last_Sync__c);
    const sfModified = new Date(sfContact.LastModifiedDate) > lastSyncTime;
    const gzModified = new Date(gzContact.dateModified) > lastSyncTime;

    if (!sfModified && !gzModified) {
      // No changes, nothing to do
      return { status: 'no_change' };
    }

    if (sfModified && !gzModified) {
      // Only SF changed, update Genesys
      await updateGenesysContact(gzContact.id, sfContact);
      return { status: 'sf_updated_gz' };
    }

    if (!sfModified && gzModified) {
      // Only Genesys changed, update SF
      await updateSalesforceContact(sfContact.Id, gzContact);
      return { status: 'gz_updated_sf' };
    }

    // CONFLICT: Both changed
    const resolution = await resolveConflictWithLogging(sfContact, gzContact);
    
    if (resolution.winner === 'salesforce') {
      await updateGenesysContact(gzContact.id, sfContact);
    } else {
      await updateSalesforceContact(sfContact.Id, gzContact);
    }

    // 3. Log conflict for audit
    await logConflict({
      timestamp: new Date(),
      contactId: sfContact.Id,
      sfLastModified: sfContact.LastModifiedDate,
      gzLastModified: gzContact.dateModified,
      winner: resolution.winner,
      differences: resolution.differences,
      action: resolution.action
    });

    return { status: 'conflict_resolved', winner: resolution.winner };

  } finally {
    // 4. Unlock records
    await unlockSalesforceRecord(sfContact.Id);
    await unlockGenesysRecord(gzContact.id);
  }
}

Pros:

Cons:


Implementation: Full Bi-Directional Sync

class BidirectionalSync {
  constructor(config) {
    this.config = config;
    this.stats = {
      synced: 0,
      conflicts: 0,
      errors: 0
    };
    this.conflicts = [];
  }

  /**
   * Main sync method
   */
  async runSync() {
    console.log('\n=== BIDIRECTIONAL SYNC START ===');

    try {
      // 1. Fetch from both systems
      const sfContacts = await this.fetchAllSalesforceContacts();
      const gzContacts = await this.fetchAllGenesysContacts();

      console.log(`Salesforce: ${sfContacts.length} contacts`);
      console.log(`Genesys: ${gzContacts.length} contacts`);

      // 2. Index for matching
      const sfById = new Map(sfContacts.map(c => [c.Id, c]));
      const sfByEmail = new Map(sfContacts.map(c => [c.Email?.toLowerCase(), c]));
      const gzByExtId = new Map(gzContacts.map(c => [c.externalId, c]));

      // 3. Find and sync all contact pairs
      const synced = new Set();

      // Process Salesforce contacts
      for (const sf of sfContacts) {
        let gz = gzByExtId.get(sf.Id) // Match by externalId first
               || sfByEmail.get(sf.Email?.toLowerCase()); // Then by email

        if (gz) {
          // Both systems have it
          await this.syncPair(sf, gz);
          synced.add(sf.Id);
        } else {
          // Only in Salesforce, create in Genesys
          await this.createInGenesys(sf);
          synced.add(sf.Id);
        }
      }

      // Process Genesys-only contacts
      for (const gz of gzContacts) {
        if (!synced.has(gz.externalId)) {
          // Only in Genesys, create in Salesforce
          await this.createInSalesforce(gz);
        }
      }

      this.printResults();

    } catch (error) {
      console.error('❌ SYNC FAILED:', error);
      throw error;
    }
  }

  /**
   * Sync a contact that exists in both systems
   */
  async syncPair(sfContact, gzContact) {
    try {
      // Detect changes
      const sfModified = this.isModifiedSinceLast(sfContact);
      const gzModified = this.isModifiedSinceLast(gzContact);

      if (!sfModified && !gzModified) {
        // No changes
        return;
      }

      if (sfModified && !gzModified) {
        // SF changed, update Genesys
        await this.updateGenesys(gzContact.id, sfContact);
        this.stats.synced++;
        return;
      }

      if (!sfModified && gzModified) {
        // Genesys changed, update SF
        await this.updateSalesforce(sfContact.Id, gzContact);
        this.stats.synced++;
        return;
      }

      // CONFLICT: Both changed
      await this.handleConflict(sfContact, gzContact);

    } catch (error) {
      console.error(`Error syncing ${sfContact.Id}:`, error);
      this.stats.errors++;
    }
  }

  /**
   * Handle conflict between SF and Genesys
   */
  async handleConflict(sfContact, gzContact) {
    const resolution = this.resolveConflict(sfContact, gzContact);

    console.warn(`⚠️ CONFLICT for ${sfContact.Email}:`);
    console.warn(`   SF: ${sfContact.LastModifiedDate}`);
    console.warn(`   GZ: ${gzContact.dateModified}`);
    console.warn(`   Winner: ${resolution.winner}`);

    // Apply resolution
    if (resolution.winner === 'salesforce') {
      await this.updateGenesys(gzContact.id, sfContact);
    } else {
      await this.updateSalesforce(sfContact.Id, gzContact);
    }

    // Log for review
    this.conflicts.push({
      email: sfContact.Email,
      sfTime: sfContact.LastModifiedDate,
      gzTime: gzContact.dateModified,
      winner: resolution.winner,
      changes: resolution.changes
    });

    this.stats.conflicts++;
    this.stats.synced++;
  }

  /**
   * Resolve conflict (strategy: last-write-wins with field priority)
   */
  resolveConflict(sfContact, gzContact) {
    const fieldPriority = {
      firstName: 'sf',
      lastName: 'sf',
      email: 'last_write',
      phoneNumbers: 'last_write',
      externalOrganization: 'sf',
      tags: 'gz',
      attributes: 'last_write'
    };

    const sfTime = new Date(sfContact.LastModifiedDate).getTime();
    const gzTime = new Date(gzContact.dateModified).getTime();
    const overallWinner = sfTime > gzTime ? 'salesforce' : 'genesys';

    const merged = {};
    const changes = {};

    for (const field in fieldPriority) {
      const priority = fieldPriority[field];
      let value;

      if (priority === 'sf') {
        value = sfContact[field];
      } else if (priority === 'gz') {
        value = gzContact[field];
      } else {
        // last_write
        value = sfTime > gzTime ? sfContact[field] : gzContact[field];
      }

      if (sfContact[field] !== gzContact[field]) {
        changes[field] = {
          sf: sfContact[field],
          gz: gzContact[field],
          winner: priority === 'sf' ? 'sf' : priority === 'gz' ? 'gz' : overallWinner
        };
      }

      merged[field] = value;
    }

    return {
      winner: overallWinner,
      merged,
      changes
    };
  }

  /**
   * Check if contact was modified since last sync
   */
  isModifiedSinceLast(contact) {
    if (contact.Last_Sync__c) {
      const lastSync = new Date(contact.Last_Sync__c).getTime();
      const lastMod = new Date(contact.LastModifiedDate || contact.dateModified).getTime();
      return lastMod > lastSync;
    }
    return true; // Assume modified if no last sync
  }

  /**
   * Update Genesys with SF data
   */
  async updateGenesys(gzId, sfContact) {
    const updateData = {
      firstName: sfContact.FirstName,
      lastName: sfContact.LastName,
      email: sfContact.Email,
      phoneNumbers: sfContact.Phone ? 
        [{ number: sfContact.Phone, type: 'WORK' }] : [],
      externalId: sfContact.Id  // Keep the link
    };

    await axios.patch(
      `https://api.mypurecloud.com/api/v2/contacts/${gzId}`,
      updateData,
      { headers: { 'Authorization': `Bearer ${this.gzToken}` } }
    );

    console.log(`  Updated Genesys: ${gzId}`);
  }

  /**
   * Update Salesforce with Genesys data
   */
  async updateSalesforce(sfId, gzContact) {
    const updateData = {
      FirstName: gzContact.firstName,
      LastName: gzContact.lastName,
      Email: gzContact.email,
      Phone: gzContact.phoneNumbers?.[0]?.number,
      Last_Sync__c: new Date().toISOString()
    };

    await axios.patch(
      `${process.env.SALESFORCE_INSTANCE}/services/data/v57.0/sobjects/Contact/${sfId}`,
      updateData,
      { headers: { 'Authorization': `Bearer ${this.sfToken}` } }
    );

    console.log(`  Updated Salesforce: ${sfId}`);
  }

  /**
   * Create in Genesys (from Salesforce)
   */
  async createInGenesys(sfContact) {
    const newContact = {
      firstName: sfContact.FirstName,
      lastName: sfContact.LastName,
      email: sfContact.Email,
      phoneNumbers: sfContact.Phone ?
        [{ number: sfContact.Phone, type: 'WORK' }] : [],
      externalId: sfContact.Id
    };

    const response = await axios.post(
      `https://api.mypurecloud.com/api/v2/contacts`,
      newContact,
      { headers: { 'Authorization': `Bearer ${this.gzToken}` } }
    );

    console.log(`  Created in Genesys: ${response.data.id}`);
    this.stats.synced++;
  }

  /**
   * Create in Salesforce (from Genesys)
   */
  async createInSalesforce(gzContact) {
    const newContact = {
      FirstName: gzContact.firstName,
      LastName: gzContact.lastName,
      Email: gzContact.email,
      Phone: gzContact.phoneNumbers?.[0]?.number,
      Last_Sync__c: new Date().toISOString()
    };

    const response = await axios.post(
      `${process.env.SALESFORCE_INSTANCE}/services/data/v57.0/sobjects/Contact`,
      newContact,
      { headers: { 'Authorization': `Bearer ${this.sfToken}` } }
    );

    // Update Genesys with SF ID (externalId)
    await this.updateGenesys(gzContact.id, { ...gzContact, Id: response.data.id });

    console.log(`  Created in Salesforce: ${response.data.id}`);
    this.stats.synced++;
  }

  /**
   * Print results
   */
  printResults() {
    console.log('\n=== SYNC RESULTS ===');
    console.log(`✓ Synced: ${this.stats.synced}`);
    console.log(`⚠️ Conflicts: ${this.stats.conflicts}`);
    console.log(`✗ Errors: ${this.stats.errors}`);

    if (this.conflicts.length > 0) {
      console.log('\nConflicts (for review):');
      this.conflicts.forEach(c => {
        console.log(`  ${c.email}: ${c.winner} won`);
      });
    }
  }
}

// Usage
const sync = new BidirectionalSync(config);
await sync.runSync();

Deduplication Across Systems

By External ID (Best)

// Each contact has ID from the other system
Salesforce Contact:
  Id: 003xx000003SG
  External_Genesys_ID__c: "contact-123"

Genesys Contact:
  Id: contact-123
  externalId: "003xx000003SG"

// Match on these, never duplicate

By Email (Good)

const sfContact = sfContacts.find(c => c.Email === gzContact.email);

// Assumes email is unique per contact
// Risk: Same person with multiple emails

By Phone (Risky)

const sfContact = sfContacts.find(c => c.Phone === gzContact.phoneNumbers[0]?.number);

// Risk: Multiple people sharing phone (family, shared line)

Monitoring & Alerts

class SyncMonitor {
  constructor() {
    this.history = [];
  }

  recordSync(result) {
    this.history.push({
      timestamp: new Date(),
      synced: result.synced,
      conflicts: result.conflicts,
      errors: result.errors
    });

    // Alert if many conflicts
    if (result.conflicts > 10) {
      console.warn(`⚠️ HIGH CONFLICT RATE: ${result.conflicts} conflicts`);
      this.alertOps(result);
    }

    // Alert if errors
    if (result.errors > 5) {
      console.error(`❌ HIGH ERROR RATE: ${result.errors} errors`);
      this.alertOps(result);
    }
  }

  getConflictRate() {
    const recent = this.history.slice(-10);
    const totalConflicts = recent.reduce((sum, h) => sum + h.conflicts, 0);
    const totalSynced = recent.reduce((sum, h) => sum + h.synced, 0);
    return totalConflicts / totalSynced;
  }
}

Best Practices

  1. Add Last_Sync timestamp to both systems

    • Used to detect what changed since last sync
    • Needed for conflict detection
  2. Use External IDs

    • SF: External_Genesys_ID__c
    • Genesys: externalId
    • Primary way to match contacts
  3. Log all conflicts

    • Build audit trail
    • Manual review capability
    • Alerts ops team
  4. Test thoroughly

    • What if SF and GZ both update same field?
    • What if network fails mid-sync?
    • What if token expires?
  5. Start one-way, migrate to bi-directional

    • Less risk
    • Easier to debug
    • Can add complexity later

GDPR & Data Governance in CRM Integration

Overview

When syncing contact data between Genesys and Salesforce, you handle personal data (PII - Personally Identifiable Information). GDPR, CCPA, and similar regulations require proper handling, deletion, and consent management.

Key Regulations:


PII Data Types

What's PII?

Information that can identify a person:

✓ PII - Don't store longer than needed:
  - Full name
  - Email address
  - Phone number
  - Address
  - Social Security Number
  - Payment card info
  - Biometric data
  - IP address + timestamp

✗ NOT PII - Can store longer:
  - Company name
  - Job title
  - Aggregated analytics (no individuals)
  - Anonymized data (truly de-identified)

GDPR Right to Deletion ("Right to be Forgotten")

Requirement

Customer asks: "Delete all my data"

You MUST:

  1. Delete from Salesforce
  2. Delete from Genesys
  3. Delete from call recordings
  4. Delete from transcripts
  5. Delete from logs/backups (after retention period)

Implementation

/**
 * GDPR: Delete all data for a contact
 */
async function deleteContactCompletelyGDPR(email) {
  console.log(`🛑 GDPR Deletion: ${email}`);

  try {
    // 1. Find contact in both systems
    const sfContact = await findSalesforceContactByEmail(email);
    const gzContact = await findGenesysContactByEmail(email);

    if (!sfContact && !gzContact) {
      console.log(`  No contact found for ${email}`);
      return { status: 'not_found' };
    }

    // 2. Delete from Salesforce
    if (sfContact) {
      console.log(`  Deleting Salesforce contact: ${sfContact.Id}`);
      await axios.delete(
        `${process.env.SALESFORCE_INSTANCE}/services/data/v57.0/sobjects/Contact/${sfContact.Id}`,
        { headers: { 'Authorization': `Bearer ${this.sfToken}` } }
      );

      // Also delete associated records
      await deleteAssociatedSalesforceRecords(sfContact.Id);
    }

    // 3. Delete from Genesys
    if (gzContact) {
      console.log(`  Deleting Genesys contact: ${gzContact.id}`);
      await axios.delete(
        `https://api.mypurecloud.com/api/v2/contacts/${gzContact.id}`,
        { headers: { 'Authorization': `Bearer ${this.gzToken}` } }
      );
    }

    // 4. Find and delete recordings (if applicable)
    const recordings = await findRecordingsByPhone(sfContact?.Phone);
    for (const recording of recordings) {
      console.log(`  Deleting recording: ${recording.id}`);
      await deleteRecording(recording.id);
    }

    // 5. Find and delete call logs
    const callLogs = await findCallLogsByEmail(email);
    for (const log of callLogs) {
      console.log(`  Deleting call log: ${log.id}`);
      await deleteCallLog(log.id);
    }

    // 6. Log the deletion (for compliance audit)
    await logGDPRDeletion({
      email,
      deletedAt: new Date(),
      deletedFrom: ['salesforce', 'genesys', 'recordings', 'call_logs'],
      reason: 'GDPR Right to Deletion'
    });

    console.log(`✅ Completely deleted: ${email}`);
    return { status: 'deleted', deletedFrom: ['salesforce', 'genesys'] };

  } catch (error) {
    console.error(`❌ Deletion failed: ${error.message}`);
    throw new Error(`Failed to delete ${email}: ${error.message}`);
  }
}

/**
 * Delete associated Salesforce records (tasks, events, etc.)
 */
async function deleteAssociatedSalesforceRecords(contactId) {
  // Delete tasks
  const tasks = await querySalesforce(`
    SELECT Id FROM Task
    WHERE WhoId = '${contactId}'
  `);

  for (const task of tasks.records) {
    await axios.delete(
      `${process.env.SALESFORCE_INSTANCE}/services/data/v57.0/sobjects/Task/${task.Id}`,
      { headers: { 'Authorization': `Bearer ${this.sfToken}` } }
    );
  }

  // Delete events
  const events = await querySalesforce(`
    SELECT Id FROM Event
    WHERE WhoId = '${contactId}'
  `);

  for (const event of events.records) {
    await axios.delete(
      `${process.env.SALESFORCE_INSTANCE}/services/data/v57.0/sobjects/Event/${event.Id}`,
      { headers: { 'Authorization': `Bearer ${this.sfToken}` } }
    );
  }

  console.log(`  Deleted ${tasks.records.length} tasks and ${events.records.length} events`);
}

Before storing/processing PII, you need:

Implementation

/**
 * Create contact with consent tracking
 */
async function createContactWithConsent(contactData, consentInfo) {
  // 1. Verify consent was given
  if (!consentInfo.consentGiven) {
    throw new Error('Cannot create contact without explicit consent');
  }

  // 2. Create contact
  const contact = await axios.post(
    `https://api.mypurecloud.com/api/v2/contacts`,
    {
      firstName: contactData.firstName,
      lastName: contactData.lastName,
      email: contactData.email,
      phoneNumbers: contactData.phoneNumbers
    },
    { headers: { 'Authorization': `Bearer ${this.gzToken}` } }
  );

  // 3. Store consent record in Salesforce
  const consentRecord = {
    Contact_Id__c: contact.id,
    Email: contactData.email,
    Consent_Given__c: true,
    Consent_Date__c: new Date().toISOString(),
    Consent_Type__c: consentInfo.type, // 'marketing', 'service', etc.
    Consent_Channel__c: consentInfo.channel, // 'email', 'phone', 'web'
    Consent_Version__c: consentInfo.version
  };

  await axios.post(
    `${process.env.SALESFORCE_INSTANCE}/services/data/v57.0/sobjects/Contact_Consent__c`,
    consentRecord,
    { headers: { 'Authorization': `Bearer ${this.sfToken}` } }
  );

  // 4. Log for audit
  console.log(`✓ Contact created with consent: ${contact.id}`);

  return contact;
}

/**
 * Withdraw consent
 */
async function withdrawConsent(email) {
  console.log(`Withdrawing consent for: ${email}`);

  // Find consent records
  const consents = await querySalesforce(`
    SELECT Id FROM Contact_Consent__c
    WHERE Email = '${email}'
  `);

  // Update all to withdrawn
  for (const consent of consents.records) {
    await axios.patch(
      `${process.env.SALESFORCE_INSTANCE}/services/data/v57.0/sobjects/Contact_Consent__c/${consent.Id}`,
      {
        Consent_Withdrawn__c: true,
        Consent_Withdrawn_Date__c: new Date().toISOString()
      },
      { headers: { 'Authorization': `Bearer ${this.sfToken}` } }
    );
  }

  console.log(`✓ Consent withdrawn for: ${email}`);
}

Call Recording Retention

Recording Retention Rules:
  ├─ Default: 7-10 years (varies by jurisdiction)
  ├─ Legal hold: Keep indefinitely if in dispute
  ├─ Customer deleted: Delete after 30 days if requested
  └─ End of retention: Delete automatically

Implementation

/**
 * Check if recording should be kept or deleted
 */
async function evaluateRecordingRetention(recording) {
  const createdDate = new Date(recording.dateCreated);
  const now = new Date();
  const ageInYears = (now - createdDate) / (1000 * 60 * 60 * 24 * 365);

  // 1. Is this contact deleted per GDPR?
  const isDeletionRequested = await checkGDPRDeletionRequest(recording.contact);
  if (isDeletionRequested) {
    console.log(`Deleting recording (GDPR): ${recording.id}`);
    await deleteRecording(recording.id);
    return 'deleted_gdpr';
  }

  // 2. Is this on legal hold?
  const isOnLegalHold = await checkLegalHold(recording.conversation);
  if (isOnLegalHold) {
    console.log(`Keeping recording (legal hold): ${recording.id}`);
    return 'kept_legal_hold';
  }

  // 3. Has retention period expired?
  const retentionYears = 7; // Your policy
  if (ageInYears > retentionYears) {
    console.log(`Deleting recording (retention expired): ${recording.id}`);
    await deleteRecording(recording.id);
    return 'deleted_retention';
  }

  // 4. Keep recording
  console.log(`Keeping recording: ${recording.id}`);
  return 'kept';
}

/**
 * Automated daily retention check
 */
async function runDailyRetentionCheck() {
  console.log('📅 Daily Retention Check');

  const recordings = await fetchAllRecordings();
  let deleted = 0;

  for (const recording of recordings) {
    const result = await evaluateRecordingRetention(recording);
    if (result.startsWith('deleted')) {
      deleted++;
    }
  }

  console.log(`  Deleted: ${deleted} recordings`);

  // Log for compliance
  await logRetentionCheck({
    timestamp: new Date(),
    recordingsChecked: recordings.length,
    recordingsDeleted: deleted
  });
}

Data Masking & Anonymization

When to Mask

Mask data for:
  ✓ Sharing with third parties
  ✓ Analytics / reporting
  ✓ Development / testing environments
  ✓ Long-term archival

Never mask (keep original):
  ✓ Active customer contact
  ✓ Legal/compliance records
  ✓ Current support tickets

Implementation

/**
 * Mask PII for analytics
 */
function maskPIIForAnalytics(contact) {
  return {
    ...contact,
    // Keep: non-PII fields
    id: contact.id,
    createdDate: contact.createdDate,
    tier: contact.tier,
    contactCount: contact.contactCount,

    // Mask: PII fields
    firstName: 'MASKED',
    lastName: 'MASKED',
    email: 'MASKED@masked.com',
    phoneNumbers: contact.phoneNumbers?.map(p => ({
      ...p,
      number: 'XXX-XXX-' + p.number.slice(-4)  // Show last 4 digits only
    }))
  };
}

/**
 * Hash email for matching without storing
 */
function hashEmail(email) {
  const crypto = require('crypto');
  return crypto
    .createHash('sha256')
    .update(email.toLowerCase())
    .digest('hex');
}

// Usage: Match on hash, not original email
const emailHash = hashEmail(contact.email);
const matchingContact = contacts.find(c => hashEmail(c.email) === emailHash);

Data Breaches & Incident Response

If Data is Compromised

/**
 * Breach notification workflow
 */
async function handleDataBreach(affectedContacts, breachDetails) {
  console.log(`🚨 DATA BREACH DETECTED`);

  try {
    // 1. Immediate actions (within 72 hours)
    console.log('1. Immediate Response:');
    
    // Stop the breach
    await shutdownCompromisedSystem(breachDetails.affectedSystem);
    
    // Assess scope
    const scope = await assessBreachScope(affectedContacts);
    console.log(`   Affected contacts: ${scope.count}`);
    console.log(`   Data exposed: ${scope.dataTypes.join(', ')}`);

    // Notify leadership
    await notifyLeadership(breachDetails);

    // 2. Regulatory notification (within required timeframe)
    console.log('2. Regulatory Notification:');
    
    // GDPR: Notify supervisory authority within 72 hours
    if (scope.locations.includes('EU')) {
      await notifyGDPRAuthority({
        date: new Date(),
        description: breachDetails.description,
        affectedIndividuals: scope.count,
        dataCategories: scope.dataTypes
      });
    }

    // CCPA: Notify state attorney general
    if (scope.locations.includes('California')) {
      await notifyACCPA({
        date: new Date(),
        description: breachDetails.description,
        affectedIndividuals: scope.count
      });
    }

    // 3. Notify affected individuals
    console.log('3. Individual Notification:');
    
    for (const contact of affectedContacts) {
      await sendBreachNotification(contact, {
        what: scope.dataTypes,
        when: breachDetails.discoveredDate,
        actions: 'Monitor credit for 1 year',
        contact: 'privacy@company.com'
      });
    }

    // 4. Document everything
    console.log('4. Documentation:');
    
    await createBreachReport({
      date: new Date(),
      description: breachDetails.description,
      rootCause: breachDetails.rootCause,
      affectedData: scope.dataTypes,
      affectedIndividuals: scope.count,
      remediation: breachDetails.remediation,
      preventionMeasures: breachDetails.preventionMeasures
    });

    console.log('✅ Breach handled per compliance requirements');

  } catch (error) {
    console.error('❌ Breach response failed:', error);
    // ESCALATE - notify compliance officer immediately
    await escalateToCompliance(error);
  }
}

Data Processing Agreement (DPA)

Required Documentation

If using third-party processors (Salesforce, Genesys), you need:

✓ Data Processing Agreement (DPA)
  - What data is processed
  - Where data is stored
  - Who has access
  - How long it's retained
  - Sub-processors used

✓ Standard Contractual Clauses (SCCs)
  - For international data transfers (EU → US)
  - Required for GDPR compliance

✓ Privacy Impact Assessment (PIA)
  - Document risks
  - Mitigation measures
  - Legal basis for processing

Compliance Checklist


Best Practices

  1. Minimize data collection

    • Only collect what you need
    • Delete when no longer needed
  2. Encrypt everything

    • Data in transit (HTTPS)
    • Data at rest (DB encryption)
    • Backups (encrypted backup systems)
  3. Access controls

    • Principle of least privilege
    • Only those who need access get it
    • Remove access when no longer needed
  4. Audit logging

    • Log all access to PII
    • Log all modifications
    • Log all deletions
    • Keep audit logs for compliance period
  5. Regular reviews

    • Review who has access
    • Review what data you're storing
    • Review retention policies
    • Review third-party access

Real-World CRM Integration Scenario

The Scenario

Company: TechSupport Inc. (50 agents, 3 locations)
Location: Austin, Toronto, São Paulo
CRM: Salesforce Service Cloud
Requirement: Integrate Genesys Cloud so agents see customer context during calls


Business Requirements

What We Need

When customer calls:
  ├─ Agent sees: Name, account, last 3 cases, contact history
  ├─ Call logged in Salesforce (Task)
  └─ Contact list stays in sync

Manual Requirements:
  ├─ Support agents can edit notes in Salesforce
  ├─ Notes should show in next call
  └─ Compliance: GDPR deletion within 30 days

Success Metrics

✓ 100% of calls show customer context (no "Contact not found")
✓ Average call handle time reduced by 10% (less lookup time)
✓ Agent satisfaction > 4/5 (easy to use)
✓ Salesforce Task creation 99%+ success (activity logging)
✓ Contact data fresh (synced daily)
✓ Zero GDPR compliance violations

Architecture

┌─────────────────────────────────────────────────────────┐
│                     Salesforce Cloud                      │
│  (Master Contact DB, Cases, Tasks, Activity Timeline)    │
└────────────────┬──────────────────────────────────────────┘
                 │
                 │ ← Data Sync (1-way: SF → Genesys)
                 │   Every 30 minutes
                 │
┌────────────────▼──────────────────────────────────────────┐
│            Genesys Cloud Contact DB                        │
│  (Cached contacts for quick lookup during calls)          │
│                                                            │
│  Architect Flows:                                          │
│  ├─ Inbound IVR: ANI lookup → screen pop                 │
│  ├─ Agent Desktop: Show contact context                   │
│  └─ Webhooks: Log calls back to SF                        │
└────────────────┬──────────────────────────────────────────┘
                 │
                 │ ← Activity Logging (Genesys → SF)
                 │   After each call
                 │
                 └─ Create Salesforce Task
                    (with call duration, recording, agent)

Phase 1: Screen Pop (Week 1-2)

Goal: When customer calls, agent sees their record

Step 1: Create Salesforce Apex Endpoint

// ContactLookupService.cls

@RestResource(urlMapping='/contact-lookup')
global class ContactLookupService {
  @HttpPost
  global static Response lookup(String phoneNumber) {
    Response response = new Response();
    
    try {
      // Normalize phone (remove formatting)
      String normalizedPhone = normalizePhone(phoneNumber);
      
      // Search for contact
      List<Contact> contacts = [
        SELECT Id, FirstName, LastName, Email, Phone,
               AccountId, Account.Name
        FROM Contact
        WHERE Phone = :normalizedPhone
           OR MobilePhone = :normalizedPhone
        LIMIT 1
      ];
      
      if (contacts.isEmpty()) {
        response.success = false;
        return response;
      }
      
      Contact contact = contacts[0];
      response.success = true;
      response.contactId = contact.Id;
      response.firstName = contact.FirstName;
      response.lastName = contact.LastName;
      response.email = contact.Email;
      response.accountId = contact.AccountId;
      response.accountName = contact.Account?.Name;
      
    } catch (Exception e) {
      response.success = false;
      response.error = e.getMessage();
    }
    
    return response;
  }
  
  private static String normalizePhone(String phone) {
    // Remove all non-digits
    String digits = phone.replaceAll('[^0-9]', '');
    
    // Add +1 if US number (10 digits)
    if (digits.length() == 10) {
      digits = '1' + digits;
    }
    
    return '+' + digits;
  }
  
  global class Response {
    public Boolean success;
    public String contactId;
    public String firstName;
    public String lastName;
    public String email;
    public String accountId;
    public String accountName;
    public String error;
  }
}

Step 2: Create Genesys Data Action

In ArchitectData Actions:

Name: lookup-contact-by-phone
Method: POST
URL: https://your-instance.salesforce.com/services/apexrest/contact-lookup

Input:
  phoneNumber: ${interaction.caller.phoneNumber}

Output:
  success: ${dataAction.response.success}
  contactId: ${dataAction.response.contactId}
  firstName: ${dataAction.response.firstName}
  lastName: ${dataAction.response.lastName}
  accountId: ${dataAction.response.accountId}
  accountName: ${dataAction.response.accountName}

Step 3: Create Architect Inbound Flow

START
  │
  ├─ Play: "Thank you for calling TechSupport..."
  │
  ├─ Data Action: lookup-contact-by-phone
  │   Input: ANI = ${interaction.caller.phoneNumber}
  │
  ├─ Decision: ${dataAction.result.success}?
  │   ├─ YES:
  │   │  ├─ Set interaction attributes:
  │   │  │  ├─ contact_id = ${dataAction.result.contactId}
  │   │  │  ├─ contact_name = ${dataAction.result.firstName} ${dataAction.result.lastName}
  │   │  │  ├─ account_id = ${dataAction.result.accountId}
  │   │  │  └─ account_name = ${dataAction.result.accountName}
  │   │  │
  │   │  └─ Transfer to Support Queue
  │   │
  │   └─ NO:
  │      ├─ Play: "Please hold while we locate your account..."
  │      └─ Transfer to Support Queue (no attributes)
  │
  └─ DISCONNECT

Expected Result

Customer dials: +1-512-555-1234
  ↓
Agent receives call
  ↓
Agent's Genesys desktop shows:
  Contact Name: John Doe
  Account: Acme Corp
  Email: john@acmecorp.com
  Last 3 Cases: [list]
  Contact History: [last 5 calls]

Phase 2: Contact Sync (Week 2-3)

Goal: Keep Genesys contact list in sync with Salesforce

Step 1: Create Sync Job

// sync-job.js (runs every 30 minutes)

const SalesforceGenesysSync = require('./lib/sync');

async function syncDaily() {
  const sync = new SalesforceGenesysSync({
    sfInstance: process.env.SALESFORCE_INSTANCE,
    sfToken: process.env.SALESFORCE_TOKEN,
    gzToken: process.env.GENESYS_TOKEN
  });

  const result = await sync.runFullSync();

  console.log(`✓ Sync complete: ${result.created} created, ${result.updated} updated`);

  if (result.errors.length > 0) {
    await sendAlert('warning', result);
  }
}

syncDaily().catch(error => {
  console.error('Sync failed:', error);
  process.exit(1);
});

Step 2: Deploy as Lambda (AWS)

# serverless.yml

service: techsupport-crm-sync

provider:
  name: aws
  runtime: nodejs18.x
  environment:
    SALESFORCE_INSTANCE: ${env:SALESFORCE_INSTANCE}
    SALESFORCE_TOKEN: ${env:SALESFORCE_TOKEN}
    GENESYS_TOKEN: ${env:GENESYS_TOKEN}

functions:
  sync:
    handler: sync-job.syncDaily
    events:
      - schedule:
          rate: rate(30 minutes)  # Every 30 minutes
          enabled: true

resources:
  Resources:
    SyncLogGroup:
      Type: AWS::Logs::LogGroup
      Properties:
        LogGroupName: /aws/lambda/techsupport-crm-sync
        RetentionInDays: 30

Deploy: serverless deploy

Step 3: Monitor Sync

Logs:
  2026-03-14 10:00:00 ✓ Fetched 2345 contacts from Salesforce
  2026-03-14 10:00:05 ✓ Found 2100 existing Genesys contacts
  2026-03-14 10:00:30 ✓ Created: 50, Updated: 200, Skipped: 5
  2026-03-14 10:00:31 Duration: 31 seconds

Metrics:
  - Success rate: 99.7%
  - Avg duration: 32 sec
  - Contacts synced: 250/day

Phase 3: Activity Logging (Week 3-4)

Goal: After call, log details in Salesforce Task

Step 1: Enable Genesys Webhooks

In Genesys AdminIntegrationsWebhooks:

Event: conversation.ended
URL: https://your-backend.com/webhook/call-ended
Payload: Include all details (recording ID, agent, duration)
Retries: 3 times

Step 2: Create Webhook Handler

// webhook-handler.js

app.post('/webhook/call-ended', async (req, res) => {
  const {
    conversationId,
    callerId,
    durationSeconds,
    recordingId,
    agentName,
    queueName,
    attributes
  } = req.body;

  try {
    // 1. Find matching Salesforce contact
    const contact = await findContactByPhone(callerId);
    if (!contact) {
      console.warn(`Contact not found for ${callerId}`);
      return res.status(200).json({ message: 'Contact not found' });
    }

    // 2. Get recording URL
    const recordingUrl = await getRecordingUrl(recordingId);

    // 3. Create Salesforce Task
    const task = {
      Subject: `Call with ${contact.Name}`,
      Description: `
Call Details:
  Duration: ${Math.floor(durationSeconds / 60)} min
  Agent: ${agentName}
  Queue: ${queueName}
  Recording: ${recordingUrl || 'Not available'}
      `.trim(),
      WhoId: contact.Id,
      WhatId: contact.AccountId,
      ActivityDate: new Date().toISOString().split('T')[0],
      CallType: 'Inbound',
      Status: 'Completed',
      Type: 'Call'
    };

    const taskResult = await createSalesforceTask(task);
    console.log(`✓ Task created: ${taskResult.id}`);

    // 4. Update Contact's LastActivityDate
    await updateSalesforceContact(contact.Id, {
      LastActivityDate: new Date().toISOString().split('T')[0]
    });

    res.status(200).json({ taskId: taskResult.id });

  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).json({ error: error.message });
  }
});

Step 3: Verify Logging

In Salesforce Contact record:
  Activity Timeline shows:
    - Call with John Doe (10 min)
      Agent: Sarah Smith
      Queue: Support
      Recording: [link]
      [Add note/next steps]

Phase 4: GDPR Compliance (Week 4)

Goal: Handle data deletion requests

Step 1: Create GDPR Deletion Procedure

// gdpr-delete.js

async function handleGDPRDeletion(email) {
  console.log(`🛑 GDPR Deletion: ${email}`);

  // 1. Find contact
  const sfContact = await findSalesforceContactByEmail(email);
  const gzContact = await findGenesysContactByEmail(email);

  // 2. Delete from both
  if (sfContact) {
    await deleteSalesforceContact(sfContact.Id);
  }

  if (gzContact) {
    await deleteGenesysContact(gzContact.id);
  }

  // 3. Delete recordings
  const recordings = await findRecordingsByPhone(email);
  for (const rec of recordings) {
    await deleteRecording(rec.id);
  }

  // 4. Log deletion
  await logGDPRDeletion({
    email,
    deletedAt: new Date(),
    deletedFrom: ['salesforce', 'genesys', 'recordings']
  });

  console.log(`✅ Deleted: ${email}`);
}

Step 2: Document Privacy Policy

Update website:

We collect:
  - Name, email, phone (for customer service)
  - Call recordings (for 7 years per compliance)

You can:
  - Request deletion: privacy@techsupport.com
  - We'll delete within 30 days

Go-Live Plan

Week 1: Preparation

Week 2: Screen Pop

Week 3: Contact Sync

Week 4: Activity Logging

Week 5: GDPR & Training


Expected Results

Before Integration

Agent experience:
  - Customer calls
  - Agent types customer name into Salesforce search (30 sec)
  - Wait for results
  - Navigate to account/cases
  - Get context, THEN handle call
  
  Average handle time: 8 minutes
  Agent satisfaction: 3/5
  Customer satisfaction: 3.5/5

After Integration

Agent experience:
  - Customer calls
  - Record auto-pops (1 sec)
  - Agent sees name, account, last cases
  - Agent handles call with context immediately
  - Call logged to Salesforce automatically
  
  Average handle time: 7 minutes (12.5% improvement)
  Agent satisfaction: 4.5/5
  Customer satisfaction: 4.2/5

Monitoring & Maintenance

Weekly Checks

□ Screen pop success rate > 99%
□ Contact sync duration < 2 min
□ Activity logging > 99% success
□ GDPR deletion requests: 0
□ Errors: < 5 per week

Monthly Review

□ Agent feedback on usability
□ Performance trends
□ Cost analysis
□ Compliance status
□ Planned improvements

Budget Estimate

Implementation:
  ├─ Salesforce Apex: $3,000 (5 days)
  ├─ Genesys Architect: $2,000 (3 days)
  ├─ Sync Job (Lambda): $1,500 (2 days)
  ├─ Webhook Handler: $1,500 (2 days)
  ├─ Testing & QA: $2,000 (3 days)
  └─ Total Dev: $10,000

Operations (annual):
  ├─ AWS Lambda: $50/month ($600/year)
  ├─ Genesys API calls: $200/month ($2,400/year)
  ├─ Salesforce: Included in license
  ├─ Monitoring: $100/month ($1,200/year)
  └─ Total Ops: $4,200/year

ROI:
  ├─ Productivity gain: 12.5% = ~200 agents × 30 min/day
  ├─ Annual value: 200 × 250 working days × 0.5 hours × $25/hr = $625,000
  ├─ Cost: $10,000 dev + $4,200 ops = $14,200
  └─ Payback: < 1 week