
# Appointments

The Appointments API lets you book appointments for your contacts on your event types, then fetch, list, update, cancel, or delete them. It also answers the question that comes first in most booking flows — which times are actually free — and covers the calendar side: listing the Google Calendars you have connected and importing events that already live in them. When a Google Calendar connection is active, the matching calendar event is created and kept in sync automatically in the background. Restaurants using Zenchef, Formitable, OpenTable or TheFork for their own reservations system can also be verified and connected here, so the AI Agent books real tables instead of internal appointments — and appointment businesses running their schedule in Trafft can connect that the same way.

All paths on this page are relative to the base URL `https://api.dmchamp.com/v1`. Every request needs your API key — see [Authentication](authentication.md) for the full list of ways to send it. The examples below use the `X-API-Key` header, with one cURL example showing the `?apiKey=` query form too.

> **Events vs. appointments:** An *event type* is a bookable slot definition (the kind of meeting, its length, its rooms). An *appointment* is one booked instance of an event type for a specific contact. You book an appointment by referencing the contact and the event type.

---

## The appointment object

Every endpoint that returns an appointment uses the same shape:

| Field | Description |
|---|---|
| `id` | Unique ID of the appointment. |
| `contact_id` | ID of the contact the appointment is booked with. |
| `event_id` | ID of the event type the appointment was booked on. |
| `status` | `Confirmed` or `Canceled`. |
| `start_time` | Start of the appointment, ISO 8601 in UTC. |
| `end_time` | End of the appointment, ISO 8601 in UTC. |
| `created_at` | When the appointment was created. |
| `last_modified_at` | When the appointment was last changed. |
| `room_name` | Room or resource the appointment is booked in, when the event type uses rooms. |
| `description` | Free-form description of the appointment. |
| `summary` | Short summary or title. |
| `cancelation_reason` | Reason supplied when the appointment was canceled, if any. |
| `google_calendar_event_id` | ID of the linked Google Calendar event. Set once calendar sync completes; `null` when no calendar is connected or while the sync is still in progress. |
| `calendar_synced` | `true` once the appointment is linked to a calendar event. |
| `imported` | `true` when the appointment was imported from an external calendar rather than booked directly. |
| `is_recurring` | `true` when the appointment is part of a recurring series. |
| `recurrence_frequency` | How often the appointment repeats, when recurring. |
| `recurring_event_id` | ID of the recurring series this appointment belongs to. |
| `recurring_interval` | Interval between repetitions, when recurring. |
| `recurring_sequence` | Position of this appointment within its recurring series. |
| `end_after_x_occurrences` | Number of occurrences after which the recurring series ends. |
| `booking_provider` | Source system the booking came from, when booked through a connected reservation provider. |

> **About calendar sync:** Right after you book or change an appointment, `google_calendar_event_id` may still be `null` and `calendar_synced` may be `false` because the sync runs in the background a moment later. Fetch the appointment again shortly afterward to see the populated calendar fields.

---

## Find available slots

`GET /appointments/available-slots`

