Callbacks
Implement three POST endpoints to control your voice agent.
Verifying Webhooks
Every callback request from Veevo includes an X-Veevo-Signatureheader. This is an HMAC-SHA256 signature of the raw JSON request body, signed with your webhook secret. You should verify this signature to ensure the request came from Veevo and the payload hasn't been tampered with.
Find your webhook secret in the dashboard.
const crypto = require('crypto');
const express = require('express');
const app = express();
// IMPORTANT: Use express.raw() to capture the raw body for signature verification,
// then parse JSON manually. express.json() alters the body which breaks verification.
app.use('/calls', express.raw({ type: 'application/json' }));
function verifyVeevoSignature(req, webhookSecret) {
const signature = req.headers['x-veevo-signature'];
if (!signature) return false;
const rawBody = req.body.toString('utf-8');
const expected = crypto
.createHmac('sha256', webhookSecret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Middleware to verify + parse
function veevoAuth(req, res, next) {
if (!verifyVeevoSignature(req, process.env.VEEVO_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
req.body = JSON.parse(req.body.toString('utf-8'));
next();
}
app.post('/calls/start', veevoAuth, (req, res) => {
// req.body is verified and parsed
res.json({ /* ... */ });
});POST onCallStart
Called when a call comes in, before the conversation starts. Your backend returns either a full call configuration, or a reject response to turn the call away without starting an AI session.
Veevo sends
{
"callSid": "CA...",
"callerNumber": "+19805551234",
"calledNumber": "+15551234567",
"timestamp": "2026-04-03T..."
}Your backend returns
{
"twilioAccountSid": "AC...",
"twilioAuthToken": "...",
"openaiApiKey": "sk-...",
"systemPrompt": "You are a helpful assistant for...",
"voice": "marin",
"model": "gpt-realtime-1.5",
"onToolCallUrl": "https://your-backend.com/calls/tool",
"tools": []
}Configuration Fields
| Field | Required | Default | Description |
|---|---|---|---|
| twilioAccountSid | yes* | — | Twilio account SID |
| twilioAuthToken | yes* | — | Twilio auth token |
| openaiApiKey | yes* | — | OpenAI key with Realtime API access |
| systemPrompt | yes* | — | The AI agent's instructions |
| voice | no | marin | alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar |
| model | no | gpt-realtime-1.5 | gpt-realtime-1.5 or gpt-realtime-mini |
| vadEagerness | no | high | low, medium, high, auto |
| noiseReduction | no | near_field | near_field or far_field |
| inactivityTimeoutMs | no | 60000 | Auto-hangup after silence (ms) |
| greetingUrl | no | — | MP3 URL played before AI connects |
| onToolCallUrl | yes* | — | Where tool calls are POSTed |
| tools | no | [] | OpenAI function definitions |
| metadata | no | — | Passed through to onToolCall and onCallEnd |
| reject | no | — | Reject the call without starting an AI session. See Rejecting a Call. |
* Required only when reject is not set. If you return a reject response, all credential fields may be omitted.
Tools Array
The tools array uses the OpenAI function calling schema:
{
"tools": [
{
"type": "function",
"name": "check_availability",
"description": "Check if a date has availability",
"parameters": {
"type": "object",
"properties": {
"date": { "type": "string", "description": "The date to check" }
},
"required": ["date"]
}
},
{
"type": "function",
"name": "schedule_appointment",
"description": "Book an appointment for the caller",
"parameters": {
"type": "object",
"properties": {
"date": { "type": "string" },
"time": { "type": "string" },
"notes": { "type": "string" }
},
"required": ["date", "time"]
}
}
]
}Rejecting a Call
Not every caller should reach your agent. Unknown numbers, suspended accounts, or known abusers can be turned away before the AI spins up — saving you OpenAI Realtime minutes and cutting the cost of spam traffic down to pennies.
To reject a call, return a minimal response with a reject field from onCallStart instead of a full configuration:
{
"reject": {
"message": "This number only accepts calls from recognized contacts. Goodbye."
}
}When reject is present, Veevo:
- Does not open an OpenAI Realtime session — zero per-minute AI charges
- Does not open a Twilio Media Stream — zero streaming charges
- Plays the
messagevia Twilio's built-in text-to-speech - Hangs up the call
You still receive an onCallEnd callback for rejected calls (with an empty transcript and durationSeconds: 0), so rejections remain visible in your audit trail. The metadata on that callback will include rejected: true so you can distinguish rejected calls from normal completions.
Reject fields
| Field | Required | Default | Description |
|---|---|---|---|
| message | yes | — | Text spoken via Twilio <Say> before hanging up. Every word is billable Twilio voice time — keep it short. |
| voice | no | Polly.Joanna | Any Twilio <Say> voice name. Neural voices (Polly.*) sound substantially better than legacy ones. |
| language | no | en-US | BCP-47 language code. Must match the voice you choose. |
Credentials are not required with reject
When returning a reject response, none of the normally required fields (twilioAccountSid, twilioAuthToken, openaiApiKey, systemPrompt, onToolCallUrl) need to be present. { reject: { message: "..." } } on its own is a complete response. If you return both a reject and a full configuration, reject wins — the rest is ignored.
This is a useful defense-in-depth property: rejected callers never cause your server to emit Twilio or OpenAI credentials over the wire.
Cost comparison
| Approach | Typical cost per unauthorized call |
|---|---|
| No reject — AI session spun up, 15s idle timeout | ~$0.04–0.10 |
| reject with Twilio <Say> + <Hangup> | ~$0.01 (Twilio voice minute only) |
For any service receiving even a handful of unknown calls a day, the reject path pays for itself immediately. Under an active spam wave, it's the difference between an uneventful morning and an incident report.
Example: gate on an allowlist
app.post('/calls/start', veevoAuth, async (req, res) => {
const caller = await db.findContactByPhone(req.body.callerNumber);
if (!caller) {
// Unauthorized — reject before any AI spins up
return res.json({
reject: {
message:
'This number only accepts calls from recognized contacts. Please contact your account manager. Goodbye.',
},
});
}
// Authorized — full config
return res.json({
twilioAccountSid: process.env.TWILIO_ACCOUNT_SID,
twilioAuthToken: process.env.TWILIO_AUTH_TOKEN,
openaiApiKey: process.env.OPENAI_API_KEY,
systemPrompt: `You are a helpful assistant for ${caller.name}...`,
onToolCallUrl: `${process.env.BASE_URL}/calls/tool`,
tools: [],
});
});/api/outbound, your backend already controls which numbers to dial — there's no reason to reject a call you just placed.POST onToolCall
Called when the AI invokes a tool defined in the tools array.
Veevo sends
{
"toolName": "check_availability",
"arguments": { "date": "April 15, 2026" },
"metadata": { "customerId": "cust_123" },
"timestamp": "2026-04-03T..."
}Your backend returns
{
"result": "We have 3 standard units available for April 15."
}POST onCallEnd
Called after the call ends. Contains the full transcript and cost breakdown.
{
"callSid": "CA...",
"callerNumber": "+19805551234",
"calledNumber": "+15551234567",
"transcript": [
{ "role": "assistant", "content": "Hi, how can I help?", "timestamp": 1711900000 },
{ "role": "user", "content": "I need two units delivered Friday.", "timestamp": 1711900005 }
],
"costBreakdown": {
"inputTextTokens": 365,
"inputAudioTokens": 90,
"outputTextTokens": 45,
"outputAudioTokens": 109,
"costInputText": 0.00146,
"costInputAudio": 0.00288,
"costOutputText": 0.00072,
"costOutputAudio": 0.006976,
"costTwilio": 0.006647,
"totalCost": 0.018483
},
"durationSeconds": 47,
"metadata": { "customerId": "cust_123" },
"timestamp": "2026-04-03T..."
}Rejected calls
Rejected calls (see Rejecting a Call) also receive an onCallEnd delivery, so your audit trail stays consistent. The shape is slimmed down:
{
"callSid": "CA...",
"callerNumber": "+19805551234",
"calledNumber": "+15551234567",
"transcript": [],
"costBreakdown": null,
"durationSeconds": 0,
"metadata": {
"rejected": true
},
"timestamp": "2026-04-03T..."
}Your original metadata (if any) from the reject response is preserved and merged with rejected: true. Filter on metadata.rejected === true to distinguish rejected calls from normal completions in your dashboards.