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

# Webhook Payloads

> JSON payloads your endpoint receives for each lifecycle event. Discriminated by the top-level `event` field.

Each payload starts with an `event` field that identifies which lifecycle event fired. Treat the body as a discriminated union and branch on that field.

```ts theme={null}
type LifecycleWebhookPayload =
  | SessionEndedPayload
  | CallEndedPayload
  | SessionFinalizedPayload
  | RecordingReadyPayload;
```

Common fields on every event:

<ResponseField name="event" type="string">
  Discriminator. One of `session.ended`, `call.ended`, `session.finalized`, `recording.ready`.
</ResponseField>

<ResponseField name="sessionId" type="string">
  The Pathors session ID.
</ResponseField>

<ResponseField name="timestamp" type="string">
  ISO 8601 timestamp of when the event was emitted.
</ResponseField>

## `session.ended`

Fires when the conversation has ended. For call sessions, the call's final status and duration may not be available yet at this moment.

<ResponseField name="currentNodeId" type="string">
  Pathway node the agent was on when the conversation ended.
</ResponseField>

<ResponseField name="messagesCount" type="number">
  Length of `messages` — the number of user/assistant turns delivered in this payload.
</ResponseField>

<ResponseField name="messages" type="array">
  Conversation transcript as `{ role, content, timestamp? }` objects, in turn order. `role` is `user` or `assistant` — tool calls/results and system prompts are stripped. `timestamp` is the ISO 8601 time of the turn, present when available. For finalized voice calls this is the agent-authoritative conversation the caller actually heard.
</ResponseField>

<ResponseField name="extractedVariables" type="object">
  Variables extracted across the session, keyed by name. Besides agent-extracted values, this carries the variables injected at session start: `startAt` / `endAt`, call metadata (`fromNumber`, `toNumber`), and — for SIP calls on a phone number with **header capture** configured — the values of captured custom SIP headers, keyed by the attribute name configured for each header (e.g. an `X-Campaign-Id` header captured as `campaignId` appears as `extractedVariables.campaignId`).
</ResponseField>

<ResponseField name="reason" type="string" deprecated>
  Legacy alias for `event` from before multi-event support existed; Will be removed in a future version — new receivers should branch on `event`.
</ResponseField>

```json theme={null}
{
  "event": "session.ended",
  "sessionId": "2c4f9a13-7e6b-4d8a-9f25-c81e3a7b6d04",
  "timestamp": "2026-05-03T08:42:11.512Z",
  "currentNodeId": "ask_intention",
  "messagesCount": 2,
  "messages": [
    { "role": "assistant", "content": "Hi, this is Pathors. How can I help?", "timestamp": "2026-05-03T08:40:52.101Z" },
    { "role": "user", "content": "I'd like to book a demo.", "timestamp": "2026-05-03T08:41:03.870Z" }
  ],
  "extractedVariables": {
    "name": "Nancy",
    "intention": "book a demo"
  },
  "reason": "session_ended"
}
```

## `call.ended`

Fires when the call has ended. **Call sessions only** — text sessions never produce this event.

<ResponseField name="projectId" type="string">
  The project that owns the agent.
</ResponseField>

<ResponseField name="callStatus" type="string">
  Final domain status. One of `userHangup`, `agentHangup`, `transferred`, `voicemail`, `errorTransferred`, `busy`, `userNoAnswer`, `userRejected`, `invalidNumber`, `sipTrunkFailure`, `agentNoAnswer`, `error`, `unknown`, `Ended`.
</ResponseField>

<ResponseField name="callDuration" type="number">
  Call duration in seconds. `0` for non-traffic outcomes (busy, no-answer, invalid number).
</ResponseField>

```json theme={null}
{
  "event": "call.ended",
  "sessionId": "2c4f9a13-7e6b-4d8a-9f25-c81e3a7b6d04",
  "projectId": "8f3e2c91-4a7b-4d6e-a23c-9b1f5e8d4c20",
  "timestamp": "2026-05-03T08:42:14.022Z",
  "callStatus": "userHangup",
  "callDuration": 78
}
```

## `recording.ready`

Fires after a call's recording finishes processing — on **both success and failure**. **Call sessions only**; text sessions never produce this event.

Recording processing **gates `session.finalized`** for call sessions: finalization waits until the recording succeeds or fails (with a timeout backstop), and the resulting `recordingUrl` also rides the `session.finalized` payload. Subscribe to `recording.ready` when you want the recording signal on its own — it is the only event that tells you a recording **failed** — otherwise `session.finalized` already carries the URL.

<ResponseField name="projectId" type="string">
  The project that owns the agent.
</ResponseField>

<ResponseField name="success" type="boolean">
  Whether the recording was processed and stored successfully. When `false`, `recordingUrl` is omitted.
</ResponseField>

<ResponseField name="recordingUrl" type="string">
  Temporary download URL for the composite audio file. Present only when `success` is `true`. The link is short-lived (valid for \~15 minutes) — download the file promptly. Once it expires there is currently no API to re-fetch it; download the recording from the call log in the Pathors dashboard instead.
</ResponseField>

**Success example:**

```json theme={null}
{
  "event": "recording.ready",
  "sessionId": "2c4f9a13-7e6b-4d8a-9f25-c81e3a7b6d04",
  "projectId": "8f3e2c91-4a7b-4d6e-a23c-9b1f5e8d4c20",
  "timestamp": "2026-05-03T08:42:30.114Z",
  "success": true,
  "recordingUrl": "https://recordings.pathors.com/recordings/projects/8f3e2c91.../sessions/2c4f9a13.../audio.ogg?..."
}
```

**Failure example:**