Returns the times that are genuinely free on one event type between two moments. This is normally the **first** call in a booking flow: show these slots, let the person pick one, then post the chosen time to [Book an appointment](#book-an-appointment).

The answer already takes into account the event type's own opening hours and slot length, its rooms, appointments you have already booked on it, and everything blocked on the connected Google Calendars — so a slot that comes back here is one you can book.

| Query parameter | Required | Description |
|---|---|---|
| `event_id` | Yes | The event type to check. Must belong to your account. |
| `start_time` | Yes | Start of the window you want slots for, ISO 8601 date-time. |
| `end_time` | Yes | End of the window, ISO 8601 date-time. The whole end day is included. |

Results come back grouped by day — and, when the event type uses rooms, one group per room per day:

| Field | Description |
|---|---|
| `date` | The day the group covers, written `DD/MM/YYYY`. |
| `day` | Weekday name in lower case, for example `monday`. |
| `room_name` | The room or resource this group belongs to, when the event type uses rooms. |
| `available_slots` | The bookable blocks on that day, earliest first. |

Each entry in `available_slots` has:

| Field | Description |
|---|---|
| `start_time` | Block start as `HH:mm`. |
| `end_time` | Block end as `HH:mm`. |
| `available` | `true` — only free time is returned. |
| `spots_left` | How many bookings still fit in this block. Only present on event types that take more than one booking per slot. |

> **Times are local to the event type, not UTC.** `date`, `start_time`, and `end_time` are wall-clock values in the event type's own timezone (its override, or your account timezone when it has none). [Book an appointment](#book-an-appointment) expects an ISO 8601 UTC instant, so convert the slot you picked before posting it.

**cURL**

```bash
curl "https://api.dmchamp.com/v1/appointments/available-slots?event_id=event_xyz789&start_time=2026-06-15T00:00:00.000Z&end_time=2026-06-19T00:00:00.000Z" \
  -H "X-API-Key: YOUR_API_KEY"
```

**JavaScript**

```javascript
const params = new URLSearchParams({
  event_id: "event_xyz789",
  start_time: "2026-06-15T00:00:00.000Z",
  end_time: "2026-06-19T00:00:00.000Z",
});
const res = await fetch(
  `https://api.dmchamp.com/v1/appointments/available-slots?${params}`,
  { headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const data = await res.json();
console.log(data.data);
```

**Python**

```python
import requests

res = requests.get(
    "https://api.dmchamp.com/v1/appointments/available-slots",
    headers={"X-API-Key": "YOUR_API_KEY"},
    params={
        "event_id": "event_xyz789",
        "start_time": "2026-06-15T00:00:00.000Z",
        "end_time": "2026-06-19T00:00:00.000Z",
    },
)
print(res.json()["data"])
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "data": [
    {
      "date": "15/06/2026",
      "day": "monday",
      "room_name": "Room A",
      "available_slots": [
        { "start_time": "10:00", "end_time": "10:30", "available": true },
        { "start_time": "10:30", "end_time": "11:00", "available": true }
      ]
    },
    {
      "date": "16/06/2026",
      "day": "tuesday",
      "room_name": "Room A",
      "available_slots": [
        { "start_time": "09:00", "end_time": "09:30", "available": true, "spots_left": 2 }
      ]
    }
  ]
}
```

A day with nothing free simply does not appear. Missing `event_id`, `start_time`, or `end_time` returns `400`; an event type that is not on your account returns `404`.

---

## Book an appointment

`POST /appointments`

Books a new appointment for a contact on one of your event types. The end time is calculated automatically from the event type's slot duration.

The booking is conflict-checked: if the requested slot overlaps an existing confirmed appointment on the same event type, the request fails with a `409` and nothing is created.

| Field | Required | Description |
|---|---|---|
| `contact_id` | Yes | ID of the contact to book for. Must belong to your account. |
| `event_id` | Yes | ID of the event type to book on. Must belong to your account. |
| `start_time` | Yes | Desired start as an ISO 8601 date-time. |
| `room_name` | No | Room or resource name, when the event type uses rooms. |

**cURL** (using the `?apiKey=` query form)

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contact_id": "contact_abc123",
    "event_id": "event_xyz789",
    "start_time": "2026-06-15T10:00:00.000Z",
    "room_name": "Room A"
  }'
```

**JavaScript**

```javascript
const res = await fetch("https://api.dmchamp.com/v1/appointments", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    contact_id: "contact_abc123",
    event_id: "event_xyz789",
    start_time: "2026-06-15T10:00:00.000Z",
    room_name: "Room A",
  }),
});
const data = await res.json();
console.log(data.appointment_id);
```

**Python**

```python
import requests

res = requests.post(
    "https://api.dmchamp.com/v1/appointments",
    headers={
        "X-API-Key": "YOUR_API_KEY",
        "Content-Type": "application/json",
    },
    json={
        "contact_id": "contact_abc123",
        "event_id": "event_xyz789",
        "start_time": "2026-06-15T10:00:00.000Z",
        "room_name": "Room A",
    },
)
print(res.json()["appointment_id"])
```

**Response** (`201 Created`):

```json
{
  "success": true,
  "appointment_id": "aBcD1234eFgH5678",
  "appointment": {
    "id": "aBcD1234eFgH5678",
    "contact_id": "contact_abc123",
    "event_id": "event_xyz789",
    "status": "Confirmed",
    "start_time": "2026-06-15T10:00:00.000Z",
    "end_time": "2026-06-15T10:30:00.000Z",
    "created_at": "2026-06-10T09:00:00.000Z",
    "last_modified_at": "2026-06-10T09:00:00.000Z",
    "room_name": "Room A",
    "google_calendar_event_id": null,
    "calendar_synced": false
  }
}
```

---

## Get an appointment

`GET /appointments/{appointmentId}`

Returns a single appointment by its ID, including its calendar sync state.

**cURL**

```bash
curl "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678" \
  -H "X-API-Key: YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678",
  { headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const data = await res.json();
console.log(data.appointment);
```

**Python**

```python
import requests

res = requests.get(
    "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
print(res.json()["appointment"])
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "appointment": {
    "id": "aBcD1234eFgH5678",
    "contact_id": "contact_abc123",
    "event_id": "event_xyz789",
    "status": "Confirmed",
    "start_time": "2026-06-15T10:00:00.000Z",
    "end_time": "2026-06-15T10:30:00.000Z",
    "room_name": "Room A",
    "google_calendar_event_id": "abc123googleevent",
    "calendar_synced": true
  }
}
```

---

## List appointments

`GET /appointments`

Lists appointments for your account, newest first, with cursor-based pagination.

| Query parameter | Required | Description |
|---|---|---|
| `contact_id` | No | Only return appointments for this contact. Contact-filtered listings include **confirmed appointments only**. |
| `date` | No | Only return appointments on this calendar day (`YYYY-MM-DD`). **Requires `contact_id`.** |
| `status` | No | Filter by `Confirmed` or `Canceled`. Only available **without** `contact_id`. |
| `limit` | No | Page size, an integer between 1 and 100. Default `50`. |
| `cursor` | No | The `next_cursor` value from a previous response. |

A few rules to keep in mind:

- **Without filters**, you get every appointment on the account, page by page.
- **By contact** — set `contact_id` to see one contact's confirmed appointments. You can narrow this to a single day by also passing `date`.
- **By status** — set `status` (without `contact_id`) to list only `Confirmed` or only `Canceled` appointments across the account.
- The `date` filter without `contact_id`, or `status=Canceled` together with `contact_id`, returns a `400`.

**cURL**

```bash
curl "https://api.dmchamp.com/v1/appointments?contact_id=contact_abc123&date=2026-06-15" \
  -H "X-API-Key: YOUR_API_KEY"
```

**JavaScript**

```javascript
const params = new URLSearchParams({
  contact_id: "contact_abc123",
  date: "2026-06-15",
});
const res = await fetch(
  `https://api.dmchamp.com/v1/appointments?${params}`,
  { headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const data = await res.json();
console.log(data.appointments, data.next_cursor);
```

**Python**

```python
import requests

res = requests.get(
    "https://api.dmchamp.com/v1/appointments",
    headers={"X-API-Key": "YOUR_API_KEY"},
    params={"contact_id": "contact_abc123", "date": "2026-06-15"},
)
data = res.json()
print(data["appointments"], data["next_cursor"])
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "appointments": [
    {
      "id": "aBcD1234eFgH5678",
      "contact_id": "contact_abc123",
      "event_id": "event_xyz789",
      "status": "Confirmed",
      "start_time": "2026-06-15T10:00:00.000Z",
      "end_time": "2026-06-15T10:30:00.000Z",
      "calendar_synced": true
    }
  ],
  "next_cursor": null
}
```

To page through results, pass the `next_cursor` from one response as the `cursor` of the next request. Keep going until `next_cursor` is `null`. See [Errors & Pagination](errors-and-pagination.md) for the shared pagination pattern.

---

## Update an appointment

`PUT /appointments/{appointmentId}`

Reschedule an appointment or change its details. Send only the fields you want to change — at least one is required. The combined start and end must stay in chronological order (`end_time` must be after `start_time`). Changes are synced to the linked calendar event automatically.

| Field | Description |
|---|---|
| `start_time` | New start, ISO 8601 date-time. |
| `end_time` | New end, ISO 8601 date-time. Must be after the start time. |
| `room_name` | New room or resource name. |
| `description` | New description, or `null` to clear it. |
| `summary` | New summary, or `null` to clear it. |

**cURL**

```bash
curl -X PUT "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "start_time": "2026-06-16T10:00:00.000Z",
    "end_time": "2026-06-16T10:30:00.000Z"
  }'
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678",
  {
    method: "PUT",
    headers: {
      "X-API-Key": "YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      start_time: "2026-06-16T10:00:00.000Z",
      end_time: "2026-06-16T10:30:00.000Z",
    }),
  }
);
const data = await res.json();
console.log(data.appointment);
```

**Python**

```python
import requests

