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

# Voice tools on your own endpoint

> Let a voice agent call your HTTPS endpoint in the middle of a call, with a signed request.

A voice agent can use tools while it talks. A tool of kind `function` sends a signed request to
one of your own HTTPS endpoints and reads your text answer back to the agent.

## Set it up

1. Add a webhook endpoint in the dashboard and tick `voice.tool.call`. The URL must be `https`
   and on the public internet. Copy the endpoint id (`we_...`) and the signing secret, which is
   shown once.
2. Create the tool. It names the endpoint by id; it never holds a URL or a secret.

```ts theme={null}
await pramaan.voice.tool.create({
  name: "lookup_order",
  kind: "function",
  endpoint: "we_0123456789abcdef0123456789abcdef",
  description: "Look up an order by its id.",
  parameters: [{ name: "orderId", type: "string", description: "The order id.", required: true }],
  timeoutSeconds: 10,
});
```

3. Add the tool's name to an assistant's `tools`.

## The request

When the agent uses the tool, your endpoint gets a `POST` with a JSON body:

```json theme={null}
{
  "callRef": "rh_...",
  "tool": "lookup_order",
  "arguments": { "orderId": "A1", "express": true },
  "idempotencyKey": "vtc_5f2b1c0d9e8a7b6c5d4e3f2a1b0c9d8e"
}
```

| Field | Meaning |
| - | - |
| `callRef` | The call. For a call you started it works with `voice.call.get` and `voice.call.end`, and equals the `ref` that `voice.calls.list` shows. For a call that came in on your number it is stable for that call and tells its requests apart. |
| `tool` | The tool's name. |
| `arguments` | At most 20 values, each a string (at most 1,000 characters), a number or a boolean. Never nested. A longer string is never shortened: that use of the tool is refused, your endpoint is not called, and the agent is told the arguments were not accepted. Written by a language model from what the caller said: treat them as untrusted input. |
| `idempotencyKey` | One key per use of the tool. A retry carries the same key and the same body. |

## Check the signature

The request is signed exactly like every other webhook Pramaan sends you, with the secret of the
endpoint you named:

| Header | Value |
| - | - |
| `webhook-id` | The `idempotencyKey`. |
| `webhook-timestamp` | Unix seconds when this attempt was sent. |
| `webhook-signature` | `v1,<base64 HMAC-SHA256>` over `{webhook-id}.{webhook-timestamp}.{raw body}`. |

There is no separate `Pramaan-Signature` header and no second secret. The key is the part of
your `whsec_...` secret after the prefix, base64-decoded. Reject a request whose timestamp is
more than 5 minutes from now.

```ts theme={null}
import { verifyWebhook } from "@pramaan/sdk";

export async function POST(request: Request) {
  const body = await request.text(); // the raw body, before any JSON parsing
  await verifyWebhook(process.env.PRAMAAN_WEBHOOK_SECRET!, request.headers, body); // throws if not genuine
  const { tool, arguments: args, idempotencyKey } = JSON.parse(body);
  if (tool !== "lookup_order") return Response.json({ error: "Unknown tool." });
  const order = await findOrder(String(args.orderId), idempotencyKey);
  return Response.json({ result: `Order ${order.id} ships on ${order.shipDate}.` });
}
```

Without the SDK, in Node:

```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function genuine(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = `v1,${createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64")}`;
  return String(headers["webhook-signature"])
    .split(" ")
    .some((s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
```

### A signed example

Use this to test your check.

* Secret: `whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=`
* `webhook-id`: `vtc_5f2b1c0d9e8a7b6c5d4e3f2a1b0c9d8e`
* `webhook-timestamp`: `1791763200`
* Raw body, one line:

```json theme={null}
{"callRef":"rh_EXAMPLE","tool":"lookup_order","arguments":{"orderId":"A1","express":true},"idempotencyKey":"vtc_5f2b1c0d9e8a7b6c5d4e3f2a1b0c9d8e"}
```

* `webhook-signature`: `v1,vJF40DxwuKzA6LmvaXIqPmQvz1i2aSM9vpBOTxgCpcE=`

## Your answer

Answer with status `200` and one of:

```json theme={null}
{ "result": "Order A1 ships on Friday." }
```

```json theme={null}
{ "error": "No order with that id." }
```

* `result` is cut at 8,000 characters and `error` at 500. Markup is removed and the text is put
  on one line. The whole response body may be at most 64 KiB.
* The agent receives your text as the tool's output: data to talk about, not an instruction.
  No other field of your answer is read, so an answer cannot transfer, extend or end the call.

## Time, retries and limits

* `timeoutSeconds` (1 to 30, default 10) is the limit for one use of the tool, **retries
  included**. Aim to answer within a second or two: the caller is waiting in silence.
* A request is retried at most twice, with the same `idempotencyKey` and body, after a dropped
  connection or a `408`, `429` or `5xx` answer, and only while at least a second of the limit
  is left. Any other `4xx`, a redirect, a timeout or an answer that is not understood is final.
  Make side effects safe to repeat by keying them on `idempotencyKey`.
* Redirects are not followed.
* Per call: at most 60 tool requests, at most 10 at once and then one every 6 seconds, and at
  most 5 in one turn of the agent.
* When your endpoint fails, is too slow or a limit is reached, the agent is told the tool is
  unavailable (or was used too often) and carries on with the call.
* Tool requests cost nothing extra: you pay for the call's minutes only.
* If you remove or switch off the endpoint, the tool stops working and new calls with an agent
  that uses it are refused until you point the tool at a working endpoint.
* If you delete the tool or point it at another endpoint, requests to the old endpoint stop at
  once, also on calls that are already under way.


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