@cemscale-voip/voip-sdk 1.40.0 → 1.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +340 -0
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -905,6 +905,346 @@ await voip.updateDid('did-uuid-here', {
905
905
  | `reason` | string | Yes | Why: "conversation_complete", "caller_goodbye", "no_response", "voicemail_detected" |
906
906
  | `summary` | string | No | Brief summary of the call |
907
907
 
908
+
909
+
910
+ ---
911
+
912
+ #### CRM Webhook Setup — Receiving AI Tool Calls
913
+
914
+ When an AI agent invokes a tool during a live call (e.g. `send_sms`, `create_lead`), the VoIP platform sends a POST request to your CRM. Your CRM **must** have a webhook endpoint that receives these tool calls, processes them, and returns a response that Gemini speaks to the caller.
915
+
916
+ **Architecture:**
917
+ ```
918
+ Caller speaks -> Gemini AI -> invokes tool (e.g. send_sms)
919
+ -> VoIP Platform (Go) -> POST to VoIP API
920
+ -> VoIP API forwards -> POST to YOUR CRM webhook
921
+ -> Your CRM processes (sends SMS, creates lead, etc.)
922
+ -> Your CRM responds with JSON
923
+ -> Gemini reads response to caller
924
+ ```
925
+
926
+ **CRITICAL: Your webhook endpoint must NOT have global auth middleware.**
927
+
928
+ The VoIP platform authenticates via `Authorization: Bearer <api_key>` where `api_key` is a VoIP SDK API key (`csk_live_...`). Your webhook handler must validate this token itself — do NOT put it behind your CRM's global auth middleware (e.g. session auth, JWT, capability checks). If your CRM's router wraps routes with automatic auth, register this endpoint WITHOUT that wrapper.
929
+
930
+ **Environment variable your CRM needs:**
931
+
932
+ ```env
933
+ # The VoIP SDK API key used to authenticate tool call webhooks.
934
+ # This key is created in the VoIP platform and shared with your CRM.
935
+ # The VoIP platform sends it as: Authorization: Bearer <this_key>
936
+ VOIP_TOOLS_WEBHOOK_KEY=csk_live_da3d1ee6d3b51696922206dc139beab84239a7935049b5a5
937
+ ```
938
+
939
+ **Complete webhook handler your CRM needs (Express/Fastify/etc.):**
940
+
941
+ ```typescript
942
+ import crypto from 'crypto';
943
+
944
+ // ──────────────────────────────────────────────────────────
945
+ // IMPORTANT: Register this route WITHOUT global auth middleware.
946
+ // This endpoint has its own auth via validateToolsWebhookAuth().
947
+ // If your router uses a `wrap()` or `requireAuth()` middleware,
948
+ // do NOT apply it to this route.
949
+ // ──────────────────────────────────────────────────────────
950
+
951
+ /**
952
+ * Validate that the request comes from the VoIP platform.
953
+ * Checks Authorization: Bearer <api_key> against VOIP_TOOLS_WEBHOOK_KEY env var.
954
+ */
955
+ function validateToolsWebhookAuth(req: { headers: Record<string, any> }): boolean {
956
+ const expectedKey = process.env.VOIP_TOOLS_WEBHOOK_KEY;
957
+ if (!expectedKey) return false;
958
+
959
+ // Method 1: Authorization Bearer token
960
+ const authHeader = req.headers.authorization || req.headers.Authorization;
961
+ if (authHeader) {
962
+ const token = typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
963
+ ? authHeader.slice(7)
964
+ : authHeader;
965
+ if (typeof token === 'string' && token.length === expectedKey.length) {
966
+ try {
967
+ return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedKey));
968
+ } catch { return false; }
969
+ }
970
+ }
971
+
972
+ // Method 2: X-API-Key header
973
+ const apiKeyHeader = req.headers['x-api-key'];
974
+ if (typeof apiKeyHeader === 'string' && apiKeyHeader.length === expectedKey.length) {
975
+ try {
976
+ return crypto.timingSafeEqual(Buffer.from(apiKeyHeader), Buffer.from(expectedKey));
977
+ } catch { return false; }
978
+ }
979
+
980
+ return false;
981
+ }
982
+
983
+ // Register WITHOUT global auth middleware:
984
+ // WRONG: router.post('/api/webhooks/ai-tools', requireAuth, handler)
985
+ // RIGHT: router.post('/api/webhooks/ai-tools', handler)
986
+ // If using a `wrap()` function that enforces auth, do NOT use it here.
987
+
988
+ app.post('/api/webhooks/ai-tools', async (req, res) => {
989
+ // 1. Validate auth
990
+ if (!validateToolsWebhookAuth(req)) {
991
+ return res.status(401).json({ status: 'error', message: 'Unauthorized' });
992
+ }
993
+
994
+ // 2. Parse the tool call
995
+ const { function: toolName, args, call_id, tenant_id } = req.body;
996
+
997
+ if (!toolName || !args) {
998
+ return res.status(400).json({ status: 'error', message: 'Missing function or args' });
999
+ }
1000
+
1001
+ console.log(`[AI Tool] ${toolName} called during call ${call_id} (tenant: ${tenant_id})`);
1002
+
1003
+ try {
1004
+ switch (toolName) {
1005
+
1006
+ // ─── SEND SMS ──────────────────────────────────────────────
1007
+ // Gemini collected: to_phone (required), message (required),
1008
+ // purpose (optional), caller_name (optional)
1009
+ //
1010
+ // When to use: The caller asks "can you send me that by text?"
1011
+ // or the AI decides to send a confirmation after booking, etc.
1012
+ case 'send_sms': {
1013
+ const toPhone = args.to_phone;
1014
+ const messageText = args.message;
1015
+
1016
+ if (!toPhone || !messageText) {
1017
+ return res.json({
1018
+ status: 'error',
1019
+ message: 'I need the phone number and message to send. Could you provide those?',
1020
+ });
1021
+ }
1022
+
1023
+ // ── Send the SMS using YOUR SMS provider ──
1024
+ // Replace this with your actual SMS sending code.
1025
+ // Examples: Twilio, Telnyx, Vonage, AWS SNS, etc.
1026
+ //
1027
+ // Twilio example:
1028
+ // const twilio = require('twilio')(TWILIO_SID, TWILIO_AUTH);
1029
+ // const sms = await twilio.messages.create({
1030
+ // to: toPhone,
1031
+ // from: YOUR_TWILIO_NUMBER,
1032
+ // body: messageText,
1033
+ // });
1034
+ //
1035
+ // Telnyx example:
1036
+ // const telnyx = require('telnyx')(TELNYX_API_KEY);
1037
+ // const sms = await telnyx.messages.create({
1038
+ // to: toPhone,
1039
+ // from: YOUR_TELNYX_NUMBER,
1040
+ // text: messageText,
1041
+ // });
1042
+
1043
+ // YOUR SMS SENDING CODE HERE:
1044
+ const smsResult = await sendSms({
1045
+ to: toPhone,
1046
+ message: messageText,
1047
+ });
1048
+
1049
+ // Log the SMS in your database
1050
+ // await db.smsLogs.create({ toPhone, message: messageText,
1051
+ // purpose: args.purpose, callerName: args.caller_name,
1052
+ // callId: call_id, tenantId: tenant_id, status: smsResult.success ? 'sent' : 'failed' });
1053
+
1054
+ if (!smsResult.success) {
1055
+ return res.json({
1056
+ status: 'failed',
1057
+ message: 'I was unable to send the text message right now. ' +
1058
+ 'Let me take note and we will send it to you shortly.',
1059
+ });
1060
+ }
1061
+
1062
+ return res.json({
1063
+ status: 'sent',
1064
+ message: 'The text message has been sent to ' + toPhone + '. ' +
1065
+ 'You should receive it in a moment.',
1066
+ });
1067
+ }
1068
+
1069
+ // ─── CREATE LEAD ──────────────────────────────────────────
1070
+ case 'create_lead': {
1071
+ // args: first_name (required), last_name, phone (required),
1072
+ // email, company, interest, notes, source
1073
+ const lead = await createLeadInCrm({
1074
+ firstName: args.first_name,
1075
+ lastName: args.last_name,
1076
+ phone: args.phone,
1077
+ email: args.email,
1078
+ company: args.company,
1079
+ interest: args.interest,
1080
+ notes: args.notes,
1081
+ callId: call_id,
1082
+ tenantId: tenant_id,
1083
+ });
1084
+
1085
+ return res.json({
1086
+ status: 'created',
1087
+ lead_id: lead.id,
1088
+ message: `I've created a record for ${args.first_name}. A team member will follow up shortly.`,
1089
+ });
1090
+ }
1091
+
1092
+ // ─── LEAVE MESSAGE ────────────────────────────────────────
1093
+ case 'leave_message': {
1094
+ // args: caller_name (required), caller_phone (required),
1095
+ // message (required), for_person, urgent, callback_preferred_time
1096
+ const msg = await saveMessageInCrm({
1097
+ callerName: args.caller_name,
1098
+ callerPhone: args.caller_phone,
1099
+ message: args.message,
1100
+ forPerson: args.for_person,
1101
+ urgent: args.urgent,
1102
+ callbackTime: args.callback_preferred_time,
1103
+ callId: call_id,
1104
+ tenantId: tenant_id,
1105
+ });
1106
+
1107
+ return res.json({
1108
+ status: 'saved',
1109
+ message: args.urgent
1110
+ ? `I've flagged this as urgent. The team will get back to ${args.caller_name} right away.`
1111
+ : `Message recorded. Someone from the team will call ${args.caller_name} back soon.`,
1112
+ });
1113
+ }
1114
+
1115
+ // ─── SCHEDULE APPOINTMENT ─────────────────────────────────
1116
+ case 'schedule_appointment': {
1117
+ // args: caller_name (required), phone (required), date (required),
1118
+ // time, duration, purpose (required), notes
1119
+ const appt = await scheduleAppointmentInCrm({
1120
+ callerName: args.caller_name,
1121
+ phone: args.phone,
1122
+ date: args.date,
1123
+ time: args.time,
1124
+ duration: args.duration,
1125
+ purpose: args.purpose,
1126
+ notes: args.notes,
1127
+ callId: call_id,
1128
+ tenantId: tenant_id,
1129
+ });
1130
+
1131
+ return res.json({
1132
+ status: 'scheduled',
1133
+ message: `Appointment confirmed for ${args.date}${args.time ? ' at ' + args.time : ''}. ` +
1134
+ `${args.caller_name} will receive a confirmation shortly.`,
1135
+ });
1136
+ }
1137
+
1138
+ // ─── CHECK POLICY STATUS ──────────────────────────────────
1139
+ case 'check_policy_status': {
1140
+ // args: policy_number (required), phone, last_name, date_of_birth, lookup_type
1141
+ const policy = await lookupPolicyInCrm({ policyNumber: args.policy_number });
1142
+
1143
+ if (!policy) {
1144
+ return res.json({
1145
+ status: 'not_found',
1146
+ message: `I couldn't find a policy with number ${args.policy_number}. Could you double-check that number?`,
1147
+ });
1148
+ }
1149
+
1150
+ return res.json({
1151
+ status: 'found',
1152
+ message: `Policy ${args.policy_number} is currently ${policy.status}.`,
1153
+ });
1154
+ }
1155
+
1156
+ // ─── UNKNOWN TOOL ─────────────────────────────────────────
1157
+ default: {
1158
+ console.warn(`[AI Tool] Unknown tool: ${toolName}`);
1159
+ return res.json({
1160
+ status: 'noted',
1161
+ message: "I've noted your request. A team member will follow up.",
1162
+ });
1163
+ }
1164
+ }
1165
+ } catch (error: any) {
1166
+ console.error(`[AI Tool] Error handling ${toolName}:`, error);
1167
+ return res.status(500).json({
1168
+ status: 'error',
1169
+ message: "I'm having trouble processing that right now. Let me take a note and have someone follow up.",
1170
+ });
1171
+ }
1172
+ });
1173
+ ```
1174
+
1175
+ **Webhook request format (what your CRM receives):**
1176
+
1177
+ Every tool call arrives as a POST with this JSON body:
1178
+
1179
+ ```json
1180
+ {
1181
+ "function": "send_sms",
1182
+ "args": {
1183
+ "to_phone": "+17865550198",
1184
+ "message": "Your appointment is confirmed for Friday at 2:00 PM. Address: 123 Main St.",
1185
+ "purpose": "appointment_confirmation",
1186
+ "caller_name": "Ana Martinez"
1187
+ },
1188
+ "call_id": "ff6a1693-b2f8-123f-b8b1-0a7dee2809a7",
1189
+ "tenant_id": "6a097cb2-504f-43be-819a-b76209976818"
1190
+ }
1191
+ ```
1192
+
1193
+ **Headers your CRM receives:**
1194
+
1195
+ | Header | Value | Description |
1196
+ |--------|-------|-------------|
1197
+ | `Content-Type` | `application/json` | Always JSON |
1198
+ | `Authorization` | `Bearer csk_live_...` | VoIP SDK API key for auth |
1199
+ | `X-Webhook-Source` | `voiceai-platform` | Identifies the source |
1200
+
1201
+ **Webhook response format (what your CRM must return):**
1202
+
1203
+ Your response MUST be JSON. The `message` field is what Gemini speaks to the caller:
1204
+
1205
+ ```json
1206
+ {
1207
+ "status": "sent",
1208
+ "message": "The text message has been sent to your phone. You should receive it in a moment."
1209
+ }
1210
+ ```
1211
+
1212
+ | Field | Type | Required | Description |
1213
+ |-------|------|----------|-------------|
1214
+ | `status` | string | Yes | Result: `"sent"`, `"created"`, `"saved"`, `"scheduled"`, `"found"`, `"not_found"`, `"error"`, `"failed"` |
1215
+ | `message` | string | Yes | **CRITICAL: This is what the AI says to the caller.** Write naturally, 1-2 sentences max. |
1216
+
1217
+ **Response rules:**
1218
+ - The `message` field is spoken by the AI to the caller. Write it as natural speech.
1219
+ - Keep `message` to 1-2 sentences maximum.
1220
+ - If something fails, STILL return a `message` with a graceful fallback.
1221
+ - Your webhook has a **9 second timeout**. If it doesn't respond, Gemini hears: `"I'm having trouble processing that right now."`
1222
+ - HTTP status should be `200` for success, `500` for errors. The AI always gets the `message` field either way.
1223
+
1224
+ **Testing your webhook with curl:**
1225
+
1226
+ ```bash
1227
+ # Test send_sms tool call
1228
+ curl -X POST https://your-crm.com/api/webhooks/ai-tools \
1229
+ -H "Content-Type: application/json" \
1230
+ -H "Authorization: Bearer YOUR_VOIP_TOOLS_WEBHOOK_KEY" \
1231
+ -d '{
1232
+ "function": "send_sms",
1233
+ "args": {
1234
+ "to_phone": "+17865551234",
1235
+ "message": "Your appointment is confirmed for Friday at 2pm.",
1236
+ "purpose": "appointment_confirmation",
1237
+ "caller_name": "Juan Garcia"
1238
+ },
1239
+ "call_id": "test-123",
1240
+ "tenant_id": "your-tenant-id"
1241
+ }'
1242
+
1243
+ # Expected response:
1244
+ # {"status":"sent","message":"The text message has been sent to +17865551234."}
1245
+ ```
1246
+
1247
+
908
1248
  ### CDR Export
909
1249
 
910
1250
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cemscale-voip/voip-sdk",
3
- "version": "1.40.0",
3
+ "version": "1.41.0",
4
4
  "description": "VoIP SDK for CemScale multi-tenant platform — API client, WebRTC, React hooks",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",