Skip to main content
Docs/API Reference/server_url Guide
API Reference

server_url Integration Guide

The server_url is the core of your AI agent integration. Every call triggers your server twice synchronously: once at the start of the call (so you can inject per-caller configuration like the system prompt, greeting, and tools), and once for each tool the AI invokes during the conversation. Staffify also streams live transcription events to your server in real time.

server_url

  • Called synchronously during a live call
  • Must respond within 5 s (call.started) or tool's timeout_seconds (tool.call)
  • Receives: call.started, tool.call, transcript.updated
  • Think: your call logic and tool execution endpoint

webhook_url

  • Called asynchronously after events happen
  • Fire and forget (respond 200, process async)
  • Receives: ringing, started, ended, transfers, etc.
  • Think: your notification and post-call automation receiver

Events your server receives

EventWhenResponse required
call.startedImmediately when a call connectsYes -- returns call configuration (system prompt, tools, etc.)
tool.callWhen AI decides to invoke a toolYes -- returns tool result within timeout_seconds (default: 5 s)
transcript.updatedEach time a new utterance is transcribedNo -- fire-and-forget, respond 200

call.started -- Per-call configuration

This is the most important event. When a call connects, Staffify calls your server_url synchronously and waits for your response before the agent speaks a single word. Your response configures the entire call: what the agent knows, how it sounds, what tools it has, and what data to extract.

This is how you make every call personalized -- look up the caller by their phone number, pull their account data, and inject it into the system prompt in real time.

Request 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, and gather_status are null on a regular inbound call. They are populated when your previous call.started response returned a pre_gather IVR menu and the caller pressed a digit. See the IVR menus section below. The signature uses the same HMAC-SHA256 scheme as tool calls (signed over the raw request body). Timeout: 5 seconds to respond -- if exceeded, the call is hung up.

Response -- full configuration object

Respond with HTTP 200 and a JSON body. All fields are optional except system_prompt, which is required for the agent to know how to behave.

{
  // Required
  "system_prompt": "You are a support agent for Acme. The caller is Jan de Vries, a premium member.",

  // Optional -- agent greeting
  "first_message":        "Hi Jan! How can I help you today?",
  "end_session_message":  "Thank you for calling. Have a great day!",
  "system_prompt_append": "Always offer a callback if the issue cannot be resolved.",

  // Optional -- per-call voice and language overrides
  "language": "nl-NL",
  "voice":    "Marieke",

  // Optional -- call limits
  "max_duration":      1200,
  "inactivity_timeout": 30,

  // Optional -- recording override
  "recording_enabled": true,

  // Optional -- STT configuration
  "stt_format":   "smart",
  "stt_keyterms": ["IBAN", "BSN", "Acme Plus", "policy number"],

  // Optional -- structured tools for this call
  "tools": [ ... ],

  // Optional -- post-call data extraction
  "extraction_schema": { ... },

  // Optional -- named transfer destinations
  "transfer_destinations": {
    "billing": "+31201234001",
    "support": "+31201234002"
  },

  // Optional -- what agent says while waiting for a tool result
  "speak_during_tool_call": "auto",

  // Optional -- DTMF digit-to-action mapping
  "dtmf_actions": {
    "0": "main_menu",
    "1": "support",
    "9": "cancel"
  },

  // Optional -- post-call SMS to the caller
  "post_call": {
    "sms": {
      "to":          "caller",
      "message":     "Thanks for calling Acme! Ref: {{call_id}} ({{duration_seconds}}s).",
      "sender_name": "Acme"
    }
  },

  // Optional -- arbitrary metadata returned in webhooks and GET /v1/calls/:id
  "metadata": {
    "customer_id": "cust_12345",
    "tier":        "premium",
    "crm_link":    "https://crm.example.com/contacts/12345"
  }
}

Field reference

