# Guide: Event Call Booking

This guide shows the standard flow to book a call:

1. Get an API key
2. Get the event `linkPrefix` (`username/event-name`)
3. Create (or upsert) a contact
4. **Insert invitee answers** (primary invitee questions + secondary / custom fields)
5. Use the response to **disqualify** the contact when needed, or to **echo routing** (`conditionalUsers` + `routingGroupIds`)
6. Fetch availabilities (constrained with those fields; use `appliedHostIds` when returned)
7. Create the event call using an exact available `dateTime` (pass the same `routingGroupIds`, and `appliedHostIds` or `conditionalUsers`)


Reference: OpenAPI [`openapi/v1/openapi.yaml`](/openapi/v1/openapi) — operations `insertInviteeAnswers`, `updateContact`, `getPublicEventDates`, `createEventCall`. You can also book and manage calls from an AI assistant via the [iClosed MCP server](/docs/mcp/introduction).

The same echo works for both event routing modes (`SPECIFIC_HOSTS_TEAMS` and `PRIORITIZE_BY_AVAILABILITY`). You do not need to branch on `routingMode` except for debugging. Optional: `GET /v1/events/detail` to inspect `routingMode` and routing config; it is not required to book.

## 1) Get your API key

Create an API key from your iClosed account settings, then send it in every request:

```http
Authorization: Bearer iclosed_<your-api-key>
```

If you have not done this yet, follow the [Authentication](/docs/authentication) guide.

## 2) Get the event link prefix

For booking APIs, use an event `linkPrefix` in this format:

```text
username/event-name
```

Example:

```text
company/discovery-call
```

Save this value. You will use it for invitee answers, availability lookup, and booking.

## 3) Create (or upsert) the contact

Before booking, create a contact with a minimal payload:

- `firstName`
- `lastName`
- `email` or `phoneNumber`


This endpoint is idempotent for existing records by `email`/`phoneNumber` and upserts the same contact when it already exists.

### Request

```http
POST /v1/contacts
Content-Type: application/json
Authorization: Bearer iclosed_<your-api-key>
```

```json
{
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane.doe@example.com"
}
```

### Response (example)

```json
{
  "data": {
    "contact": {
      "id": 139,
      "accountId": 4,
      "userId": null,
      "email": "jane.doe.updated@example.com",
      "firstName": "Jane",
      "lastName": "Doe",
      "status": "QUALIFIED",
      "phoneNumber": "+15551234567",
      "previewId": "contact_BpK9_yC5DFG7",
      "tagId": 7,
      "blockedByRecaptcha": false,
      "joinedTime": "2026-03-04T20:38:06.409Z",
      "country": "US",
      "timeZone": "America/New_York",
      "ipAddress": "192.168.1.1",
      "blockedId": null,
      "referrerUrl": "https://example.com/referral",
      "createdAt": "2026-03-04T20:38:06.410Z",
      "updatedAt": "2026-03-05T09:41:22.614Z",
      "deletedAt": null
    }
  }
}
```

Save `data.contact.id` as your `contactId`.

## 4) Insert invitee answers

After the contact exists, call **`POST /v1/fields/inviteeAnswers`** to submit:

- **Primary invitee questions** — `inviteeQuestionAnswers`: each item has `type` and `answer`. Allowed `type` values in the API are `EMAIL`, `PHONE_NO`, `NAME`, `FIRST_NAME`, and `LAST_NAME`. Each `type` must match an invitee question of that **kind** on the event (see your event setup in iClosed, or `GET /v1/events/detail` for context).
- **Secondary (custom) questions** — `secondaryQuestionsAnswer`: each item has `answer` (always an array of strings) and either `customFieldId` or `identifier` (slug). Use `[]` if there are none.


`linkPrefix` and `contactId` tie answers to the event and contact. You may use `previewId` instead of `contactId` when that fits your integration.

### Request

```http
POST /v1/fields/inviteeAnswers
Content-Type: application/json
Authorization: Bearer iclosed_<your-api-key>
```

