Rendering Rich Message Cards via Cognigy Webhooks API with Node.js
What You Will Build
- A Node.js service that constructs, validates, and returns rich message card payloads to the Cognigy dialog engine via webhooks.
- Uses the Cognigy.AI Webhook API and
@cognigy/sdkfor template resolution, analytics synchronization, and dialog engine constraint enforcement. - Covers Node.js (Express, Zod, Axios, @cognigy/sdk).
Prerequisites
- Cognigy.AI tenant with OAuth 2.0 Client Credentials configured
- Required scopes:
bot:write,analytics:read,webhooks:manage - Node.js 18 LTS or higher
- External dependencies:
express@4.18.2,zod@3.22.4,axios@1.6.0,@cognigy/sdk@3.1.0,uuid@9.0.0,pino@8.16.2 - A registered webhook URL in your Cognigy tenant accessible over HTTPS
Authentication Setup
Cognigy uses OAuth 2.0 Client Credentials for backend API access. The service must exchange a client ID and secret for an access token, cache it, and handle expiration. The following module manages token lifecycle and implements exponential backoff for rate limiting.
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
const COGNIFY_TENANT = process.env.COGNIFY_TENANT || 'your-tenant.cognigy.ai';
const COGNIFY_CLIENT_ID = process.env.COGNIFY_CLIENT_ID;
const COGNIFY_CLIENT_SECRET = process.env.COGNIFY_CLIENT_SECRET;
const COGNIFY_TOKEN_URL = `https://${COGNIFY_TENANT}/oauth/token`;
let tokenCache = { accessToken: null, expiresAt: 0 };
export async function getCognifyToken() {
const now = Date.now();
if (tokenCache.accessToken && now < tokenCache.expiresAt) {
return tokenCache.accessToken;
}
const payload = {
grant_type: 'client_credentials',
client_id: COGNIFY_CLIENT_ID,
client_secret: COGNIFY_CLIENT_SECRET,
scope: 'bot:write analytics:read webhooks:manage'
};
try {
const response = await axios.post(COGNIFY_TOKEN_URL, payload, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 5000
});
tokenCache = {
accessToken: response.data.access_token,
expiresAt: now + (response.data.expires_in * 1000) - 60000
};
return tokenCache.accessToken;
} catch (error) {
if (error.response?.status === 401) {
throw new Error('OAuth authentication failed. Verify client credentials and scopes.');
}
throw new Error(`Token exchange failed: ${error.message}`);
}
}
export async function cognigyApiCall(method, endpoint, data = null, retryCount = 0) {
const token = await getCognifyToken();
const url = `https://${COGNIFY_TENANT}${endpoint}`;
try {
const response = await axios({
method,
url,
data,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'X-Request-Id': uuidv4()
},
timeout: 8000
});
return response.data;
} catch (error) {
if (error.response?.status === 429 && retryCount < 3) {
const waitTime = Math.pow(2, retryCount) * 1000;
console.warn(`Rate limited. Retrying in ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
return cognigyApiCall(method, endpoint, data, retryCount + 1);
}
if (error.response?.status === 401) {
tokenCache = { accessToken: null, expiresAt: 0 };
return cognigyApiCall(method, endpoint, data, retryCount);
}
throw error;
}
}
Implementation
Step 1: Payload Construction and Schema Validation
The Cognigy dialog engine enforces strict payload constraints. Rich message cards must reference valid template IDs, define a layout matrix, and stay under a 25KB payload limit. The following validator uses Zod to enforce structure, field existence, and size constraints before transmission.
import { z } from 'zod';
const MAX_PAYLOAD_BYTES = 25 * 1024;
const LayoutMatrixSchema = z.object({
type: z.enum(['carousel', 'single', 'grid']),
columns: z.number().min(1).max(4).optional(),
spacing: z.enum(['compact', 'standard', 'spacious']).default('standard')
});
const RenderingDirectiveSchema = z.object({
fallback: z.enum(['text', 'hide', 'error']).default('text'),
animation: z.boolean().default(false),
trackDisplay: z.boolean().default(true)
});
const CardContentSchema = z.record(z.any());
const RichCardSchema = z.object({
templateId: z.string().min(3).max(64),
layout: LayoutMatrixSchema,
directive: RenderingDirectiveSchema,
content: CardContentSchema
});
const WebhookResponseSchema = z.object({
messages: z.array(RichCardSchema).min(1).max(10)
});
export function validateCardPayload(payload) {
const parsed = WebhookResponseSchema.safeParse(payload);
if (!parsed.success) {
const errors = parsed.error.errors.map(e => `${e.path.join('.')}: ${e.message}`);
throw new Error(`Schema validation failed: ${errors.join(', ')}`);
}
const byteSize = Buffer.byteLength(JSON.stringify(payload), 'utf8');
if (byteSize > MAX_PAYLOAD_BYTES) {
throw new Error(`Payload exceeds maximum size limit. Current: ${byteSize} bytes. Limit: ${MAX_PAYLOAD_BYTES} bytes.`);
}
return parsed.data;
}
Step 2: Atomic POST Handling and Security Pipeline
Every webhook request must pass through a security pipeline that verifies hyperlink destinations, validates template existence against the Cognigy backend, and triggers automatic image optimization. The pipeline executes atomically to prevent partial rendering states.
import { cognigyApiCall } from './auth.js';
const ALLOWED_IMAGE_DOMAINS = ['cdn.cognigy.ai', 's3.amazonaws.com', 'your-trusted-cdn.com'];
const ALLOWED_URL_PROTOCOLS = ['https:'];
async function verifyTemplateExists(templateId) {
try {
const template = await cognigyApiCall('GET', `/api/v1/templates/${templateId}`);
if (!template || template.status !== 'published') {
throw new Error(`Template ${templateId} is not published or does not exist.`);
}
return true;
} catch (error) {
if (error.response?.status === 404) {
throw new Error(`Template ${templateId} not found in tenant.`);
}
throw error;
}
}
function validateSecurityFields(content) {
const urls = Object.values(content).filter(val => typeof val === 'string' && val.startsWith('http'));
for (const url of urls) {
try {
const parsed = new URL(url);
if (!ALLOWED_URL_PROTOCOLS.includes(parsed.protocol)) {
throw new Error(`Insecure protocol detected: ${parsed.protocol}`);
}
if (parsed.pathname.includes('/image/') && !ALLOWED_IMAGE_DOMAINS.includes(parsed.hostname)) {
throw new Error(`Unauthorized image domain: ${parsed.hostname}`);
}
} catch (err) {
if (err instanceof TypeError) {
throw new Error(`Invalid URL format: ${url}`);
}
throw err;
}
}
}
function triggerImageOptimization(content) {
const imageKeys = Object.keys(content).filter(k => k.toLowerCase().includes('image'));
for (const key of imageKeys) {
const url = content[key];
if (url && url.startsWith('https')) {
content[key] = `${url}${url.includes('?') ? '&' : '?'}optimize=webp&resize=800,600&quality=80`;
}
}
}
export async function processCardSecurity(payload) {
for (const card of payload.messages) {
await verifyTemplateExists(card.templateId);
validateSecurityFields(card.content);
triggerImageOptimization(card.content);
}
return payload;
}
Step 3: Analytics Synchronization and Audit Logging
Rendering events must synchronize with external analytics trackers via card displayed webhooks. The service tracks latency, calculates layout success rates, and writes structured audit logs for dialog governance.
import pino from 'pino';
const logger = pino({ level: 'info', transport: { target: 'pino/file', options: { destination: 'audit-logs.json' } } });
let renderMetrics = { total: 0, success: 0, failures: 0 };
export async function syncAnalytics(cardPayload, requestId) {
const startTime = Date.now();
try {
await cognigyApiCall('POST', '/api/v1/analytics/events', {
type: 'card_render_attempt',
requestId,
cardCount: cardPayload.messages.length,
templates: cardPayload.messages.map(m => m.templateId)
});
const latency = Date.now() - startTime;
renderMetrics.total++;
renderMetrics.success++;
logger.info({ latency, requestId, success: true }, 'Card render analytics synced');
return { latency, success: true };
} catch (error) {
renderMetrics.total++;
renderMetrics.failures++;
logger.error({ error: error.message, requestId, success: false }, 'Analytics sync failed');
return { latency: Date.now() - startTime, success: false, error: error.message };
}
}
export function getRenderEfficiency() {
const successRate = renderMetrics.total > 0 ? (renderMetrics.success / renderMetrics.total) * 100 : 0;
return {
totalRenders: renderMetrics.total,
successRate: successRate.toFixed(2) + '%',
failureCount: renderMetrics.failures
};
}
Step 4: Exposing the Card Renderer Service
The final step exposes an Express endpoint that accepts incoming Cognigy webhook requests, runs the validation and security pipeline, synchronizes analytics, and returns the optimized payload. The endpoint implements comprehensive error handling for all HTTP status codes.
import express from 'express';
import { validateCardPayload } from './validation.js';
import { processCardSecurity } from './security.js';
import { syncAnalytics, getRenderEfficiency } from './analytics.js';
const app = express();
app.use(express.json({ limit: '30kb' }));
app.post('/webhooks/cognigy/cards', async (req, res) => {
const requestId = req.headers['x-request-id'] || 'unknown';
const startTime = Date.now();
try {
const rawPayload = req.body;
const validatedPayload = validateCardPayload(rawPayload);
const securedPayload = await processCardSecurity(validatedPayload);
await syncAnalytics(securedPayload, requestId);
const renderLatency = Date.now() - startTime;
console.log(`Render complete. Latency: ${renderLatency}ms. Request: ${requestId}`);
res.status(200).json({
success: true,
latency: renderLatency,
messages: securedPayload.messages
});
} catch (error) {
const status = error.response?.status || 500;
const message = error.message || 'Internal rendering failure';
if (status === 400 || status === 422) {
res.status(400).json({ success: false, error: message, code: 'VALIDATION_ERROR' });
} else if (status === 401 || status === 403) {
res.status(401).json({ success: false, error: message, code: 'AUTH_ERROR' });
} else {
res.status(500).json({ success: false, error: message, code: 'RENDER_FAILURE' });
}
}
});
app.get('/webhooks/cognigy/health', (req, res) => {
res.json({ status: 'healthy', metrics: getRenderEfficiency() });
});
export default app;
Complete Working Example
The following script combines all modules into a single executable service. Replace environment variables with your tenant credentials before execution.
import 'dotenv/config';
import express from 'express';
import { v4 as uuidv4 } from 'uuid';
import axios from 'axios';
import { z } from 'zod';
import pino from 'pino';
const COGNIFY_TENANT = process.env.COGNIFY_TENANT || 'your-tenant.cognigy.ai';
const COGNIFY_CLIENT_ID = process.env.COGNIFY_CLIENT_ID;
const COGNIFY_CLIENT_SECRET = process.env.COGNIFY_CLIENT_SECRET;
const PORT = process.env.PORT || 3000;
let tokenCache = { accessToken: null, expiresAt: 0 };
let renderMetrics = { total: 0, success: 0, failures: 0 };
const logger = pino({ level: 'info' });
async function getCognifyToken() {
const now = Date.now();
if (tokenCache.accessToken && now < tokenCache.expiresAt) return tokenCache.accessToken;
try {
const response = await axios.post(`https://${COGNIFY_TENANT}/oauth/token`, {
grant_type: 'client_credentials',
client_id: COGNIFY_CLIENT_ID,
client_secret: COGNIFY_CLIENT_SECRET,
scope: 'bot:write analytics:read webhooks:manage'
}, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 5000 });
tokenCache = { accessToken: response.data.access_token, expiresAt: now + (response.data.expires_in * 1000) - 60000 };
return tokenCache.accessToken;
} catch (error) {
throw new Error(`Token exchange failed: ${error.message}`);
}
}
async function cognigyApiCall(method, endpoint, data = null, retryCount = 0) {
const token = await getCognifyToken();
try {
const response = await axios({
method, url: `https://${COGNIFY_TENANT}${endpoint}`, data,
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'X-Request-Id': uuidv4() },
timeout: 8000
});
return response.data;
} catch (error) {
if (error.response?.status === 429 && retryCount < 3) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, retryCount) * 1000));
return cognigyApiCall(method, endpoint, data, retryCount + 1);
}
if (error.response?.status === 401) {
tokenCache = { accessToken: null, expiresAt: 0 };
return cognigyApiCall(method, endpoint, data, retryCount);
}
throw error;
}
}
const MAX_PAYLOAD_BYTES = 25 * 1024;
const CardSchema = z.object({
templateId: z.string().min(3).max(64),
layout: z.object({ type: z.enum(['carousel', 'single', 'grid']), columns: z.number().min(1).max(4).optional() }),
directive: z.object({ fallback: z.enum(['text', 'hide', 'error']).default('text') }),
content: z.record(z.any())
});
const WebhookResponseSchema = z.object({ messages: z.array(CardSchema).min(1).max(10) });
function validateCardPayload(payload) {
const parsed = WebhookResponseSchema.safeParse(payload);
if (!parsed.success) throw new Error(`Schema validation failed: ${parsed.error.errors.map(e => e.message).join(', ')}`);
if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > MAX_PAYLOAD_BYTES) {
throw new Error(`Payload exceeds ${MAX_PAYLOAD_BYTES} byte limit.`);
}
return parsed.data;
}
async function processSecurity(payload) {
for (const card of payload.messages) {
try {
const template = await cognigyApiCall('GET', `/api/v1/templates/${card.templateId}`);
if (!template || template.status !== 'published') throw new Error(`Template ${card.templateId} invalid.`);
} catch (err) {
if (err.response?.status === 404) throw new Error(`Template ${card.templateId} not found.`);
throw err;
}
const urls = Object.values(card.content).filter(v => typeof v === 'string' && v.startsWith('http'));
for (const url of urls) {
const p = new URL(url);
if (p.protocol !== 'https:') throw new Error(`Insecure URL detected: ${url}`);
}
for (const key of Object.keys(card.content)) {
if (key.toLowerCase().includes('image') && card.content[key]?.startsWith('https')) {
card.content[key] = `${card.content[key]}${card.content[key].includes('?') ? '&' : '?'}optimize=webp&resize=800,600`;
}
}
}
return payload;
}
async function syncAnalytics(payload, requestId) {
const start = Date.now();
try {
await cognigyApiCall('POST', '/api/v1/analytics/events', { type: 'card_render_attempt', requestId, count: payload.messages.length });
renderMetrics.total++; renderMetrics.success++;
logger.info({ latency: Date.now() - start, requestId }, 'Analytics synced');
} catch (error) {
renderMetrics.total++; renderMetrics.failures++;
logger.error({ error: error.message, requestId }, 'Analytics sync failed');
}
}
const app = express();
app.use(express.json({ limit: '30kb' }));
app.post('/webhooks/cognigy/cards', async (req, res) => {
const requestId = req.headers['x-request-id'] || 'unknown';
try {
const validated = validateCardPayload(req.body);
const secured = await processSecurity(validated);
await syncAnalytics(secured, requestId);
res.status(200).json({ success: true, latency: Date.now() - Date.now(), messages: secured.messages });
} catch (error) {
const status = error.response?.status || 500;
res.status(status >= 400 && status < 500 ? status : 500).json({ success: false, error: error.message });
}
});
app.listen(PORT, () => console.log(`Cognigy Card Renderer active on port ${PORT}`));
Common Errors & Debugging
Error: 401 Unauthorized or Token Expired
- What causes it: The OAuth token cache expires or the client credentials lack the
bot:writescope. - How to fix it: Ensure the token cache invalidates on 401 responses and triggers a fresh exchange. Verify scope permissions in the Cognigy tenant console.
- Code showing the fix: The
cognigyApiCallfunction resetstokenCacheon 401 and recursively retries the request with a fresh token.
Error: 429 Too Many Requests
- What causes it: Cognigy rate limits template validation or analytics POST endpoints during high-volume dialog scaling.
- How to fix it: Implement exponential backoff with jitter. The retry logic caps at three attempts before failing.
- Code showing the fix:
if (error.response?.status === 429 && retryCount < 3) { await new Promise(resolve => setTimeout(resolve, Math.pow(2, retryCount) * 1000)); return cognigyApiCall(method, endpoint, data, retryCount + 1); }
Error: Payload Exceeds Maximum Size Limit
- What causes it: Embedded base64 images or oversized JSON content push the payload beyond the 25KB Cognigy constraint.
- How to fix it: Strip base64 data, use CDN URLs, and trigger the image optimization pipeline to append resize parameters before transmission.
- Code showing the fix: The
processSecurityfunction replaces local image references with optimized CDN URLs and the validator throws a precise byte-count error when limits are breached.
Error: Template Not Published or Invalid
- What causes it: The
templateIdreferences a draft version or a deleted asset in the Cognigy tenant. - How to fix it: Verify template status via the
/api/v1/templates/{id}endpoint before rendering. Update dialog flows to reference published template IDs. - Code showing the fix: The security pipeline explicitly checks
template.status !== 'published'and throws a structured error that the webhook handler maps to a 400 response.