> ## Documentation Index
> Fetch the complete documentation index at: https://docs.60db.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent Tools and Webhooks

> Let an agent call your API during a call, and receive signed call and recording events

## Overview

Agents talk to your server in two ways. Both are HTTPS endpoints that you set on the agent, and 60db signs every request.

| Direction | When | Your field | Your reply |
| - | - | - | - |
| **Tool call** | The agent needs data or an action during a call | `tool_webhook_url` | JSON, within about 6 seconds. The agent speaks it |
| **Event** | A call ends, or its recording is ready | `event_webhook_url` | Any `2xx` status. The body is ignored |

Both use the same signing scheme. See [Verify signatures](#verify-signatures).

## Define tools

Add tools to an agent with `tools`. Each tool is described with JSON Schema, so the agent knows when to use it and what arguments to send.

```json theme={null}
{
  "name": "book_table",
  "description": "Book a table for guests. Call this only after the caller gives a date, time and party size.",
  "parameters": {
    "type": "object",
    "properties": {
      "date": { "type": "string", "description": "YYYY-MM-DD" },
      "time": { "type": "string", "description": "HH:MM, 24-hour" },
      "guests": { "type": "integer", "minimum": 1 }
    },
    "required": ["date", "time", "guests"]
  }
}
```

| Rule | Limit |
| - | - |
| `name` | Letters, digits and underscores. Must not start with a digit. Up to 64 characters. Unique per agent. `transfer_call` and `forget_me` are reserved |
| `description` | 1 to 500 characters |
| `parameters` | JSON Schema with `"type": "object"`. Up to 4 KB of JSON |
| Tools per agent | Up to 16 |
| `tool_webhook_url` | Required when the agent has tools |

Set the URL on the agent when you create it or later with `PATCH /agents/{agentId}`. A URL that is not `https://` returns `400 VALIDATION_ERROR`. A URL on port other than 443, one with credentials or a fragment, or one that resolves to a private or loopback address returns `422 UNSAFE_WEBHOOK_URL`.

## Tool call request

When the agent calls a tool, 60db sends a `POST` to your `tool_webhook_url`:

```http theme={null}
POST /your/tool/path HTTP/1.1
Content-Type: application/json
X-60db-Event: tool.call
X-60db-Timestamp: 1762165200
X-60db-Signature: v1=3f2a…
```

```json theme={null}
{
  "call_id": "6b1f0c52-4d7e-4b8a-9d3e-2a1c8e7f0a11",
  "agent_id": "9c2e1d7a-0b3f-4e6a-8f1d-5a7b9c0e2d44",
  "tool": "book_table",
  "tool_call_id": "call_8f21c0",
  "turn": 3,
  "arguments": { "date": "2026-11-05", "time": "19:30", "guests": 4 },
  "metadata": { "crm_lead_id": "L-1042" }
}
```

| Field | Meaning |
| - | - |
| `call_id` | The call's id. `null` only if the call could not be matched |
| `agent_id` | The agent's id |
| `tool` | The tool name you defined |
| `tool_call_id` | Unique id for this tool call. Use it to dedupe |
| `turn` | Conversation turn number |
| `arguments` | The arguments the agent chose. Validate them before you act |
| `metadata` | The `metadata` you passed when you placed the call |

## Your reply

Reply with a JSON object within about 6 seconds. The agent speaks what it needs from that object.

```json theme={null}
{ "available": true, "slots": ["19:30", "20:00"] }
```

If your reply is late, not JSON, too large or not `2xx`, the agent receives an error object instead of your reply. The call continues.

| What your endpoint does | What the agent receives |
| - | - |
| Returns `2xx` with a JSON object | Your JSON object |
| Takes longer than about 6 seconds | `{"error": "tool_unavailable", "reason": "timeout"}` |
| Returns a non-`2xx` status | `{"error": "tool_unavailable", "reason": "http_500"}` (the status code varies) |
| Returns a body that is not JSON | `{"error": "tool_unavailable", "reason": "not_json"}` |
| Returns more than 16 KB | `{"error": "tool_unavailable", "reason": "too_large"}` |
| Cannot be reached | `{"error": "tool_unavailable", "reason": "unreachable"}` |

Other `reason` values: `unsafe_url` (the address failed the safety check at call time).

Notes:

* **No retries.** A tool call is sent once. If it fails, the agent gets the error.
* **Duplicate calls.** If the same `tool_call_id` arrives again, 60db returns the saved answer. Your endpoint is not called a second time.
* **Tool log.** Every tool call is logged with its arguments, your reply, status code and latency. See the **Tool log** in the dashboard, or `GET /agents/{agentId}/tool-events` for the last 50. Logs are kept 30 days.

### Example tool endpoint (Node.js)

```javascript theme={null}
import http from 'node:http';
import { verifyWebhookSignature } from '60db-service';

const SECRET = process.env.SIXTYDB_WEBHOOK_SECRET; // whsec_… from create or rotate

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);
    const ok = verifyWebhookSignature({
      rawBody: raw,
      signature: req.headers['x-60db-signature'],
      timestamp: req.headers['x-60db-timestamp'],
      secret: SECRET,
    });
    if (!ok) { res.writeHead(401).end(); return; }

    const msg = JSON.parse(raw.toString('utf8'));
    const result = msg.tool === 'book_table'
      ? { booked: true, reference: `BK-${msg.tool_call_id.slice(-6)}` }
      : { error: 'unknown_tool' };
    res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(result));
  });
}).listen(8080);
```

### Example tool endpoint (Python)

```python theme={null}
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
from sixtydb import verify_webhook_signature
import os

SECRET = os.environ["SIXTYDB_WEBHOOK_SECRET"]  # whsec_… from create or rotate

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        ok = verify_webhook_signature(raw, self.headers.get("X-60db-Signature"),
                                      self.headers.get("X-60db-Timestamp"), SECRET)
        if not ok:
            self.send_response(401); self.end_headers(); return
        msg = json.loads(raw)
        result = {"booked": True, "reference": f"BK-{msg['tool_call_id'][-6:]}"} if msg["tool"] == "book_table" \
            else {"error": "unknown_tool"}
        body = json.dumps(result).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(body)

HTTPServer(("", 8080), Handler).serve_forever()
```

## Call transfer

Besides webhook (JSON) tools, an agent can hand a call to a person. Set `transfer_targets` on the agent, or per call in `overrides.transfer_targets`:

```json theme={null}
"transfer_targets": [
  { "name": "Sales", "numbers": ["+919876543210", "+919876543211"] },
  { "name": "Support", "numbers": ["+14155550100"] }
]
```

> [!NOTE]
>
> **How it works.** The `name` is what callers ask for ("connect me to Sales"), so use a team name callers would say, not "Call Transfer". The numbers are tried in turn until someone answers. The first target is also used when a caller asks for "a person". 60db adds the matching instruction to your agent automatically; you don't need to write it in the script.

* Each group becomes a function the agent can use, `transfer_to_<name>` (`Sales Team` becomes `transfer_to_sales_team`). The agent only picks a name; the numbers stay with you.
* The first group is used when a caller asks for a person without saying which team.
* With several numbers in a group, they are tried in turn. If one fails or refuses, the next is tried, and the call stays with the agent if none answers.
* Up to 10 groups of up to 10 E.164 numbers. Names must be distinct, and webhook tools cannot be named `transfer_call` or start with `transfer_to_`.
* `summary.transferred` on the call says whether it was handed over. The transferred leg is billed as Dialer minutes on your line.

In the dashboard, open the agent's **Tools & webhooks** tab, choose **Add tool**, then **Call transfer**.

## Events

When a call ends, or its recording is ready, 60db sends an event to `event_webhook_url`.

| Event | Sent when | `data` |
| - | - | - |
| `call.completed` | The call ended normally | The call object, as returned by [Get call](https://docs.60db.ai/api-reference/agents/get-call.md) |
| `call.failed` | The call did not connect, or ended with an error | The call object, as returned by [Get call](https://docs.60db.ai/api-reference/agents/get-call.md) |
| `recording.ready` | A recorded call's audio and transcript are stored | See below |

Events are sent only when the agent has an `event_webhook_url`. `recording.ready` is sent only for recorded calls. It can arrive after `call.completed`.

### Envelope

Every event has the same envelope:

```json theme={null}
{
  "id": "0f7a3c1e-9b2d-4e5f-a6c7-1d2e3f4a5b6c",
  "type": "call.completed",
  "created_at": "2026-11-03T10:17:42.000Z",
  "data": {
    "id": "6b1f0c52-4d7e-4b8a-9d3e-2a1c8e7f0a11",
    "agent_id": "9c2e1d7a-0b3f-4e6a-8f1d-5a7b9c0e2d44",
    "agent_name": "Front desk",
    "phone_line_id": "2d8e4f10-6a3b-4c9d-8e7f-0b1a2c3d4e5f",
    "direction": "outbound",
    "to": "+919999999999",
    "from": "+919876543210",
    "status": "completed",
    "reason": null,
    "duration_secs": 62,
    "started_at": "2026-11-03T10:16:30.000Z",
    "connected_at": "2026-11-03T10:16:38.000Z",
    "ended_at": "2026-11-03T10:17:40.000Z",
    "record": true,
    "has_recording": true,
    "has_transcript": true,
    "summary": { "turns": 6, "tool_calls": 1, "tool_failures": 0 },
    "cost": { "runtime_usd": 0, "runtime_billing_status": "skipped" },
    "source": "api",
    "metadata": { "crm_lead_id": "L-1042" },
    "created_at": "2026-11-03T10:16:30.000Z"
  }
}
```

`recording.ready` carries the recording details and transcript, not a download link:

```json theme={null}
{
  "id": "5e4d3c2b-1a09-4f8e-b7d6-c5a4b3e2f1d0",
  "type": "recording.ready",
  "created_at": "2026-11-03T10:17:55.000Z",
  "data": {
    "call_id": "6b1f0c52-4d7e-4b8a-9d3e-2a1c8e7f0a11",
    "recording": {
      "bytes": 1978284,
      "content_type": "audio/wav",
      "audio_path": "/agents/calls/6b1f0c52-4d7e-4b8a-9d3e-2a1c8e7f0a11/recording"
    },
    "transcript": { "turns": [{ "speaker": "agent", "text": "Hello, Juniper Kitchen.", "at_ms": 0 }] }
  }
}
```

Download the audio with your API key at `https://api.60db.ai{audio_path}` (add `?download=1` to save it as a file).

### Event headers

| Header | Meaning |
| - | - |
| `X-60db-Event` | Event type, for example `call.completed` |
| `X-60db-Event-Id` | Unique delivery id. Same as the envelope `id`. Use it to dedupe |
| `X-60db-Delivery-Attempt` | Attempt number, starting at `1` |
| `X-60db-Timestamp` | Unix time in seconds when the request was signed |
| `X-60db-Signature` | `v1=` plus the hex HMAC, see below |

### Retries

* Any response other than `2xx`, a timeout or a network error counts as a failed attempt. Redirects count as failures too.
* Reply quickly. Acknowledge with `2xx` before doing slow work. A reply slower than about 5 seconds counts as a timeout.
* 60db makes up to **5 attempts**. It waits 30 seconds, then 1, 2 and 4 minutes between them.
* After the fifth failed attempt the event is marked failed and is not sent again.
* Events can arrive more than once, for example if your response was lost. Process each `X-60db-Event-Id` once.
* Events can arrive out of order. Read the call again with `GET /agents/calls/{callId}` if you need its current state.

## Verify signatures

Verify every tool call and event before you act on it.

| Header | Value |
| - | - |
| `X-60db-Timestamp` | Unix time in seconds |
| `X-60db-Signature` | `v1=` followed by the hex HMAC-SHA256 of `{timestamp}.{raw body}` |

* Use the secret exactly as returned (`whsec_…`). Use its UTF-8 bytes as the HMAC key. Do not decode it.
* Hash the **raw body bytes** as received. Do not parse and re-serialise the JSON first.
* Reject timestamps more than **300 seconds** from your server's clock.
* Compare signatures in constant time.

### Node.js

Use the SDK helper:

```javascript theme={null}
import { verifyWebhookSignature } from '60db-service';

const ok = verifyWebhookSignature({
  rawBody,                                          // the exact bytes received
  signature: req.headers['x-60db-signature'],
  timestamp: req.headers['x-60db-timestamp'],
  secret: process.env.SIXTYDB_WEBHOOK_SECRET,       // whsec_…
});
```

Or with Node's crypto module:

```javascript theme={null}
import crypto from 'node:crypto';

function verifyRaw(rawBody, timestamp, signature, secret) {
  if (!/^\d+$/.test(timestamp || '') || !/^v1=[a-f0-9]{64}$/.test(signature || '')) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected = 'v1=' + crypto.createHmac('sha256', secret)
    .update(timestamp).update('.').update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
```

### Python

Use the SDK helper:

```python theme={null}
from sixtydb import verify_webhook_signature

ok = verify_webhook_signature(raw_body, request.headers.get("X-60db-Signature"),
                              request.headers.get("X-60db-Timestamp"), secret)
```

Or with the standard library:

```python theme={null}
import hashlib
import hmac
import time

def verify_raw(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
    if not (timestamp or "").isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    expected = "v1=" + hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body,
                                hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")
```

### Test with curl

curl cannot verify a signature, but you can send a signed test request to your endpoint:

```bash theme={null}
TS=$(date +%s)
BODY='{"type":"call.completed","data":{"id":"test"}}'
SECRET='whsec_your_secret'
SIG="v1=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"

curl -X POST https://your-server.example.com/webhooks/60db \
  -H "Content-Type: application/json" \
  -H "X-60db-Event: call.completed" \
  -H "X-60db-Event-Id: test-1" \
  -H "X-60db-Timestamp: $TS" \
  -H "X-60db-Signature: $SIG" \
  -d "$BODY"
```

## Rotate the secret

```bash theme={null}
curl -X POST https://api.60db.ai/agents/AGENT_ID/rotate-webhook-secret \
  -H "Authorization: Bearer $SIXTYDB_API_KEY"
```

The response includes a new `webhook_secret`. The old secret stops working at once. This covers tool calls and events, including retries that have not been sent yet.

Update your server with the new secret right after you rotate. Until you do, signatures will not verify. To limit the gap, store the new value and redeploy in one step.

## Related

* [Agents](https://docs.60db.ai/features/agents.md): setup, call lifecycle, pricing and limits
* [Get call](https://docs.60db.ai/api-reference/agents/get-call.md) and [Get transcript](https://docs.60db.ai/api-reference/agents/get-transcript.md)
* [List tool events](https://docs.60db.ai/api-reference/agents/list-tool-events.md)
* [Rotate webhook secret](https://docs.60db.ai/api-reference/agents/rotate-webhook-signing.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.