# Web hooks

Source: https://www.veeting.com/en/developer-documentation/web-hooks

## Overview

Web hooks let Veeting call your system when something happens in a meeting, so you do not have to poll the API. When an event fires, the platform sends an HTTP request to a URL you configure, with the affected object as the JSON body.

Web hooks are configured in two places, and both fire for the same event:

- **White-label instance level**, in the "Web hooks" section of the System Configuration. Fires for every account on the instance.
- **Account level**, per account. Fires only for that account.

If you configure both, your endpoint is called twice for the same event, once from each level.

## Rules that apply to every web hook

1. **Delivery is attempted once.** There is no retry, no backoff, and no dead-letter queue. If your endpoint is down, times out, or answers with an error status, the event is logged on our side and lost on yours. Design for that: acknowledge quickly, queue the work internally, and if you need certainty, read the meeting back with [`GET /meeting/{id}`](/en/developer-documentation/api-usage) rather than trusting that every event arrived.

2. **Answer quickly.** No delivery timeout is configured, so a slow endpoint holds a connection open rather than failing fast. Return a `2xx` as soon as you have accepted the payload, then do the work.

3. **The body is the object, not an envelope.** Unlike REST API responses, web hook payloads are not wrapped in `responseCode` and `data`. Two events differ: `onMeetingSummaryCreated` arrives wrapped in `responseCode` and `data`, and `onMeetingRecordingCreated` arrives as an object with the meeting nested inside it rather than as the meeting itself. Both are described below.

4. **Check the payload shape before reading it.** Some events send a single object and some send an array. They are listed below.

## Configuration

Each web hook is configured separately, with four settings:

| Setting | Meaning                                                                       |
| ------- | ----------------------------------------------------------------------------- |
| enabled | Whether this web hook fires at all                                            |
| url     | The endpoint we call                                                          |
| method  | `POST`, `PUT`, or `DELETE`. Empty or unrecognized values fall back to `POST`. |
| apiKey  | Optional. See below.                                                          |

### Protecting your endpoint

Set `apiKey` and the platform sends it as an `X-API-KEY` header on every call, so your endpoint can reject requests that do not carry it. If you leave it empty, no header is sent.

This is the only authentication on a web hook call. An endpoint without an API key can be called by anyone who learns its URL.

`content-type: application/json` is always sent.

## The events

There are fourteen events.

| Event                               | Fires when                                                                        | Payload                   |
| ----------------------------------- | --------------------------------------------------------------------------------- | ------------------------- |
| `onMeetingScheduled`                | A meeting is created                                                              | Meeting                   |
| `onMeetingUpdated`                  | A meeting is changed                                                              | Meeting                   |
| `onMeetingClosed`                   | A meeting closes, automatically or manually                                       | Meeting                   |
| `onMeetingDeleted`                  | Meetings are deleted                                                              | **Array** of Meeting      |
| `onMeetingCancelled`                | A single occurrence of a recurring meeting is canceled                            | `{ meeting, day }`        |
| `onMeetingJoined`                   | Somebody joins a meeting                                                          | MeetingParticipantsStatus |
| `onMeetingLeft`                     | Somebody leaves a meeting                                                         | MeetingParticipantsStatus |
| `onMeetingSummaryCreated`           | A meeting summary is created, usually about five minutes after the meeting closes | MeetingSummary, wrapped   |
| `onMeetingRecordingCreated`         | A recorded meeting has been merged                                                | Recording                 |
| `onMeetingsReminder15MinutesBefore` | 15 minutes before meetings start                                                  | **Array** of Meeting      |
| `onMeetingsReminder30MinutesBefore` | 30 minutes before meetings start                                                  | **Array** of Meeting      |
| `onMeetingsReminder60MinutesBefore` | 60 minutes before meetings start                                                  | **Array** of Meeting      |
| `onMeetingsReminder24HoursBefore`   | 24 hours before meetings start                                                    | **Array** of Meeting      |
| `onMeetingsReminder48HoursBefore`   | 48 hours before meetings start                                                    | **Array** of Meeting      |

