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

# Webhook Config

> Receive roleplay completion data in your own systems via webhooks, and link results back to your users

## Overview

Webhooks let Real Talk Studio push roleplay completion data to your own systems the moment a session finishes. When a user completes a roleplay, we send a `POST` request containing their score, skill feedback, timings, the full conversation transcript, and an optional anonymous identifier you supply (`extCustId`) so you can link the result back to the right user in your platform (CRM, LMS, etc.).

There are two parts to setting this up:

<Steps>
  <Step title="Configure your app to send a user identifier in the URL">
    Pass `extCustId` as a query parameter on the embed URL so we can return it to you on completion.
  </Step>

  <Step title="Add your webhook address in Studio">
    Tell Real Talk Studio where to send completion data.
  </Step>
</Steps>

Once both are in place, every completed session triggers a webhook to your endpoint.

***

## Setup

### What is `extCustId`?

`extCustId` (external customer ID) is an **anonymous key that you generate and control on your side**. It is simply an opaque identifier you attach to a session so that, when the completion webhook arrives, you can associate that completion record with the right user, learner, or account in your own system.

Real Talk Studio never interprets, validates, or stores meaning against this value — we just hold it for the session and hand it back to you untouched in the webhook. Because it is your own reference, you decide what it is:

* A CRM contact ID, LMS user ID, or internal account ID
* A hashed or tokenised identifier (recommended — it keeps personal data out of the URL)
* Any random key you mint per session and store alongside the user in your database

<Tip>
  Treat it as an **anonymous correlation key**, not as personal data. Avoid putting raw emails or names in the URL — use an ID or hash that only your system can resolve back to a person.
</Tip>

### 1. Send your identifier in the embed URL

When you embed or link to a roleplay, append an `extCustId` query parameter set to your key for that user. We store it for the session and send it straight back to you in the completion webhook so you can match the result to the correct record.

```text Embed URL with identifier theme={null}
https://www.realtalkstudio.com/embed/coaching-underperformance-xhkxkh?studioId=6527d7dc-0b56-4f0d-b8bf-afab894dae7a&extCustId=crm-user-12345
```

<Note>
  Append `extCustId` to your existing embed URL alongside any other parameters (such as `studioId`), separating each with `&`.
</Note>

<Note>
  The parameter name is exactly **`extCustId`** (camelCase). It is optional — if you omit it, the webhook still fires, but `extCustId` will be `null` and you will need another way to correlate the result.
</Note>

<Info>
  **How it works:** the `extCustId` from the URL is held for the duration of the session and then included in the completion webhook. It is a round trip — you supply it on the way in, and the same value comes back to you on the way out.
</Info>

### 2. Add your webhook address in Studio

Within Real Talk Studio Admin Panel, open the roleplay studio you want to configure and edit its settings. In the **Webhook URL** field, enter the HTTPS endpoint that should receive completion data:

```text Example endpoint theme={null}
https://your-api.com/webhook/endpoint
```

<Warning>
  The endpoint must be a publicly reachable HTTPS URL that accepts a `POST` request with a JSON body. We expect a `2x` response; non-`2xx` responses are treated as a failed delivery.
</Warning>

***

## Session: what we send on completion

When a user finishes a roleplay, we send a single `POST` request to your configured Webhook URL.

### Request headers

```text theme={null}
Content-Type: application/json
User-Agent: RealTalkStudio-Webhook/1.0
```

### Body

All fields are nested under a top-level `data` key.

```json Example webhook payload theme={null}
{
  "data": {
    "studioSlug": "acme-sales",
    "studioId": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
    "roleplaySlug": "cold-call-intro",
    "roleplayId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "score": 82,
    "skillsFeedback": [
      { "skill": "Discovery questions", "rating": "Strong" },
      { "skill": "Objection handling", "rating": "Developing" },
      { "skill": "Closing", "rating": "Needs work" }
    ],
    "rationale": "The rep opened confidently and asked good discovery questions, but missed two buying signals and rushed the close.",
    "completedAt": "2026-06-09T12:30:45.000Z",
    "startedAt": "2026-06-09T12:22:10.000Z",
    "endedAt": "2026-06-09T12:30:45.000Z",
    "isTooShort": false,
    "avatarRating": 4,
    "avatarFeedback": "Maintained good eye contact and an even tone throughout.",
    "durationSeconds": 515,
    "extCustId": "crm-user-12345",
    "personaId": "f9e8d7c6-b5a4-3210-fedc-ba9876543210",
    "personaName": "Skeptical CFO",
    "personaType": "buyer",
    "transcript": [
      { "speaker": "user", "text": "Hi, thanks for taking the time today.", "timestamp": 0, "isComplete": true },
      { "speaker": "avatar", "text": "Of course. What did you want to discuss?", "timestamp": 4200, "isComplete": true },
      { "speaker": "user", "text": "I wanted to talk through last quarter's numbers.", "timestamp": 9100, "isComplete": true }
    ]
  }
}
```

