veevo.ai

Agent Skill Reference

Copy this SKILL.md into your AI agent or Claude Code skill to give it full context on the Veevo API.

Usage

Drop this file as SKILL.md into your project or agent configuration. Any AI agent with this context will know how to register accounts, configure phone numbers, implement callbacks, and manage billing through the Veevo API.

SKILL.md

---
name: Veevo Management API
description: This skill should be used when the user asks to "register for Veevo", "get an API key", "add a phone number", "check usage", "upgrade my plan", "manage my subscription", "set up billing", "register a callback URL", "how many minutes do I have left", "build a voice agent", "integrate with Veevo", "reject a call", "block unauthorized callers", "gate callers", "allowlist callers", or needs to build a system that uses the Veevo real-time voice engine.
---

# Veevo Management API

The Veevo Management API provides developer access to the Veevo real-time voice engine. Register an account, configure phone numbers with callback URLs, track usage, and manage billing — all through a REST API.

## Prerequisites

To use Veevo, the developer needs:

- **A Twilio account** with at least one phone number that has voice capability
- **Twilio credentials** (`twilioAccountSid` and `twilioAuthToken`) from the Twilio console
- **An OpenAI API key** with access to Realtime API models (`gpt-realtime-1.5` or `gpt-realtime-mini`)
- **A backend server** that implements three callback endpoints (see Callback Implementation below)

The developer's Twilio and OpenAI credentials are never stored by Veevo. They are returned by the developer's backend on each call via the `onCallStart` callback.

## Getting Started

### 1. Create an Account

Sign up at `https://veevo.ai/login`. After creating your account:

1. Complete Stripe Checkout ($5/mo base) to activate your account
2. Create an API key from the dashboard under **API Keys & Secrets**
3. Copy your **webhook signing secret** from the same page — you'll need it to verify callbacks
4. Access the dashboard at `https://veevo.ai/dashboard` to manage keys, numbers, and usage

### 2. Register a Phone Number

```bash
curl -X POST https://veevo.ai/api/phone-numbers \
  -H "Authorization: Bearer rtk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+15551234567",
    "onCallStartUrl": "https://your-backend.com/calls/start",
    "onCallEndUrl": "https://your-backend.com/calls/end"
  }'
```

### 3. Point Twilio at the Engine

In the Twilio console, navigate to Phone Numbers → Active Numbers → select the number → Voice Configuration. Under "A call comes in", set the webhook URL to `https://engine.veevo.ai/voice` and select HTTP POST. (The engine URL is also shown in the dashboard after registering a number.)

### 4. Implement Callbacks

Build three endpoints on the developer's backend. The engine calls these during each call.

## Callback Implementation

### Verifying Webhooks

Every callback request from Veevo includes an `X-Veevo-Signature` header — an HMAC-SHA256 signature of the raw JSON body using your webhook signing secret (found in the dashboard under API Keys & Secrets).

**Important:** You must verify against the **raw** body bytes, not a re-serialized JSON object. Use `express.raw()` to capture the bytes, compute the signature, then parse manually.

```javascript
const express = require('express');
const crypto = require('crypto');
const app = express();

const WEBHOOK_SECRET = process.env.VEEVO_WEBHOOK_SECRET;

// Capture raw body for signature verification on callback routes only
app.use('/calls', express.raw({ type: 'application/json' }));

function veevoAuth(req, res, next) {
  const signature = req.headers['x-veevo-signature'];
  if (!signature) return res.status(401).json({ error: 'Missing signature' });

  const rawBody = req.body.toString('utf-8');
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  req.body = JSON.parse(rawBody);
  next();
}

// Use as middleware on every callback route:
app.post('/calls/start', veevoAuth, (req, res) => { /* ... */ });
```