```json
{
  "linkPrefix": "company/discovery-call",
  "contactId": 139,
  "inviteeQuestionAnswers": [
    { "type": "EMAIL", "answer": "jane.doe@example.com" },
    { "type": "PHONE_NO", "answer": "+15551234567" },
    { "type": "FIRST_NAME", "answer": "Jane" },
    { "type": "LAST_NAME", "answer": "Doe" }
  ],
  "secondaryQuestionsAnswer": [
    {
      "identifier": "company-size",
      "answer": ["11-50"]
    }
  ]
}
```

If you have no secondary answers, send an empty array (the field is required):

```json
"secondaryQuestionsAnswer": []
```

### Response (shape)

On success (**201**), `data` includes:

| Field | Meaning |
|  --- | --- |
| `message` | Result message (e.g. confirmation that answers were stored). |
| `disqualifyingCondition` | Array of objects describing **disqualification rules that fired**, if any. Each entry typically identifies the rule (e.g. `id`, `eventId`, `disqualificationGroupId`), human-readable `statement`, how it was evaluated (`operator`, `condition`, `value`), and ties back to your event’s disqualification group. Use for logging, UI copy, or redirect URLs alongside `isDisqualified`. |
| `conditionalUsers` | **Array of host user IDs** (numbers) from matched routing branches. Includes **direct hosts and expanded team members**. Empty when no branch matched (use all event hosts). Join with commas for later requests. |
| `routingGroupIds` | **Array of matched EventLogicGroup IDs** (numbers). Empty when no branch matched. Join with commas for later requests. Required for **PRIORITIZE_BY_AVAILABILITY** assignment and **SPECIFIC_HOSTS_TEAMS** availability waterfall. |
| `routingMode` | Event-level mode: `SPECIFIC_HOSTS_TEAMS` or `PRIORITIZE_BY_AVAILABILITY` (may be `null`). Echo `conditionalUsers` and `routingGroupIds` for both; do not branch on this unless you need it for logging. |
| `isDisqualified` | `true` when at least one disqualification condition triggered. |


Example (qualified lead with routing):

```json
{
  "data": {
    "message": "Questions Answers Inserted Successfully",
    "disqualifyingCondition": [],
    "conditionalUsers": [3684, 3685],
    "routingGroupIds": [10],
    "routingMode": "SPECIFIC_HOSTS_TEAMS",
    "isDisqualified": false
  }
}
```

Save `conditionalUsers` and `routingGroupIds`. In application code:

```js
const conditionalUsersCsv = (data.conditionalUsers ?? []).join(',') || undefined;
const routingGroupIdsCsv = (data.routingGroupIds ?? []).join(',') || undefined;
```

Skip each field on later requests when the array is empty.

When a disqualification rule matches, `isDisqualified` is `true` and `disqualifyingCondition` lists the rules that matched. Field names and extra keys can vary by event configuration; a typical entry looks like this:

```json
{
  "data": {
    "message": "Questions Answers Inserted Successfully",
    "disqualifyingCondition": [
      {
        "id": 4,
        "eventId": 1,
        "statement": "Disqualify",
        "operator": "AND",
        "value": "Yes",
        "condition": "IS",
        "disqualificationGroupId": 4
      }
    ],
    "conditionalUsers": [],
    "routingGroupIds": [],
    "routingMode": "SPECIFIC_HOSTS_TEAMS",
    "isDisqualified": true
  }
}
```

In this example, the invitee’s answers satisfied a rule labeled “Disqualify” (e.g. a secondary question answered **Yes** with condition **IS** and logical **AND** within its disqualification group). Your app should treat **`isDisqualified`** as the authoritative flag and use **`disqualifyingCondition`** for detail when updating CRM, showing messaging, or choosing a redirect.

### Troubleshooting

- **`INVALID_INVITEE_QUESTION_TYPE`**: The event has no invitee question matching the `type` you sent. Align `inviteeQuestionAnswers[].type` with the invitee questions configured on that event for the public API (the event detail payload may label some fields differently in the UI; the insert endpoint expects the normalized types above).


## 5) Disqualify the contact (after invitee answers)

When `isDisqualified` is `true` (and optionally when `disqualifyingCondition` is non-empty), update the contact’s pipeline status to **`DISQUALIFIED`** with **`PUT /v1/contacts`**. The request body must include `id` (the contact id); other fields are optional.