<Warning>
  Everything is wrapped in `data`. Read `body.data.score`, not `body.score`. This is the most common integration mistake.
</Warning>

### Field reference

| Field             | Type                       | Notes                                                                                                                                           |
| ----------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `studioSlug`      | string                     | Studio identifier slug                                                                                                                          |
| `studioId`        | string (UUID)              | Studio ID                                                                                                                                       |
| `roleplaySlug`    | string                     | Roleplay identifier slug                                                                                                                        |
| `roleplayId`      | string (UUID)              | Roleplay ID                                                                                                                                     |
| `score`           | number                     | Overall feedback score                                                                                                                          |
| `skillsFeedback`  | array                      | Each item is `{ "skill": string, "rating": string }`                                                                                            |
| `rationale`       | string                     | Narrative explanation of the feedback                                                                                                           |
| `completedAt`     | string (ISO 8601)          | Same as `endedAt`                                                                                                                               |
| `startedAt`       | string (ISO 8601)          | Session start time                                                                                                                              |
| `endedAt`         | string (ISO 8601)          | Session end time                                                                                                                                |
| `isTooShort`      | boolean *(optional)*       | `true` if the session was too short to score meaningfully                                                                                       |
| `avatarRating`    | number *(optional)*        | Non-verbal / presence rating                                                                                                                    |
| `avatarFeedback`  | string *(optional)*        | Non-verbal / presence feedback                                                                                                                  |
| `durationSeconds` | number *(optional)*        | Session length in seconds                                                                                                                       |
| `extCustId`       | string *(optional)*        | The anonymous key you passed in the embed URL, returned untouched — use it to associate this completion record with the right user on your side |
| `personaId`       | string (UUID) *(optional)* | Persona used in the roleplay                                                                                                                    |
| `personaName`     | string *(optional)*        | Persona display name                                                                                                                            |
| `personaType`     | string *(optional)*        | Persona category / type                                                                                                                         |
| `transcript`      | array *(optional)*         | Full conversation, in order. Each turn is `{ speaker, text, timestamp, isComplete?, isSystemMessage? }`. `null` if no transcript was captured   |

<Note>
  Optional fields may be `null` or absent depending on what was captured for a given session. Code defensively against missing values.
</Note>

### Transcript format

When a transcript was captured, `transcript` is an ordered array of conversation turns:

| Field             | Type                 | Notes                                                         |
| ----------------- | -------------------- | ------------------------------------------------------------- |
| `speaker`         | string               | Who spoke this turn (e.g. `user`, `avatar`)                   |
| `text`            | string               | What was said                                                 |
| `timestamp`       | number               | Milliseconds from the start of the session                    |
| `isComplete`      | boolean *(optional)* | `false` for partial/interim turns                             |
| `isSystemMessage` | boolean *(optional)* | `true` for system-generated turns rather than spoken dialogue |

<Warning>
  The transcript contains the **full conversation** and can be large. Make sure your endpoint accepts sizeable request bodies, and treat the contents as potentially sensitive (PII) when storing or displaying it.
</Warning>

***

## Consuming the webhook

A typical integration receives the webhook, stores it in a `completions` table keyed by your `extCustId`, and then surfaces the data in your own reporting and dashboards. The pattern is the same regardless of stack:

<Steps>
  <Step title="Receive and validate">
    Accept the `POST`, confirm the `data` object is present, and respond with `2xx` quickly.
  </Step>

  <Step title="Store">
    Upsert a row into your `completions` table, using `extCustId` to link it to the user.
  </Step>

  <Step title="Report">
    Query that table to build dashboards, leaderboards, manager views, or exports.
  </Step>
</Steps>

### 1. A table to hold completions