FieldTypeDescription
system_promptstringThe LLM system prompt. Defines the agent's personality, knowledge, and instructions. Inject per-caller data here.
system_prompt_appendstringAppended to system_prompt. Useful for adding caller-specific context without rewriting the full prompt.
first_messagestringThe agent's opening greeting. If omitted, the agent waits for the caller to speak first.
end_session_messagestringMessage spoken by the agent before ending the call programmatically.
languagestringBCP-47 language code override for this call (e.g. "nl-NL"). Overrides the agent's default language.
voicestringTTS voice name override for this call. Must be valid for the chosen language.
max_durationintegerMaximum call duration in seconds (60--7200). Overrides the agent's default.
recording_enabledbooleanOverride recording on/off for this specific call.
inactivity_timeoutintegerSeconds of caller silence before the agent ends the call automatically.
stt_formatstring"smart" (default) | "numerals" | "raw". Controls how numbers and punctuation are formatted in transcripts.
stt_keytermsstring[]Up to 100 domain-specific terms to boost STT accuracy (e.g. product names, IDs, industry jargon). Case-sensitive; any characters allowed; empty strings are filtered out.
toolsobject[]Structured tool definitions for this call. See Structured tools below.
extraction_schemaobjectJSON Schema defining what structured data to extract from the call. See Data extraction below.
transfer_destinationsobjectNamed transfer targets (key: label, value: E.164 number). Reference these names in your system prompt.
speak_during_tool_callstring"auto" (language-appropriate wait phrase) or a custom string the agent speaks while waiting for a tool result.
dtmf_actionsobjectMap DTMF digits to action names (e.g. { "0": "main_menu" }). Triggers dtmf webhook with the action name.
post_call.smsobjectSMS sent after the call ends. Fields: to ("caller" or E.164), message (max 160 chars after substitution), sender_name (max 11 chars, EU/UK/AU only). Template variables: {{call_id}}, {{duration_seconds}}.
metadataobjectArbitrary JSON stored on the call. Returned in GET /v1/calls/:id and all call webhooks. Max 65,536 bytes; max nesting depth: 5 levels.

Example: customer-specific configuration

app.post('/call-handler', async (req, res) => {
  const { event, from, call_id } = req.body;

  if (event === 'call.started') {
    // Look up caller in your database
    const customer = await db.customers.findByPhone(from);

    if (customer) {
      return res.json({
        system_prompt: `You are a support agent for Acme Corp.
You are speaking with ${customer.name}, a ${customer.tier} member.
Their account number is ${customer.account_id}.
Open tickets: ${customer.open_tickets}.
Always greet them by first name and offer to resolve their open tickets first.`,
        first_message: `Hi ${customer.firstName}! How can I help you today?`,
        metadata: { customer_id: customer.id, tier: customer.tier },
        tools: [/* ... */],
      });
    }

    // Unknown caller
    return res.json({
      system_prompt: 'You are a support agent for Acme Corp. Ask for the caller's name and account number.',
      first_message: 'Thank you for calling Acme. May I have your name?',
    });
  }

  // handle tool.call, transcript.updated ...
});

Structured tools

Instead of (or in addition to) describing tools in your system prompt, you can define them as structured objects in the tools array of your call.started response. Structured tools give the AI precise JSON Schema-typed parameter definitions and are recommended for complex integrations.

"tools": [
  {
    "name":        "get_customer",
    "description": "Retrieve the customer record for the caller. Call at the start of every conversation.",
    "parameters": {
      "type": "object",
      "properties": {}
    },
    "timeout_seconds":    4,
    "usage_instructions": "Call immediately when the conversation starts. No arguments needed."
  },
  {
    "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."
  }
]
FieldTypeDescription
namestringFunction name. Must match what you handle in tool.call.
descriptionstringWhat this tool does. The AI uses this to decide when to call it.
parametersobjectJSON Schema object describing the arguments. Use empty properties for no-argument tools.
timeout_secondsintegerPer-tool timeout in seconds. Default: 5. Each tool is timed independently -- exceeding this fires a tool.timeout webhook and the AI continues without the result. Set higher for slower external APIs.
usage_instructionsstringAdditional guidance for the AI on when and how to use this tool.
gatherobjectWhen set, triggers mid-call DTMF digit collection when the AI calls this tool. See Mid-call DTMF below.