res = requests.put(
    "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678",
    headers={
        "X-API-Key": "YOUR_API_KEY",
        "Content-Type": "application/json",
    },
    json={
        "start_time": "2026-06-16T10:00:00.000Z",
        "end_time": "2026-06-16T10:30:00.000Z",
    },
)
print(res.json()["appointment"])
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "appointment_id": "aBcD1234eFgH5678",
  "appointment": {
    "id": "aBcD1234eFgH5678",
    "contact_id": "contact_abc123",
    "event_id": "event_xyz789",
    "status": "Confirmed",
    "start_time": "2026-06-16T10:00:00.000Z",
    "end_time": "2026-06-16T10:30:00.000Z",
    "calendar_synced": true
  }
}
```

---

## Cancel an appointment

`POST /appointments/{appointmentId}/cancel`

Cancels a confirmed appointment, optionally recording a reason. The appointment stays in your account with status `Canceled`, and the linked calendar event is removed automatically in the background. Cancelling an already-canceled appointment returns a `400`.

| Field | Required | Description |
|---|---|---|
| `cancellation_reason` | No | Reason for the cancellation, stored on the appointment. |

**cURL**

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678/cancel" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "cancellation_reason": "Client asked to reschedule next month"
  }'
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678/cancel",
  {
    method: "POST",
    headers: {
      "X-API-Key": "YOUR_API_KEY",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      cancellation_reason: "Client asked to reschedule next month",
    }),
  }
);
const data = await res.json();
console.log(data.success);
```

**Python**

```python
import requests

res = requests.post(
    "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678/cancel",
    headers={
        "X-API-Key": "YOUR_API_KEY",
        "Content-Type": "application/json",
    },
    json={"cancellation_reason": "Client asked to reschedule next month"},
)
print(res.json()["success"])
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "appointment_id": "aBcD1234eFgH5678"
}
```

---

## Delete an appointment

`DELETE /appointments/{appointmentId}`

