Files
calendar/apps/web/app/api/webhooks/retell-ai/route.ts
T
Udit TakkarandGitHub 3915348d03 feat: support web calls in webhook (#23788)
* feat: support web calls in webhook

* fix: test

* chore: formating

* fix: type error

* chore: improvements

* chore: improvements
2025-09-12 19:04:19 +00:00

300 lines
9.2 KiB
TypeScript

import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { Retell } from "retell-sdk";
import { z } from "zod";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { PrismaAgentRepository } from "@calcom/lib/server/repository/PrismaAgentRepository";
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import { CreditUsageType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
const RetellWebhookSchema = z.object({
event: z.enum(["call_started", "call_ended", "call_analyzed"]),
call: z
.object({
call_id: z.string(),
agent_id: z.string().optional(),
// Make phone fields optional for web calls
from_number: z.string().optional(),
to_number: z.string().optional(),
direction: z.enum(["inbound", "outbound"]).optional(),
call_type: z.string().optional(),
call_status: z.string(),
start_timestamp: z.number(),
end_timestamp: z.number().optional(),
disconnection_reason: z.string().optional(),
metadata: z.record(z.any()).optional(),
retell_llm_dynamic_variables: z.record(z.any()).optional(),
transcript: z.string().optional(),
opt_out_sensitive_data_storage: z.boolean().optional(),
call_cost: z
.object({
product_costs: z
.array(
z.object({
product: z.string(),
unitPrice: z.number().optional(),
cost: z.number().optional(),
})
)
.optional(),
total_duration_seconds: z.number().optional(),
total_duration_unit_price: z.number().optional(),
total_one_time_price: z.number().optional(),
combined_cost: z.number().optional(),
})
.optional(),
call_analysis: z
.object({
call_summary: z.string().optional(),
in_voicemail: z.boolean().optional(),
user_sentiment: z.string().optional(),
call_successful: z.boolean().optional(),
custom_analysis_data: z.record(z.any()).optional(),
})
.optional(),
})
.passthrough(),
});
async function chargeCreditsForCall({
userId,
teamId,
callCost,
callId,
callDuration,
}: {
userId?: number;
teamId?: number;
callCost: number;
callId: string;
callDuration: number;
}) {
const rawRatePerMinute = process.env.CAL_AI_CALL_RATE_PER_MINUTE ?? "0.29";
const ratePerMinute = Number.parseFloat(rawRatePerMinute);
const safeRatePerMinute = Number.isFinite(ratePerMinute) && ratePerMinute > 0 ? ratePerMinute : 0.29;
const durationInMinutes = callDuration / 60;
const calculatedCallCost = durationInMinutes * safeRatePerMinute;
// Convert to cents and round up to ensure we don't undercharge
const creditsToDeduct = Math.ceil(calculatedCallCost * 100);
const creditService = new CreditService();
try {
await creditService.chargeCredits({
userId: userId ?? undefined,
teamId: teamId ?? undefined,
credits: creditsToDeduct,
callDuration: callDuration,
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
externalRef: `retell:${callId}`,
});
return {
success: true,
message: `Successfully charged ${creditsToDeduct} credits (${callDuration}s at $${safeRatePerMinute}/min) for ${
teamId ? `team:${teamId}` : ""
} ${userId ? `user:${userId}` : ""}, call ${callId}`,
};
} catch (e) {
log.error("Error charging credits for Retell AI call", {
error: e,
call_id: callId,
call_cost: callCost,
userId,
teamId,
});
return {
success: false,
message: `Error charging credits for Retell AI call: ${
e instanceof Error ? e.message : "Unknown error"
}`,
};
}
}
async function handleCallAnalyzed(callData: any) {
const { from_number, call_id, call_cost, call_type, agent_id } = callData;
if (
!call_cost ||
typeof call_cost.total_duration_seconds !== "number" ||
!Number.isFinite(call_cost.total_duration_seconds) ||
call_cost.total_duration_seconds <= 0
) {
log.info(
`Invalid or missing call_cost.total_duration_seconds for call ${call_id}: ${safeStringify(call_cost)}`
);
return {
success: true,
message: `Invalid or missing call_cost.total_duration_seconds for call ${call_id}`
};
}
let userId: number | undefined;
let teamId: number | undefined;
// Handle web calls vs phone calls
if (call_type === "web_call" || !from_number) {
if (!agent_id) {
log.error(`Web call ${call_id} missing agent_id, cannot charge credits`);
return {
success: false,
message: `Web call ${call_id} missing agent_id, cannot charge credits`
};
}
const agent = await PrismaAgentRepository.findByProviderAgentId({
providerAgentId: agent_id,
});
if (!agent) {
log.error(`No agent found for providerAgentId ${agent_id}, call ${call_id}`);
return {
success: false,
message: `No agent found for providerAgentId ${agent_id}, call ${call_id}`
};
}
userId = agent.userId ?? undefined;
teamId = agent.teamId ?? undefined;
log.info(`Processing web call ${call_id} for agent ${agent_id}, user ${userId}, team ${teamId}`);
} else {
const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({
phoneNumber: from_number,
});
if (!phoneNumber) {
const msg = `No phone number found for ${from_number}, call ${call_id}`;
log.error(msg);
return { success: false, message: msg };
}
userId = phoneNumber.userId ?? undefined;
teamId = phoneNumber.teamId ?? undefined;
log.info(`Processing phone call ${call_id} from ${from_number}, user ${userId}, team ${teamId}`);
}
if (!userId && !teamId) {
log.error(`Call ${call_id} has no associated user or team`);
return {
success: false,
message: `Call ${call_id} has no associated user or team`,
};
}
return await chargeCreditsForCall({
userId,
teamId,
callCost: call_cost.combined_cost || 0,
callId: call_id,
callDuration: call_cost.total_duration_seconds,
});
}
/**
* Retell AI Webhook Handler
*
* Setup Instructions:
* 1. Add this webhook URL to your Retell AI dashboard: https://yourdomain.com/api/webhooks/retell-ai
* 2. Ensure your domain is accessible from the internet (for local development, use ngrok or similar)
* 3. Set the RETELL_AI_KEY environment variable with your Retell API key (must have webhook badge)
*
* This webhook will:
* - Verify webhook signature for security
* - Receive call_analyzed events from Retell AI
* - Charge credits based on the call cost from the user's or team's credit balance
* - Log all transactions for audit purposes
*/
async function handler(request: NextRequest) {
const rawBody = await request.text();
const body = JSON.parse(rawBody);
// Verify webhook signature
const signature = request.headers.get("x-retell-signature");
const apiKey = process.env.RETELL_AI_KEY;
if (!signature || !apiKey) {
log.error("Missing signature or API key for webhook verification");
return NextResponse.json(
{
error: "Unauthorized",
message: "Missing signature or API key",
},
{ status: 401 }
);
}
if (!Retell.verify(rawBody, apiKey, signature)) {
log.error("Invalid webhook signature");
return NextResponse.json(
{
error: "Unauthorized",
message: "Invalid signature",
},
{ status: 401 }
);
}
if (body.event !== "call_analyzed") {
return NextResponse.json(
{
success: true,
message: `No handling for ${body.event} for call ${body.call?.call_id ?? "unknown"}`,
},
{ status: 200 }
);
}
try {
const payload = RetellWebhookSchema.parse(body);
const callData = payload.call;
// Skip inbound calls (only for phone calls, web calls don't have direction)
if (callData.direction === "inbound") {
return NextResponse.json(
{
success: true,
message: `Inbound calls are not charged or supported for now. Ignoring call ${callData.call_id}`,
},
{ status: 200 }
);
}
log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`, {
call_id: callData.call_id,
});
const result = await handleCallAnalyzed(callData);
return NextResponse.json(
{
success: result?.success ?? true,
message: result?.message ?? `Processed ${payload.event} for call ${callData.call_id}`,
},
{ status: 200 }
);
} catch (error) {
log.error("Error processing Retell AI webhook:", safeStringify(error));
return NextResponse.json(
{
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
},
// we need to return 200 to retell ai to avoid retries
{ status: 200 }
);
}
}
export const POST = defaultResponderForAppDir(handler);