How to Implement Custom Data Lookups with Architect GetExternalContactAction

How to Implement Custom Data Lookups with Architect GetExternalContactAction

What You Will Build

  • You will build a Node.js Express microservice that acts as a custom data provider for Genesys Cloud CX Architect.
  • This service receives a POST request from the GetExternalContactAction node, extracts the caller ID, and queries a local database or external API to retrieve customer context.
  • The tutorial covers the exact JSON schema required by the GetExternalContactAction node and demonstrates how to handle the asynchronous nature of the lookup.

Prerequisites

  • Platform: Genesys Cloud CX.
  • Feature: Architect Flow using GetExternalContactAction node.
  • Language: Node.js (v18+).
  • Dependencies: express, cors, dotenv.
  • Genesys Configuration:
    • An Architect Flow containing a GetExternalContactAction node.
    • The node must be configured with a valid URL pointing to your service.
    • The node must be configured to pass the CallerID (or other relevant attributes) in the request body.

Authentication Setup

The GetExternalContactAction node does not use standard OAuth2 client credentials for the HTTP POST it sends to your external service. Instead, it relies on IP Allowlisting or Basic Auth configured within the Architect node properties. For this tutorial, we assume IP Allowlisting is the security model, as it is the most common pattern for high-throughput data lookups.

If you require authentication, you can configure the Architect node to send a custom header (e.g., Authorization: Bearer <token>) or use Basic Auth. The code below includes a simple middleware to validate a shared secret header, which is a robust pattern for securing these endpoints without the overhead of OAuth token rotation on every call.

Environment Variables

Create a .env file:

PORT=3000
SHARED_SECRET=your_super_secret_key_here
DB_CONNECTION_STRING=your_database_url

Implementation

Step 1: Configure the Architect GetExternalContactAction Node