Permanently deletes an appointment and its references. If you only want to call the booking off while keeping the record, use [cancel](#cancel-an-appointment) instead.

**cURL**

```bash
curl -X DELETE "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678" \
  -H "X-API-Key: YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch(
  "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678",
  { method: "DELETE", headers: { "X-API-Key": "YOUR_API_KEY" } }
);
const data = await res.json();
console.log(data.success);
```

**Python**

```python
import requests

res = requests.delete(
    "https://api.dmchamp.com/v1/appointments/aBcD1234eFgH5678",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
print(res.json()["success"])
```

**Response** (`200 OK`):

```json
{
  "success": true
}
```

---

## List your connected Google Calendars

`GET /appointments/google-calendars`

Returns the Google Calendars available on this account, straight from Google — useful for showing the account holder a picker of which calendar to import from below, or just to confirm the connection is live.

This only works once the account has connected Google Calendar (Settings → Integrations) with at least read access. If it hasn't, or the granted access no longer includes the calendar-read scope, you get a `400` telling you to (re)connect it.

**cURL**

```bash
curl "https://api.dmchamp.com/v1/appointments/google-calendars" \
  -H "X-API-Key: YOUR_API_KEY"
```

**JavaScript**

```javascript
const res = await fetch("https://api.dmchamp.com/v1/appointments/google-calendars", {
  headers: { "X-API-Key": "YOUR_API_KEY" },
});
const data = await res.json();
console.log(data.data);
```

**Python**

```python
import requests

res = requests.get(
    "https://api.dmchamp.com/v1/appointments/google-calendars",
    headers={"X-API-Key": "YOUR_API_KEY"},
)
print(res.json()["data"])
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "data": [
    {
      "id": "primary",
      "summary": "jane@example.com",
      "timeZone": "America/New_York",
      "accessRole": "owner",
      "primary": true
    },
    {
      "id": "abcdefg1234567890@group.calendar.google.com",
      "summary": "Bookings",
      "timeZone": "America/New_York",
      "accessRole": "writer"
    }
  ]
}
```

Each entry is Google's own [`CalendarListEntry`](https://developers.google.com/calendar/api/v3/reference/calendarList) shape, so field names follow Google's `camelCase`, not this API's usual `snake_case` — that's Google's data passed through as-is, not ours. A missing or revoked connection returns `400` with an error explaining Google Calendar needs to be (re)connected.

---

## Import events from a Google Calendar

`POST /appointments/import-calendar-events`

Pulls the events already sitting in a campaign's or AI Agent's connected Google Calendar(s) and turns them into appointments — useful the first time you connect a calendar that already has bookings on it. This can take a while (each event goes through extraction to figure out who it's for), so it never runs inline: the request enqueues a background job and hands you back a `job_id` to poll.

| Field | Required | Description |
|---|---|---|
| `campaign_id` | One of these two | The campaign whose connected calendar(s) to import from. |
| `agent_id` | One of these two | The AI Agent whose connected calendar(s) to import from. |
| `identifier` | Yes | `"EMAIL"` or `"PHONE_NUMBER"` — which piece of contact info to extract from each calendar event to match or create the contact it belongs to. |

Send exactly one of `campaign_id` / `agent_id`, never both and never neither — either combination returns a `400`. Whichever one you send must belong to your account, or you get a `404`.

**cURL**

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/import-calendar-events?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "agent_abc123",
    "identifier": "EMAIL"
  }'
```

**JavaScript**

```javascript
const res = await fetch("https://api.dmchamp.com/v1/appointments/import-calendar-events", {
  method: "POST",
  headers: {
    "X-API-Key": "YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    agent_id: "agent_abc123",
    identifier: "EMAIL",
  }),
});
const data = await res.json();
console.log(data.job_id);
```

**Python**

```python
import requests

res = requests.post(
    "https://api.dmchamp.com/v1/appointments/import-calendar-events",
    headers={
        "X-API-Key": "YOUR_API_KEY",
        "Content-Type": "application/json",
    },
    json={"agent_id": "agent_abc123", "identifier": "EMAIL"},
)
print(res.json()["job_id"])
```

**Response** (`202 Accepted`):

```json
{
  "success": true,
  "job_id": "jK9mQ2xR7pL4wN1t",
  "status": "queued",
  "campaign_id": null,
  "agent_id": "agent_abc123"
}
```

`campaign_id` and `agent_id` echo back whichever one you sent; the other is always `null`.

### Poll the import job

`GET /appointments/import-calendar-events/{jobId}`

```bash
curl "https://api.dmchamp.com/v1/appointments/import-calendar-events/jK9mQ2xR7pL4wN1t" \
  -H "X-API-Key: YOUR_API_KEY"
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "job_id": "jK9mQ2xR7pL4wN1t",
  "status": "completed",
  "message": "Imported 12 events as appointments.",
  "error": null
}
```

| `status` | Meaning |
|---|---|
| `queued` | Not picked up yet. Keep polling. |
| `processing` | The import is running. Keep polling. |
| `completed` | Done — `message` has a short human-readable summary. |
| `failed` | Something went wrong — `error` has the reason. |

`GET` on a `jobId` that doesn't exist (or belongs to a different account) returns `404`.

---

<a id="restaurant-booking-integrations-zenchef-formitable"></a>

## External booking integrations (Zenchef / Formitable / OpenTable / TheFork / Trafft)

Zenchef and Formitable are restaurant reservation systems your AI Agent can book real tables through; [Trafft](#trafft) is a scheduling platform for appointment businesses, connected once per account rather than per restaurant. The two restaurant platforms each have a **public, unauthenticated booking widget** (`https://api.dmchamp.com/v1/zenchef-widget/...` and `https://api.dmchamp.com/v1/formitable-widget/...`) that renders inside chat for the diner — those widget routes are plain HTML pages meant to be opened in a browser, not JSON API endpoints, so they aren't documented here. What follows are the account-management endpoints: verifying a restaurant ID belongs to the account holder, then adding, updating, or removing it.

