@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.
- package/README.md +340 -0
- 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
|