Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
5cac626
feat: cal.ai self serve
Udit-takkar Jun 15, 2025
e38f713
chore: progress
Udit-takkar Jun 16, 2025
f52c497
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar Jun 24, 2025
b6be1fd
fix: form
Udit-takkar Jun 24, 2025
e7a61c4
fix: links[endpoint] error
Udit-takkar Jun 25, 2025
d2ad1cf
chore: form
Udit-takkar Jun 26, 2025
ca041da
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar Jun 26, 2025
049c1bb
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar Jul 2, 2025
3b9cf80
feat: finish setup
Udit-takkar Jul 3, 2025
def1186
chore: fix
Udit-takkar Jul 3, 2025
8a5967b
fix: trpc hang bug
Udit-takkar Jul 4, 2025
b1c0524
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar Jul 4, 2025
436301c
chore: save progress
Udit-takkar Jul 4, 2025
e9c3c05
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar Jul 8, 2025
254a2e0
feat: add phone number billing
Udit-takkar Jul 9, 2025
807d515
feat: retell ai webhook
Udit-takkar Jul 9, 2025
6771fa7
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar Jul 9, 2025
39cd886
fix: type error
Udit-takkar Jul 9, 2025
a27a800
feat: remove api key input
Udit-takkar Jul 9, 2025
9676ef3
feat: add delete
Udit-takkar Jul 10, 2025
fb852c2
feat: add delete logic and assign phone number
Udit-takkar Jul 10, 2025
6d06d74
refactor: use design pattern
Udit-takkar Jul 10, 2025
7106fca
chore: remove comment
Udit-takkar Jul 10, 2025
bf7b9c9
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar Jul 11, 2025
610bd75
fix: type errror
Udit-takkar Jul 11, 2025
95f82aa
refactor: decouple retell ai
Udit-takkar Jul 11, 2025
b79c4b5
fix: name
Udit-takkar Jul 14, 2025
130e38c
chore: comment unued
Udit-takkar Jul 14, 2025
7ff4667
fix: type errors
Udit-takkar Jul 14, 2025
d29eeb9
fix: type error
Udit-takkar Jul 14, 2025
e6700d2
feat: import phone number functionality
Udit-takkar Jul 15, 2025
47d4ad7
Merge branch 'main' into feat/cal-ai-self-serve
Udit-takkar Jul 16, 2025
cdaeae4
feat: wwebhook handler
Udit-takkar Jul 17, 2025
8791002
fix: type errors
Udit-takkar Jul 17, 2025
d9baa63
fix: type error
Udit-takkar Jul 17, 2025
443fa77
fix: 404 new trpc endpoint bug
Udit-takkar Jul 17, 2025
82cca61
fix: impor
Udit-takkar Jul 18, 2025
b1b9aac
chore
Udit-takkar Jul 18, 2025
9be39b8
feat: new version (wip)
Udit-takkar Jul 28, 2025
0754d7b
chore: improvements
Udit-takkar Jul 29, 2025
2701675
tests: add unit tests
Udit-takkar Jul 29, 2025
470f034
fix: types
Udit-takkar Jul 30, 2025
20bcd41
feat: new desing
Udit-takkar Jul 31, 2025
0b9f2b8
refactor: move everything to service
Udit-takkar Jul 31, 2025
351c4db
tests: add unit tests for service and client
Udit-takkar Jul 31, 2025
0ad600a
fix: deleting cal ai action and workflow
Udit-takkar Jul 31, 2025
b0d50a7
chore: update
Udit-takkar Jul 31, 2025
e7a804d
refactor: improve code
Udit-takkar Aug 1, 2025
9b9ef44
chore: use new app route
Udit-takkar Aug 1, 2025
326621a
fix: infinite rendering bug
Udit-takkar Aug 1, 2025
feb6236
feat: add team support completely
Udit-takkar Aug 1, 2025
1df875e
chore: remove old design and unused
Udit-takkar Aug 4, 2025
3146566
perf: improve agent repository query
Udit-takkar Aug 4, 2025
f50049e
perf: improve query
Udit-takkar Aug 4, 2025
faebeb1
chore: improvements and rate limiting
Udit-takkar Aug 4, 2025
4c62e65
fix: credit reposiotory
Udit-takkar Aug 4, 2025
8d8ff2d
fix: update unit tests and type error
Udit-takkar Aug 4, 2025
62bbd3f
fix: type
Udit-takkar Aug 4, 2025
f9886a2
chore: types
Udit-takkar Aug 4, 2025
5253927
fix: use types from retell sdk
Udit-takkar Aug 4, 2025
9301519
chore: remove PhoneData
Udit-takkar Aug 4, 2025
81f0145
chore: code improvements
Udit-takkar Aug 4, 2025
e97d2ed
chore: code improvements
Udit-takkar Aug 5, 2025
c1791b8
fix: tests and types
Udit-takkar Aug 5, 2025
b34588e
chore: improve UI
Udit-takkar Aug 5, 2025
a34a25e
chore: type error
Udit-takkar Aug 5, 2025
fe7ec30
fix: improvements and i18n
Udit-takkar Aug 5, 2025
69f1ec1
chore: remove unused
Udit-takkar Aug 5, 2025
c153c8b
chore: more i18n
Udit-takkar Aug 5, 2025
b9c2de2
chore: i18n
Udit-takkar Aug 5, 2025
fcfb4f6
fix: type err
Udit-takkar Aug 5, 2025
13ba93a
chore: missing check
Udit-takkar Aug 5, 2025
990c9c8
chore: update docs
Udit-takkar Aug 5, 2025
85878ec
nit
Udit-takkar Aug 5, 2025
8d0d762
chore: i18n, other improvements
Udit-takkar Aug 5, 2025
265e536
fix: formatting
Udit-takkar Aug 5, 2025
70c18b8
fix: type
Udit-takkar Aug 5, 2025
953001f
refactor: stripe related webhooks
Udit-takkar Aug 6, 2025
49bd22a
Merge branch 'main' into feat/cal-ai-self-serve
emrysal Aug 6, 2025
2cdbfa7
chore: imporovements
Udit-takkar Aug 7, 2025
2f52003
fix: types
Udit-takkar Aug 7, 2025
15ddc3d
chore
Udit-takkar Aug 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -383,10 +383,13 @@ TASKER_ENABLE_EMAILS=0
# Ratelimiting via unkey
UNKEY_ROOT_KEY=