### Zenchef

Connecting a Zenchef restaurant is a two-step verification, so the account holder proves they actually run the restaurant before it gets wired into the bot: first check the ID exists (without revealing the name), then have them type the restaurant's name themselves and verify it matches.

**Step 1 — Check a restaurant ID exists**

`POST /appointments/zenchef-restaurants/check`

| Field | Required | Description |
|---|---|---|
| `restaurant_id` | Yes | The Zenchef restaurant ID to check. |

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/zenchef-restaurants/check?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "restaurant_id": "12345" }'
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "data": { "exists": true, "requiresNameVerification": true }
}
```

`exists: false` means no Zenchef restaurant has that ID — nothing else to do. Rate-limited to 10 checks per 5 minutes per account; going over returns `429`.

**Step 2 — Verify the restaurant's name**

`POST /appointments/zenchef-restaurants/verify-name`

| Field | Required | Description |
|---|---|---|
| `restaurant_id` | Yes | The Zenchef restaurant ID from step 1. |
| `user_input_name` | Yes | The name the account holder typed in — compared against the restaurant's real name on Zenchef (case/whitespace-insensitive). |

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/zenchef-restaurants/verify-name?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "restaurant_id": "12345", "user_input_name": "The Blue Door Bistro" }'
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "data": {
    "verified": true,
    "restaurantDetails": {
      "id": "12345",
      "name": "The Blue Door Bistro",
      "address": "1 Rue de Rivoli, Paris",
      "status": "active"
    }
  }
}
```

`verified: false` means the name didn't match — `restaurantDetails` is omitted, ask the account holder to try again. Rate-limited to 3 attempts per 5 minutes (tighter than the existence check, since this is the actual proof step). A `restaurant_id` that no longer resolves on Zenchef returns `404`.

**Step 3 — Save the restaurant**

`POST /appointments/zenchef-restaurants`

| Field | Required | Description |
|---|---|---|
| `restaurant_id` | Yes | 1–64 chars, letters/numbers/underscore/hyphen. |
| `restaurant_name` | Yes | The verified restaurant name from step 2. |

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/zenchef-restaurants?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "restaurant_id": "12345", "restaurant_name": "The Blue Door Bistro" }'
```

**Response** (`201 Created`):

```json
{ "success": true, "data": { "restaurantId": "12345" } }
```

**Update a saved Zenchef restaurant**

`PUT /appointments/zenchef-restaurants/{restaurantId}`

| Field | Required | Description |
|---|---|---|
| `restaurant_name` | No | New display name. |
| `is_active` | No | Set `false` to stop the bot from booking against this restaurant without removing it. |

```bash
curl -X PUT "https://api.dmchamp.com/v1/appointments/zenchef-restaurants/12345" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "is_active": false }'
```

**Response** (`200 OK`): same shape as the save response above.

**Remove a Zenchef restaurant**

`DELETE /appointments/zenchef-restaurants/{restaurantId}`

```bash
curl -X DELETE "https://api.dmchamp.com/v1/appointments/zenchef-restaurants/12345" \
  -H "X-API-Key: YOUR_API_KEY"
```

**Response** (`200 OK`): `{ "success": true, "data": { "restaurantId": "12345" } }`

A `restaurantId` not currently on the account returns `404` on update or delete.

### Formitable

Formitable doesn't need the two-step name proof Zenchef does — its restaurant IDs are already scoped per business, so one verification call is enough. It also has a details lookup used to cache the restaurant's website URL during setup.

**Verify a restaurant ID**

`POST /appointments/formitable-restaurants/verify`

| Field | Required | Description |
|---|---|---|
| `restaurant_id` | Yes | The Formitable restaurant ID. |
| `language` | No | Language tag for the probe request. Defaults to `"nl"`. |

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/formitable-restaurants/verify?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "restaurant_id": "the-blue-door", "language": "en" }'
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "data": {
    "verified": true,
    "restaurantDetails": {
      "restaurantId": "the-blue-door",
      "productCount": 4,
      "sampleProductTitle": "Dinner for two",
      "language": "en"
    }
  }
}
```

A `restaurant_id` Formitable doesn't recognize returns `404`. Rate-limited to 10 attempts per 5 minutes per account.

**Get restaurant details**

`GET /appointments/formitable-restaurants/{restaurantId}/details?language=en`

Fetches the restaurant's public profile from Formitable, including its website — used to cache the website URL while setting the restaurant up. `language` is an optional query parameter, defaulting to `"en"`.

```bash
curl "https://api.dmchamp.com/v1/appointments/formitable-restaurants/the-blue-door/details?language=en" \
  -H "X-API-Key: YOUR_API_KEY"
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "data": {
    "uid": "the-blue-door",
    "name": "The Blue Door Bistro",
    "website": "https://thebluedoorbistro.com",
    "email": "info@thebluedoorbistro.com",
    "telephone": "+31201234567",
    "streetAddress": "Prinsengracht 1",
    "zipcode": "1015 AB",
    "city": "Amsterdam",
    "country": "Netherlands",
    "countryCode": "NL",
    "currency": "EUR"
  }
}
```