Two things about the reminders are easy to miss:

- They deliver an **array** because one reminder run covers every meeting due at that moment. Even a single meeting arrives as an array of one.
- The scope differs by level. A white-label instance reminder receives every due meeting on the instance. An account-level reminder receives only that account's meetings, so the same run sends different arrays to different endpoints.

## Payloads

### Meeting

Sent by `onMeetingScheduled`, `onMeetingUpdated`, and `onMeetingClosed`. The same object the REST API returns from `POST /meeting`, abridged here:

```json
{
  "id": "5e945c36b863b82cefdddf54",
  "meetingId": "9974-7653-8886-0485",
  "topic": "My Meeting Topic",
  "startTime": "2026-09-07T09:00:00.000Z",
  "endTime": "2026-09-07T10:00:00.000Z",
  "duration": 60,
  "type": "standard",
  "roomId": "5e9459c5b863b82cefdddf50",
  "isRecurring": false,
  "isRecorded": false,
  "isDialin": false,
  "invitedParticipants": [],
  "agenda": "",
  "whitelabelId": "5c737902b377b0f7fbf81fce",
  "accountId": "5e9459c5b863b82cefdddf4f",
  "addedByUserId": "5e9459c5b863b82cefdddf4e",
  "addedByUserEmail": "test-user@example.com",
  "addedByUserName": "Test User",
  "timezone": "Europe/Zurich",
  "isActive": false,
  "isOpen": false,
  "isClosed": true
}
```

The two identifiers behave exactly as they do in the [REST API](/en/developer-documentation/api-usage): use `id` when calling back into the API, and `meetingId` when building a link or showing the meeting to somebody.

### Array of Meeting

Sent by `onMeetingDeleted` and by all five reminders. The same object as above, in an array:

```json
[
  { "id": "5e945c36b863b82cefdddf54", "meetingId": "9974-7653-8886-0485", "topic": "My Meeting Topic" }
]
```

On `onMeetingDeleted`, each meeting may also carry a `cancellationMessage` field with the text the organizer supplied when deleting the meeting.

### Canceled occurrence

Sent by `onMeetingCancelled` when one occurrence of a recurring meeting is canceled rather than the whole series. The meeting is nested, and `day` identifies the occurrence:

```json
{
  "meeting": { "id": "5e945c36b863b82cefdddf54", "meetingId": "9974-7653-8886-0485", "isRecurring": true },
  "day": "2026-09-14"
}
```

### MeetingParticipantsStatus

Sent by `onMeetingJoined` and `onMeetingLeft`:

```json
{
  "meetingId": "5df78199c015b37195230596",
  "meetingToken": "0000-0000-0000-0000",
  "isNamedRoom": false,
  "participant": {
    "name": "Joe Doe",
    "email": "joe@example.com",
    "meetingParticipantId": "4edaf0d2-cb6c-42f1-a266-4ae7d971f2e6",
    "isModerator": false
  },
  "totalNumberOfGuests": 3,
  "totalNumberOfModerators": 1,
  "whitelabelId": "5c737902b377b0f7fbf81fce",
  "accountId": "5e9459c5b863b82cefdddf4f"
}
```

> **Note:** in this payload, `meetingId` holds the 24-character meeting `id`, and `meetingToken` holds the dashed number. That is the opposite of the naming used everywhere else, so read these fields carefully.

### MeetingSummary

Sent by `onMeetingSummaryCreated`. This is the one payload delivered wrapped in `responseCode` and `data`:

```json
{
  "responseCode": 0,
  "data": {
    "id": "5f7d49eb62e8f9a43b23f986",
    "meetingId": "5f7d49e662e8f9a43b23f983",
    "meetingType": "boardroom",
    "agenda": "",
    "minutes": "",
    "participants": [
      {
        "name": "Participant 1",
        "durations": [
          { "action": "joined", "timestamp": 1602046445811 },
          { "action": "left", "timestamp": 1602046670808 }
        ],
        "info": {
          "browserName": "Chrome",
          "browserVersion": "82.0.4062.0",
          "osName": "macOS",
          "osVersion": "10.15.6",
          "platformType": "desktop",
          "platformVendor": "Apple"
        }
      }
    ],
    "documents": [],
    "pdf": null,
    "accountId": "5c73790ab377b0f7fbf81fde",
    "isNamedRoom": false,
    "isRecordingPrepared": false,
    "sfuHostname": "ch-01-sfu-02.wlvmr.net",
    "meetingQuestions": [],
    "meetingConversationDuration": 25,
    "meetingConversationStartToEndDuration": 25,
    "meetingParticipantsStartToEndDuration": 35,
    "meetingStartToEndDuration": 60
  }
}
```

The four duration fields measure different things and are all in seconds:

| Field                                 | What it measures                                                        |
| ------------------------------------- | ----------------------------------------------------------------------- |
| meetingConversationDuration           | How long at least two people were in the room at the same time          |
| meetingConversationStartToEndDuration | From the first time two people were in the room together until the last |
| meetingParticipantsStartToEndDuration | From the first person joining to the last person leaving                |
| meetingStartToEndDuration             | The scheduled duration, including any extension                         |

### Recording

Sent by `onMeetingRecordingCreated`. Unlike the other payloads, this one is a wrapper: the meeting and the summary are nested inside it rather than forming the body themselves.

```json
{
  "meeting": { "id": "5e945c36b863b82cefdddf54", "meetingId": "9974-7653-8886-0485", "topic": "My Meeting Topic" },
  "summary": { "id": "5f48a701ee0c388a879bffa7" },
  "mergedRecordingUrl": "https://<recording-host>/prepared/<meeting>/<token>.webm",
  "recordingUrls": [
    "video-file-1.webm",
    "video-file-2.webm",
    "video-file-3.webm"
  ],
  "emailNotificationRecipients": ["organizer@example.com"]
}
```

| Property                    | Meaning                                                                   |
| --------------------------- | ------------------------------------------------------------------------- |
| meeting                     | The full meeting object, as described above                               |
| summary                     | The meeting summary this recording belongs to                             |
| mergedRecordingUrl          | The single merged recording                                               |
| recordingUrls               | The individual parts that were merged, as the recording host reports them |
| emailNotificationRecipients | Who the platform is about to email about this recording                   |

> **Note:** read the meeting's identifiers from `meeting.id` and `meeting.meetingId`. There is no `meetingId` at the top level of this payload.

## Common mistakes

- Assuming a failed delivery will be retried. It will not.
- Treating a reminder payload as a single meeting. All five reminders send an array.
- Reading `onMeetingSummaryCreated` as a bare object. It is the one wrapped payload.
- Reading `meetingId` in the join and leave payloads as the dashed number. In those payloads it is the 24-character `id`, and `meetingToken` holds the dashed number.
- Configuring the same endpoint at both instance and account level and then treating the duplicate call as a bug.
- Leaving `apiKey` empty on an endpoint that changes state.
- Doing slow work before answering, because nothing times out the call for you.

---

## The rest of this documentation

- [Custom tools](https://www.veeting.com/en/developer-documentation/custom-tools)
- [External meeting authorization service](https://www.veeting.com/en/developer-documentation/external-meeting-authorization-service)
- [iFrame and Web Components](https://www.veeting.com/en/developer-documentation/iframe-and-web-components)
- [JavaScript APIs](https://www.veeting.com/en/developer-documentation/javascript-apis)
- [Query parameters](https://www.veeting.com/en/developer-documentation/query-parameters)
- [Veeting Blocks - Components](https://www.veeting.com/en/veeting-blocks/components)
- [Veeting Blocks - Introduction](https://www.veeting.com/en/veeting-blocks/introduction)
- [Veeting Blocks - JavaScript and Typescript APIs](https://www.veeting.com/en/veeting-blocks/apis)
- [Veeting Rooms REST APIs](https://www.veeting.com/en/developer-documentation/api-usage)
- [Video display calculator](https://www.veeting.com/en/developer-documentation/video-display-calculator)

All of it in one file: https://www.veeting.com/llms-full.txt
