Blanket per-endpoint limits would have been the wrong shape here. Every REST/WS/MCP surface is owner-only — an unauthenticated request is rejected by resolve_bearer/is_owner before any handler runs — so limiting them would mostly throttle the single legitimate operator, and real spend control for outbound calls is already max_concurrent_calls in gateway.make_call. What is genuinely exposed is the handful of /auth/* routes that must answer before an identity exists. /auth/callback and /auth/refresh-callback each make an outbound token exchange with Casdoor on every request; /auth/me opens a DB session and runs a token lookup. All are free to trigger and none are cheap to serve. /auth/logout is left unlimited — it builds a redirect URL and does no I/O. Not a defence against credential guessing: PATs are secrets.token_urlsafe(32) (256 bits) compared by SHA-256 digest, so brute force was never the threat. This is about unauthenticated work an attacker controls. Fixed-window, in-process, no new dependency — one operator and one process make a shared counter store infrastructure without a purpose. The bucket store is bounded and evicts oldest-first, since an unbounded map keyed by source address would itself be the exhaustion vector. The limiter keys on the socket peer and deliberately ignores X-Forwarded-For. That header is attacker-controlled unless a trusted proxy overwrites it, and this app establishes no such trust; keying on it would let one client present as thousands and make the limiter worse than useless. Behind the estate's reverse proxy the limit is therefore per-proxy, not per-caller — correct for exhaustion and honest about what it can enforce. Per-caller limits need an explicit trusted-proxy config, noted in CLAUDE.md so it isn't added silently. Verified against a real server: exactly 30 requests pass, then 429 with Retry-After: 60, while an owner-gated route serves 40/40. The 429s appear in the JSON access log with queryable status_code and client_addr, so an attack is visible in Loki. The wiring test identifies the dependency by qualname rather than string search, and was mutation-checked by removing the limit from /auth/me. Also documents 401/403/429 in the API reference — 401 and 403 have existed since auth landed but were never in the status-code table. Phase 4 is now complete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
390 lines
7.8 KiB
Markdown
390 lines
7.8 KiB
Markdown
# API Reference
|
|
|
|
Hold Slayer exposes a REST API, WebSocket endpoint, and MCP server.
|
|
|
|
## REST API
|
|
|
|
Base URL: `http://localhost:8000/api`
|
|
|
|
### Calls
|
|
|
|
#### Place an Outbound Call
|
|
|
|
```
|
|
POST /api/v1/calls/outbound
|
|
```
|
|
|
|
**Request:**
|
|
|
|
```json
|
|
{
|
|
"number": "+18005551234",
|
|
"mode": "hold_slayer",
|
|
"intent": "dispute Amazon charge from December 15th",
|
|
"device": "sip_phone",
|
|
"call_flow_id": "chase_bank_disputes",
|
|
"services": {
|
|
"recording": true,
|
|
"transcription": true
|
|
}
|
|
}
|
|
```
|
|
|
|
**Call Modes:**
|
|
|
|
| Mode | Description |
|
|
|------|-------------|
|
|
| `direct` | Dial and connect to your device immediately |
|
|
| `hold_slayer` | Navigate IVR, wait on hold, transfer when human detected |
|
|
| `ai_assisted` | Connect with noise cancel, transcription, recording |
|
|
|
|
**Response:**
|
|
|
|
```json
|
|
{
|
|
"call_id": "call_abc123",
|
|
"status": "trying",
|
|
"number": "+18005551234",
|
|
"mode": "hold_slayer",
|
|
"started_at": "2026-01-15T10:30:00Z"
|
|
}
|
|
```
|
|
|
|
#### Launch Hold Slayer
|
|
|
|
```
|
|
POST /api/v1/calls/hold-slayer
|
|
```
|
|
|
|
Convenience endpoint — equivalent to `POST /outbound` with `mode=hold_slayer`.
|
|
|
|
**Request:**
|
|
|
|
```json
|
|
{
|
|
"number": "+18005551234",
|
|
"intent": "dispute Amazon charge from December 15th",
|
|
"call_flow_id": "chase_bank_disputes",
|
|
"transfer_to": "sip_phone"
|
|
}
|
|
```
|
|
|
|
#### Get Call Status
|
|
|
|
```
|
|
GET /api/v1/calls/{call_id}
|
|
```
|
|
|
|
**Response:**
|
|
|
|
```json
|
|
{
|
|
"call_id": "call_abc123",
|
|
"status": "on_hold",
|
|
"number": "+18005551234",
|
|
"mode": "hold_slayer",
|
|
"duration": 847,
|
|
"hold_time": 780,
|
|
"audio_type": "music",
|
|
"transcript_excerpt": "...your call is important to us...",
|
|
"classification_history": [
|
|
{"timestamp": 1706000000, "type": "ringing", "confidence": 0.95},
|
|
{"timestamp": 1706000003, "type": "ivr_prompt", "confidence": 0.88},
|
|
{"timestamp": 1706000010, "type": "music", "confidence": 0.92}
|
|
],
|
|
"services": {"recording": true, "transcription": true}
|
|
}
|
|
```
|
|
|
|
#### List Active Calls
|
|
|
|
```
|
|
GET /api/v1/calls
|
|
```
|
|
|
|
**Response:**
|
|
|
|
```json
|
|
{
|
|
"calls": [
|
|
{"call_id": "call_abc123", "status": "on_hold", "number": "+18005551234", "duration": 847},
|
|
{"call_id": "call_def456", "status": "connected", "number": "+18009876543", "duration": 120}
|
|
],
|
|
"total": 2
|
|
}
|
|
```
|
|
|
|
#### End a Call
|
|
|
|
```
|
|
POST /api/v1/calls/{call_id}/hangup
|
|
```
|
|
|
|
#### Transfer a Call
|
|
|
|
```
|
|
POST /api/v1/calls/{call_id}/transfer
|
|
```
|
|
|
|
**Request:**
|
|
|
|
```json
|
|
{
|
|
"device": "sip_phone"
|
|
}
|
|
```
|
|
|
|
### Call Flows
|
|
|
|
#### List Call Flows
|
|
|
|
```
|
|
GET /api/v1/call-flows
|
|
GET /api/v1/call-flows?company=Chase+Bank
|
|
GET /api/v1/call-flows?tag=banking
|
|
```
|
|
|
|
**Response:**
|
|
|
|
```json
|
|
{
|
|
"flows": [
|
|
{
|
|
"id": "chase_bank_disputes",
|
|
"name": "Chase Bank — Disputes",
|
|
"company": "Chase Bank",
|
|
"phone_number": "+18005551234",
|
|
"step_count": 7,
|
|
"success_count": 12,
|
|
"fail_count": 1,
|
|
"tags": ["banking", "disputes"]
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
#### Get Call Flow
|
|
|
|
```
|
|
GET /api/v1/call-flows/{flow_id}
|
|
```
|
|
|
|
Returns the full call flow with all steps.
|
|
|
|
#### Create Call Flow
|
|
|
|
```
|
|
POST /api/v1/call-flows
|
|
```
|
|
|
|
**Request:**
|
|
|
|
```json
|
|
{
|
|
"name": "Chase Bank — Disputes",
|
|
"company": "Chase Bank",
|
|
"phone_number": "+18005551234",
|
|
"steps": [
|
|
{"id": "wait", "type": "WAIT", "description": "Wait for greeting", "timeout": 5.0, "next_step": "menu"},
|
|
{"id": "menu", "type": "LISTEN", "description": "Main menu", "next_step": "press3"},
|
|
{"id": "press3", "type": "DTMF", "description": "Account services", "dtmf": "3", "next_step": "hold"},
|
|
{"id": "hold", "type": "HOLD", "description": "Wait for agent", "next_step": "transfer"},
|
|
{"id": "transfer", "type": "TRANSFER", "description": "Connect to user"}
|
|
]
|
|
}
|
|
```
|
|
|
|
#### Update Call Flow
|
|
|
|
```
|
|
PUT /api/v1/call-flows/{flow_id}
|
|
```
|
|
|
|
#### Delete Call Flow
|
|
|
|
```
|
|
DELETE /api/v1/call-flows/{flow_id}
|
|
```
|
|
|
|
### Devices
|
|
|
|
#### List Registered Devices
|
|
|
|
```
|
|
GET /api/v1/devices
|
|
```
|
|
|
|
**Response:**
|
|
|
|
```json
|
|
{
|
|
"devices": [
|
|
{
|
|
"id": "dev_001",
|
|
"name": "Office SIP Phone",
|
|
"type": "sip_phone",
|
|
"sip_uri": "sip:robert@gateway.helu.ca",
|
|
"is_online": true,
|
|
"priority": 10
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
#### Register a Device
|
|
|
|
```
|
|
POST /api/v1/devices
|
|
```
|
|
|
|
**Request:**
|
|
|
|
```json
|
|
{
|
|
"name": "Office SIP Phone",
|
|
"type": "sip_phone",
|
|
"sip_uri": "sip:robert@gateway.helu.ca",
|
|
"priority": 10,
|
|
"capabilities": ["voice"]
|
|
}
|
|
```
|
|
|
|
#### Update Device
|
|
|
|
```
|
|
PUT /api/v1/devices/{device_id}
|
|
```
|
|
|
|
#### Remove Device
|
|
|
|
```
|
|
DELETE /api/v1/devices/{device_id}
|
|
```
|
|
|
|
### Error Responses
|
|
|
|
All errors follow a consistent format:
|
|
|
|
```json
|
|
{
|
|
"detail": "Call not found: call_xyz789"
|
|
}
|
|
```
|
|
|
|
| Status Code | Meaning |
|
|
|-------------|---------|
|
|
| `400` | Bad request (invalid parameters) |
|
|
| `401` | Not authenticated (missing or invalid bearer token) |
|
|
| `403` | Authenticated but not the owner |
|
|
| `404` | Resource not found (call, flow, device) |
|
|
| `409` | Conflict (call already ended, device already registered) |
|
|
| `429` | Rate limited — `/auth/*` routes only. Carries `Retry-After` (seconds) |
|
|
| `500` | Internal server error |
|
|
|
|
`429` applies solely to the unauthenticated `/auth/*` edge: those routes must
|
|
answer before an identity exists, and each does real work (`/auth/callback`
|
|
makes an outbound token exchange with Casdoor, `/auth/me` opens a DB session).
|
|
The owner-gated API is not rate limited — it is already restricted to a single
|
|
operator. Limits are per client, per route, in a fixed 60-second window; the
|
|
client is the **socket peer**, so behind a reverse proxy the limit applies
|
|
per-proxy. See [core/rate_limit.py](../core/rate_limit.py).
|
|
|
|
## WebSocket
|
|
|
|
### Event Stream
|
|
|
|
```
|
|
ws://localhost:8000/ws/events
|
|
ws://localhost:8000/ws/events?call_id=call_abc123
|
|
ws://localhost:8000/ws/events?types=human_detected,hold_detected
|
|
```
|
|
|
|
**Query Parameters:**
|
|
|
|
| Param | Description |
|
|
|-------|-------------|
|
|
| `call_id` | Filter events for a specific call |
|
|
| `types` | Comma-separated event types to receive |
|
|
|
|
**Event Format:**
|
|
|
|
```json
|
|
{
|
|
"type": "hold_detected",
|
|
"call_id": "call_abc123",
|
|
"timestamp": "2026-01-15T10:35:00Z",
|
|
"data": {
|
|
"audio_type": "music",
|
|
"confidence": 0.92,
|
|
"hold_duration": 0
|
|
}
|
|
}
|
|
```
|
|
|
|
### Event Types
|
|
|
|
| Type | Data Fields |
|
|
|------|------------|
|
|
| `call_started` | `number`, `mode`, `intent` |
|
|
| `call_ringing` | `number` |
|
|
| `call_connected` | `number`, `duration` |
|
|
| `call_ended` | `number`, `duration`, `reason` |
|
|
| `call_failed` | `number`, `error` |
|
|
| `hold_detected` | `audio_type`, `confidence` |
|
|
| `human_detected` | `confidence`, `transcript_excerpt` |
|
|
| `transfer_started` | `device`, `from_call_id` |
|
|
| `transfer_complete` | `device`, `bridge_id` |
|
|
| `ivr_step` | `step_id`, `step_type`, `description` |
|
|
| `ivr_dtmf_sent` | `digits`, `step_id` |
|
|
| `ivr_menu_detected` | `transcript`, `options` |
|
|
| `audio_classified` | `audio_type`, `confidence`, `features` |
|
|
| `transcript_chunk` | `text`, `speaker`, `is_final` |
|
|
| `recording_started` | `recording_id`, `path` |
|
|
| `recording_stopped` | `recording_id`, `duration`, `file_size` |
|
|
|
|
### Client Example
|
|
|
|
```javascript
|
|
const ws = new WebSocket("ws://localhost:8000/ws/events");
|
|
|
|
ws.onopen = () => {
|
|
console.log("Connected to Hold Slayer events");
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
|
|
switch (data.type) {
|
|
case "human_detected":
|
|
alert("🚨 A live person picked up! Pick up your phone!");
|
|
break;
|
|
case "hold_detected":
|
|
console.log("⏳ On hold...");
|
|
break;
|
|
case "transcript_chunk":
|
|
console.log(`📝 ${data.data.speaker}: ${data.data.text}`);
|
|
break;
|
|
}
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error("WebSocket error:", error);
|
|
};
|
|
```
|
|
|
|
### Python Client Example
|
|
|
|
```python
|
|
import asyncio
|
|
import websockets
|
|
import json
|
|
|
|
async def listen():
|
|
async with websockets.connect("ws://localhost:8000/ws/events") as ws:
|
|
async for message in ws:
|
|
event = json.loads(message)
|
|
print(f"[{event['type']}] {event.get('data', {})}")
|
|
|
|
asyncio.run(listen())
|
|
```
|