Skip to content
Last updated

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 narrow hosts with conditionalUsers
  6. Fetch availabilities (optionally constrained to conditionalUsers)
  7. Create the event call using an exact available dateTime (pass the same conditionalUsers when routing applies)

Reference: OpenAPI openapi/v1/openapi.json — operations insertInviteeAnswers, updateContact, getPublicEventDates, createEventCall. You can also inspect these endpoints with the iClosed MCP (get-endpoint-info for each path/method).

1) Get your API key

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

Authorization: Bearer iclosed_<your-api-key>

If you have not done this yet, follow the Authentication guide.

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

username/event-name

Example:

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

POST /v1/contacts
Content-Type: application/json
Authorization: Bearer iclosed_<your-api-key>
{
  "firstName": "Jane",
  "lastName": "Doe",
  "email": "jane.doe@example.com"
}

Response (example)

{
  "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 questionsinviteeQuestionAnswers: 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) questionssecondaryQuestionsAnswer: 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

POST /v1/fields/inviteeAnswers
Content-Type: application/json
Authorization: Bearer iclosed_<your-api-key>
{
  "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):

"secondaryQuestionsAnswer": []

Response (shape)

On success (201), data includes:

FieldMeaning
messageResult message (e.g. confirmation that answers were stored).
disqualifyingConditionArray 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.
conditionalUsersArray of user IDs (numbers) selected by the event’s conditional routing after evaluating answers. Empty when no conditional hosts apply.
isDisqualifiedtrue when at least one disqualification condition triggered.

Example:

{
  "data": {
    "message": "Questions Answers Inserted Successfully",
    "disqualifyingCondition": [],
    "conditionalUsers": [1, 2],
    "isDisqualified": false
  }
}

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:

{
  "data": {
    "message": "Questions Answers Inserted Successfully",
    "disqualifyingCondition": [
      {
        "id": 4,
        "eventId": 1,
        "statement": "Disqualify",
        "operator": "AND",
        "value": "Yes",
        "condition": "IS",
        "disqualificationGroupId": 4
      }
    ],
    "conditionalUsers": [],
    "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

PUT /v1/contacts
Content-Type: application/json
Authorization: Bearer iclosed_<your-api-key>
{
  "id": 139,
  "status": "DISQUALIFIED"
}

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

Response (example)

{
  "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 conditionalUsers)

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 the same IDs returned in data.conditionalUsers from Insert invitee answers (join the numeric array into a string). This restricts availability to those hosts so slots reflect conditional routing.

Request (no conditional routing)

POST /v1/events/eventDates
Content-Type: application/json
Authorization: Bearer iclosed_<your-api-key>
{
  "linkPrefix": "company/discovery-call",
  "timeZone": "America/New_York",
  "currentDate": "2026-02-26"
}

Request (after invitee answers returned conditionalUsers: [3684, 3685])

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

In application code, derive the string from the array, for example: conditionalUsers.join(',') (skip the field or pass undefined when the array is empty).

Response (example)

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

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

conditionalUsers (optional): same semantics as for Get Event Date and Time — a comma-separated string of user IDs. If you computed availabilities with conditional hosts, pass the same conditionalUsers value here so the booking is validated against those users’ calendars and assignment stays consistent.

Request (using contactId + conditionalUsers)

POST /v1/eventCalls
Content-Type: application/json
Authorization: Bearer iclosed_<your-api-key>
{
  "linkPrefix": "company/discovery-call",
  "contactId": 139,
  "dateTime": "2026-03-15T14:00:00.000Z",
  "timeZone": "America/New_York",
  "conditionalUsers": "3684,3685",
  "secondaryQuestionsAnswer": []
}

Request (upsert contact during booking)

{
  "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",
  "secondaryQuestionsAnswer": []
}

Response (example)

{
  "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": "nayyab142@iclosed.io",
        "token": "ULC5lA1vKpS3OLD+m07eOz1U9/mAaXm6Q2j7wLCp1agN53Qt2gYOmbqAx3UQfMmMRQU4FIdSuh0kbIbUJBeBmw==",
        "closerName": "zarrar khan",
        "previewId": "call_fBdaH9gAZm8h",
        "closerEmail": "zarrar1961@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.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.

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.