**Save the restaurant**

`POST /appointments/formitable-restaurants`

| Field | Required | Description |
|---|---|---|
| `restaurant_id` | Yes | 1–64 chars, letters/numbers/underscore/hyphen. |
| `restaurant_name` | Yes | Display name. |
| `language` | Yes | ISO language tag, e.g. `"en"` or `"en-GB"`. |
| `website_url` | No | The restaurant's website, from the details lookup above. Must be `http(s)://`. |

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/formitable-restaurants?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "restaurant_id": "the-blue-door",
    "restaurant_name": "The Blue Door Bistro",
    "language": "en",
    "website_url": "https://thebluedoorbistro.com"
  }'
```

**Response** (`201 Created`): `{ "success": true, "data": { "restaurantId": "the-blue-door" } }`

**Update a saved Formitable restaurant**

`PUT /appointments/formitable-restaurants/{restaurantId}`

| Field | Required | Description |
|---|---|---|
| `restaurant_name` | No | New display name. |
| `language` | No | New ISO language tag. |
| `is_active` | No | Set `false` to stop the bot from booking against this restaurant without removing it. |
| `website_url` | No | New website URL. |

```bash
curl -X PUT "https://api.dmchamp.com/v1/appointments/formitable-restaurants/the-blue-door" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "is_active": false }'
```

**Response** (`200 OK`): same shape as the save response above.

**Remove a Formitable restaurant**

`DELETE /appointments/formitable-restaurants/{restaurantId}`

```bash
curl -X DELETE "https://api.dmchamp.com/v1/appointments/formitable-restaurants/the-blue-door" \
  -H "X-API-Key: YOUR_API_KEY"
```

**Response** (`200 OK`): `{ "success": true, "data": { "restaurantId": "the-blue-door" } }`

A `restaurantId` not currently on the account returns `404` on update or delete.

### OpenTable

OpenTable restaurants are reached through the platform's OpenTable partner credentials, and OpenTable only lets those credentials see restaurants that connected the platform's listing inside OpenTable's Integrations Marketplace. So, like Formitable, one verification call is enough: a reachable Restaurant ID (the numeric "RID") proves both that the restaurant exists and that it connected the integration. Until the OpenTable partner listing is enabled on the platform, the verify call answers `503`.

**Verify an OpenTable restaurant**

`POST /appointments/opentable-restaurants/verify`

| Field | Required | Description |
| --- | --- | --- |
| `restaurant_id` | Yes | The OpenTable Restaurant ID (RID), a number such as `1038007`. |

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/opentable-restaurants/verify?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "restaurant_id": "1038007" }'
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "data": {
    "verified": true,
    "restaurantDetails": {
      "restaurantId": "1038007",
      "diningAreaCount": 2,
      "diningAreaNames": ["Main Room", "Garden"],
      "tableTypes": ["default", "outdoor", "bar"]
    }
  }
}
```

`verified: false` means no OpenTable restaurant has that ID. A `403` means the restaurant exists but has not connected the platform's integration inside OpenTable yet. Rate-limited to 10 attempts per 5 minutes per account.

**Add an OpenTable restaurant**

`POST /appointments/opentable-restaurants`

| Field | Required | Description |
| --- | --- | --- |
| `restaurant_id` | Yes | The verified Restaurant ID. |
| `restaurant_name` | Yes | Display name (a label; also what the AI calls the restaurant). |
| `website_url` | No | An http(s) URL shown to guests when the AI hands them off to the restaurant. |

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/opentable-restaurants?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "restaurant_id": "1038007", "restaurant_name": "The Blue Door", "website_url": "https://thebluedoor.example" }'
```

**Response** (`201 Created`): `{ "success": true, "data": { "restaurantId": "1038007" } }`

**Update a saved OpenTable restaurant**

`PUT /appointments/opentable-restaurants/{restaurantId}`

Send any of `restaurant_name`, `is_active` (pause with `false`) or `website_url` (empty string clears it); omitted fields are left unchanged.

```bash
curl -X PUT "https://api.dmchamp.com/v1/appointments/opentable-restaurants/1038007" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "is_active": false }'
```

**Response** (`200 OK`): `{ "success": true, "data": { "restaurantId": "1038007" } }`

**Remove an OpenTable restaurant**

`DELETE /appointments/opentable-restaurants/{restaurantId}`

```bash
curl -X DELETE "https://api.dmchamp.com/v1/appointments/opentable-restaurants/1038007" \
  -H "X-API-Key: YOUR_API_KEY"