Data extraction

Return an extraction_schema in your call.started response to instruct the AI to extract structured data from the conversation. After the call ends, the extracted values are available in the extracted_data field of GET /v1/calls/:callId and in the call.ended webhook payload.

// 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" }
}

// In GET /v1/calls/:callId response:
"extracted_data": {
  "caller_intent":      "Request refund for damaged item",
  "issue_category":     "Returns and Refunds",
  "ticket_created":     true,
  "estimated_value":    49.95,
  "callback_requested": false
}

Supported types: string, number, boolean. The AI fills in the values based on what it heard during the conversation.

Constraints: must be a JSON object (not an array). Minimum 1 field, maximum 20 fields. Each field can carry description, format, and pattern hints to guide extraction accuracy.

Named transfer destinations

Return transfer_destinations in your call.started response. Staffify automatically creates a transfer_call tool for the AI and executes transfers natively -- no server_url routing needed for the transfer itself. Reference the destination names in your system prompt so the AI knows when to use each one.

// In call.started response:
{
  "system_prompt": "You are a support agent for Acme Corp.
If the caller wants to dispute a charge, transfer to 'billing'.
If they have a technical problem, transfer to 'technical'.
If they want to cancel their subscription, transfer to 'cancellations'.",

  "transfer_destinations": {
    "billing":       "+31201234001",
    "technical":     "+31201234002",
    "cancellations": "+31201234003"
  }
}

// Staffify adds a transfer_call tool automatically.
// When the AI calls it, the blind transfer executes immediately.
// Your webhook_url receives transfer.initiated and transfer.failed events.
// No tool.call event is sent to your server_url for the transfer.

A transfer.initiated or transfer.failed webhook fires after each transfer attempt. To also receive these events, include them in your agent's webhook_events list.

Platform tools

Some tool names are reserved by Staffify and executed natively -- they are never routed to your server_url. Enable them by including them in the tools array of your call.started response.

end_call

Lets the AI hang up gracefully when the conversation is complete. The AI chooses a disposition (resolved, customer_satisfied, no_further_questions, customer_requested, wrong_number, spam, abusive). Can also be enabled by setting end_call_enabled: true on the agent itself.

"tools": [
  {
    "name": "end_call",
    "usage_instructions": "End the call only after the issue is fully resolved and the caller confirms."
  }
]
staffify_send_sms

Lets the AI send an SMS during the call without routing to your server_url. The AI provides: to (E.164 number), message (max 160 chars), and optionally sender_name (max 11 chars, EU/UK/AU only). For US/CA the SMS is sent from the agent's assigned phone number. A sms.sent webhook fires after delivery.

"tools": [
  {
    "name": "staffify_send_sms",
    "usage_instructions": "After booking an appointment, send a confirmation SMS to the caller."
  }
]

IVR menus via pre_gather

Instead of returning system_prompt in your call.started response, you can return a pre_gather object to play an IVR menu before the AI starts. Staffify speaks your prompt, collects DTMF digit(s) from the caller, then calls your server_url again with the result. Your second response must include system_prompt to start the AI.

pre_gather fields

FieldDefaultDescription
prompt(required)Text spoken to the caller (e.g. "For sales press 1, for support press 2")
digits1Number of digits to collect (1--20). Use 1 for single-key IVR, higher for PINs.
language"en-US"BCP-47 language for TTS
voice"female"TTS voice ("female" or "male")
timeout8Seconds to wait for input (1--60)
max_retries2Times to re-prompt if no digit pressed (0--10)
terminator"#"Terminating digit for multi-digit input. Empty string for none.

On the second call.started request, gather_status is "valid" (digit received), "timeout" (no input), or "invalid". Route based on gathered_digit.

Example: route caller to sales or support based on keypress

