# Webhooks

Get a signed POST at your URL when a call is saved, summarised, scored, shared, renamed or deleted. Professional and Enterprise.

Source: https://docs.itsconvo.com/webhooks

API Base URL: https://www.itsconvo.com/api/v1

A webhook sends a signed JSON `POST` to your URL for each event you subscribe to.

- Professional plans can have 3 webhooks and Enterprise 999.
- A delivery tells you that something happened. Read the data itself from the API, such as `GET /conversations/{id}/keypoints`.
- Reply with a 2xx within 10 seconds and do the work afterwards. Convo ignores the response.

## Setup

1. Register your URL and events with `POST /webhooks`. The response contains the signing secret, which you see only once.
2. Send a test event with `POST /webhooks/test`.
3. [Verify the signature](#verify-the-signature) of every request.

### Register a URL

<Tabs items={["curl", "JavaScript"]}>
<Tab value="curl">
```bash
curl -X POST https://www.itsconvo.com/api/v1/webhooks \
  -H "Authorization: Bearer $CONVO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/convo", "events": ["conversation.keypoints.ready", "conversation.deleted"] }'
```
</Tab>
<Tab value="JavaScript">
```javascript
const res = await fetch("https://www.itsconvo.com/api/v1/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CONVO_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com/webhooks/convo",
    events: ["conversation.keypoints.ready", "conversation.deleted"],
  }),
});
const { data } = await res.json();
// data.secret is shown only in this response
```
</Tab>
</Tabs>

```json
{
  "data": {
    "id": "8d3f6a52-1c4e-4b7a-9e2d-5f0b8c1a7e34",
    "url": "https://example.com/webhooks/convo",
    "events": ["conversation.keypoints.ready", "conversation.deleted"],
    "secret": "whsec_3c9f0e1a7b2d4c6e8f0a1b3c5d7e9f1a2b4c6d8e0f1a3b5c",
    "createdAt": "2026-03-18T15:00:00.000Z"
  }
}
```

- The URL must use HTTPS. Localhost and private network addresses are refused with `400`.
- List, test and delete webhooks with the [webhook endpoints](/api-reference/webhooks/list-webhooks).
- Webhooks can't be changed. To change the URL or events, delete the webhook (`DELETE /webhooks` with `{ "id": "..." }` as the body) and register a new one.

### Send a test event

```bash
curl -X POST https://www.itsconvo.com/api/v1/webhooks/test \
  -H "Authorization: Bearer $CONVO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "webhookId": "8d3f6a52-1c4e-4b7a-9e2d-5f0b8c1a7e34" }'
```

```json
{ "data": { "webhookId": "8d3f6a52-1c4e-4b7a-9e2d-5f0b8c1a7e34", "delivered": true, "statusCode": 200 } }
```

`delivered` is true whenever your server answered, so check `statusCode`. If your server doesn't answer within 10 seconds, you get `delivered: false`. The test is sent even when the webhook isn't subscribed to `webhook.test`.

## Events

| Event | When it fires | `data` |
|-------|---------------|--------|
| `conversation.created` | The desktop app saves a new call after it ends | `conversationId` |
| `transcript.ready` | The desktop app saves a call's transcript. `segmentCount` is the number of segments in that save. | `conversationId`, `segmentCount` |
| `transcript.failed` | Saving the transcript failed | `conversationId`, `error` |
| `conversation.keypoints.ready` | Key points are generated, by the app after a call or by `POST /keypoints`. Returning saved key points doesn't fire it. | `conversationId`, `title` |
| `conversation.summary.ready` | With `keypoints.ready`, when the key points have sections | `conversationId`, `title`, `sectionCount` |
| `conversation.action_items.ready` | With `keypoints.ready`, when there are action items | `conversationId`, `actionItemCount` |
| `conversation.feedback.ready` | Feedback is generated, in the app or by `POST /feedback` | `conversationId`, `overallScore` |
| `conversation.shared` | A call is shared, in the app or by `POST /share` | `conversationId`, `shareUrl` |
| `conversation.updated` | A call is renamed with `PATCH /conversations/{id}` | `conversationId`, `title` |
| `conversation.deleted` | A call is deleted, in the app or through the API | `conversationId` |
| `webhook.test` | `POST /webhooks/test` | `message` |

Each request looks like this:

```json
{
  "event": "conversation.keypoints.ready",
  "data": { "conversationId": "session_1773842400000", "title": "Acme pilot pricing call" },
  "timestamp": "2026-03-18T15:00:12.000Z"
}
```

| Header | Value |
|--------|-------|
| `Content-Type` | `application/json` |
| `X-Convo-Event` | The event name |
| `X-Convo-Signature` | The hex HMAC-SHA256 of the raw body, keyed with your secret |

## Verify the signature

Use the whole secret as the key, including `whsec_`. Compute the HMAC over the raw body before you parse it, and compare in constant time. The signature covers the body's `timestamp`, so you can reject old requests by checking it.

<Tabs items={["Node.js", "Python"]}>
<Tab value="Node.js">
```javascript
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/webhooks/convo", express.raw({ type: "application/json" }), (req, res) => {
  const expected = crypto
    .createHmac("sha256", process.env.CONVO_WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");
  const received = req.get("X-Convo-Signature") ?? "";
  const valid =
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  res.sendStatus(200);
  const { event, data } = JSON.parse(req.body);
  // handle the event
});

app.listen(3000);
```
</Tab>
<Tab value="Python">
```python
import hashlib
import hmac
import os

from flask import Flask, abort, request

app = Flask(__name__)

@app.post("/webhooks/convo")
def convo_webhook():
    secret = os.environ["CONVO_WEBHOOK_SECRET"].encode()
    expected = hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Convo-Signature", "")):
        abort(401)
    payload = request.get_json()
    # handle payload["event"] and payload["data"]
    return "", 200
```
</Tab>
</Tabs>

## Delivery

- Convo sends each event once. It doesn't retry and keeps no delivery log, so a delivery that fails or times out is lost. To catch up, list recent calls with `GET /conversations`.
- Deliveries don't count against your [rate limit](/rate-limits).
- Webhooks keep sending after a downgrade below Professional. `DELETE /webhooks` works on any paid plan, so you can still remove them.