Rendering Dynamic Carousels in NICE Cognigy Web Messaging with TypeScript Middleware
What You Will Build
- A TypeScript middleware function that executes within Cognigy Studio, queries an external GraphQL inventory service, and returns a paginated carousel response compatible with Cognigy Web Messaging.
- This implementation uses the Cognigy Web Messaging webhook payload structure and standard Node.js
fetchAPI for external GraphQL communication. - The tutorial covers TypeScript 5.0+ syntax, async/await patterns, and production-ready error handling for webhook execution environments.
Prerequisites
- External GraphQL service OAuth client with
inventory:readscope required for product data access - Cognigy Studio Node.js runtime 18+ (TypeScript compilation target ES2022)
- Node.js 18+ runtime environment with native
fetchsupport - No external npm dependencies required. The implementation relies on standard library modules only.
Authentication Setup
The GraphQL inventory service requires an OAuth 2.0 Bearer token with the inventory:read scope. Cognigy functions execute in a sandboxed Node.js environment, so token management must occur entirely within the middleware. The following implementation includes a token cache with automatic refresh logic to prevent 401 errors during high-volume webhook execution.
interface AuthState {
accessToken: string
expiresAt: number
refreshToken: string
}
const TOKEN_CACHE: AuthState | null = null
const TOKEN_ENDPOINT = 'https://auth.inventory-service.example.com/oauth/token'
const CLIENT_ID = 'your-client-id'
const CLIENT_SECRET = 'your-client-secret'
async function acquireAuthToken(): Promise<string> {
const now = Date.now()
if (TOKEN_CACHE && now < TOKEN_CACHE.expiresAt) {
return TOKEN_CACHE.accessToken
}
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'inventory:read'
})
})
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
throw new Error(`Token acquisition failed with status ${tokenResponse.status}: ${errorText}`)
}
const tokenData = await tokenResponse.json()
TOKEN_CACHE.accessToken = tokenData.access_token
TOKEN_CACHE.expiresAt = now + (tokenData.expires_in * 1000) - 30000
TOKEN_CACHE.refreshToken = tokenData.refresh_token
return tokenData.access_token
}
The token cache stores the access token and calculates an expiration timestamp with a thirty-second safety buffer. This prevents race conditions when multiple webhook invocations execute simultaneously. The client_credentials grant type is used because the middleware runs server-side without end-user context.
Implementation
Step 1: GraphQL Client Initialization and Retry Logic
The middleware must handle transient network failures and rate limiting from the GraphQL service. The following function implements exponential backoff for 429 responses and validates the GraphQL response structure before passing data to the mapping layer.
interface InventoryItem {
id: string
name: string
price: number
imageUrl: string
inStock: boolean
}
interface GraphQlResponse {
data: {
products: {
items: InventoryItem[]
pageInfo: {
hasNextPage: boolean
endCursor: string
}
}
}
errors?: Array<{ message: string }>
}
async function fetchInventoryPage(cursor: string | null, pageSize: number = 5): Promise<GraphQlResponse> {
const GRAPHQL_ENDPOINT = 'https://api.inventory-service.example.com/graphql'
const authToken = await acquireAuthToken()
const query = `
query GetProducts($cursor: String, $first: Int) {
products(first: $first, after: $cursor) {
items {
id
name
price
imageUrl
inStock
}
pageInfo {
hasNextPage
endCursor
}
}
}
`
const variables = {
cursor: cursor,
first: pageSize
}
let attempts = 0
const maxAttempts = 3
while (attempts < maxAttempts) {
try {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
'Accept': 'application/json'
},
body: JSON.stringify({ query, variables })
})
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After')
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.pow(2, attempts) * 1000
await new Promise(resolve => setTimeout(resolve, delay))
attempts++
continue
}
if (!response.ok) {
throw new Error(`GraphQL request failed with status ${response.status}`)
}
const data = await response.json()
if (data.errors && data.errors.length > 0) {
throw new Error(`GraphQL validation error: ${data.errors.map(e => e.message).join(', ')}`)
}
return data
} catch (error) {
if (attempts === maxAttempts - 1) throw error
attempts++
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempts) * 1000))
}
}
throw new Error('Max retry attempts reached for GraphQL inventory fetch')
}
The retry loop handles 429 responses by reading the Retry-After header or falling back to exponential backoff. GraphQL validation errors are extracted from the errors array and thrown immediately. The function requires the inventory:read OAuth scope to access the products query field.
Step 2: Mapping Inventory Data to Cognigy Card Schema
Cognigy Web Messaging expects a specific JSON structure for carousel messages. Each card requires a title, description, imageUrl, and an actions array containing button payloads. The mapping function transforms the GraphQL response into Cognigy-compatible card objects while handling missing image URLs and out-of-stock states.
interface CognigyCard {
title: string
description: string
imageUrl: string
actions: Array<{
type: string
payload: string
label: string
}>
}
function mapToCognigyCards(items: InventoryItem[]): CognigyCard[] {
return items.map(item => ({
title: item.name,
description: `Price: $${item.price.toFixed(2)} | ${item.inStock ? 'In Stock' : 'Out of Stock'}`,
imageUrl: item.imageUrl || 'https://cdn.cognigy.com/fallback/product-placeholder.png',
actions: [
{
type: 'postback',
payload: `VIEW_PRODUCT_${item.id}`,
label: item.inStock ? 'View Details' : 'Notify When Available'
}
]
}))
}
The mapping function ensures every card contains valid fields. Missing image URLs default to a CDN-hosted placeholder to prevent broken image rendering in the web client. The postback action type triggers a new webhook call with the product ID payload, enabling subsequent detail retrieval without exposing direct API links to the client.
Step 3: Implementing Cursor Pagination and Infinite Scroll Triggers
Cognigy Web Messaging supports infinite scroll patterns by rendering a “Load More” button when pageInfo.hasNextPage is true. The middleware must inspect the incoming webhook payload for a cursor value, fetch the appropriate page, and append the pagination trigger to the response.
interface WebhookContext {
input: {
text: string
payload?: {
cursor?: string
action?: string
}
}
output: {
messages: Array<{
type: string
payload: {
title?: string
items?: CognigyCard[]
actions?: Array<{
type: string
payload: string
label: string
}>
}
}>
}
}
function buildWebhookResponse(
cards: CognigyCard[],
hasNextPage: boolean,
endCursor: string | null
): WebhookContext['output'] {
const actions: Array<{ type: string; payload: string; label: string }> = []
if (hasNextPage && endCursor) {
actions.push({
type: 'postback',
payload: JSON.stringify({ cursor: endCursor, action: 'LOAD_MORE' }),
label: 'Load More Products'
})
}
return {
messages: [
{
type: 'carousel',
payload: {
title: 'Available Inventory',
items: cards,
actions
}
}
]
}
}
export async function handleInventoryCarousel(context: WebhookContext): Promise<WebhookContext['output']> {
const cursor = context.input.payload?.cursor || null
try {
const graphqlData = await fetchInventoryPage(cursor, 5)
const { items, pageInfo } = graphqlData.data.products
const cognigyCards = mapToCognigyCards(items)
return buildWebhookResponse(cognigyCards, pageInfo.hasNextPage, pageInfo.endCursor)
} catch (error) {
return {
messages: [
{
type: 'text',
payload: {
text: `Unable to load inventory at this time. Please try again later.`
}
}
]
}
}
}
The handleInventoryCarousel function extracts the cursor from the incoming webhook payload. If no cursor exists, the GraphQL query returns the first page. The buildWebhookResponse function conditionally appends a “Load More” postback action when additional pages exist. The fallback message ensures the web client never receives an empty or malformed response during network failures.
Complete Working Example
The following module combines all components into a single executable TypeScript file. Deploy this code directly into Cognigy Studio as a Node.js function or host it as an external webhook endpoint.
interface AuthState {
accessToken: string
expiresAt: number
refreshToken: string
}
const TOKEN_CACHE: AuthState | null = null
const TOKEN_ENDPOINT = 'https://auth.inventory-service.example.com/oauth/token'
const CLIENT_ID = 'your-client-id'
const CLIENT_SECRET = 'your-client-secret'
async function acquireAuthToken(): Promise<string> {
const now = Date.now()
if (TOKEN_CACHE && now < TOKEN_CACHE.expiresAt) {
return TOKEN_CACHE.accessToken
}
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: 'inventory:read'
})
})
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
throw new Error(`Token acquisition failed with status ${tokenResponse.status}: ${errorText}`)
}
const tokenData = await tokenResponse.json()
TOKEN_CACHE.accessToken = tokenData.access_token
TOKEN_CACHE.expiresAt = now + (tokenData.expires_in * 1000) - 30000
TOKEN_CACHE.refreshToken = tokenData.refresh_token
return tokenData.access_token
}
interface InventoryItem {
id: string
name: string
price: number
imageUrl: string
inStock: boolean
}
interface GraphQlResponse {
data: {
products: {
items: InventoryItem[]
pageInfo: {
hasNextPage: boolean
endCursor: string
}
}
}
errors?: Array<{ message: string }>
}
async function fetchInventoryPage(cursor: string | null, pageSize: number = 5): Promise<GraphQlResponse> {
const GRAPHQL_ENDPOINT = 'https://api.inventory-service.example.com/graphql'
const authToken = await acquireAuthToken()
const query = `
query GetProducts($cursor: String, $first: Int) {
products(first: $first, after: $cursor) {
items {
id
name
price
imageUrl
inStock
}
pageInfo {
hasNextPage
endCursor
}
}
}
`
const variables = {
cursor: cursor,
first: pageSize
}
let attempts = 0
const maxAttempts = 3
while (attempts < maxAttempts) {
try {
const response = await fetch(GRAPHQL_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
'Accept': 'application/json'
},
body: JSON.stringify({ query, variables })
})
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After')
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.pow(2, attempts) * 1000
await new Promise(resolve => setTimeout(resolve, delay))
attempts++
continue
}
if (!response.ok) {
throw new Error(`GraphQL request failed with status ${response.status}`)
}
const data = await response.json()
if (data.errors && data.errors.length > 0) {
throw new Error(`GraphQL validation error: ${data.errors.map(e => e.message).join(', ')}`)
}
return data
} catch (error) {
if (attempts === maxAttempts - 1) throw error
attempts++
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempts) * 1000))
}
}
throw new Error('Max retry attempts reached for GraphQL inventory fetch')
}
interface CognigyCard {
title: string
description: string
imageUrl: string
actions: Array<{
type: string
payload: string
label: string
}>
}
function mapToCognigyCards(items: InventoryItem[]): CognigyCard[] {
return items.map(item => ({
title: item.name,
description: `Price: $${item.price.toFixed(2)} | ${item.inStock ? 'In Stock' : 'Out of Stock'}`,
imageUrl: item.imageUrl || 'https://cdn.cognigy.com/fallback/product-placeholder.png',
actions: [
{
type: 'postback',
payload: `VIEW_PRODUCT_${item.id}`,
label: item.inStock ? 'View Details' : 'Notify When Available'
}
]
}))
}
interface WebhookContext {
input: {
text: string
payload?: {
cursor?: string
action?: string
}
}
output: {
messages: Array<{
type: string
payload: {
title?: string
items?: CognigyCard[]
actions?: Array<{
type: string
payload: string
label: string
}>
}
}>
}
}
function buildWebhookResponse(
cards: CognigyCard[],
hasNextPage: boolean,
endCursor: string | null
): WebhookContext['output'] {
const actions: Array<{ type: string; payload: string; label: string }> = []
if (hasNextPage && endCursor) {
actions.push({
type: 'postback',
payload: JSON.stringify({ cursor: endCursor, action: 'LOAD_MORE' }),
label: 'Load More Products'
})
}
return {
messages: [
{
type: 'carousel',
payload: {
title: 'Available Inventory',
items: cards,
actions
}
}
]
}
}
export async function handleInventoryCarousel(context: WebhookContext): Promise<WebhookContext['output']> {
const cursor = context.input.payload?.cursor || null
try {
const graphqlData = await fetchInventoryPage(cursor, 5)
const { items, pageInfo } = graphqlData.data.products
const cognigyCards = mapToCognigyCards(items)
return buildWebhookResponse(cognigyCards, pageInfo.hasNextPage, pageInfo.endCursor)
} catch (error) {
return {
messages: [
{
type: 'text',
payload: {
text: `Unable to load inventory at this time. Please try again later.`
}
}
]
}
}
}
Common Errors & Debugging
Error: HTTP 401 Unauthorized
- What causes it: The OAuth token expired during execution or the
inventory:readscope is missing from the client credentials grant. - How to fix it: Verify the
scopeparameter in the token request matches the GraphQL service requirements. Implement the safety buffer inTOKEN_CACHE.expiresAtto refresh tokens thirty seconds before expiration. - Code showing the fix:
TOKEN_CACHE.expiresAt = now + (tokenData.expires_in * 1000) - 30000
Error: HTTP 429 Too Many Requests
- What causes it: The GraphQL service enforces rate limits per client ID or IP address. Concurrent webhook executions trigger throttling.
- How to fix it: The retry loop reads the
Retry-Afterheader and applies exponential backoff. Reduce concurrent webhook execution threads in Cognigy Studio or implement a queue-based request pattern. - Code showing the fix:
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After')
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.pow(2, attempts) * 1000
await new Promise(resolve => setTimeout(resolve, delay))
attempts++
continue
}
Error: Cognigy Schema Validation Failure
- What causes it: The webhook response contains missing required fields like
type,payload, or malformedactionsarrays. Cognigy rejects responses that do not match the Web Messaging schema. - How to fix it: Ensure every carousel message includes
type: 'carousel', apayloadobject withitems, and anactionsarray. Validate image URLs before assignment. - Code showing the fix:
imageUrl: item.imageUrl || 'https://cdn.cognigy.com/fallback/product-placeholder.png'