app.post('/call-handler', async (req, res) => {
  const { event, from, gathered_digit, gather_status } = req.body;

  if (event === 'call.started') {
    // First call: no digit yet -- play IVR menu
    if (!gathered_digit) {
      return res.json({
        pre_gather: {
          prompt:      'Welcome to Acme. For sales press 1, for support press 2.',
          digits:      1,
          language:    'en-US',
          timeout:     8,
          max_retries: 2,
        }
      });
    }

    // Second call: digit collected -- route to the right agent persona
    const routes = {
      '1': { dept: 'Sales',   prompt: 'You are the Acme sales team. Help the caller with pricing and demos.' },
      '2': { dept: 'Support', prompt: 'You are the Acme support team. Help resolve the caller\'s issue.' },
    };
    const route = gather_status === 'valid' && routes[gathered_digit]
      ? routes[gathered_digit]
      : routes['2']; // default to support on timeout/invalid

    return res.json({
      system_prompt: route.prompt,
      first_message: `Hi! You reached Acme ${route.dept}. How can I help you today?`,
    });
  }

  // tool.call, transcript.updated ...
});

Mid-call DTMF collection

Add a gather field to any custom tool definition to trigger DTMF digit collection when the AI calls that tool. Staffify speaks your prompt, waits for the caller to press digits, then sends the tool.call event to your server_url with the collected input injected into the arguments.

Tool definition with gather (collect last 4 digits of SSN)

{
  "name":        "verify_identity",
  "description": "Verify the caller's identity by asking for the last 4 digits of their 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":      ""
  }
}

tool.call your server receives after digit collection

{
  "event":        "tool.call",
  "name":         "verify_identity",
  "arguments":    {
    "gathered_input": "1234",
    "gather_status":  "valid"
  },
  "call_id":      "call_00000001",
  "from":         "+31612345678"
}

Handler

case 'verify_identity': {
  const { gathered_input, gather_status } = args;
  if (gather_status !== 'valid' || !gathered_input) {
    return res.json({ result: { verified: false, reason: 'No input received' } });
  }
  const customer = await db.getCustomerByPhone(from);
  return res.json({
    result: { verified: customer?.ssn_last4 === gathered_input }
  });
}

gather_status is "valid" when digits were received or "timeout" when the caller did not press anything. gather config fields: prompt, max_digits (default 10), timeout_seconds (default 15), terminator (default "#"), voice, language.

Tool call request format

When the AI decides to invoke a tool, Staffify POSTs this JSON to your server_url:

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"
}
FieldTypeDescription
eventstringAlways "tool.call" for tool invocations
tool_call_idstringUnique ID for this specific tool call (include in logs)
namestringThe tool function name the AI decided to call
argumentsobjectJSON arguments parsed from the AI's decision
call_idstringThe Staffify call ID (public_id format)
fromstringThe caller's phone number in E.164 format

Tool call response format

Respond with HTTP 200 and a JSON body. The value of result is passed to the AI as the tool output.

// Object result
{ "result": { "customer_name": "Jan de Vries", "tier": "premium", "balance": 142.50 } }

// String result
{ "result": "No customer found for this number" }

// Boolean result
{ "result": true }
5-second timeout (default). If your server does not respond within the tool's timeout_seconds (default: 5), Staffify fires a tool.timeout webhook event and the AI continues without the result. Always respond fast -- use async DB queries where possible.

Transcript events

Staffify sends transcript.updated events to your server_url in real-time as each utterance is transcribed. These are fire-and-forget -- no response needed, just return 200.

{
  "event":     "transcript.updated",
  "call_id":   "call_00000001",
  "role":      "user",
  "content":   "I need to file a claim",
  "timestamp": 2.4
}

role is either "user" (caller) or "assistant" (AI). Timestamp is seconds from call start.