This schema mirrors the webhook payload. `ext_cust_id` is the anonymous key you sent in the embed URL, so it joins straight to your existing users table.

```sql Postgres example theme={null}
create table completions (
  id            bigint generated always as identity primary key,
  ext_cust_id   text,                       -- your anonymous user key (links to your users table)
  studio_slug   text not null,
  roleplay_slug text not null,
  roleplay_id   uuid,
  score         integer,
  skills_feedback jsonb,                     -- array of { skill, rating }
  transcript    jsonb,                        -- ordered array of { speaker, text, timestamp, ... }
  rationale     text,
  avatar_rating integer,
  avatar_feedback text,
  duration_seconds integer,
  is_too_short  boolean default false,
  persona_name  text,
  persona_type  text,
  started_at    timestamptz,
  ended_at      timestamptz,
  completed_at  timestamptz,
  received_at   timestamptz not null default now()
);

create index on completions (ext_cust_id);
create index on completions (roleplay_slug);
create index on completions (completed_at);
```

### 2. An endpoint that stores the payload

```ts Node.js / Express theme={null}
import express from "express";

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

app.post("/webhook/realtalk", async (req, res) => {
  const { data } = req.body ?? {};

  // Validate before doing any work
  if (!data || typeof data.score !== "number") {
    return res.status(400).json({ error: "Invalid payload" });
  }

  // Acknowledge fast — persist, then return 2xx
  await db.query(
    `insert into completions (
       ext_cust_id, studio_slug, roleplay_slug, roleplay_id, score,
       skills_feedback, transcript, rationale, avatar_rating, avatar_feedback,
       duration_seconds, is_too_short, persona_name, persona_type,
       started_at, ended_at, completed_at
     ) values (
       $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17
     )`,
    [
      data.extCustId ?? null,
      data.studioSlug,
      data.roleplaySlug,
      data.roleplayId ?? null,
      data.score,
      JSON.stringify(data.skillsFeedback ?? []),
      JSON.stringify(data.transcript ?? []),
      data.rationale ?? null,
      data.avatarRating ?? null,
      data.avatarFeedback ?? null,
      data.durationSeconds ?? null,
      data.isTooShort ?? false,
      data.personaName ?? null,
      data.personaType ?? null,
      data.startedAt ?? null,
      data.endedAt ?? null,
      data.completedAt ?? null,
    ]
  );

  return res.status(200).json({ received: true });
});
```

<Tip>
  Storing `skillsFeedback` as a JSON column keeps the per-skill detail intact while remaining easy to query. In Postgres you can later expand it with `jsonb_array_elements` for skill-level reporting.
</Tip>

### 3. Integrate into your reporting pipeline

Once completions land in your table, they become a normal data source for your analytics. A few examples:

```sql Average score per roleplay theme={null}
select roleplay_slug,
       count(*)            as attempts,
       round(avg(score))   as avg_score
from completions
where is_too_short = false
group by roleplay_slug
order by avg_score desc;
```

```sql Per-user progress (joined to your users table) theme={null}
select u.name,
       c.roleplay_slug,
       c.score,
       c.completed_at
from completions c
join users u on u.id = c.ext_cust_id   -- ext_cust_id is your own key
order by u.name, c.completed_at;
```

```sql Skill-level breakdown across all sessions theme={null}
select sf->>'skill'  as skill,
       sf->>'rating' as rating,
       count(*)      as occurrences
from completions,
     jsonb_array_elements(skills_feedback) as sf
group by skill, rating
order by skill, rating;
```

From here you can feed the table into BI tools (Looker, Power BI, Metabase), push aggregates into a warehouse (BigQuery, Snowflake), or render dashboards directly in your product — for example a learner-facing **score history** view, a manager **team leaderboard**, or a **skills heatmap** built from the `skills_feedback` data.

<Info>
  Because every row carries `ext_cust_id`, you can attribute results to individual users, roll them up to teams or cohorts, and track improvement over time — all within your own platform.
</Info>

***

## Tips

* **Respond quickly with a `2xx`.** Do any heavy processing asynchronously after acknowledging receipt.
* **Use `extCustId` to correlate.** It is your own anonymous key, returned untouched, and the reliable way to associate a completion record with a specific user in your system.
* **Validate the payload shape.** Confirm `data` exists and handle optional fields gracefully.
