# Staffify AI Developer API - Complete Reference > Full REST API documentation for building voice AI applications with Staffify. Base URL: https://api.staffifyai.com/v1 Console: https://console.staffifyai.com Support: support@staffifyai.com --- ## AUTHENTICATION --- Two equivalent methods: - Authorization: Bearer sfy_live_YOUR_KEY - X-Api-Key: sfy_live_YOUR_KEY ### Key Types Project Keys (sfy_live_*): - Scoped to one project - Used for all resource endpoints - Recommended for production apps Org Keys (sfy_org_*): - Manage all projects in org - Use X-Project-Id header to scope to a specific project - Recommended for admin scripts / CI Keys are formatted as a prefix followed by 48 random hex characters. Keys not starting with sfy_live_ or sfy_org_ are rejected immediately with 401 INVALID_API_KEY. ### Key Scopes full: Read and write access to all endpoints. read: GET requests only. Write operations return 403 READ_ONLY_KEY. ### Using Org Keys with X-Project-Id curl https://api.staffifyai.com/v1/agents \ -H "Authorization: Bearer sfy_org_YOUR_ORG_KEY" \ -H "X-Project-Id: proj_00000001" ### Rate Limits (per org, per minute) PAYG: 300 req/min, 10 concurrent calls Starter: 600 req/min, 50 concurrent calls Growth: 1,200 req/min, 100 concurrent calls Enterprise: Unlimited (no rate limit headers returned except X-RateLimit-Tier and X-Concurrent-Call-Limit) Response headers on every authenticated response: - X-RateLimit-Limit: 300/600/1200 (absent for Enterprise) - X-RateLimit-Remaining: requests left this window - X-RateLimit-Reset: Unix epoch when window resets - X-Concurrent-Call-Limit: max simultaneous active calls - X-RateLimit-Tier: payg / starter / growth / enterprise When exceeded: HTTP 429 { "error": "Rate limit exceeded", "code": "RATE_LIMIT_EXCEEDED", "retryAfter": 43 } ### Authentication Error Codes MISSING_API_KEY (401): No Authorization or X-Api-Key header found INVALID_API_KEY (401): Key not found, revoked, or malformed INSUFFICIENT_CREDITS (402): Account paused due to zero credits (PAYG/Starter/Growth only, not Enterprise) READ_ONLY_KEY (403): Write operation attempted with a read-only key ORG_KEY_REQUIRED (403): Endpoint requires an org-level key PROJECT_NOT_FOUND (404): X-Project-Id header points to unknown project RATE_LIMIT_EXCEEDED (429): Per-minute request limit exceeded AUTH_RATE_LIMITED (429): 100+ failed auth attempts from same IP in 15-minute window ### Security Best Practices - Never expose API keys in client-side JavaScript or public repositories - Use environment variables: process.env.STAFFIFY_API_KEY - Create separate keys per environment (dev / staging / prod) - Use read-only keys where write access is not needed - Rotate keys immediately if compromised — revoke from console, create a new one --- ## GENERAL: PAGINATION --- All list endpoints use cursor-based pagination. - after= — pass next_cursor from previous response - limit=N — results per page (max 100) - Response includes next_cursor: null when there are no more pages Example: curl "https://api.staffifyai.com/v1/agents?limit=50" \ -H "Authorization: Bearer sfy_live_YOUR_KEY" Response: { "agents": [...], "next_cursor": "agent_abc123" } Next page: curl "https://api.staffifyai.com/v1/agents?limit=50&after=agent_abc123" ... --- ## GENERAL: ERROR FORMAT --- All errors return JSON with a human-readable message and machine-readable code: { "error": "Rate limit exceeded", "code": "RATE_LIMIT_EXCEEDED", "retryAfter": 43 } --- ## GENERAL: RESOURCE ID PREFIXES --- agent_* — Agents call_* — Calls kb_* — Knowledge bases proj_* — Projects --- ## HEALTH CHECK --- GET /v1/health Authorization: Bearer sfy_live_YOUR_KEY Response: { "status": "ok", "tier": "payg", "project_id": 1, "org_id": 1 } --- ## AGENTS --- Agents are the AI voices that answer your calls. Each agent has a language, voice, server_url for real-time call control, and optional knowledge bases. ### Endpoints GET /v1/agents — List all agents (cursor pagination) POST /v1/agents — Create a new agent (returns 201) GET /v1/agents/:agentId — Get single agent (by public_id or numeric id) PATCH /v1/agents/:agentId — Partial update DELETE /v1/agents/:agentId — Soft-delete (fails if active calls) POST /v1/agents/:agentId/clone — Clone with all settings and knowledge bases POST /v1/agents/:agentId/rotate-webhook-secret — New webhook secret (old immediately invalidated) GET /v1/agents/:agentId/audit-log — Change history ### The Agent Object { "public_id": "agent_28c51f81", "name": "Support Agent", "language": "en-US", "voice": "Sarah", "max_duration": 1800, "recording": true, "end_call_enabled": false, "server_url": "https://your-server.com/call-handler", "webhook_url": "https://your-server.com/webhooks", "webhook_secret_hint": "...a1b2c3d4", "webhook_events": ["call.started", "call.ended"], "knowledge_base_ids": [{ "id": 42, "public_id": "kb_00000001" }], "created_at": "2026-01-15T10:00:00Z", "updated_at": "2026-01-15T12:00:00Z" } webhook_secret_hint is the last 8 characters of the signing secret. knowledge_base_ids is always an array (empty if none attached). webhook_events is null when no webhook subscription is active. ### server_url vs webhook_url server_url: Real-time call control. Called synchronously at call start (call.started) and for each tool the AI invokes. Must respond within 5 seconds (call.started) or tool's timeout_seconds (tool.call). webhook_url: Async event notifications. Staffify POSTs here after events happen. Fire-and-forget — respond 200 and process async. ### Create Agent — Request Fields name (string, required): Display name. Whitespace trimmed. server_url (string, required): Public HTTPS URL for real-time call control. Private/internal IPs (RFC1918, loopback, link-local, AWS metadata) are rejected — hostname is DNS-resolved and checked. Cannot be removed once set. language (string, optional): BCP-47 language code. Default: en-US. voice (string, optional): TTS voice name. Default: Sarah. Must be valid for the language. See GET /v1/voices. max_duration (integer, optional): Max call seconds. Default 1800. Clamped to 60–7200. recording (boolean, optional): Record calls. Default false. end_call_enabled (boolean, optional): Let agent end the call. Default false. webhook_url (string, optional): HTTPS URL for async events. Same IP restrictions as server_url. Set to null in PATCH to remove. webhook_events (string[], optional): Events to subscribe to. Must be a non-empty array of valid event names. Sending empty array returns 400. Omit to subscribe to all events. knowledge_base_ids (string[], optional): KB public_ids (kb_*) or numeric ids. Max 20. Returns 400 if any id not found. On PATCH, replaces the full list — send [] to detach all. ### Create Agent — Response (HTTP 201) { "agent": { ...agent object... }, "webhook_secret": "64-char hex string", <- only if webhook_url was provided "webhook_secret_note": "Save this secret — it will not be shown again." } IMPORTANT: webhook_secret is shown ONCE only. Save it immediately. ### Update Agent (PATCH) — Rules - server_url cannot be set to null or empty string once set (returns 400). - voice is cross-validated against the current (or newly set) language. Returns 400 if incompatible. - Setting webhook_url to null/empty clears the webhook secret and removes all webhook subscriptions. - Setting knowledge_base_ids to [] removes all attached knowledge bases. - webhook_events must be non-empty array. Empty array returns 400. - If webhook_url is newly added via PATCH, the secret is NOT returned in the PATCH response. Use rotate-webhook-secret endpoint immediately after. - If no valid fields in request body, returns 400 "No valid fields to update". ### Delete Agent Returns 409 if agent has active calls in progress. On success: phone numbers assigned to agent are unassigned, KBs detached, webhook subscriptions removed. ### Clone Agent Returns 201. Returns 409 if source agent has active calls. Clone name defaults to "Copy of {original name}" if name not provided. Generates a fresh webhook secret if source has a webhook_url. ### Rotate Webhook Secret Returns 400 if agent has no webhook_url configured. Old secret is immediately invalidated. Update your server BEFORE rotating. Response: { "id": 12345, "webhook_secret": "new_secret_value_64_hex_chars...", "webhook_secret_note": "Save this secret — it will not be shown again." } ### Audit Log Query Params limit (default 50, max 100) offset (default 0, max 1,000,000) action: created | updated | deleted | cloned | webhook_secret_rotated start_date, end_date: ISO date strings (inclusive) Response shape: { "audit_logs": [...], "total": 5, "limit": 20, "offset": 0, "has_more": false } Each log entry: { "id": 99, "agent_id": 12345, "api_key_id": 42, "action": "updated", "changed_fields": ["recording", "voice"], "old_values": { "recording": false, "voice": "Michael" }, "new_values": { "recording": true, "voice": "Sarah" }, "created_at": "2026-01-15T12:00:00Z" } ### Valid webhook_events Values Call lifecycle: call.ringing, call.started, call.ended, call.failed, call.recording_ready, call.dtmf.received, call.gather.completed Speech: transcript.updated, speech.started, speech.ended, ai.speech.started, ai.speech.ended, barge_in Tools: tool.invoked, tool.completed, tool.timeout Transfers: transfer.initiated, transfer.completed, transfer.failed SMS & KB: sms.sent, knowledge_base.refreshed, knowledge_base.refresh_failed, knowledge_base.auto_refresh_disabled Address: address.verification.pending, address.verification.verified, address.verification.partially_verified, address.verification.rejected, address.verification.expired ### Supported Languages en-US, en-GB, es-ES, nl-NL, de-DE, fr-FR, it-IT, pt-PT, pl-PL, sv-SE, da-DK, nb-NO, fi-FI Use GET /v1/voices to list available voice names per language. --- ## SERVER_URL INTEGRATION GUIDE --- The server_url is the core of your integration. Every call triggers your server synchronously: 1. At call start (call.started) — inject system prompt, greeting, tools, voice 2. For each tool the AI invokes (tool.call) — execute and return results Plus: real-time transcript.updated events (fire-and-forget) ### Events Your Server Receives call.started — immediately when call connects — Response required (system prompt, tools, etc.) — 5s timeout tool.call — when AI invokes a tool — Response required (tool result) — tool's timeout_seconds (default 5s) transcript.updated — each new utterance transcribed — No response needed, just return 200 ### call.started Request (what your server receives) POST https://your-server.com/call-handler Content-Type: application/json X-Staffify-Signature: sha256=abc123... X-Call-Id: call_00000001 { "event": "call.started", "call_id": "call_00000001", "from": "+31612345678", "to": "+31201234567", "agent_id": "agent_28c51f81", "gathered_digit": null, "gathered_input": null, "gather_status": null } gathered_digit, gathered_input, gather_status are null on regular inbound calls. They are populated when your previous call.started response returned a pre_gather IVR menu. ### call.started Response Fields (respond HTTP 200 + JSON) system_prompt (string, REQUIRED): The LLM system prompt. Defines agent personality, knowledge, instructions. Inject per-caller data here. system_prompt_append (string): Appended to system_prompt. Useful for adding caller-specific context. first_message (string): Agent's opening greeting. If omitted, agent waits for caller to speak first. end_session_message (string): Spoken before programmatic hangup. language (string): BCP-47 override for this call (e.g. "nl-NL"). voice (string): TTS voice override. Must be valid for the chosen language. max_duration (integer): Max call seconds (60–7200). recording_enabled (boolean): Override recording for this specific call. inactivity_timeout (integer): Seconds of caller silence before auto-hangup. stt_format (string): "smart" (default) | "numerals" | "raw". Controls number/punctuation formatting. stt_keyterms (string[]): Up to 100 domain-specific terms to boost STT accuracy (e.g. product names, IDs, jargon). Case-sensitive. tools (object[]): Structured tool definitions for this call. extraction_schema (object): JSON Schema for post-call data extraction. transfer_destinations (object): Named transfer targets { "billing": "+31201234001", "support": "+31201234002" }. speak_during_tool_call (string): "auto" or custom string agent speaks while waiting for tool result. dtmf_actions (object): Map DTMF digits to action names e.g. { "0": "main_menu" }. post_call.sms (object): SMS sent after call ends. Fields: to ("caller" or E.164), message (max 160 chars, supports {{call_id}} and {{duration_seconds}}), sender_name (max 11 chars, EU/UK/AU only). metadata (object): Arbitrary JSON stored on the call. Max 65,536 bytes, max 5 nesting levels. Returned in GET /v1/calls/:id and all call webhooks. ### Structured Tool Definition { "name": "create_ticket", "description": "Create a support ticket for a reported issue.", "parameters": { "type": "object", "properties": { "subject": { "type": "string", "description": "Brief summary of the issue" }, "priority": { "type": "string", "enum": ["low", "normal", "high"] } }, "required": ["subject"] }, "timeout_seconds": 6, "usage_instructions": "Use when the caller reports a problem that needs follow-up.", "gather": null // optional: triggers DTMF collection when AI calls this tool } Tool fields: - name: Function name. Must match what you handle in tool.call. - description: What this tool does. AI uses this to decide when to call it. - parameters: JSON Schema object. Use empty properties for no-argument tools. - timeout_seconds: Per-tool timeout (default 5). Exceeding fires tool.timeout webhook and AI continues without result. - usage_instructions: Additional guidance for the AI on when/how to use this tool. - gather: When set, triggers mid-call DTMF digit collection when AI calls this tool. ### Platform Tools (native — never routed to server_url) Include these in the tools array to enable them: end_call: Lets AI hang up gracefully when conversation is complete. AI chooses a disposition: resolved, customer_satisfied, no_further_questions, customer_requested, wrong_number, spam, abusive. Also enabled by setting end_call_enabled: true on the agent. Example: { "name": "end_call", "usage_instructions": "End only after issue is fully resolved." } staffify_send_sms: Lets AI send SMS during call. AI provides: to (E.164), message (max 160 chars), sender_name (max 11 chars, EU/UK/AU only). US/CA: sent from agent's assigned phone number. sms.sent webhook fires after delivery. Example: { "name": "staffify_send_sms", "usage_instructions": "Send confirmation SMS after booking." } ### IVR Menus via pre_gather Instead of system_prompt, return pre_gather to play an IVR menu before the AI starts: First call.started response: { "pre_gather": { "prompt": "For sales press 1, for support press 2.", "digits": 1, "language": "en-US", "voice": "female", "timeout": 8, "max_retries": 2, "terminator": "#" } } Staffify speaks the prompt, collects DTMF, then calls server_url again. Second call.started has: gathered_digit (the digit pressed), gather_status ("valid"/"timeout"/"invalid"). Second response MUST include system_prompt. pre_gather fields: - prompt (required): Text spoken to the caller - digits (default 1): Number of digits to collect (1-20) - language (default "en-US"): BCP-47 for TTS - voice (default "female"): "female" or "male" - timeout (default 8): Seconds to wait for input (1-60) - max_retries (default 2): Times to re-prompt if no digit pressed (0-10) - terminator (default "#"): Terminating digit for multi-digit input. Empty string for none. ### Mid-call DTMF Collection (gather field on tool) Add gather field to a tool definition to collect digits when AI calls that tool: { "name": "verify_identity", "description": "Verify caller identity using last 4 digits of SSN.", "parameters": { "type": "object", "properties": {} }, "gather": { "prompt": "Please enter the last 4 digits of your Social Security Number.", "max_digits": 4, "timeout_seconds": 15, "terminator": "" } } The tool.call your server receives after collection: { "event": "tool.call", "name": "verify_identity", "arguments": { "gathered_input": "1234", "gather_status": "valid" }, "call_id": "call_00000001", "from": "+31612345678" } gather_status: "valid" (digits received) or "timeout" (no input). ### tool.call Request Format POST https://your-server.com/call-handler Content-Type: application/json X-Call-Id: call_00000001 { "event": "tool.call", "tool_call_id": "call_01AbCdEfGhIjKl", "name": "get_customer", "arguments": { "phone_number": "+31612345678" }, "call_id": "call_00000001", "from": "+31612345678" } ### tool.call Response Format (HTTP 200) { "result": { "customer_name": "Jan de Vries", "tier": "premium", "balance": 142.50 } } // or string: { "result": "No customer found for this number" } // or boolean: { "result": true } IMPORTANT: If server does not respond within timeout_seconds (default 5), Staffify fires tool.timeout webhook and AI continues without result. Never return HTTP 500 — return { "result": { "error": "..." } } instead. ### transcript.updated Events { "event": "transcript.updated", "call_id": "call_00000001", "role": "user", "content": "I need to file a claim", "timestamp": 2.4 } role: "user" (caller) or "assistant" (AI). timestamp is seconds from call start. Fire-and-forget — just return 200. ### Data Extraction Return extraction_schema in call.started response: "extraction_schema": { "caller_intent": { "type": "string" }, "issue_category": { "type": "string" }, "ticket_created": { "type": "boolean" }, "estimated_value": { "type": "number" }, "callback_requested": { "type": "boolean" } } After call, available in extracted_data on GET /v1/calls/:callId and in call.ended webhook. Supported types: string, number, boolean. Constraints: JSON object (not array), min 1 field, max 20 fields. Each field can have description, format, and pattern hints. ### Named Transfer Destinations Return transfer_destinations in call.started response: { "system_prompt": "If caller wants to dispute a charge, transfer to 'billing'. Technical problem: 'technical'.", "transfer_destinations": { "billing": "+31201234001", "technical": "+31201234002", "cancellations": "+31201234003" } } Staffify automatically creates a transfer_call tool for the AI. Transfers execute natively — no tool.call event sent to server_url. Webhooks: transfer.initiated and transfer.completed/transfer.failed fire after each attempt. ### Timeout Behavior call.started: 5 second timeout — call is hung up if exceeded. tool.call: per-tool timeout_seconds (default 5) — AI continues without result on timeout. If call.started response is missing system_prompt, call is hung up immediately. Pre-warm database connections — call.started runs before caller hears a single word. ### Node.js Example (complete) const express = require('express'); const app = express(); app.use(express.json()); app.post('/call-handler', async (req, res) => { const { event, name, arguments: args, call_id, from } = req.body; if (event === 'call.started') { const customer = await db.customers.findByPhone(from); return res.json({ system_prompt: customer ? `You are a support agent for Acme. Speaking with ${customer.name}, a ${customer.tier} member.` : 'You are a support agent for Acme. Ask for the caller name and account number.', first_message: customer ? `Hi ${customer.firstName}! How can I help you today?` : undefined, metadata: customer ? { customer_id: customer.id, tier: customer.tier } : {}, extraction_schema: { caller_intent: { type: 'string' }, ticket_created: { type: 'boolean' }, }, tools: [{ name: 'create_ticket', description: 'Create a support ticket when the caller reports a problem.', parameters: { type: 'object', properties: { subject: { type: 'string' }, priority: { type: 'string', enum: ['low', 'normal', 'high'] }, }, required: ['subject'], }, }], }); } if (event === 'transcript.updated') { return res.sendStatus(200); } if (event === 'tool.call') { switch (name) { case 'create_ticket': const result = await tickets.create({ phone: from, subject: args.subject }); return res.json({ result }); default: return res.json({ result: { error: `Unknown tool: ${name}` } }); } } res.sendStatus(200); }); --- ## CALLS --- ### Endpoints GET /v1/calls/summary — Aggregate stats for date range GET /v1/calls — Paginated list (excludes transcript and latency_data) GET /v1/calls/:callId — Full call including transcript and latency_data GET /v1/calls/:callId/status — Lightweight status polling (reads from Redis) GET /v1/calls/:callId/recording — Pre-signed S3 URL (?expires_in=60-86400, default 3600) POST /v1/calls/:callId/end — End in-progress call (ended_reason: "api") POST /v1/calls/:callId/transfer — Transfer to phone number { destination: "+31850099999" } POST /v1/calls/:callId/gather — Collect DTMF digits (HTTP 202 accepted, async) GET /v1/calls/:callId/debug-link — Shareable debug link (masked numbers, latency metrics) GET /v1/calls/:callId/webhooks — Webhook delivery history (max 500) ### The Call Object { "call_id": "call_00000001", "agent_id": "agent_28c51f81", "from_number": "+31612345678", "to_number": "+31850012345", "status": "completed", "disposition": "answered", "ended_reason": "caller-hangup", "duration_seconds": 127, "minutes_used": 2.12, "rate_per_minute": 0.25, "total_cost": 0.5300, "has_recording": false, "turn_count": 6, "tool_call_count": 2, "tools_called": ["get_policy", "create_ticket"], "metadata": {}, "ws_unavailable": false, "transcript": [...], // only on GET /v1/calls/:callId "latency_data": {...}, // only on GET /v1/calls/:callId "call_cost": 0.4664, "recording_cost": 0.0, "llm_discount": 0.0, "transfer_duration_seconds": 0, "transfer_cost": 0.0, "transferred_to": null, "recording_enabled": false, "extracted_data": {}, "started_at": "2026-01-15T10:00:00Z", "ended_at": "2026-01-15T10:02:07Z" } transcript format: [{ "role": "assistant"|"user", "content": "...", "timestamp": 0.0 }] latency_data format: { "turns": [{ "turn": 1, "ts": 0, "llm_ttfb_ms": 210, "tts_start_ms": 320, "tts_ttfb_ms": 95 }] } ### Status Values ringing | in-progress | completed | failed | busy | no-answer | canceled | rejected ### Disposition Values answered | no-answer | transferred | busy | failed | voicemail | rejected ### Ended Reason Values agent-hangup | caller-hangup | api | timeout | transfer | silence-timeout | error | max-duration ### GET /v1/calls/summary Query Params agent_id: Filter to specific agent (numeric id only, agent_* public IDs not supported here) from_date: Start date YYYY-MM-DD (inclusive) to_date: End date YYYY-MM-DD (inclusive) Response: { "total_calls": 1240, "completed_calls": 1198, "failed_calls": 12, "answered_calls": 1195, "no_answer_calls": 30, "total_duration_seconds": 153624, "total_minutes_used": 2560.4, "total_cost": 563.29, "total_recording_cost": 0.0, "total_llm_discount": 0.0, "filters": { "from_date": "2026-01-01", "to_date": "2026-01-31" } } ### GET /v1/calls Query Params agent_id: Filter by agent (agent_* public_id or numeric id) status: ringing | in-progress | completed | failed | busy | no-answer | canceled | rejected disposition: answered | no-answer | transferred | busy | failed | voicemail | rejected from_date: YYYY-MM-DD (inclusive) to_date: YYYY-MM-DD (inclusive) after: Cursor from previous response's next_cursor limit: Results per page (default 50, max 100) ### POST /v1/calls/:callId/transfer Body: { "destination": "+31850099999" } (E.164 phone number, required) Response: { "success": true, "destination": "+31850099999" } Fires: transfer.initiated then transfer.completed or transfer.failed webhooks. Error responses: 400: "destination must be a valid E.164 phone number" 409: "Call is not in progress" 409: "Call does not have an active control channel" 422: "Transfer failed: " ### POST /v1/calls/:callId/gather Body: { "prompt": "Please enter your 8-digit account number followed by the hash key.", "max_digits": 8, "terminator": "#", "timeout_seconds": 20, "voice": "female", "language": "en-US" } Fields: prompt (required), max_digits (default 10, 1-20), terminator (default "#"), timeout_seconds (default 15, 5-60), voice (default "female"), language (default "en-US"). Returns HTTP 202: { "message": "Gather initiated", "call_id": 1 } — note call_id here is internal numeric id, not public call_* id. Fires call.gather.completed webhook: { "event": "call.gather.completed", "call_id": "call_00000001", "agent_id": "agent_...", "status": "completed"|"timeout", "input": "12345678"|null } Error: 409 if another gather already active. Error: 409 if call not in-progress. ### GET /v1/calls/:callId/debug-link Returns: { "debug_token": "kR7xQ2m...", "url": "/api/v1/call-debug/kR7xQ2m..." } Full URL: https://api.staffifyai.com/api/v1/call-debug/ Public endpoint — no API key required. Shows: masked phone numbers, transcript with per-turn latency (llm_ttfb_ms, tts_start_ms, tts_ttfb_ms). Returns 404 if call has no debug token. ### Recordings Format: MP3 Retention: 90 days. Suspended accounts: 7 days before deletion. Recording cost: billed per minute of actual call duration. 1 minute pre-debited at start, settled when call ends. GET /v1/calls/:callId/recording returns 404 if recording not enabled or not yet available. ### Webhook Delivery History GET /v1/calls/:callId/webhooks returns up to 500 delivery records ordered by created_at ASC, attempt ASC. Each record: { id, event, event_id, url, attempt, status_code, success, error, created_at } --- ## WEBHOOKS --- ### Three Levels 1. Org webhooks (POST /v1/webhooks): Events across your entire org. Supports agent_id / project_id filters. 2. Project webhook (PATCH /v1/project/webhook): Events for one project only. 3. Agent webhook (agent.webhook_url field): Events for one agent's calls only. ### All Event Types Call lifecycle: call.ringing, call.started, call.ended, call.failed, call.recording_ready, call.dtmf.received, call.gather.completed Real-time speech: transcript.updated, speech.started, speech.ended, ai.speech.started, ai.speech.ended, barge_in Tools: tool.invoked, tool.completed, tool.timeout Transfers: transfer.initiated, transfer.completed, transfer.failed SMS: sms.sent Knowledge base: knowledge_base.refreshed, knowledge_base.refresh_failed, knowledge_base.auto_refresh_disabled Address verification: address.verification.pending, address.verification.verified, address.verification.partially_verified, address.verification.rejected, address.verification.expired Use "*" to subscribe to all events (org/agent level only, not project level). IMPORTANT: Agent-level webhook_url receives call, speech, tool, transfer, and SMS events. Knowledge base and address verification events are only delivered to org-level and project-level webhooks. ### Event Payload Envelope { "id": "whe_a1b2c3d4-e5f6-...", "event": "call.ended", "timestamp": "2026-01-15T10:02:07Z", "api_version": "2026-05-20", "org_id": 42, "project_id": 1, "agent_id": "agent_28c51f81", "data": { ... event-specific fields ... } } ### Event Data Fields call.ringing, call.started: data: { call_id, agent_id, from, to } call.ended: data: { call_id, agent_id, from, to, started_at, ended_at, duration_seconds, minutes_used, call_cost, recording_cost, transfer_cost, llm_discount, total_cost, rate_per_minute, disposition, ended_reason, ws_unavailable, transferred_to, metadata, language, turn_count, tool_call_count, tools_called, extracted_data, transcript } call.failed: data: { call_id, agent_id, from|null, to|null, ended_reason } call.recording_ready: data: { call_id, agent_id, recording_url, recording_expires_at } recording_url: pre-signed S3 URL valid for 24 hours. call.dtmf.received: data: { call_id, agent_id, digit, action } digit: single char (0-9, *, #). action: context string e.g. "gather". call.gather.completed: data: { call_id, agent_id, status, input } status: "completed"|"timeout". input: digits string or null on timeout. transcript.updated: data: { call_id, agent_id, from|null, turn: { role, content }, turn_index } role: "user"|"assistant". turn_index: 0-based. Fire-and-forget. speech.started, speech.ended: data: { call_id, agent_id, from|null, timestamp } speech.ended also includes: transcript (transcribed text|null) ai.speech.started, ai.speech.ended: data: { call_id, agent_id, text|null, timestamp } ai.speech.ended also includes: duration_ms (integer ms|null) barge_in: data: { call_id, agent_id, from|null, user_text|null, interrupted_text|null, timestamp } tool.invoked: data: { call_id, agent_id, tool_name, tool_call_id, arguments } tool.completed: data: { call_id, agent_id, tool_name, tool_call_id, result } tool.timeout: data: { call_id, agent_id, tool_name, tool_call_id, timeout_seconds } transfer.initiated: data: { call_id, agent_id, from|null, transfer_to, reason|null } transfer.completed: data: { call_id, agent_id, from|null, transfer_to|null } transfer.failed: data: { call_id, agent_id, from|null, transfer_to|null, reason|null, failure_reason } failure_reason: e.g. "Destination busy", "Unknown error" sms.sent: data: { call_id, agent_id, from|null, to, from_number|null, sender_name|null, message, sender_type, telnyx_message_id|null, cost_eur|null, status } status: "sent"|"failed" knowledge_base.refreshed: data: { kb_id, entries_added, entries_removed, entries_truncated } knowledge_base.refresh_failed: data: { kb_id, error } (error e.g. "HTTP 404") knowledge_base.auto_refresh_disabled: data: { kb_id, reason, count } (reason: "consecutive_failures") address.verification.*: data: { address_id, status } verified/partially_verified/rejected/expired also include: number_types: [{ type, status, rejection_reason }] ### Webhook Object { "id": 42, "url": "https://your-server.com/webhooks", "events": ["call.started", "call.ended"], "filters": { "agent_id": 123 }, "is_active": true, "agent_id": null, "project_id": null, "is_agent_managed": false, "is_project_managed": false, "failure_count": 0, "consecutive_failures": 0, "disabled_reason": null, "last_failure_at": null, "api_version": "2026-05-20", "created_at": "2026-01-15T10:00:00Z", "updated_at": "2026-01-15T10:00:00Z" } ### Delivery Headers (sent with every delivery) Content-Type: application/json X-Webhook-Event: call.ended X-Webhook-Timestamp: Unix timestamp (seconds) X-Webhook-Signature: sha256= X-Staffify-Api-Version: 2026-05-20 User-Agent: Staffify-API/1.0 X-Staffify-Test-Event: 1 (test deliveries only) X-Staffify-Replay: 1 (replayed deliveries only) ### Signature Verification HMAC-SHA256 computed over: timestamp + "." + rawBody Using your webhook secret. Always verify before processing. Use timing-safe comparison. Node.js example: const crypto = require('crypto'); app.post('/webhooks', express.raw({ type: '*/*' }), (req, res) => { const sig = req.headers['x-webhook-signature']; const timestamp = req.headers['x-webhook-timestamp']; const secret = process.env.STAFFIFY_WEBHOOK_SECRET; const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(timestamp + '.' + req.body) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) { return res.status(401).send('Invalid signature'); } const event = JSON.parse(req.body); processEventAsync(event).catch(console.error); res.sendStatus(200); }); ### Delivery and Retries Attempt 1: Immediate Attempt 2: ~30 seconds Attempt 3: ~5 minutes Attempt 4: ~30 minutes Auto-disable: After 10 consecutive failures (re-enable with PATCH is_active=true) HTTP timeout per attempt: 10 seconds All-failed deliveries go to dead-letter queue (DLQ). Always respond 200 within 5 seconds. Process events asynchronously. ### Org Webhook Endpoints POST /v1/webhooks — Create (response includes secret ONCE) GET /v1/webhooks — List (hard-capped at 100, no cursor) GET /v1/webhooks/:id — Get single PATCH /v1/webhooks/:id — Update: url, events, filters, is_active DELETE /v1/webhooks/:id — Delete POST /v1/webhooks/:id/rotate — New secret (old immediately invalid) POST /v1/webhooks/:id/test — Send test payload { "event_type": "call.ended" } GET /v1/webhooks/:id/deliveries — Delivery history POST /v1/webhooks/:id/deliveries/:did/resend — Resend single delivery POST /v1/webhooks/:id/replay — Replay events { from: ISO, to: ISO } (max 7-day range, max 500 events). Returns { queued: N }. GET /v1/webhooks/:id/dlq — Dead-letter queue items POST /v1/webhooks/:id/dlq/resend-all — Resend up to 200 DLQ items Valid filter keys: agent_id (numeric), project_id (numeric). ### Project-Level Webhook Endpoints GET /v1/project/webhook — Get config { webhook_url, webhook_secret_hint, webhook_events } PATCH /v1/project/webhook — Set/update { webhook_url, webhook_events }. Null webhook_url removes it. DELETE /v1/project/webhook — Remove. Returns { deleted: true }. POST /v1/project/webhook/rotate — Rotate secret. Returns { webhook_secret, note }. POST /v1/project/webhook/test — Test delivery { "event_type": "call.ended" } GET /v1/project/webhook/deliveries — Delivery history (cursor paginated) GET /v1/project/webhook/dlq — DLQ items POST /v1/project/webhook/dlq/resend-all — Resend up to 200 DLQ items Note: Wildcard "*" not accepted for project-level webhook_events. --- ## KNOWLEDGE BASES --- Store Q&A data your agents retrieve during calls. Attach to agents via knowledge_base_ids (max 20 per agent). GET /v1/knowledge-bases — List POST /v1/knowledge-bases — Create GET /v1/knowledge-bases/:id — Get PATCH /v1/knowledge-bases/:id — Update DELETE /v1/knowledge-bases/:id — Delete POST /v1/knowledge-bases/:id/refresh — Trigger manual refresh from URL source --- ## PHONE NUMBERS --- Search, purchase, and assign numbers to agents. GET /v1/phone-numbers — List purchased numbers GET /v1/phone-numbers/search — Search available numbers by country/area POST /v1/phone-numbers/purchase — Purchase a number PATCH /v1/phone-numbers/:id — Assign/unassign agent DELETE /v1/phone-numbers/:id — Release number Cost: EUR 5.00/month per number. --- ## ADDRESSES --- Address verification is required for phone numbers in some countries. GET /v1/addresses — List addresses POST /v1/addresses — Create address GET /v1/addresses/:id — Get address DELETE /v1/addresses/:id — Delete address Address verification events delivered via webhooks: address.verification.pending/verified/partially_verified/rejected/expired. --- ## SMS --- Send outbound SMS from your Staffify phone numbers. POST /v1/sms/send Body: { from_number, to, message, sender_name (optional, max 11 chars, EU/UK/AU) } The agent's phone number is used as sender for US/CA. sms.sent webhook fires after delivery. --- ## USAGE --- Query call minutes, costs, and SMS usage over time. GET /v1/usage Query params: from_date, to_date, agent_id, granularity (day|month) Response includes: total_minutes, total_cost, call_count, sms_count, sms_cost, grouped by granularity. --- ## VOICES --- List available TTS voices by language. GET /v1/voices GET /v1/voices?language=nl-NL Response: { "voices": [{ "name": "Marieke", "language": "nl-NL", "gender": "female", "preview_url": "..." }] } --- ## VERIFY --- Fuzzy-match caller answers for identity verification flows. POST /v1/verify/match Body: { "expected": "Jan de Vries", "actual": "Jan the Freights", "threshold": 0.8 } Response: { "match": true|false, "score": 0.91 } Useful for fuzzy matching of names, addresses, dates of birth spoken over the phone. --- ## CREDITS --- GET /v1/credits Response: { "balance_eur": 142.50, "tier": "payg", "concurrent_call_limit": 10, "is_paused": false } --- ## ORGANIZATIONS & PROJECTS --- GET /v1/org — Get org info GET /v1/org/api-keys — List org-level API keys POST /v1/org/api-keys — Create org key DELETE /v1/org/api-keys/:id — Revoke org key GET /v1/projects — List projects (requires org key) POST /v1/projects — Create project (requires org key) GET /v1/projects/:id — Get project (requires org key) DELETE /v1/projects/:id — Delete project (requires org key) GET /v1/project — Get current project (project key) GET /v1/project/api-keys — List project keys POST /v1/project/api-keys — Create project key DELETE /v1/project/api-keys/:id — Revoke project key --- ## TEST SESSIONS --- Test agent logic without a real phone call. POST /v1/test-sessions — Create test session POST /v1/test-sessions/:id/message — Send a message GET /v1/test-sessions/:id — Get session transcript DELETE /v1/test-sessions/:id — End session --- ## ERROR CODES REFERENCE --- Standard HTTP status codes used throughout the API: 200 OK — Success 201 Created — Resource created 202 Accepted — Request accepted, processing async 400 Bad Request — Invalid parameters or request body 401 Unauthorized — Missing or invalid API key 402 Payment Required — Account paused (insufficient credits) 403 Forbidden — Read-only key or org key required 404 Not Found — Resource not found 409 Conflict — State conflict (e.g. active calls, duplicate) 422 Unprocessable Entity — Semantically invalid (e.g. transfer failed) 429 Too Many Requests — Rate limit exceeded 500 Internal Server Error — Staffify error Machine-readable error codes: MISSING_API_KEY, INVALID_API_KEY, INSUFFICIENT_CREDITS, READ_ONLY_KEY, ORG_KEY_REQUIRED, PROJECT_NOT_FOUND, RATE_LIMIT_EXCEEDED, AUTH_RATE_LIMITED --- ## NODE.JS SDK --- npm install staffify import Staffify from 'staffify'; const client = new Staffify({ apiKey: process.env.STAFFIFY_API_KEY }); ### Agents const { data } = await client.agents.list({ limit: 20 }); const { data } = await client.agents.create({ name, server_url, language, voice }); const { data } = await client.agents.get('agent_abc12345'); const { data } = await client.agents.update('agent_abc12345', { recording: true }); const { data } = await client.agents.delete('agent_abc12345'); const { data } = await client.agents.clone('agent_abc12345', { name: 'Copy' }); const { data } = await client.agents.rotateWebhookSecret('agent_abc12345'); const { data } = await client.agents.listAuditLog('agent_abc12345', { limit: 50 }); ### Calls const { data } = await client.calls.getSummary({ from_date: '2026-01-01', to_date: '2026-01-31' }); const { data } = await client.calls.list({ limit: 50, status: 'completed' }); const { data } = await client.calls.get('call_abc12345'); const { data } = await client.calls.getStatus('call_abc12345'); const { data } = await client.calls.getRecording('call_abc12345', { expires_in: 3600 }); const { data } = await client.calls.end('call_abc12345'); const { data } = await client.calls.transfer('call_abc12345', { destination: '+31850099999' }); const { data } = await client.calls.gather('call_abc12345', { prompt: 'Enter PIN', max_digits: 4 }); const { data } = await client.calls.getDebugLink('call_abc12345'); ### Webhooks const { data } = await client.webhooks.create({ url, events: ['call.ended'] }); const { data } = await client.webhooks.list(); const { data } = await client.webhooks.update(id, { is_active: true }); const { data } = await client.webhooks.delete(id); ### Auto-pagination for await (const agent of client.agents.autoPaginate({ limit: 50 })) { console.log(agent.public_id); } for await (const call of client.calls.autoPaginate()) { console.log(call.call_id); }