veevo.ai

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.

Verification example (Node.js / Express)
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({ /* ... */ });
});
!
Always verify the signature before returning credentials in your onCallStart handler. Without verification, anyone who discovers your callback URL could extract your Twilio and OpenAI keys.

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": []
}
!
Timeout: 10 seconds. If your backend doesn't respond, the call is rejected.

Configuration Fields

FieldRequiredDefaultDescription
twilioAccountSidyes*Twilio account SID
twilioAuthTokenyes*Twilio auth token
openaiApiKeyyes*OpenAI key with Realtime API access
systemPromptyes*The AI agent's instructions
voicenomarinalloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar
modelnogpt-realtime-1.5gpt-realtime-1.5 or gpt-realtime-mini
vadEagernessnohighlow, medium, high, auto
noiseReductionnonear_fieldnear_field or far_field
inactivityTimeoutMsno60000Auto-hangup after silence (ms)
greetingUrlnoMP3 URL played before AI connects
onToolCallUrlyes*Where tool calls are POSTed
toolsno[]OpenAI function definitions
metadatanoPassed through to onToolCall and onCallEnd
rejectnoReject 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 message via 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

FieldRequiredDefaultDescription
messageyesText spoken via Twilio <Say> before hanging up. Every word is billable Twilio voice time — keep it short.
voicenoPolly.JoannaAny Twilio <Say> voice name. Neural voices (Polly.*) sound substantially better than legacy ones.
languagenoen-USBCP-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

ApproachTypical 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: [],
  });
});
i
Reject is an inbound-only feature. For outbound calls initiated via /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."
}
i
Timeout: 30 seconds. The AI is blocked during this time — keep responses fast (<500ms) for natural conversation flow.

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.