---
title: "Webhooks"
url: "https://raconte.ai/en/docs/webhooks"
---

### Documentation

[Introduction](/en/docs)[Getting started](/en/docs/getting-started)[MCP server](/en/docs/mcp)[Webhooks](/en/docs/webhooks)[REST API](/en/docs/api)[CLI](/en/docs/cli)[SDK](/en/docs/sdk)[Agent skill](/en/docs/skill)

### Guides

[Configure the MCP server](/en/guides/mcp-setup)[Using the MCP server](/en/guides/mcp-usage)

[Raconte](/) ⟩ [Documentation](/en/docs)

# Webhooks

Receive a POST request when an interview starts and when it completes.

Raconte fires HTTP webhooks for two events per organization: `interview.started` and `interview.completed`. They are configured from [Settings → Webhooks](/settings/webhook) in the app.

## How delivery works

*   One URL per organization receives every event. There is no per-event routing.
*   The request method is always `POST`, content-type `application/json`.
*   Delivery is best-effort. Raconte does not retry on failure; it logs the response code and moves on.
*   Delivery timeout is 5 seconds. Slow endpoints get an aborted request.

Headers Raconte sends:

Header

Value

`user-agent`

`Raconte-Webhook/1.0`

`x-raconte-event`

The event name, e.g. `interview.completed`

`x-raconte-signature`

`sha256=<hex digest>` (only when a signing secret is set; see below)

The payload envelope is always the same:

```
{
  "event": "interview.completed",
  "createdAt": "2026-05-29T10:14:22.123Z",
  "data": { /* event-specific fields */ }
}
```

## Verifying the signature

When a signing secret is configured, every request carries an `x-raconte-signature` header computed as:

```
HMAC-SHA256(secret, raw_request_body)
```

Verify on your side by re-computing the HMAC with your secret and the **raw** body bytes (before any JSON parse), then comparing in constant time. A signature mismatch should be treated as a 401.

Example with Node.js + Express:

```
import { createHmac, timingSafeEqual } from 'node:crypto'
import express, { Request, Response } from 'express'

const SECRET = process.env.RACONTE_WEBHOOK_SECRET!

function verifyRaconteSignature(rawBody: Buffer, header: string | undefined): boolean {
  if (!header || !header.startsWith('sha256=')) return false
  const expected = createHmac('sha256', SECRET).update(rawBody).digest('hex')
  const provided = header.slice('sha256='.length)
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(provided, 'hex')
  // Length check first: timingSafeEqual throws on unequal-length inputs.
  if (a.length !== b.length) return false
  return timingSafeEqual(a, b)
}

const app = express()

// IMPORTANT: keep the raw bytes around. JSON.parse(rawBody.toString()) is fine
// for handling, but the HMAC must be computed over the exact bytes sent.
app.post(
  '/webhooks/raconte',
  express.raw({ type: 'application/json' }),
  (req: Request, res: Response) => {
    const signature = req.header('x-raconte-signature')
    if (!verifyRaconteSignature(req.body as Buffer, signature)) {
      return res.status(401).send('invalid signature')
    }
    const payload = JSON.parse((req.body as Buffer).toString('utf8'))
    // payload.event is 'interview.started' or 'interview.completed'
    // payload.data contains the event-specific fields documented below.
    res.status(204).end()
  },
)
```

Generate (or regenerate) the secret from **Settings → Webhooks**. Regenerating invalidates the previous one immediately; any server still using the old secret will start rejecting requests.

## interview.started

Fired the first time an invitee sends a message to the AI on a given invitation (the call moved out of READY into IN\_PROGRESS).

`data` payload:

Field

Type

Description

`id`

uuid

Interview id

`title`

string | null

Interview title

`locale`

string

Interview language

`invitation`

object

The specific invitation that started (fields below)

`startedAt`

ISO 8601 string

When IN\_PROGRESS was reached

The `invitation` object holds `id`, `slug`, `status`, `name` (invitee name, or `null`), `recipientEmail` and `recipientPhone`.

Example envelope:

```
{
  "event": "interview.started",
  "createdAt": "2026-05-29T10:14:22.123Z",
  "data": {
    "id": "demo_interview_id",
    "title": "Sample interview",
    "locale": "en",
    "invitation": {
      "id": "demo_invitation_id",
      "slug": "demo-slug",
      "status": "in_progress",
      "name": "Jane Doe",
      "recipientEmail": "jane.doe@example.com",
      "recipientPhone": null
    },
    "startedAt": "2026-05-29T10:14:22.000Z"
  }
}
```

## interview.completed

Fired once the invitee ends the call (status moves to COMPLETED). The webhook waits for the AI insights pass to settle so the summary and per-message sentiments ship with the payload when they are available. An insights failure does not block delivery; in that case `summary` is `null` and message `sentiment` fields are `null`.

`data` payload:

Field

Type

Description

`id`

uuid

Interview id

`title`

string | null

Interview title

`locale`

string

Interview language

`invitation`

object

The specific invitation that completed (fields below)

`durationSeconds`

number

Total duration of the recorded audio segments of the interview, in seconds

`completedAt`

ISO 8601 string

When COMPLETED was reached

`summary`

string | null

Generated summary of what the respondent said (when available)

`messages`

array of `{role, content, createdAt, sentiment}`

Full transcript, chronological

The `invitation` object holds `id`, `slug`, `status`, `name` (invitee name, or `null`), `recipientEmail` and `recipientPhone`.

Example envelope:

```
{
  "event": "interview.completed",
  "createdAt": "2026-05-29T10:21:07.541Z",
  "data": {
    "id": "demo_interview_id",
    "title": "Sample interview",
    "locale": "en",
    "invitation": {
      "id": "demo_invitation_id",
      "slug": "demo-slug",
      "status": "completed",
      "name": "Jane Doe",
      "recipientEmail": "jane.doe@example.com",
      "recipientPhone": null
    },
    "durationSeconds": 184,
    "completedAt": "2026-05-29T10:21:07.000Z",
    "summary": "Jane appreciated the onboarding flow but flagged the pricing page as confusing.",
    "messages": [
      {
        "role": "assistant",
        "content": "Hi Jane, thanks for taking the time. How was your first week with the product?",
        "createdAt": "2026-05-29T10:18:07.000Z",
        "sentiment": null
      },
      {
        "role": "user",
        "content": "Onboarding felt smooth, but I got stuck on the pricing page.",
        "createdAt": "2026-05-29T10:19:07.000Z",
        "sentiment": "neutral"
      }
    ]
  }
}
```

## Testing your endpoint

From **Settings → Webhooks**, the **Send a test** button fires a mock payload of the selected event against your configured URL. The mock envelope adds `"test": true` to the `data` block so you can branch on it during development.

**ℹ️ 2xx == delivered**

Any response code in the `200..299` range counts as delivered. Anything else is logged on Raconte’s side but the event is not retried.

Table of contents

[1\. How delivery works](#how-delivery-works)[2\. Verifying the signature](#verifying-the-signature)[3\. interview.started](#interviewstarted)[4\. interview.completed](#interviewcompleted)[5\. Testing your endpoint](#testing-your-endpoint)
