Files
hold-slayer/docs/api-reference.md
Robert Helewka 5c178bb7bd
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s
feat(auth): rate-limit the unauthenticated /auth/* edge
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>
2026-07-30 18:46:54 -04:00

7.8 KiB

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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

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

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:

{
  "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

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

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())