```json theme={null}
{
  "event": "recording.ready",
  "sessionId": "2c4f9a13-7e6b-4d8a-9f25-c81e3a7b6d04",
  "projectId": "8f3e2c91-4a7b-4d6e-a23c-9b1f5e8d4c20",
  "timestamp": "2026-05-03T08:42:30.114Z",
  "success": false
}
```

## `session.finalized`

Fires once everything is done for this session — for call sessions that includes recording processing, so the payload merges the data you would otherwise piece together from `session.ended`, `call.ended`, and `recording.ready`, and your receiver only has to handle one event.

For text sessions, the call fields (`callStatus`, `callDuration`) are **omitted entirely** — not set to `null`. Branch on whether they're present to discriminate call vs text:

```ts theme={null}
if ("callStatus" in payload) {
  // call session — payload.callStatus, .callDuration are present
} else {
  // text session — call fields are absent
}
```

<ResponseField name="projectId" type="string">
  The project that owns the agent.
</ResponseField>

<ResponseField name="currentNodeId" type="string">
  Pathway node the agent was on when the conversation ended.
</ResponseField>

<ResponseField name="messagesCount" type="number">
  Length of `messages`, same as `session.ended`'s `messagesCount`.
</ResponseField>

<ResponseField name="messages" type="array">
  Conversation transcript, same shape and filtering as `session.ended`'s `messages`.
</ResponseField>

<ResponseField name="extractedVariables" type="object">
  Same as `session.ended`'s `extractedVariables` — including captured custom SIP header values when the phone number has header capture configured.
</ResponseField>

<ResponseField name="callStatus" type="string">
  *Call sessions only.* Same as `call.ended`'s `callStatus`. Omitted for text sessions.
</ResponseField>

<ResponseField name="callDuration" type="number">
  *Call sessions only.* Same as `call.ended`'s `callDuration`. Omitted for text sessions.
</ResponseField>

<ResponseField name="recordingUrl" type="string">
  *Call sessions only.* Temporary download URL for the composite audio file, same as `recording.ready`'s `recordingUrl`. Present when the call was recorded and processing succeeded; omitted for text sessions and failed recordings. Short-lived (\~15 minutes) — download promptly.
</ResponseField>

**Call session example:**

```json theme={null}
{
  "event": "session.finalized",
  "sessionId": "2c4f9a13-7e6b-4d8a-9f25-c81e3a7b6d04",
  "projectId": "8f3e2c91-4a7b-4d6e-a23c-9b1f5e8d4c20",
  "timestamp": "2026-05-03T08:42:14.300Z",
  "currentNodeId": "ask_intention",
  "messagesCount": 2,
  "messages": [
    { "role": "assistant", "content": "Hi, this is Pathors. How can I help?", "timestamp": "2026-05-03T08:40:52.101Z" },
    { "role": "user", "content": "I'd like to book a demo.", "timestamp": "2026-05-03T08:41:03.870Z" }
  ],
  "extractedVariables": {
    "name": "Nancy",
    "intention": "book a demo"
  },
  "callStatus": "userHangup",
  "callDuration": 78,
  "recordingUrl": "https://recordings.pathors.com/recordings/projects/8f3e2c91.../sessions/2c4f9a13.../audio.ogg?..."
}
```

**Text session example:**

```json theme={null}
{
  "event": "session.finalized",
  "sessionId": "5d8e1f2a-9c4b-4e7d-b3a8-6f9c2d5e8a01",
  "projectId": "8f3e2c91-4a7b-4d6e-a23c-9b1f5e8d4c20",
  "timestamp": "2026-05-03T08:48:15.288Z",
  "currentNodeId": "start",
  "messagesCount": 2,
  "messages": [
    { "role": "user", "content": "Hi, I'm Nancy.", "timestamp": "2026-05-03T08:47:41.512Z" },
    { "role": "assistant", "content": "Nice to meet you, Nancy!", "timestamp": "2026-05-03T08:47:43.006Z" }
  ],
  "extractedVariables": { "name": "Nancy" }
}
```

## Implementation example

A receiver that handles every event with `event` as the discriminator:

```ts theme={null}
import express from "express";

const app = express();
app.use(express.json());

app.post("/pathors-webhook", async (req, res) => {
  const payload = req.body;

  switch (payload.event) {
    case "session.ended":
      await onSessionEnded(payload);
      break;

    case "call.ended":
      await onCallEnded(payload);
      break;

    case "recording.ready":
      if (payload.success && payload.recordingUrl) {
        await onRecordingReady(payload); // fetch the URL before it expires
      }
      break;

    case "session.finalized":
      if ("callStatus" in payload) {
        await onCallCompleted(payload);
      } else {
        await onTextCompleted(payload);
      }
      break;

    default:
      // Unknown event — accept and ignore so future Pathors event types
      // do not break your receiver.
      break;
  }

  res.status(200).json({ status: "ok" });
});
```

## Ordering

`session.finalized` is always **last** for a given session: it fires only after every other expected step has completed — `session.ended` for text sessions; `session.ended`, `call.ended`, **and** `recording.ready` for call sessions.

Typical order for a call session:

1. `session.ended`
2. `call.ended`
3. `recording.ready`
4. `session.finalized`

For text sessions, `session.ended` and `session.finalized` arrive back-to-back.

The individual steps (`session.ended`, `call.ended`, `recording.ready`) are not strictly ordered with respect to each other in edge cases (e.g. hard hangup before the agent reaches its final node), and HTTP delivery of `recording.ready` can race the `session.finalized` request it unblocked. What you can rely on: when `session.finalized` arrives, recording processing has finished, and a successful recording's URL is in that payload.
