Building a Payment Processing Webhook for Cognigy.AI with TypeScript
What You Will Build
This tutorial builds a TypeScript webhook that intercepts payment intents from a Cognigy.AI dialog, converts currencies, creates Stripe payment intents with idempotency keys, handles retries, updates dialog state via the Session API, and routes declined transactions to specific error flows. The implementation uses the Stripe REST API, the Cognigy.AI Session API, and a public exchange rate service. The code is written in TypeScript with Node.js and Express.
Prerequisites
- Cognigy.AI workspace URL and API token with
sessions:writepermission - Stripe Secret API key (
sk_test_orsk_live_) - Exchange rate API key (ExchangeRate-API or compatible provider)
- Node.js 18+ and TypeScript 5+
- Dependencies:
express,axios,typescript,@types/express,cors,dotenv
Authentication Setup
Cognigy.AI uses token-based authentication for the Session API. You generate an API token in your Cognigy workspace settings and attach it to the Authorization header as a Bearer token. The required permission is sessions:write. Stripe uses a Secret API key passed as a Bearer token. The exchange rate service uses a query parameter API key. Store all secrets in a .env file and load them at startup.
import dotenv from 'dotenv';
dotenv.config();
const AUTH_CONFIG = {
COGNIGY_BASE_URL: process.env.COGNIGY_WORKSPACE_URL || 'https://your-workspace.api.cognigy.ai',
COGNIGY_TOKEN: process.env.COGNIGY_API_TOKEN!,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY!,
EXCHANGE_RATE_API_KEY: process.env.EXCHANGE_RATE_API_KEY!,
};
Implementation
Step 1: Webhook Server and Exponential Backoff Retry Logic
The webhook listens for POST requests from Cognigy.AI. You must implement exponential backoff to handle transient network failures or rate limits from upstream services. The retry function accepts an async function, a maximum retry count, and a base delay. It calculates the delay using baseDelay * Math.pow(2, attempt) and adds uniform jitter to prevent thundering herd problems.
import express, { Request, Response } from 'express';
import cors from 'cors';
import axios, { AxiosError } from 'axios';
const app = express();
app.use(cors());
app.use(express.json());
async function retryWithBackoff<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000
): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt === maxRetries) break;
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
app.post('/webhook/payment', async (req: Request, res: Response) => {
try {
const { sessionId, amount, currency, baseCurrency } = req.body;
if (!sessionId || !amount || !currency) {
return res.status(400).json({ error: 'Missing required fields: sessionId, amount, currency' });
}
const result = await processPaymentIntent(sessionId, amount, currency, baseCurrency || 'USD');
return res.status(200).json({ success: true, paymentStatus: result.status });
} catch (error) {
console.error('Webhook processing failed:', error);
return res.status(500).json({ error: 'Internal processing error' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Payment webhook listening on port ${PORT}`));
Step 2: Currency Conversion and Stripe Payment Intent Creation
You fetch the latest exchange rates, convert the amount to the target currency, and generate a UUID for the idempotency key. The idempotency key prevents duplicate charges if the webhook retries or Cognigy.AI resends the request. You pass the key in the Idempotency-Key header to Stripe.
import { v4 as uuidv4 } from 'uuid';
import { AUTH_CONFIG } from './config';
interface ExchangeRateResponse {
rates: Record<string, number>;
}
interface StripePaymentIntentResponse {
id: string;
status: string;
amount: number;
currency: string;
last_payment_error?: {
code: string;
message: string;
};
}
async function fetchExchangeRate(base: string, target: string): Promise<number> {
const url = `https://v6.exchangerate-api.com/v6/${AUTH_CONFIG.EXCHANGE_RATE_API_KEY}/latest/${base}`;
const response = await axios.get<ExchangeRateResponse>(url, {
timeout: 5000,
headers: { 'Content-Type': 'application/json' }
});
const rate = response.data.rates[target];
if (!rate) throw new Error(`Exchange rate for ${base} to ${target} not found`);
return rate;
}
async function createStripePaymentIntent(
amount: number,
currency: string,
idempotencyKey: string
): Promise<StripePaymentIntentResponse> {
const url = 'https://api.stripe.com/v1/payment_intents';
const params = new URLSearchParams();
params.append('amount', Math.round(amount * 100).toString());
params.append('currency', currency);
params.append('payment_method_types[]', 'card');
params.append('capture_method', 'manual');
const response = await axios.post<StripePaymentIntentResponse>(url, params, {
headers: {
'Authorization': `Bearer ${AUTH_CONFIG.STRIPE_SECRET_KEY}`,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/x-www-form-urlencoded'
},
timeout: 10000
});
return response.data;
}
Required Scope/Permission: sessions:write (Cognigy), payment_intents.create (Stripe API capability)
Example HTTP Request Cycle:
POST /v1/payment_intents HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_test_51ABC...
Idempotency-Key: 8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d
Content-Type: application/x-www-form-urlencoded
amount=2500¤cy=usd&payment_method_types[]=card&capture_method=manual
Example Response:
{
"id": "pi_3O1k2L4M5n6P7q8R",
"object": "payment_intent",
"amount": 2500,
"currency": "usd",
"status": "requires_payment_method",
"last_payment_error": null
}
Step 3: Response Parsing and Cognigy Session API State Update
You parse the Stripe response to extract the payment status and error details. You then construct a context payload for Cognigy.AI and send it to the Session API. The Session API expects a JSON object containing key-value pairs that map to dialog variables.
interface CognigyContextPayload {
[key: string]: string | number | boolean | null;
}
async function updateCognigyContext(
sessionId: string,
context: CognigyContextPayload
): Promise<void> {
const url = `${AUTH_CONFIG.COGNIGY_BASE_URL}/api/v2/session/${sessionId}/context`;
await axios.put(url, context, {
headers: {
'Authorization': `Bearer ${AUTH_CONFIG.COGNIGY_TOKEN}`,
'Content-Type': 'application/json'
},
timeout: 8000
});
}
Step 4: Decline Code Handling and Error Flow Routing
Stripe returns specific decline codes in the last_payment_error.code field. You map these codes to Cognigy dialog variables that trigger conditional branches in your dialog flow. Common codes include card_declined, insufficient_funds, expired_card, and processing_error. You update the Cognigy context with the decline code and a user-friendly message, then Cognigy routes to the appropriate error node.
interface PaymentResult {
status: string;
paymentIntentId: string;
declineCode?: string;
errorMessage?: string;
}
async function processPaymentIntent(
sessionId: string,
amount: number,
currency: string,
baseCurrency: string
): Promise<PaymentResult> {
const idempotencyKey = uuidv4();
let convertedAmount = amount;
if (currency !== baseCurrency) {
const rate = await retryWithBackoff(() => fetchExchangeRate(baseCurrency, currency));
convertedAmount = amount * rate;
}
let stripeResponse: StripePaymentIntentResponse;
try {
stripeResponse = await retryWithBackoff(() =>
createStripePaymentIntent(convertedAmount, currency, idempotencyKey)
);
} catch (error) {
const axiosError = error as AxiosError<{ error?: { code: string; message: string } }>;
const declineCode = axiosError.response?.data?.error?.code || 'unknown_error';
await updateCognigyContext(sessionId, {
paymentStatus: 'failed',
declineCode,
errorMessage: axiosError.response?.data?.error?.message || 'Payment processing failed'
});
return { status: 'failed', paymentIntentId: '', declineCode, errorMessage: axiosError.message };
}
const contextUpdate: CognigyContextPayload = {
paymentStatus: stripeResponse.status,
paymentIntentId: stripeResponse.id,
convertedAmount: convertedAmount.toFixed(2),
currency
};
if (stripeResponse.last_payment_error) {
const declineCode = stripeResponse.last_payment_error.code;
contextUpdate.declineCode = declineCode;
contextUpdate.errorMessage = stripeResponse.last_payment_error.message;
const declineMapping: Record<string, string> = {
'card_declined': 'Your card was declined. Please use a different payment method.',
'insufficient_funds': 'Insufficient funds in your account.',
'expired_card': 'Your card has expired.',
'processing_error': 'The payment processor encountered an error. Please try again later.'
};
contextUpdate.errorFlowMessage = declineMapping[declineCode] || 'Payment could not be processed.';
contextUpdate.paymentStatus = 'declined';
} else {
contextUpdate.errorFlowMessage = 'Payment authorized successfully.';
}
await updateCognigyContext(sessionId, contextUpdate);
return {
status: contextUpdate.paymentStatus as string,
paymentIntentId: stripeResponse.id,
declineCode: contextUpdate.declineCode as string | undefined,
errorMessage: contextUpdate.errorMessage as string | undefined
};
}
Complete Working Example
The following TypeScript module combines all components into a single runnable service. Save it as index.ts, install dependencies, compile, and run.
import express, { Request, Response } from 'express';
import cors from 'cors';
import axios, { AxiosError } from 'axios';
import { v4 as uuidv4 } from 'uuid';
import dotenv from 'dotenv';
dotenv.config();
const CONFIG = {
COGNIGY_BASE_URL: process.env.COGNIGY_WORKSPACE_URL || 'https://your-workspace.api.cognigy.ai',
COGNIGY_TOKEN: process.env.COGNIGY_API_TOKEN!,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY!,
EXCHANGE_RATE_API_KEY: process.env.EXCHANGE_RATE_API_KEY!,
PORT: process.env.PORT || 3000
};
// Type definitions
interface ExchangeRateResponse { rates: Record<string, number>; }
interface StripePaymentIntentResponse {
id: string; status: string; amount: number; currency: string;
last_payment_error?: { code: string; message: string; };
}
interface CognigyContextPayload { [key: string]: string | number | boolean | null; }
interface PaymentResult { status: string; paymentIntentId: string; declineCode?: string; errorMessage?: string; }
// Retry utility with exponential backoff and jitter
async function retryWithBackoff<T>(fn: () => Promise<T>, maxRetries: number = 3, baseDelay: number = 1000): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { return await fn(); }
catch (error) {
lastError = error;
if (attempt === maxRetries) break;
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError;
}
// Currency conversion
async function fetchExchangeRate(base: string, target: string): Promise<number> {
const url = `https://v6.exchangerate-api.com/v6/${CONFIG.EXCHANGE_RATE_API_KEY}/latest/${base}`;
const response = await axios.get<ExchangeRateResponse>(url, { timeout: 5000 });
const rate = response.data.rates[target];
if (!rate) throw new Error(`Exchange rate for ${base} to ${target} not found`);
return rate;
}
// Stripe payment intent creation
async function createStripePaymentIntent(amount: number, currency: string, idempotencyKey: string): Promise<StripePaymentIntentResponse> {
const url = 'https://api.stripe.com/v1/payment_intents';
const params = new URLSearchParams();
params.append('amount', Math.round(amount * 100).toString());
params.append('currency', currency);
params.append('payment_method_types[]', 'card');
params.append('capture_method', 'manual');
const response = await axios.post<StripePaymentIntentResponse>(url, params, {
headers: {
'Authorization': `Bearer ${CONFIG.STRIPE_SECRET_KEY}`,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/x-www-form-urlencoded'
},
timeout: 10000
});
return response.data;
}
// Cognigy context update
async function updateCognigyContext(sessionId: string, context: CognigyContextPayload): Promise<void> {
const url = `${CONFIG.COGNIGY_BASE_URL}/api/v2/session/${sessionId}/context`;
await axios.put(url, context, {
headers: {
'Authorization': `Bearer ${CONFIG.COGNIGY_TOKEN}`,
'Content-Type': 'application/json'
},
timeout: 8000
});
}
// Core payment processing logic
async function processPaymentIntent(sessionId: string, amount: number, currency: string, baseCurrency: string): Promise<PaymentResult> {
const idempotencyKey = uuidv4();
let convertedAmount = amount;
if (currency !== baseCurrency) {
convertedAmount = amount * await retryWithBackoff(() => fetchExchangeRate(baseCurrency, currency));
}
let stripeResponse: StripePaymentIntentResponse;
try {
stripeResponse = await retryWithBackoff(() => createStripePaymentIntent(convertedAmount, currency, idempotencyKey));
} catch (error) {
const axiosError = error as AxiosError<{ error?: { code: string; message: string } }>;
const declineCode = axiosError.response?.data?.error?.code || 'unknown_error';
await updateCognigyContext(sessionId, {
paymentStatus: 'failed',
declineCode,
errorMessage: axiosError.response?.data?.error?.message || 'Payment processing failed'
});
return { status: 'failed', paymentIntentId: '', declineCode, errorMessage: axiosError.message };
}
const contextUpdate: CognigyContextPayload = {
paymentStatus: stripeResponse.status,
paymentIntentId: stripeResponse.id,
convertedAmount: convertedAmount.toFixed(2),
currency
};
if (stripeResponse.last_payment_error) {
const declineCode = stripeResponse.last_payment_error.code;
contextUpdate.declineCode = declineCode;
contextUpdate.errorMessage = stripeResponse.last_payment_error.message;
const declineMapping: Record<string, string> = {
'card_declined': 'Your card was declined. Please use a different payment method.',
'insufficient_funds': 'Insufficient funds in your account.',
'expired_card': 'Your card has expired.',
'processing_error': 'The payment processor encountered an error. Please try again later.'
};
contextUpdate.errorFlowMessage = declineMapping[declineCode] || 'Payment could not be processed.';
contextUpdate.paymentStatus = 'declined';
} else {
contextUpdate.errorFlowMessage = 'Payment authorized successfully.';
}
await updateCognigyContext(sessionId, contextUpdate);
return { status: contextUpdate.paymentStatus as string, paymentIntentId: stripeResponse.id, declineCode: contextUpdate.declineCode as string | undefined, errorMessage: contextUpdate.errorMessage as string | undefined };
}
// Express server setup
const app = express();
app.use(cors());
app.use(express.json());
app.post('/webhook/payment', async (req: Request, res: Response) => {
try {
const { sessionId, amount, currency, baseCurrency } = req.body;
if (!sessionId || !amount || !currency) {
return res.status(400).json({ error: 'Missing required fields: sessionId, amount, currency' });
}
const result = await processPaymentIntent(sessionId, amount, currency, baseCurrency || 'USD');
return res.status(200).json({ success: true, paymentStatus: result.status });
} catch (error) {
console.error('Webhook processing failed:', error);
return res.status(500).json({ error: 'Internal processing error' });
}
});
app.listen(CONFIG.PORT, () => console.log(`Payment webhook listening on port ${CONFIG.PORT}`));
Common Errors and Debugging
Error: 401 Unauthorized (Cognigy Session API)
- Cause: The Bearer token is missing, expired, or lacks the
sessions:writepermission. - Fix: Regenerate the API token in your Cognigy workspace settings. Verify the token is correctly injected into the
Authorizationheader. Ensure the token scope includes session context modification rights.
Error: 400 Idempotency Key Already Used (Stripe)
- Cause: You retried a request with the same
Idempotency-Keybut changed the payload parameters. Stripe requires identical payloads for repeated idempotency keys. - Fix: Generate a fresh UUID for each new payment attempt. Only reuse the idempotency key when retrying the exact same request parameters after a network timeout.
Error: 429 Too Many Requests (Exchange Rate API or Stripe)
- Cause: Exceeding the rate limit of the external service.
- Fix: The included
retryWithBackofffunction handles transient 429 responses automatically. If failures persist, implement a client-side request queue or increase thebaseDelayparameter. Monitor your API usage dashboard to adjust quota limits.
Error: 502 Bad Gateway or Connection Timeout (Cognigy Session API)
- Cause: The Cognigy.AI platform is temporarily unreachable or the webhook server cannot resolve the workspace URL.
- Fix: Verify DNS resolution and network connectivity. Increase the
timeoutvalue in theaxios.putcall. Add a health check endpoint to your webhook service to monitor upstream availability.