# Inbound webhook

> Configure each inbound call and chat from your server: PUT /v1/inbound-webhook, the session.request it gets, and its answer.

An outbound call or an API chat is started by a request of yours, which can carry `vars`, `secrets`, `agent_override` and `userdata`. An inbound phone call, an inbound WhatsApp call or a new WhatsApp or Telegram chat has no such request. The inbound webhook is that request: before the session starts, Talqing asks your server for its configuration, and applies the answer exactly as `POST /v1/calls/outbound` would.

It is asked only by agents that turn it on (`inbound_webhook: true`), and only the agent that answers counts. It is never asked for web calls, API chats, outbound calls or media streams, which each have a request of their own. Nor for a WhatsApp or Telegram chat started with an agent you [posted](https://docs.talqing.com/channels/posting.md#choosing-the-agent) or sent in a batch: that request already said what runs.

> **Warning:**
> **No answer refuses the session.** An agent that turns this on is saying it cannot run without your configuration. If your server does not return a valid answer within **3 seconds**, the call is hung up or the chat is not answered. See [refusals](#refusals).

## Set it up

One webhook per workspace. `PUT` sets or changes its URL, which must be `https://` and resolve to a public address. The signing secret is generated on the first `PUT` and returned once; later `PUT`s change only the URL.

```bash cURL
curl -X PUT https://api.talqing.com/v1/inbound-webhook \
  -H "Authorization: Bearer $TALQING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://api.example.com/talqing/inbound" }'
```

```typescript TypeScript
const hook = await talqing.inboundWebhook.set({
  setInboundWebhookRequest: { url: "https://api.example.com/talqing/inbound" },
});
console.log(hook.secret); // shown once
```

```python Python
hook = talqing.inbound_webhook.set(url="https://api.example.com/talqing/inbound")
print(hook["secret"])  # shown once
```

Then turn it on per agent: `inbound_webhook: true` in the agent's config (**Ask my server before each inbound session** in the agent's **Prompt & voice** card; the URL itself is set on **Webhooks**). Publishing such an agent is refused until the workspace has a webhook, and removing the webhook (`DELETE /v1/inbound-webhook`) is refused with `409` while a published agent still asks it.

| Route | Does |
| --- | --- |
| `GET /v1/inbound-webhook` | The webhook, with `secret_hint`; `404` when none is set. |
| `PUT /v1/inbound-webhook` | Set the URL. |
| `DELETE /v1/inbound-webhook` | Remove it; `409` naming the agents that still ask it. |
| `POST /v1/inbound-webhook/rotate-secret` | A new secret, returned once. The old one stops working at once. |
| `POST /v1/inbound-webhook/test` | Send a sample request; see [testing](#test-it). |

## The request

`POST` to your URL, `Content-Type: application/json`, `User-Agent: talqing-inbound/1`:

```json
{
  "type": "session.request",
  "session_id": "6a0f…",
  "agent_id": "dd5e…",
  "agent_version": 7,
  "channel": "whatsapp_message",
  "direction": "inbound",
  "from": "+919876543210",
  "to": "+912248900123",
  "contact_key": "phone:+912248900123:+919876543210",
  "conversation_id": "9ad0…",
  "profile_name": "Asha",
  "message": { "text": "APP-1234", "button": null }
}
```

| Field | Meaning |
| --- | --- |
| `session_id` | The call or chat being started; `GET /v1/calls/{id}` or `GET /v1/chats/{id}` once it exists. |
| `agent_id`, `agent_version` | The agent that will answer, at its published version. |
| `channel` | `phone_call`, `whatsapp_call`, `whatsapp_message` or `telegram`. |
| `direction` | `inbound`, or `outbound` for a chat opened by an [instruction](https://docs.talqing.com/channels/posting.md) you posted. |
| `from`, `to` | Their number and yours (the reverse on an outbound chat). A WhatsApp user who hides their number is sent as their WhatsApp id; on Telegram these are the chat and bot ids. |
| `contact_key`, `conversation_id` | The person and the thread this session writes to. |
| `profile_name` | Their WhatsApp profile name, when known. |
| `message` | The message that opened a chat: its `text`, and `button` (`{text, payload}`) when it was a button tap. Null on calls and on instruction-opened chats. |

A chat asks once, when it opens; later messages in the same chat do not ask again, so an agent that needs live state reads it with a tool. A chat stays open until it is ended, so its answer stands for that long.

## Verify it

| Header | Value |
| --- | --- |
| `X-Talqing-Timestamp` | Unix seconds when the request was signed. |
| `X-Talqing-Signature` | `sha256=` + lowercase hex HMAC-SHA256 of `"<timestamp>." + raw body`, keyed with the secret. |

This differs from the [event webhooks](https://docs.talqing.com/observability/webhooks.md), which sign the body alone: a request you answer must not be replayable, so reject a timestamp more than five minutes old.

```javascript Node
import crypto from "node:crypto";

function verify(rawBody, timestamp, header, secret) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const expected =
    "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
  const a = Buffer.from(header ?? "", "utf8");
  const b = Buffer.from(expected, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

```python Python
import hashlib
import hmac
import time

def verify(raw_body: bytes, timestamp: str, header: str | None, secret: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")
```

## The answer

Return `2xx` with a JSON object. Every field is optional, `{}` included, and an unknown field is an error.

```json
{
  "vars": { "language": "hi" },
  "secrets": { "CRM_TOKEN": "tok_8d1f…" },
  "agent_override": { "language": "hi" },
  "metadata": { "tenant": "t1", "candidate_id": "c_123" },
  "userdata": { "name": "Asha" }
}
```

| Field | Means what it means on `POST /v1/calls/outbound` |
| --- | --- |
| `vars` | `{{vars.*}}` values over the agent's defaults. Strings only. |
| `secrets` | [Session secrets](https://docs.talqing.com/platform/secrets.md) for this session's tools. |
| `agent_override` | Changes layered on the answering agent's published version, validated as publishing is. |
| `metadata` | Up to 16 string labels of your own, on the call or chat. |
| `userdata` | Merged into the person's userdata and the session's starting userdata. |

`metadata` is shown on the call and chat, carried by `session.started` and `session.completed`, and filters `GET /v1/calls` and `GET /v1/chats` (`?metadata=tenant:t1`, repeatable, all must match). It is kept when the session's content is deleted.

## Refusals

Any of these refuses the session with the close reason `inbound_webhook_failed`, and `sessions.error.message` says which:

| `message` | When |
| --- | --- |
| `timed out after 3 s` | No complete answer in 3 seconds. |
| `HTTP <code>` | A non-`2xx` status. Redirects are not followed. |
| `response over 64 KB` | The body was larger. |
| `invalid JSON` | The body was not JSON. |
| `invalid response: <first error>` | The JSON did not match the answer above. |
| `agent_override invalid: <first error>` | The override does not hold together with the agent. |
| `secrets missing: <names>` | A tool reads a secret neither the workspace nor the answer has. |
| `no inbound webhook is set` | The agent asks it and none is set. |
| `request failed: …`, `blocked destination: …` | A connection or TLS error, or a URL that resolves to a private address. |

- **Phone call:** the caller hears no greeting and is hung up while still ringing; livekit-sip sends the carrier `486 Busy Here`. The refused call is listed in **Calls** with its reason.
- **WhatsApp call:** the call is hung up before the agent says anything.
- **Chat:** the message that opened it is kept, marked with the error, and nothing is sent. The chat is closed; the person's next message opens a new chat, which asks again.

Every refused session sends [`session.refused`](https://docs.talqing.com/api-reference/webhook-events.md#session-refused). Every ask, answered or not, is on the session's timeline as `inbound_webhook` with its status code, time, the request sent and what came back: a JSON answer with each `secrets` value replaced by `"[redacted]"`, or the first 2 KB of anything else.

## Test it

`POST /v1/inbound-webhook/test` with `{channel, number, peer}` sends a sample `session.request` signed as a real one is (`from` = `peer`, `to` = `number`) and returns what came back: `{status_code, duration_ms, valid, errors, response}`. `valid` checks the answer's shape; an `agent_override` is checked against an agent only on a real session.

```bash
curl -X POST https://api.talqing.com/v1/inbound-webhook/test \
  -H "Authorization: Bearer $TALQING_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "phone_call", "number": "+912248900123", "peer": "+919876543210" }'
```

- [Secrets](https://docs.talqing.com/platform/secrets.md): Workspace and session secrets.
- [Close reasons](https://docs.talqing.com/observability/close-reasons.md): Every way a session ends.