# Used for Cal.ai Enterprise Voice AI Agents
# Used for Cal.ai Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=

# Used for buying phone number for cal ai voice agent
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID=

# Used for the huddle01 integration
HUDDLE01_API_TOKEN=

Expand Down
94 changes: 94 additions & 0 deletions apps/web/app/api/phone-numbers/subscription/success/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { z } from "zod";

import stripe from "@calcom/features/ee/payments/server/stripe";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";

const querySchema = z.object({
session_id: z.string().min(1),
});

const checkoutSessionMetadataSchema = z.object({
userId: z.string().transform(Number),
teamId: z
.string()
.optional()
.transform((val) => (val ? Number(val) : undefined)),
eventTypeId: z
.string()
.optional()
.transform((val) => (val ? Number(val) : undefined)),
agentId: z.string().optional(),
workflowId: z.string().optional(),
type: z.literal("phone_number_subscription"),
});

type CheckoutSessionMetadata = z.infer<typeof checkoutSessionMetadataSchema>;

async function handler(request: NextRequest) {
try {
const { session_id } = querySchema.parse(Object.fromEntries(request.nextUrl.searchParams));
const checkoutSession = await getCheckoutSession(session_id);
const metadata = validateAndExtractMetadata(checkoutSession);

return redirectToSuccess(metadata);
} catch (error) {
return handleError(error);
}
}

async function getCheckoutSession(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["subscription"] });
if (!session) {
throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
}
return session;
}

function validateAndExtractMetadata(session: Stripe.Checkout.Session): CheckoutSessionMetadata {
if (session.payment_status !== "paid") {
throw new HttpError({ statusCode: 402, message: "Payment required" });
}
if (!session.subscription) {
throw new HttpError({ statusCode: 400, message: "No subscription found in checkout session" });
}

const result = checkoutSessionMetadataSchema.safeParse(session.metadata);
if (!result.success) {
throw new HttpError({
statusCode: 400,
message: `Invalid checkout session metadata: ${result.error}`,
});
}

return result.data;
}

