A Custom Function (type: "custom") calls your HTTP endpoint when the LLM decides to. Use it to look up orders, check availability, create bookings, or anything your own API can do. The voice worker makes the request directly, waits for the response and hands the response text to the LLM.
Configure in the dashboard#
- Open the agent, expand Functions, click Add and choose Custom Function.
- Name and Description: when the agent should call it.
- Method (
GET,POST,PUT,PATCH,DELETE) and Endpoint URL. The URL must start withhttp://orhttps://. - Timeout (ms): 1,000 to 600,000. Default 120,000.
- Headers and Query parameters: key and value pairs. Values can use
{{variables}}, for exampleAuthorization: Bearer {{crm_token}}. - Parameters: the arguments the LLM fills in, in Form or JSON schema mode. Pin value sends a constant.
- Retries and Body encoding.
- Arguments at root, if your endpoint expects the arguments as the whole body.
- Response variables: values to save from the response.
- While it runs: Speak during execution, Typing sound, Speak after execution.
- Click Save.
Fields#
| Field | Type | Default | Description |
|---|---|---|---|
type |
"custom" |
||
name |
string | ^[a-zA-Z0-9_-]{1,64}$. Sent in the body as name |
|
description |
string | "" |
When to call it |
url |
string (URL) | Endpoint. {{variables}} are rendered |
|
method |
GET, POST, PUT, PATCH, DELETE |
POST |
|
headers |
object of string to string | Rendered with variables | |
query_params |
object of string to string | Rendered with variables | |
parameters |
JSON schema object | Arguments for the LLM | |
timeout_ms |
integer, 1,000 to 600,000 | 120,000 | Per attempt |
max_retry |
integer, 0 to 5 | 0 | Retries on network errors, timeouts, 429 and 5xx |
args_at_root |
boolean | off | Body is the arguments object alone |
parameter_type |
"json" or "form" |
json |
Body encoding for POST, PUT and PATCH |
response_variables |
object of variable name to JSON path | Values saved as dynamic variables | |
speak_during_execution |
boolean | false |
|
speak_after_execution |
boolean | true |
|
execution_message_description, execution_message_type, typing_sound |
See execution options |
Request format#
POST, PUT and PATCH#
The default body is a JSON object with the function name, a snapshot of the call and the arguments:
{
"name": "check_availability",
"call": {
"call_id": "call_9f2c0b6d1e4a7c3b5d8e0f12",
"call_type": "phone_call",
"agent_id": "agent_3fa2c91e7b0d4e5f8a6b1c2d",
"direction": "inbound",
"from_number": "+61412345678",
"to_number": "+61290001234",
"metadata": {},
"retell_llm_dynamic_variables": { "business_name": "Bondi Dental", "first_name": "Sam" },
"collected_dynamic_variables": { "preferred_day": "Tuesday" },
"transcript": "Agent: Hi, thanks for calling Bondi Dental.\nUser: Can I book a check-up on Tuesday?"
},
"args": { "date": "2026-10-20", "time": "14:30" }
}| Body field | Description |
|---|---|
name |
The tool's name |
call.retell_llm_dynamic_variables |
Agent defaults merged with the request variables |
call.collected_dynamic_variables |
Values collected during this call so far |
call.transcript |
The conversation so far, one Agent: or User: line per turn |
args |
The LLM's arguments plus any pinned values |
The request sets content-type: application/json unless you set it yourself.
- Arguments at root (
args_at_root: true): the body is only theargsobject. - Body encoding set to Form (x-www-form-urlencoded) (
parameter_type: "form"): the same fields are form-encoded. Object values (such ascall) are sent as JSON strings.
GET and DELETE#
There is no body. Each argument is added as a query parameter (objects as JSON strings), merged over your query_params.
GET https://api.example.com/orders?order_id=A1234&source=phoneVerifying the signature#
When the workspace has an API key, every request carries two identical headers:
x-tried-signature: v=1760832000000,d=5c1b...e9
x-retell-signature: v=1760832000000,d=5c1b...e9vis the timestamp in milliseconds.dis the hex HMAC-SHA256 of the raw body followed by the timestamp, keyed with the workspace's webhook signing key (the API key marked for webhooks, otherwise the oldest API key in the workspace).- For GET and DELETE the body is the empty string.
This is the same scheme as Tried webhooks, so one verifier covers both.
import crypto from "node:crypto";
export function verify(rawBody: string, header: string, secret: string, maxAgeMs = 5 * 60_000) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const ts = Number(parts.v);
if (!ts || Math.abs(Date.now() - ts) > maxAgeMs) return false;
const expected = crypto.createHmac("sha256", secret).update(rawBody + parts.v).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.d ?? ""));
}import hashlib, hmac, time
def verify(raw_body: str, header: str, secret: str, max_age_ms: int = 300_000) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts.get("v", "0"))
if not ts or abs(time.time() * 1000 - ts) > max_age_ms:
return False
expected = hmac.new(secret.encode(), (raw_body + parts["v"]).encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("d", ""))Verify against the raw request body, before any JSON parsing or re-serialisation.
Response handling#
| Response | Text given to the LLM |
|---|---|
| Status below 400 | The response body, cut to 15,000 characters |
| Status 400 or above | Error <status>: <body>, cut to 15,000 characters |
| Timeout after all retries | Error: the function call timed out after <ms> ms. |
| Network error after all retries | Error: the function call failed: ... |
| Blocked URL | Error: the function URL is not allowed (<reason>). |
Return short, plain JSON or text. The LLM reads it as context for its next turn, so a sentence like {"result": "Tuesday 2:30 pm is free"} works better than a large payload.
With Speak after execution on (the default), the agent replies once the result arrives. Turn it off for fire-and-forget calls such as logging; the result is still recorded in the transcript.
Response variables#
response_variables maps a variable name to a JSON path in the response body. Paths start with optional $, use dots for keys and [n] for array items, and ['key with spaces'] for awkward keys.
{
"response_variables": {
"next_slot": "$.data.slots[0].start",
"clinic_name": "$['clinic info'].name"
}
}Values are saved only when the response is valid JSON and the path exists. Strings are stored as is; numbers, booleans, objects and arrays as JSON text. They become {{next_slot}} for the rest of the call and appear in collected_dynamic_variables.
Retries#
Retries (max_retry, 0 to 5) repeats the request after a network error, a timeout, or a 429, 500, 502, 503 or 504 response. The waits between attempts double from 0.5 seconds: 0.5 s, 1 s, 2 s and so on. Each attempt gets the full Timeout, so the worst-case wait is about timeout_ms x (max_retry + 1) plus the backoff.
Only retry idempotent endpoints, or make your endpoint idempotent (for example with a booking reference in the arguments). A slow request that succeeds after the timeout is retried anyway.
Security: blocked destinations#
Custom functions call URLs on the public internet only. The worker checks the URL before the request and again when each connection opens, so a hostname that resolves to a private address at connect time (DNS rebinding) is also refused.
Refused:
- Schemes other than
httpandhttps. - Hosts
localhost,metadata,metadata.google.internal,metadata.goog,instance-data, and any host ending in.localhostor.internal. - Private, loopback, link-local, multicast, reserved and unspecified IP addresses (IPv4 and IPv6, including IPv4-mapped IPv6).
- The carrier-grade NAT range
100.64.0.0/10. - Cloud metadata addresses
169.254.169.254,100.100.100.200andfd00:ec2::254.
Every resolved address is checked, so one private answer blocks the request. Outbound proxies in HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY are honoured. For local development, TRIED_ALLOW_PRIVATE_TOOL_URLS=1 on the worker turns the address check off. Leave it unset in production.
Pinned values#
A parameter with Pin value checked is stored as a JSON schema const. The worker hides it from the LLM and adds it to args on every call. Pinned values can use {{variables}}, so you can pin customer_id to {{customer_id}} and never let the LLM guess it.
Example#
{
"type": "custom",
"name": "check_availability",
"description": "Check free appointment slots once the caller has said which day they want.",
"url": "https://api.example.com/clinics/{{clinic_id}}/availability",
"method": "POST",
"headers": { "Authorization": "Bearer {{api_token}}" },
"parameters": {
"type": "object",
"properties": {
"date": { "type": "string", "description": "Requested date, YYYY-MM-DD" },
"source": { "type": "string", "const": "phone" }
},
"required": ["date"]
},
"timeout_ms": 8000,
"max_retry": 1,
"speak_during_execution": true,
"execution_message_type": "static_text",
"execution_message_description": "Let me check the diary.",
"response_variables": { "next_slot": "$.slots[0]" }
}Gotchas#
{{variables}}that do not exist stay as literal text in the URL, headers and query values.- A slow endpoint is dead air on a phone call. Keep timeouts short and use Speak during execution or Typing sound for anything over a second.
- Test LLM in the dashboard does not call your endpoint. Use Mocks to test the conversation and Test Audio or a phone call to test the endpoint.
- In conversation flows, a custom function only runs from a function node or a subagent node.