Complete Node.js example

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;

  // 1. Configure the call when it starts
  if (event === 'call.started') {
    const customer = await db.customers.findByPhone(from);

    return res.json({
      system_prompt: customer
        ? `You are a support agent for Acme. You are speaking with ${customer.name},
           a ${customer.tier} member. Open tickets: ${customer.openTickets}.`
        : 'You are a support agent for Acme. Ask for the caller\'s name and account number.',
      first_message: customer ? `Hi ${customer.firstName}! How can I help you today?` : undefined,
      extraction_schema: {
        caller_intent:      { type: 'string' },
        ticket_created:     { type: 'boolean' },
        callback_requested: { type: 'boolean' },
      },
      metadata: customer ? { customer_id: customer.id, tier: customer.tier } : {},
      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'],
          },
        },
      ],
    });
  }

  // 2. Handle transcript events (fire-and-forget)
  if (event === 'transcript.updated') {
    console.log(`[${call_id}] ${req.body.role}: ${req.body.content}`);
    return res.sendStatus(200);
  }

  // 3. Handle tool calls
  if (event === 'tool.call') {
    let result;

    switch (name) {
      case 'create_ticket':
        result = await tickets.create({ phone: from, subject: args.subject, priority: args.priority ?? 'normal' });
        break;
      default:
        result = { error: `Unknown tool: ${name}` };
    }

    return res.json({ result });
  }

  res.sendStatus(200);
});

app.listen(3001, () => console.log('Call handler ready on :3001'));

Python (FastAPI) example

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()

@app.post('/call-handler')
async def handle_call(request: Request):
    body = await request.json()
    event = body.get('event')
    caller = body.get('from')
    call_id = body.get('call_id')

    if event == 'call.started':
        customer = await db.get_customer_by_phone(caller)
        config = {
            'system_prompt': f'You are a support agent for Acme. '
                             f'Speaking with {customer.name}, a {customer.tier} member.'
                             if customer else
                             'You are a support agent for Acme. Ask for the caller name.',
            'extraction_schema': {
                'caller_intent':  {'type': 'string'},
                'ticket_created': {'type': 'boolean'},
            },
        }
        if customer:
            config['first_message'] = f'Hi {customer.first_name}! How can I help you?'
            config['metadata'] = {'customer_id': customer.id}
        return JSONResponse(config)

    if event == 'transcript.updated':
        return JSONResponse({})

    if event == 'tool.call':
        name = body['name']
        args = body.get('arguments', {})

        if name == 'create_ticket':
            ticket = await tickets.create(args['subject'], caller)
            return JSONResponse({'result': ticket})

    return JSONResponse({'result': None})

Best practices

  • Always handle call.started first: This is where you configure the entire call. Fetch caller data and inject it into system_prompt so the AI is personalized from the first word.
  • Respond within 5 seconds: Applies to both call.started and tool.call. Use async operations and avoid blocking on slow external APIs.
  • Return meaningful tool results: Return data the AI can naturally paraphrase to the caller ("Hi Jan, I can see you're a premium member").
  • Use the from field for caller recognition: Look up the caller by phone number in call.started and inject their name, tier, and context into the prompt automatically.
  • Log every tool call: Include tool_call_id in your logs for easy debugging of conversation flows.
  • Return errors in result, don't HTTP 500: If a lookup fails, return { "result": { "error": "Customer not found" } } -- the AI handles it gracefully.
  • Use extraction_schema for post-call automation: Define a schema to automatically extract intent, outcome, and actions from every call. Use the data in your CRM or analytics pipeline.
  • Secure your endpoint: Use IP allowlisting or verify the X-Call-Id header. Your endpoint processes sensitive call data in real time.

Timeout behavior

If your server does not respond to a tool.call within the tool's timeout_seconds (default: 5, configurable per tool), a tool.timeout webhook event is fired and the AI continues the conversation without the tool result. It will typically say something like "I wasn't able to retrieve that information right now" and offer to help another way. Subscribe to tool.timeout in your webhook events to monitor for latency issues. Each tool times out independently -- a slow tool does not affect other tool calls or the call itself.

If your server does not respond to call.started within 5 seconds, or if the response is missing system_prompt, the call is hung up immediately. Pre-warm your database connections and avoid cold-start latency on call.started -- it runs before the caller hears a single word.

server_url Integration Guide - Staffify API