```

**Response** (`200 OK`): `{ "success": true, "data": { "restaurantId": "1038007" } }`

A `restaurantId` not currently on the account returns `404` on update.

### TheFork

TheFork restaurants are reached through the platform's TheFork partner credentials, and TheFork only lets those credentials see restaurants that have the platform's partner enabled on their TheFork account. So, like Formitable and OpenTable, one verification call is enough: a reachable Restaurant ID proves both that the restaurant exists and that the partner is enabled on it. The ID is the UUID TheFork gives the restaurant in TheFork Manager, sent as a string. Until TheFork has approved the platform as a partner and issued the credentials, the verify call answers `503` — see [TheFork](../integrations/thefork.md) for what that means today.

**Verify a TheFork restaurant**

`POST /appointments/thefork-restaurants/verify`

| Field | Required | Description |
| --- | --- | --- |
| `restaurant_id` | Yes | The TheFork Restaurant ID, a UUID such as `9f2a1c34-5b6d-4e7f-8a90-1b2c3d4e5f60`. |

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/thefork-restaurants/verify?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "restaurant_id": "9f2a1c34-5b6d-4e7f-8a90-1b2c3d4e5f60" }'
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "data": {
    "verified": true,
    "restaurantDetails": {
      "restaurantId": "9f2a1c34-5b6d-4e7f-8a90-1b2c3d4e5f60",
      "partySizes": [1, 2, 3, 4, 5, 6, 7, 8]
    }
  }
}
```

`partySizes` are the party sizes the restaurant takes online over the next 30 days. A `404` or `403` means TheFork would not give us a restaurant with that ID — either the ID is wrong, or the platform's partner is not enabled on that restaurant yet; a `400` means the ID is not a UUID. Rate-limited to 10 attempts per 5 minutes per account.

**Add a TheFork restaurant**

`POST /appointments/thefork-restaurants`

| Field | Required | Description |
| --- | --- | --- |
| `restaurant_id` | Yes | The verified Restaurant ID (UUID). |
| `restaurant_name` | Yes | Display name (a label; also what the AI calls the restaurant). |
| `website_url` | No | An http(s) URL shown to guests when the AI hands them off to the restaurant. |

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/thefork-restaurants?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "restaurant_id": "9f2a1c34-5b6d-4e7f-8a90-1b2c3d4e5f60", "restaurant_name": "The Blue Door", "website_url": "https://thebluedoor.example" }'
```

**Response** (`201 Created`): `{ "success": true, "data": { "restaurantId": "9f2a1c34-5b6d-4e7f-8a90-1b2c3d4e5f60" } }`

**Update a saved TheFork restaurant**

`PUT /appointments/thefork-restaurants/{restaurantId}`

Send any of `restaurant_name`, `is_active` (pause with `false`) or `website_url` (empty string clears it); omitted fields are left unchanged.

```bash
curl -X PUT "https://api.dmchamp.com/v1/appointments/thefork-restaurants/9f2a1c34-5b6d-4e7f-8a90-1b2c3d4e5f60" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "is_active": false }'
```

**Response** (`200 OK`): `{ "success": true, "data": { "restaurantId": "9f2a1c34-5b6d-4e7f-8a90-1b2c3d4e5f60" } }`

**Remove a TheFork restaurant**

`DELETE /appointments/thefork-restaurants/{restaurantId}`

```bash
curl -X DELETE "https://api.dmchamp.com/v1/appointments/thefork-restaurants/9f2a1c34-5b6d-4e7f-8a90-1b2c3d4e5f60" \
  -H "X-API-Key: YOUR_API_KEY"
```

**Response** (`200 OK`): `{ "success": true, "data": { "restaurantId": "9f2a1c34-5b6d-4e7f-8a90-1b2c3d4e5f60" } }`

A `restaurantId` not currently on the account returns `404` on update or delete.

### Trafft

Trafft is connected once for the whole account, not per location: one company address plus the API credentials from the Trafft admin panel (**Features & Integrations → API & Connectors**, part of Trafft's Business plan). The connect call checks those credentials against Trafft before storing anything, so a wrong address, wrong credentials or a plan without API access fails here rather than in a customer conversation. The client secret is stored encrypted and is never returned by any endpoint.

All three write calls (`POST`, `PUT`, `DELETE`) require the Integrations **edit** permission; the status `GET` needs Integrations **view**.

**Connect Trafft**

`POST /appointments/trafft/connect`

| Field | Required | Description |
|---|---|---|
| `subdomain` | Yes | The company address — the part before `.admin.trafft.com` in the URL you sign in at, e.g. `acme`. A full address is accepted and reduced to the same value. |
| `client_id` | Yes | Client ID from Trafft's API & Connectors page. |
| `client_secret` | Yes | Client Secret from the same page. Stored encrypted, never returned. |
| `company_name` | No | A label for your own list. |

```bash
curl -X POST "https://api.dmchamp.com/v1/appointments/trafft/connect?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "subdomain": "acme",
    "client_id": "YOUR_TRAFFT_CLIENT_ID",
    "client_secret": "YOUR_TRAFFT_CLIENT_SECRET",
    "company_name": "Acme Salon"
  }'
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "data": {
    "subdomain": "acme",
    "service_count": 12,
    "employee_count": 4,
    "location_count": 2
  }
}
```

The counts come back from Trafft during the check — they are the quickest way to confirm the credentials point at the account you meant.

**Get the connection status**

`GET /appointments/trafft`

```bash
curl "https://api.dmchamp.com/v1/appointments/trafft" \
  -H "X-API-Key: YOUR_API_KEY"