function redirectToSuccess(metadata: CheckoutSessionMetadata) {
const basePath = metadata.workflowId
? `${WEBAPP_URL}/workflows/${metadata.workflowId}`
: `${WEBAPP_URL}/workflows`;

return NextResponse.redirect(basePath);
}

function handleError(error: unknown) {
console.error("Error handling phone number subscription success:", error);

const url = new URL(`${WEBAPP_URL}/workflows`);
url.searchParams.set("error", "true");

if (error instanceof HttpError) {
url.searchParams.set("message", error.message);
} else {
url.searchParams.set("message", "An error occurred while processing your subscription");
}

return NextResponse.redirect(url.toString());
}

export const GET = defaultResponderForAppDir(handler);
194 changes: 194 additions & 0 deletions apps/web/app/api/webhooks/retell-ai/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
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 { RETELL_API_KEY } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { prisma } from "@calcom/prisma";

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(),
from_number: z.string(),
to_number: z.string(),
direction: z.enum(["inbound", "outbound"]),
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 handleCallAnalyzed(callData: any) {
const { from_number, call_id, call_cost } = callData;
if (!call_cost || typeof call_cost.combined_cost !== "number") {
log.error(`No call_cost.combined_cost in payload for call ${call_id}`);
return;
}

const phoneNumber = await prisma.calAiPhoneNumber.findFirst({
where: { phoneNumber: from_number },
include: {
user: { select: { id: true, email: true, name: true } },
team: { select: { id: true, name: true } },
},
});

if (!phoneNumber) {
log.error(`No phone number found for ${from_number}, cannot deduct credits`);
return;
}

// Support both personal and team phone numbers
const userId = phoneNumber.userId;
const teamId = phoneNumber.teamId;

if (!userId && !teamId) {
log.error(`Phone number ${from_number} has no associated user or team`);
return;
}

const baseCost = call_cost.combined_cost; // in cents
const creditsToDeduct = Math.ceil(baseCost * 1.8);

const creditService = new CreditService();
const hasCredits = await creditService.hasAvailableCredits({ userId, teamId });
if (!hasCredits) {
log.error(
`${
teamId ? `Team ${teamId}` : `User ${userId}`
} has insufficient credits for call ${call_id} (${creditsToDeduct} credits needed)`
);
return;
}

await creditService.chargeCredits({
userId: userId ?? undefined,
teamId: teamId ?? undefined,
credits: creditsToDeduct,
});

return {
success: true,
message: `Successfully charged ${creditsToDeduct} credits for ${
teamId ? `team ${teamId}` : `user ${userId}`
}, call ${call_id} (base cost: ${baseCost} cents)`,
};
}

/**
* 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_API_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) {
// Get the raw body for signature verification
const rawBody = await request.text();
const body = JSON.parse(rawBody);

// Verify webhook signature
const signature = request.headers.get("x-retell-signature");
const apiKey = RETELL_API_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"}`,
});
}

try {
const payload = RetellWebhookSchema.parse(body);
const callData = payload.call;
log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`);

const result = await handleCallAnalyzed(callData);

return NextResponse.json({
success: true,
message: result?.message ?? `Processed ${payload.event} for call ${callData.call_id}`,
});
} 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",
},
{ status: 500 }
);
}
}

export const POST = defaultResponderForAppDir(handler);
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
"react-use-intercom": "1.5.1",
"recoil": "^0.7.7",
"remove-markdown": "^0.5.0",
"retell-sdk": "^4.40.0",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need this for calling retell ai API and validating request to webhook endpoint after the call to deduct credits

"rrule": "^2.7.1",
"sanitize-html": "^2.10.0",
"schema-dts": "^1.1.0",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/pages/api/trpc/ai/[trpc].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { aiRouter } from "@calcom/trpc/server/routers/viewer/ai/_router";

export default createNextApiHandler(aiRouter);
4 changes: 4 additions & 0 deletions apps/web/pages/api/trpc/phoneNumber/[trpc].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { phoneNumberRouter } from "@calcom/trpc/server/routers/viewer/phoneNumber/_router";

export default createNextApiHandler(phoneNumberRouter);
Loading
Loading