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

# Post-call data capture

> Turn every voice call into structured JSON. Declare a schema on your agent; PyAI extracts the fields after each call and POSTs them, signed, to your webhook.

After an Omni call ends, PyAI can run an extraction pass over the transcript and
deliver a structured JSON object, captured fields, intent, outcome, to your
webhook. **No client code and no engine integration required**: you declare a
JSON Schema on the agent and point it at an HTTPS endpoint.

<Info>
  This is **post-call** capture (after the call, fully PyAI-run). For **in-call**
  function calling where your code executes mid-conversation, see
  [Function calling](/guides/omni-tools). For the difference between the tool
  systems, see [Tools overview](/concepts/tools).
</Info>

## Configure

Set two fields on the agent, a JSON Schema of the fields to capture and a signed
delivery URL:

```bash theme={null}
curl -sS -X PATCH "https://api.pyai.com/v1/agents/agent_…" \
  -H "Authorization: Bearer $PYAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "extraction_schema": {
      "type": "object",
      "properties": {
        "caller_name": { "type": "string" },
        "intent": { "type": "string" },
        "order_id": { "type": "string" },
        "follow_up_needed": { "type": "boolean" }
      }
    },
    "extraction_webhook_url": "https://your-app.example.com/pyai/extraction"
  }'
```

Or use the console: **Agents → your agent → Data capture**.

Both fields are required to enable extraction. Set either to `null` to turn it
off. `extraction_webhook_url` must be `https`.

## How a call is matched to your agent

Omni is **zero-state**, a call is authorized by your key's org, not a stored
agent. To attribute a call to a specific agent's extraction config, set the
connect URL's `session_label` to that agent's id:

```
wss://api.pyai.com/v1/omni?format=pcm16&rate=24000&session_label=agent_7f3a0b12
```

PyAI resolves the agent only when the `session_label` equals an agent id **in the
same org**, so another tenant's id can never trigger your extraction.

## The delivery (engine → your webhook)

When the call completes, PyAI POSTs the extracted object, signed with
`X-PyAI-Signature` (same scheme as transcription webhooks):

```
POST <extraction_webhook_url>
Content-Type: application/json
X-PyAI-Signature: t=1718900000,v1=<hex hmac-sha256>

{
  "type": "omni.call.extracted",
  "created": 1718900000,
  "data": {
    "object": "omni.call.extraction",
    "call_id": "call_abc",
    "org_id": "org_…",
    "agent_id": "agent_7f3a0b12",
    "session_label": "agent_7f3a0b12",
    "external_id": "twilio_CA9f…",
    "meta": { "external_id": "twilio_CA9f…" },
    "extracted": {
      "caller_name": "Alex",
      "intent": "track_order",
      "order_id": "ORD-7782",
      "follow_up_needed": false
    }
  }
}
```

<Note>
  **Correlating to your own call leg.** `call_id` is PyAI-generated. To map it back
  to your telephony leg (Twilio/SIP), set a `meta` object on the session (e.g. a
  `meta.external_id` you control); PyAI echoes `meta` verbatim and lifts a string
  `meta.external_id` to the top-level `external_id`. `session_label` is also echoed,
  but it doubles as the `agent_…` resolution id, so prefer `meta.external_id` for a
  per-call key. Both `external_id` and `meta` are `null` when nothing was set.
</Note>

### Verify the signature

The signature is `t=<unix_seconds>,v1=<hmac>` where the HMAC-SHA256 is computed
over `` `${t}.${rawBody}` `` with your webhook signing secret:

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

function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}
```

<Note>
  **Get your signing secret with `POST /v1/webhooks/signing-secret`**, it's scoped
  to your org and returned **once**. Check status (configured? last 4) with
  `GET /v1/webhooks/signing-secret`. To rotate with no dropped events: deploy
  verification that accepts **both** the old and new secret, call the endpoint
  again, then drop the old one.
</Note>

## Behavior & limits

* **Best-effort / fail-open.** Extraction never blocks or fails a call. If the
  transcript is empty, the model can't produce valid JSON, or your webhook is
  down, the call is unaffected and nothing is delivered.
* **Fields you can't determine** come back `null`, values are never invented.
* The transcript is truncated for very long calls before the extraction pass.
* **Delivery retries** up to 3 times with a short backoff on a transient failure
  (network error, `429`, or `5xx`); a `4xx` is treated as a permanent client error
  and is not retried. Return `2xx` quickly. Delivery is at-most-once per attempt and
  not strongly durable, treat a missed event as best-effort.

## See also

<CardGroup cols={2}>
  <Card title="Tools overview" href="/concepts/tools">Knowledge vs function-calling tools.</Card>
  <Card title="Function calling" href="/guides/omni-tools">In-call tools your client executes.</Card>
  <Card title="Omni protocol" href="/realtime/omni-protocol">Connect URL, `session_label`, frames.</Card>
  <Card title="Conversation intelligence" href="/guides/conversation-intelligence">Transcripts, summaries, recordings.</Card>
</CardGroup>