```

**Response** (`200 OK`):

```json
{
  "success": true,
  "data": {
    "connected": true,
    "subdomain": "acme",
    "hostname": "acme.admin.trafft.com",
    "client_id": "YOUR_TRAFFT_CLIENT_ID",
    "company_name": "Acme Salon",
    "is_active": true,
    "service_count": 12,
    "employee_count": 4,
    "location_count": 2
  }
}
```

`connected: false` means nothing is set up yet. The client secret is never included in this response.

**Update the connection**

`PUT /appointments/trafft`

| Field | Required | Description |
|---|---|---|
| `is_active` | No | Set `false` to pause — the AI stops booking into Trafft, the connection stays. `true` resumes it. |
| `company_name` | No | New label. |

```bash
curl -X PUT "https://api.dmchamp.com/v1/appointments/trafft" \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "is_active": false }'
```

**Response** (`200 OK`): the same connection object as the `GET` above.

**Disconnect Trafft**

`DELETE /appointments/trafft`

```bash
curl -X DELETE "https://api.dmchamp.com/v1/appointments/trafft" \
  -H "X-API-Key: YOUR_API_KEY"
```

**Response** (`200 OK`): `{ "success": true }`

Disconnecting removes the stored credentials only. Appointments already in Trafft are untouched.

> **Error shape on all Zenchef/Formitable/OpenTable/TheFork endpoints:** unlike the rest of this page, errors here carry their status twice — once as the HTTP status and once as `error_code` in the body — for example `{ "success": false, "error": "Restaurant not found", "error_code": 404 }`. Handle it the same way as any other error: check `success`, read `error` for the message.

---

## Appointments API errors

Appointment endpoints return the standard error envelope:

```json
{
  "success": false,
  "error": "Appointment not found"
}
```

| Status | When it happens on an appointment endpoint |
|---|---|
| `400` | A required field is missing or invalid — for example a bad `start_time`, an `end_time` not after `start_time`, an invalid filter combination, no fields to update, or an already-canceled appointment. |
| `404` | The appointment, contact, or event type was not found. |
| `409` | The requested time slot is already taken (booking conflict). |

The shared codes every endpoint can return — `401`, `403` (your plan does not include API access), `429` (rate limit) and `500` — are listed with retry guidance in [Errors & Pagination](errors-and-pagination.md).

---

::: master-only
## Use your own Google OAuth client (Calendar consent screen)

When an account connects Google Calendar, Google's sign-in window names the OAuth client's project — by default the platform's. An agency can register its own Google OAuth 2.0 client on the agency account; from then on the calendar connect for that account and every sub-account under it runs through that client, so the consent screen shows the agency's name and logo. Nothing else changes: the connect flow, the two-way sync and the appointment endpoints above work exactly as before.

> **Google Calendar only.** The Gmail mailbox OAuth for the Email channel is unaffected.

### What your client needs first

1. **An OAuth 2.0 client** of type Web application in your Google Cloud project, with the **Google Calendar API** enabled on that project.
2. **Every `redirect_uris` entry** (returned by the endpoints below) added under the client's Authorized redirect URIs. The first entry is your verified `api.` domain when you have one — Google only verifies a brand whose redirect lives on a domain you own — followed by the platform's neutral host as the fallback used until then.
3. **The consent screen** with your brand, your domain under Authorized domains, and the two Calendar scopes declared (`scopes` in the response). Until the app is published and verified by Google, users see an unverified-app warning and the client is capped at 100 users.

### Save your client

`PUT /account-config/google-oauth-client`

| Field | Required | Description |
|---|---|---|
| `client_id` | Yes | The OAuth 2.0 client ID, ending in `.apps.googleusercontent.com`. |
| `client_secret` | Yes | The client secret. Verified against Google before it is stored, then encrypted. Never returned by any endpoint. |

```bash
curl -X PUT "https://api.dmchamp.com/v1/account-config/google-oauth-client?apiKey=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "123456789012-abcdefghijklmnop.apps.googleusercontent.com",
    "client_secret": "GOCSPX-your-client-secret"
  }'
```

**Response**

```json
{
  "success": true,
  "configured": true,
  "client_id": "123456789012-abcdefghijklmnop.apps.googleusercontent.com",
  "redirect_uris": [
    "https://api.youragency.com/v1/auth-google-callback",
    "https://api.dmchamp.com/v1/auth-google-callback"
  ],
  "scopes": [
    "https://www.googleapis.com/auth/calendar.events",
    "https://www.googleapis.com/auth/calendar.readonly"
  ],
  "setup": ["…"]
}
```

A wrong secret or an unknown client ID is refused with `400` and Google's own reason in `error`, and nothing is stored.

### Read or remove it

`GET /account-config/google-oauth-client` returns the same summary at any time — `configured: false` plus the `redirect_uris` and `scopes` before anything is saved, so you can set up the Google side first. `DELETE /account-config/google-oauth-client` removes the client: new connects revert to the platform client, and calendars that were connected through the removed client must be reconnected, because only the client that issued a connection can refresh it.

Team members need **Integrations: view** for `GET` and **Integrations: edit** for `PUT` / `DELETE`.
:::

---

## Next steps

- [Contacts](contacts.md) — create and look up the contacts you book for.
- [Messages & Conversations](messages.md) — send a contact a confirmation or reminder.
- [Webhooks](webhooks.md) — get notified when appointments change.