`disqualifyingCondition` does not perform the status change by itself — your app should decide when to sync CRM state (for example, whenever `isDisqualified` is true).

### Request

```http
PUT /v1/contacts
Content-Type: application/json
Authorization: Bearer iclosed_<your-api-key>
```

```json
{
  "id": 139,
  "status": "DISQUALIFIED"
}
```

Allowed `status` values on this endpoint: `POTENTIAL`, `QUALIFIED`, `DISQUALIFIED`.

### Response (example)

```json
{
  "data": {
    "contact": {
      "id": 139,
      "accountId": 4,
      "userId": null,
      "email": "jane.doe@example.com",
      "firstName": "Jane",
      "lastName": "Doe",
      "status": "DISQUALIFIED",
      "phoneNumber": "+15551234567",
      "previewId": "contact_BpK9_yC5DFG7",
      "tagId": 7,
      "blockedByRecaptcha": false,
      "joinedTime": "2026-03-04T20:38:06.409Z",
      "country": "US",
      "timeZone": "America/New_York",
      "blockedId": null,
      "referrerUrl": "https://example.com/referral",
      "createdAt": "2026-03-04T20:38:06.410Z",
      "updatedAt": "2026-03-05T10:00:00.000Z",
      "deletedAt": null
    }
  }
}
```

If the lead is disqualified, **stop the booking flow** (no need to fetch slots or create a call unless your product allows exceptions).

## 6) Fetch available dates/times (with optional routing)

Call **`POST /v1/events/eventDates`** with:

- `linkPrefix` (required)
- `timeZone` and `currentDate` when you want a specific anchor (recommended for predictable calendars)
- **`conditionalUsers`** (optional): a **comma-separated string of user IDs**, e.g. `"3684"` or `"1,2"`. Pass `data.conditionalUsers` from **Insert invitee answers** (`conditionalUsers.join(',')`). Omit when the array is empty.
- **`routingGroupIds`** (optional): a **comma-separated string of EventLogicGroup IDs**, e.g. `"10"`. Pass `data.routingGroupIds` the same way. Omit when the array is empty.


`conditionalUsers` is the initial host pool. `routingGroupIds` selects which routing branch matched: **SPECIFIC_HOSTS_TEAMS** uses it for the availability waterfall (widen hosts if too few slots); **PRIORITIZE_BY_AVAILABILITY** uses it to union hosts from those groups.

### Request (no conditional routing)

```http
POST /v1/events/eventDates
Content-Type: application/json
Authorization: Bearer iclosed_<your-api-key>
```

```json
{
  "linkPrefix": "company/discovery-call",
  "timeZone": "America/New_York",
  "currentDate": "2026-02-26"
}
```

### Request (after invitee answers returned `conditionalUsers: [3684, 3685]` and `routingGroupIds: [10]`)

```json
{
  "linkPrefix": "company/discovery-call",
  "timeZone": "America/New_York",
  "currentDate": "2026-02-26",
  "conditionalUsers": "3684,3685",
  "routingGroupIds": "10"
}
```

### Response (example)

```json
{
  "data": {
    "isPreview": false,
    "availabilities": {
      "2026-02-26": ["09:00", "09:15", "10:00"],
      "2026-02-27": ["09:00", "14:00"]
    }
  }
}
```

When **SPECIFIC_HOSTS_TEAMS** waterfall **widens** the host pool to meet slot thresholds, the response also includes **`appliedHostIds`** (the expanded list used to build the shown slots):

```json
{
  "data": {
    "isPreview": false,
    "availabilities": {
      "2026-02-26": ["09:00", "09:15", "10:00"],
      "2026-02-27": ["09:00", "14:00"]
    },
    "appliedHostIds": [3684, 3685, 3701]
  }
}
```

If `appliedHostIds` is present, use **that** list as `conditionalUsers` when creating the call so assignment matches the slots you showed. Otherwise keep the original `conditionalUsers` from invitee answers.

Pick an available slot and construct an exact booking `dateTime` in ISO 8601 UTC format (for example, `2026-03-15T14:00:00.000Z`).

## 7) Create the event call

Use the exact available `dateTime` from the previous step.

Minimum booking fields:

- `dateTime`
- `timeZone`
- `linkPrefix` (or `eventId`)
- `secondaryQuestionsAnswer` (may be `[]`)


And one of:

- `contactId`, or
- contact details (`firstName`, `lastName`, and `email`/`phoneNumber`) to upsert at booking time


Pass the **same routing context** you used for dates:

- **`routingGroupIds`** (optional): same comma-separated group IDs as invitee answers / event dates. Required for **PRIORITIZE_BY_AVAILABILITY** so team arrangement is preserved. Also used for **SPECIFIC_HOSTS_TEAMS** if you skip the dates step.
- **`conditionalUsers`** (optional): comma-separated host IDs. Use `appliedHostIds` from event dates when present; otherwise the original `conditionalUsers` from invitee answers.


```js
const hostsForBooking = eventDates.appliedHostIds ?? answers.conditionalUsers;
const conditionalUsersCsv = (hostsForBooking ?? []).join(',') || undefined;
```

### Request (using `contactId` + routing echo)

```http
POST /v1/eventCalls
Content-Type: application/json
Authorization: Bearer iclosed_<your-api-key>
```

```json
{
  "linkPrefix": "company/discovery-call",
  "contactId": 139,
  "dateTime": "2026-03-15T14:00:00.000Z",
  "timeZone": "America/New_York",
  "conditionalUsers": "3684,3685",
  "routingGroupIds": "10",
  "secondaryQuestionsAnswer": []
}
```

If event dates returned `appliedHostIds: [3684, 3685, 3701]`, send `"conditionalUsers": "3684,3685,3701"` instead and keep the same `"routingGroupIds": "10"`.

### Request (upsert contact during booking)

```json
{
  "linkPrefix": "company/discovery-call",
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane.doe@example.com",
  "dateTime": "2026-03-15T14:00:00.000Z",
  "timeZone": "America/New_York",
  "conditionalUsers": "3684",
  "routingGroupIds": "10",
  "secondaryQuestionsAnswer": []
}
```

### Response (example)

```json
{
  "data": {
    "eventCall": {
      "message": "Event call created successfully",
      "status": 200,
      "data": {
        "startTime": "2026-03-15T14:00:00.000Z",
        "endTime": "2026-03-15T14:15:00.000Z",
        "id": 1585584,
        "closerId": 3685,
        "timeZone": "America/New_York",
        "guestEmail": "inviteeEmail@iclosed.io",
        "token": "ULC5lA1vKpS3OLD+m07eOz1U9/mAaXm6Q2j7wLCp1agN53Qt2gYOmbqAx3UQfMmMRQU4FIdSuh0kbIbUJBeBmw==",
        "closerName": "Closer Test",
        "previewId": "call_fBdaH9gAZm8h",
        "closerEmail": "closerTest@gmail.com",
        "confirmationLink": "www.google.com"
      }
    }
  }
}
```

Save `data.eventCall.data.id` and `data.eventCall.data.previewId` for later operations (search, reschedule, cancel, and tracking).

## End-to-end flow (summary)

End-to-end flow (summary): create contact, submit qualifying answers, branch on isDisqualified, then either disqualify the contact or schedule event slots and an event call.
Echo algorithm (both routing modes):

1. Insert invitee answers → save `conditionalUsers`, `routingGroupIds`, `isDisqualified`.
2. If disqualified → `PUT /v1/contacts` with `DISQUALIFIED` and stop.
3. Get event dates with both values as comma-separated strings (omit a field when its array is empty).
4. Create the call with the same `routingGroupIds` and `conditionalUsers` = `appliedHostIds ?? original conditionalUsers`.


## Verification notes

The following were exercised against the production API with a valid account key:

- **`POST /v1/events/eventDates`** with `conditionalUsers` set to a single host id returned **201** with a normal `availabilities` payload.
- **`PUT /v1/contacts`** with `{ "id", "status": "DISQUALIFIED" }` returned **200** and an updated contact.


**`POST /v1/fields/inviteeAnswers`** depends on the event having invitee questions whose **types match** the `inviteeQuestionAnswers[].type` values you send; otherwise the API returns **`INVALID_INVITEE_QUESTION_TYPE`**. Confirm invitee question configuration on the event before relying on this step in production.