**Always verify the signature before returning credentials in onCallStart.** 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 caller away without starting an AI session (see [Rejecting Calls](#rejecting-calls) below).

**Engine sends:**
```json
{
  "callSid": "CA...",
  "callerNumber": "+19805551234",
  "calledNumber": "+15551234567",
  "timestamp": "2026-04-03T..."
}
```

**Developer returns:**
```json
{
  "twilioAccountSid": "AC...",
  "twilioAuthToken": "...",
  "openaiApiKey": "sk-...",
  "systemPrompt": "You are a helpful assistant for...",
  "voice": "marin",
  "model": "gpt-realtime-1.5",
  "vadEagerness": "high",
  "noiseReduction": "near_field",
  "inactivityTimeoutMs": 60000,
  "greetingUrl": "https://cdn.example.com/greeting.mp3",
  "onToolCallUrl": "https://your-backend.com/calls/tool",
  "tools": [],
  "metadata": { "customerId": "cust_123" }
}
```

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `twilioAccountSid` | yes\* | — | Twilio account SID (starts with AC) |
| `twilioAuthToken` | yes\* | — | Twilio auth token for signature validation and call control |
| `openaiApiKey` | yes\* | — | OpenAI API key with Realtime API access |
| `systemPrompt` | yes\* | — | The AI agent's instructions, persona, and knowledge |
| `voice` | no | `marin` | OpenAI voice: alloy, ash, ballad, coral, echo, sage, shimmer, verse, marin, cedar |
| `model` | no | `gpt-realtime-1.5` | `gpt-realtime-1.5` (best quality) or `gpt-realtime-mini` (cost-efficient) |
| `vadEagerness` | no | `high` | Voice activity detection sensitivity: low, medium, high, auto |
| `noiseReduction` | no | `near_field` | Noise reduction: near_field or far_field |
| `inactivityTimeoutMs` | no | `60000` | Auto-hangup after this many ms of silence |
| `greetingUrl` | no | — | URL to an MP3 played before the AI connects |
| `onToolCallUrl` | yes\* | — | Where the engine POSTs tool calls |
| `tools` | no | `[]` | OpenAI function definitions for the AI to invoke |
| `metadata` | no | — | Opaque data passed through to onToolCall and onCallEnd |
| `reject` | no | — | Reject the call without opening an AI session. See [Rejecting Calls](#rejecting-calls). |

\* Required only when `reject` is not present. If you return a `reject` response, all credential fields may be omitted.

**Timeout:** 10 seconds. If the backend doesn't respond, the call is rejected.

### Rejecting Calls

To reject a caller before an AI session is opened, return a minimal `onCallStart` response with a `reject` field:

```json
{
  "reject": {
    "message": "This number only accepts calls from recognized contacts. Goodbye.",
    "voice": "Polly.Joanna",
    "language": "en-US"
  }
}
```

| 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. |

**Behavior:** Veevo skips OpenAI entirely, plays the `message` via Twilio text-to-speech, and hangs up. No OpenAI Realtime session is ever opened, so you don't pay for per-minute AI charges.

**Credentials are optional with reject.** If `reject` is present, you may omit `twilioAccountSid`, `twilioAuthToken`, `openaiApiKey`, `systemPrompt`, and `onToolCallUrl`. If you return both a `reject` and a full configuration, `reject` wins — the rest is ignored.

**onCallEnd still fires.** Rejected calls receive a slimmed-down `onCallEnd` callback with an empty `transcript`, `durationSeconds: 0`, `costBreakdown: null`, and `metadata: { rejected: true, ...yourMetadata }`. Filter on `metadata.rejected === true` to distinguish rejected calls from normal completions in your audit trail.

**Reject is inbound-only.** 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.

**Typical cost:** ~$0.01 per rejected call (Twilio voice minute only) vs ~$0.04–0.10 per call if an AI session is opened. For services receiving unknown calls or spam, this is the single largest lever for keeping costs predictable.

**Example — gate on an allowlist:**

```javascript
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: [],
  });
});
```

### POST onToolCall

Called when the AI invokes a tool defined in the `tools` array.

**Engine sends:**
```json
{
  "toolName": "check_availability",
  "arguments": { "date": "April 15, 2026" },
  "metadata": { "customerId": "cust_123" },
  "timestamp": "2026-04-03T..."
}
```

**Developer returns:**
```json
{
  "result": "We have 3 standard units available for April 15."
}
```

**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.

```json
{
  "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** also receive an `onCallEnd` callback for audit trail consistency, but with a slimmed shape: empty `transcript`, `durationSeconds: 0`, `costBreakdown: null`, and `metadata.rejected === true`. Filter on that flag to distinguish rejected from normal completions.

## Pricing

- **$5/mo** platform access (card required on signup via Stripe Checkout)
- **$0.05/min** usage (metered, each call rounded up to nearest minute, billed at end of billing period)
- Unlimited phone numbers
- No tiers, no included minutes — pure pay-as-you-go after the base fee

Note: these are Veevo platform fees only. The developer pays OpenAI and Twilio directly through their own accounts.

## API Reference

Base URL: `https://veevo.ai`

All endpoints require `Authorization: Bearer rtk_...`.

### Account & API Keys

Sign up at `https://veevo.ai/login` and get your API key from the dashboard.

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/account` | Account details, subscription, resource counts |
| POST | `/api/api-keys` | Create additional key. Body: `{ label? }` |
| GET | `/api/api-keys` | List active keys (prefixes only) |
| DELETE | `/api/api-keys/:id` | Revoke a key |

### Phone Numbers

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/phone-numbers` | Register. Body: `{ phoneNumber, onCallStartUrl, onCallEndUrl, onErrorUrl }` |
| GET | `/api/phone-numbers` | List registered numbers |
| PATCH | `/api/phone-numbers/:id` | Update callback URLs. Body: `{ onCallStartUrl?, onCallEndUrl?, onErrorUrl? }` |
| DELETE | `/api/phone-numbers/:id` | Remove a number |

Phone number must be a US number in E.164 format (`+1XXXXXXXXXX`). Unlimited numbers.

### Outbound Calls

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/outbound` | Initiate an outbound call. Body: `{ to, from, onCallStartUrl, onCallEndUrl, twilioAccountSid, twilioAuthToken, onErrorUrl, metadata?, machineDetection?, timeout? }` |

The `from` number must be registered to your account. Both numbers must be US E.164 format.

### Usage

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/usage` | Call history. Query: `limit` (max 100), `offset` |
| GET | `/api/usage/current` | Current month and all-time summary with `billedMinutes` (per-call rounded up) |

### Billing

| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/billing/portal` | Get Stripe Billing Portal URL |
| GET | `/api/billing/subscription` | Current status, pricing, usage totals |

## Error Codes

| Code | Meaning |
|------|---------|
| 400 | Invalid request body or callback URL |
| 401 | Missing or invalid API key / credentials |
| 402 | Payment required — complete Stripe Checkout to activate account |
| 403 | Phone number not owned by account (outbound) |
| 404 | Resource not found |
| 409 | Phone number already registered by another account |
| 500 | Internal server error |

## Example Integration

A minimal Node.js backend implementing all three callbacks with signature verification and a caller allowlist:

```javascript
const express = require('express');
const crypto = require('crypto');
const app = express();

const WEBHOOK_SECRET = process.env.VEEVO_WEBHOOK_SECRET;

// Capture raw body for signature verification on callback routes
app.use('/calls', express.raw({ type: 'application/json' }));

// Middleware: verify X-Veevo-Signature on every callback
function veevoAuth(req, res, next) {
  const signature = req.headers['x-veevo-signature'];
  if (!signature) return res.status(401).json({ error: 'Missing signature' });

  const rawBody = req.body.toString('utf-8');
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(rawBody)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  req.body = JSON.parse(rawBody);
  next();
}

const AUTHORIZED_CALLERS = new Set([
  '+15551234567',
  '+15559876543',
]);

app.post('/calls/start', veevoAuth, (req, res) => {
  // Gate: reject calls from unrecognized numbers without spinning up an AI
  // session. Saves ~$0.04–0.10 per unknown call and prevents spam traffic
  // from burning OpenAI credits.
  if (!AUTHORIZED_CALLERS.has(req.body.callerNumber)) {
    return res.json({
      reject: {
        message:
          'This number only accepts calls from recognized contacts. Goodbye.',
      },
    });
  }

  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. Greet the caller warmly.',
    voice: 'marin',
    onToolCallUrl: `${process.env.BASE_URL}/calls/tool`,
    tools: [],
  });
});

app.post('/calls/tool', veevoAuth, (req, res) => {
  res.json({ result: `Handled ${req.body.toolName}` });
});

app.post('/calls/end', veevoAuth, (req, res) => {
  if (req.body.metadata?.rejected) {
    console.log(`Call ${req.body.callSid} rejected (unauthorized caller)`);
  } else {
    console.log(`Call ${req.body.callSid} ended — ${req.body.durationSeconds}s`);
    console.log(`Caller: ${req.body.callerNumber} → ${req.body.calledNumber}`);
    console.log(`Cost: $${req.body.costBreakdown?.totalCost}`);
  }
  res.json({ received: true });
});

app.listen(3001);
```