Before writing code, you must understand the contract. The GetExternalContactAction node sends a POST request to your URL. The body of this request is defined by the Input Attributes you select in the Architect UI.

  1. Open your Architect Flow.
  2. Add a GetExternalContactAction node.
  3. Set the URL to your endpoint (e.g., https://api.yourcompany.com/lookup).
  4. In the Input Attributes section, add the attribute containing the phone number.
    • Common source: system.call.caller.id or system.call.caller.displayName.
    • Let us assume you map system.call.caller.id to a variable named callerPhoneNumber.
  5. In the Output Attributes section, define what you expect back.
    • The node expects a JSON object. The top-level keys of your JSON response will become variables in the flow.
    • Example: If you return { "customerId": "123", "tier": "gold" }, the flow will have variables customerId and tier available downstream.

Critical Note: The GetExternalContactAction node has a timeout. By default, it waits for 5 seconds. If your database query takes longer, the node fails. Ensure your lookup is optimized or increase the timeout in the node settings if necessary.

Step 2: Build the Express Server

Initialize your project:

mkdir genesys-lookup-service
cd genesys-lookup-service
npm init -y
npm install express cors dotenv

Create server.js. This service will parse the incoming request, validate the security header, perform the lookup, and return the JSON payload.

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

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(cors());
app.use(express.json());

// Simple security middleware
// In production, use a robust HMAC verification or API Key management system.
const authenticateRequest = (req, res, next) => {
    const providedSecret = req.headers['x-api-key'];
    if (providedSecret !== process.env.SHARED_SECRET) {
        return res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
    }
    next();
};

// Mock Database Service
// Replace this with your actual database call (e.g., MongoDB, PostgreSQL, Redis)
const mockCustomerDatabase = {
    '+15551234567': {
        customerId: 'CUST-9012',
        firstName: 'John',
        lastName: 'Doe',
        tier: 'Platinum',
        recentOrderTotal: 150.00
    },
    '+15559876543': {
        customerId: 'CUST-3321',
        firstName: 'Jane',
        lastName: 'Smith',
        tier: 'Silver',
        recentOrderTotal: 45.50
    }
};

/**
 * GET /health
 * Health check endpoint for load balancers
 */
app.get('/health', (req, res) => {
    res.status(200).json({ status: 'ok' });
});

/**
 * POST /lookup
 * The endpoint consumed by Genesys GetExternalContactAction
 */
app.post('/lookup', authenticateRequest, async (req, res) => {
    try {
        // 1. Parse Input Attributes
        // The body structure depends on how you configured the Input Attributes in Architect.
        // Typically, Genesys sends the attributes in the root of the JSON body.
        const { callerPhoneNumber } = req.body;

        // 2. Validate Input
        if (!callerPhoneNumber) {
            return res.status(400).json({ 
                error: 'Bad Request', 
                message: 'callerPhoneNumber is required in the request body.' 
            });
        }

        console.log(`Received lookup request for: ${callerPhoneNumber}`);

        // 3. Perform Data Lookup
        // Simulate an async database call
        const customerData = await performCustomerLookup(callerPhoneNumber);

        // 4. Construct Response
        // If customer not found, we still return a 200 OK with null/empty values.
        // Returning a 404 or 500 will cause the GetExternalContactAction node to fail/error out.
        const responsePayload = customerData ? {
            ...customerData,
            lookupSuccess: true
        } : {
            customerId: null,
            firstName: null,
            lastName: null,
            tier: null,
            recentOrderTotal: 0,
            lookupSuccess: false
        };

        // 5. Send Response
        // Important: Set Content-Type to application/json
        res.status(200).json(responsePayload);

    } catch (error) {
        console.error('Lookup failed:', error);
        // Always return 200 OK with error details in the body if possible,
        // or log internally. Genesys expects a JSON body.
        res.status(200).json({
            error: 'Internal Server Error',
            message: error.message,
            lookupSuccess: false
        });
    }
});

/**
 * Simulates an asynchronous database query
 */
async function performCustomerLookup(phoneNumber) {
    // Simulate network/database latency
    await new Promise(resolve => setTimeout(resolve, 50));
    
    // Normalize phone number if necessary
    const normalizedPhone = phoneNumber.trim();
    
    return mockCustomerDatabase[normalizedPhone] || null;
}

// Start Server
if (require.main === module) {
    app.listen(PORT, () => {
        console.log(`Genesys Lookup Service running on port ${PORT}`);
    });
}

module.exports = app;

Step 3: Handling Edge Cases and Performance

The GetExternalContactAction is synchronous from the flow’s perspective. The flow stops at the node until the HTTP request completes. This introduces strict latency requirements.

1. Caching

If you have a high call volume, hitting your primary database for every call is inefficient and risky for timeouts. Implement a caching layer (e.g., Redis or In-Memory LRU cache).

Add this logic to server.js:

// Simple In-Memory Cache with TTL
const cache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

async function performCustomerLookupWithCache(phoneNumber) {
    const cacheKey = `customer_${phoneNumber}`;
    const cachedItem = cache.get(cacheKey);

    if (cachedItem && Date.now() < cachedItem.expiry) {
        console.log(`Cache hit for ${phoneNumber}`);
        return cachedItem.data;
    }

    console.log(`Cache miss for ${phoneNumber}, querying DB...`);
    const data = await performCustomerLookup(phoneNumber); // Original DB call
    
    // Store in cache
    cache.set(cacheKey, {
        data: data,
        expiry: Date.now() + CACHE_TTL
    });

    return data;
}

Replace the call in your route handler:

// Inside app.post('/lookup', ...)
const customerData = await performCustomerLookupWithCache(callerPhoneNumber);

2. Handling Missing Data

Always return a consistent JSON structure. If the customer is not found, do not return an empty object {}. Return the expected keys with null or default values. This prevents “Undefined” errors in downstream Architect nodes that expect specific variable types.

3. Timeout Management

If your lookup involves multiple microservices, chain them with Promise.all or use a timeout wrapper to ensure the response is sent before Genesys cuts the connection.

// Utility to enforce timeout
function withTimeout(promise, ms) {
    let timer;
    return Promise.race([
        promise,
        new Promise((_, reject) => {
            timer = setTimeout(() => reject(new Error('Lookup timeout')), ms);
        })
    ]).finally(() => clearTimeout(timer));
}

// Usage in route
try {
    const customerData = await withTimeout(performCustomerLookupWithCache(callerPhoneNumber), 3000); // 3s timeout
    // ... rest of logic
} catch (error) {
    if (error.message === 'Lookup timeout') {
        console.warn('Lookup timed out for', callerPhoneNumber);
        // Return fallback data
    }
    // ...
}

Complete Working Example

Here is the full server.js file combining security, caching, and robust error handling.

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

const app = express();
const PORT = process.env.PORT || 3000;

// Configuration
const SHARED_SECRET = process.env.SHARED_SECRET || 'default_secret_change_me';
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const LOOKUP_TIMEOUT_MS = 3000; // 3 seconds max for DB call

// Middleware
app.use(cors());
app.use(express.json());

// Security Middleware
const authenticateRequest = (req, res, next) => {
    const providedSecret = req.headers['x-api-key'];
    if (providedSecret !== SHARED_SECRET) {
        return res.status(401).json({ error: 'Unauthorized' });
    }
    next();
};

// In-Memory Cache
const cache = new Map();

// Mock Database
const mockDB = {
    '+15551234567': { customerId: 'CUST-9012', firstName: 'John', lastName: 'Doe', tier: 'Platinum' },
    '+15559876543': { customerId: 'CUST-3321', firstName: 'Jane', lastName: 'Smith', tier: 'Silver' }
};

// Database Service
async function queryDatabase(phoneNumber) {
    // Simulate DB latency
    await new Promise(resolve => setTimeout(resolve, 50));
    return mockDB[phoneNumber.trim()] || null;
}

// Cache Service
async function getCachedData(phoneNumber) {
    const key = `cust_${phoneNumber}`;
    const item = cache.get(key);

    if (item && Date.now() < item.expiry) {
        return item.data;
    }

    const data = await queryDatabase(phoneNumber);
    cache.set(key, { data, expiry: Date.now() + CACHE_TTL });
    return data;
}

// Timeout Wrapper
function withTimeout(promise, ms) {
    let timer;
    return Promise.race([
        promise,
        new Promise((_, reject) => {
            timer = setTimeout(() => reject(new Error('Timeout')), ms);
        })
    ]).finally(() => clearTimeout(timer));
}

// Main Lookup Endpoint
app.post('/lookup', authenticateRequest, async (req, res) => {
    try {
        const { callerPhoneNumber } = req.body;

        if (!callerPhoneNumber) {
            return res.status(400).json({ error: 'Missing callerPhoneNumber' });
        }

        let customerData;
        try {
            customerData = await withTimeout(getCachedData(callerPhoneNumber), LOOKUP_TIMEOUT_MS);
        } catch (timeoutError) {
            console.error(`Timeout for ${callerPhoneNumber}`);
            customerData = null;
        }

        // Standardize Output
        const response = customerData ? {
            ...customerData,
            lookupSuccess: true
        } : {
            customerId: null,
            firstName: null,
            lastName: null,
            tier: null,
            lookupSuccess: false
        };

        res.status(200).json(response);

    } catch (error) {
        console.error('Server Error:', error);
        // Genesys expects JSON. Return a safe fallback.
        res.status(200).json({
            lookupSuccess: false,
            error: 'Internal Error'
        });
    }
});

// Health Check
app.get('/health', (req, res) => res.status(200).json({ status: 'ok' }));

if (require.main === module) {
    app.listen(PORT, () => {
        console.log(`Service running on port ${PORT}`);
    });
}

module.exports = app;

Common Errors & Debugging

Error: GetExternalContactAction Node Fails with “Timeout”

  • Cause: Your service took longer than the node’s timeout setting (default 5s) to respond.
  • Fix:
    1. Check your server logs for slow queries.
    2. Implement the caching strategy shown above.
    3. In Architect, open the GetExternalContactAction node and increase the Timeout value (e.g., to 10s).
    4. Ensure your database connection pool is not exhausted.

Error: Node Fails with “HTTP 500/400”

  • Cause: Your service returned a non-200 status code.
  • Fix: The GetExternalContactAction node is strict. It expects a 200 OK with a valid JSON body. If your service crashes or returns 404 Not Found, the node fails.
    • Update your catch blocks to always return res.status(200).json(...).
    • Use the lookupSuccess: false flag in your JSON to handle “not found” logic in Architect downstream, rather than relying on HTTP status codes.

Error: Variables are Undefined in Downstream Nodes

  • Cause: The JSON keys returned by your service do not match the Output Attributes defined in the Architect node, or the casing is different.
  • Fix:
    1. In Architect, ensure the Output Attributes are defined.
    2. In your code, ensure the JSON keys match exactly.
    3. Example: If Architect expects customerId, your JSON must contain "customerId": "...". It will not map "CustomerID" or "customer_id" automatically.

Error: CORS Policy Blocked the Request

  • Cause: If you are testing locally and the Architect node is trying to hit a local IP without proper headers, or if you are using a proxy.
  • Fix: Ensure cors() middleware is active. For production, restrict CORS to only allow requests from Genesys Cloud domains if necessary, though IP allowlisting is preferred for backend-to-backend communication.

Official References