# Appointments endpoints

> Reference for reading, filtering and syncing appointments with updated_since, and for updating, rescheduling, cancelling and completing them through the Scheduling API.

Source: https://help.meetone.io/developers/scheduling-api/appointments-endpoints
Last updated: 2026-10-08

---

Appointments are what your integration ultimately cares about. This article covers reading them, keeping your own system in sync, and changing them after they are booked. How they are created is described in [Free slots and booking](https://help.meetone.io/developers/scheduling-api/availability-slots-and-booking).

| Endpoint | Purpose |
|---|---|
| `GET /api/v1/appointments` | List and filter appointments; the interface for syncing. |
| `GET /api/v1/appointments/{id}` | Show one appointment. |
| `POST /api/v1/appointments` | Book an appointment. See [Free slots and booking](https://help.meetone.io/developers/scheduling-api/availability-slots-and-booking). |
| `PATCH /api/v1/appointments/{id}` | Update the fields that do not touch the time. |
| `POST /api/v1/appointments/{id}/reschedule` | Move an appointment. |
| `POST /api/v1/appointments/{id}/cancel` | Cancel an appointment. |
| `POST /api/v1/appointments/{id}/complete` | Record how an appointment went. |

Read-only tokens can call the two `GET` endpoints only. Instance tokens may name the organization with `organization_id` or `organization_external_id`; on a single appointment it is optional and must match the appointment's organization. Organization tokens get `404` for appointments of other organizations.

## The appointment object

| Field | Type | Description |
|---|---|---|
| `id` | string (uuid) | MeetOne's ID of the appointment. |
| `external_id` | string, nullable | Your key for the appointment. Unique within the organization. See [Identifiers](https://help.meetone.io/developers/scheduling-api/identifiers). |
| `status` | string (enum) | `requested`, `unconfirmed`, `confirmed`, `completed` or `cancelled`. See [Statuses](https://help.meetone.io/developers/scheduling-api/appointments-endpoints#statuses). |
| `scheduled_at` | string (date-time), nullable | The start, in `Europe/Berlin`. `null` for a `requested` appointment. |
| `ends_at` | string (date-time), nullable | The end: `scheduled_at` plus `duration`. |
| `duration` | integer, nullable | Minutes. |
| `confirmed_at`, `cancelled_at`, `completed_at` | string (date-time), nullable | When the appointment reached that state. |
| `cancelled_reason` | string, nullable | The reason given when it was cancelled. |
| `completed_status` | string, nullable | `success` or `no_success` once completed. |
| `availability_type` | object, nullable | The type: `id`, `external_id`, `display_name`. |
| `appointment_resource` | object, nullable | The place: `id`, `external_id`, `name`, `address`. `null` is an online video appointment. |
| `doctor` | object, nullable | The practitioner: `id`, `external_id`, `first_name`, `last_name`. |
| `patient` | object, nullable | The patient account: `id`, `external_id`, `email`, `first_name`, `last_name`. Appointments name their patient by email, so this is `null` when no patient account of the organization has that email, even if an account with that email exists in another organization. `patient_email` is shown either way. |
| `patient_name`, `patient_email` | string, nullable | The name and email the appointment was booked for. |
| `video` | object, nullable | `host_join_url` and `guest_join_url` of the video call. `null` for in-person appointments. |
| `message_to_patient`, `message_to_doctor` | string, nullable | Messages attached to the appointment. |
| `source` | string (enum) | `api` for appointments booked through this API, `manual` for all others. |
| `created_at`, `updated_at` | string (date-time) | Timestamps. `updated_at` drives [syncing](https://help.meetone.io/developers/scheduling-api/appointments-endpoints#keep-your-system-in-sync). |

Medical history, private notes of the practitioner, and bank and tax data are never part of the object.

> **Warning**
>
> `video.host_join_url` lets whoever opens it join the call as the host. Treat it like a credential: keep it on your server and hand it only to the practitioner. The patient gets `guest_join_url`.

```json
{
  "data": {
    "id": "c6e0b3a9-72d4-4f1b-9a58-0d3f8e2c6b17",
    "external_id": "booking-8812",
    "status": "confirmed",
    "scheduled_at": "2026-10-12T09:00:00+02:00",
    "ends_at": "2026-10-12T09:30:00+02:00",
    "duration": 30,
    "confirmed_at": "2026-10-08T10:20:00+02:00",
    "cancelled_at": null,
    "cancelled_reason": null,
    "completed_at": null,
    "completed_status": null,
    "availability_type": {
      "id": "5d9a2f77-c3e1-4b08-8a64-7e1f0c92d3b6",
      "external_id": "first-visit",
      "display_name": "First visit"
    },
    "appointment_resource": null,
    "doctor": {
      "id": "b7d2e9c4-1a3f-4c58-8e66-2f9a0d7c5b13",
      "external_id": "staff-12",
      "first_name": "Maria",
      "last_name": "Muster"
    },
    "patient": {
      "id": "e41c8a0f-6b9d-4d27-a3f5-90c2b7d1e8a4",
      "external_id": "pat-1001",
      "email": "mary.major@example.com",
      "first_name": "Mary",
      "last_name": "Major"
    },
    "patient_name": "Mary Major",
    "patient_email": "mary.major@example.com",
    "video": {
      "host_join_url": "https://acme.meetone.io/r/7bbb22b73d4e4732",
      "guest_join_url": "https://acme.meetone.io/r/3d4e4722b7327bbb"
    },
    "message_to_patient": "See you on Monday.",
    "message_to_doctor": null,
    "source": "api",
    "created_at": "2026-10-08T10:20:00+02:00",
    "updated_at": "2026-10-08T10:20:00+02:00"
  }
}
```

### Statuses

| `status` | Meaning |
|---|---|
| `requested` | An appointment request without a time yet (`scheduled_at` is `null`). Appointments booked through this API are never `requested`. |
| `unconfirmed` | Has a time, but is not confirmed yet. |
| `confirmed` | Has a time and is confirmed. Everything you book through the API starts here. |
| `completed` | Has been completed. `completed_status` is `success` or `no_success`. |
| `cancelled` | Has been cancelled. `cancelled_at` and `cancelled_reason` are set. A cancelled appointment stays in the list. |

`cancelled` takes precedence over `completed`, and both over the other statuses.

## List appointments

`GET /api/v1/appointments`

| Query parameter | Type | Description |
|---|---|---|
| `from` | string (date-time) | Only appointments starting at or after this time. |
| `to` | string (date-time) | Only appointments starting before this time. |
| `status` | string (enum) | Only appointments in this [status](https://help.meetone.io/developers/scheduling-api/appointments-endpoints#statuses). An unknown status returns `422`. |
| `doctor_id`, `doctor_external_id` | string | Only this practitioner's appointments. |
| `patient_id`, `patient_external_id` | string | Only appointments booked for this patient account (matched by its email). |
| `patient_email` | string | Only appointments booked for this email address, case-insensitive. |
| `availability_type_id`, `availability_type_external_id` | string | Only appointments of this type. |
| `appointment_resource_id`, `appointment_resource_external_id` | string | Only appointments at this place. |
| `external_id` | string | Only the appointment with this external ID. |
| `organization_id`, `organization_external_id` | string | Instance tokens: only this organization's appointments. |
| `updated_since` | string (date-time) | Only appointments changed at or after this time. See [below](https://help.meetone.io/developers/scheduling-api/appointments-endpoints#keep-your-system-in-sync). |
| `page`, `per_page` | integer | Pagination. `per_page` defaults to 50, at most 100. With `updated_since`, `page` is an opaque cursor taken from the `next` link instead of a page number. |

Times without an offset are read in `Europe/Berlin`. Filters combine: all that you send must match.

Without `updated_since`, the list is ordered by start time, with appointments that have no time last. With `updated_since`, it is ordered by `updated_at`.

```bash
curl "https://acme.meetone.io/api/v1/appointments?from=2026-10-12T00:00:00%2B02:00&to=2026-10-13T00:00:00%2B02:00&status=confirmed&doctor_external_id=staff-12" \
  -H "Authorization: Bearer $TOKEN"
```

> **Note**
>
> In a query string, a `+` stands for a space. Encode the offset of a time as `%2B` (`...T09:00:00%2B02:00`), or use `Z` for UTC, or let your HTTP client build the query.

## Show an appointment

`GET /api/v1/appointments/{id}` returns the appointment in the object shown above, or `404` if it does not exist or belongs to another organization.

## Keep your system in sync

The list is the interface for keeping an external system in sync: **cancelled appointments stay in it**, so a cancellation is just another change you read, and `updated_since` returns only what changed.

- `updated_since=<timestamp>` returns the appointments whose `updated_at` is **at or after** that time, ordered by `updated_at`, oldest change first.
- **Such a sync pages by cursor, not by page number.** Follow the `next` URL of the `link` response header until there is none. Its `page` value is an opaque cursor: do not build it or count it up. A response to a sync has no `total-count` or `total-pages` header, so the missing `next` link is the only signal that you reached the end. A cursor that is malformed returns `422` with the code `invalid` and the attribute `page`.
- **An appointment that changes while you page comes again further on**, after the ones you have not read yet. Store appointments by `id` (upsert), so reading one twice does no harm.
- **Next time, send the `updated_at` of the last appointment you processed** as `updated_since`, or a minute earlier to be safe. What comes again is simply stored again.
- For the first run, send an `updated_since` far in the past, for example `2000-01-01T00:00:00+00:00`. That reads every appointment, in the same order as later runs, and the last `updated_at` becomes your first cursor.

```python
import requests
from datetime import datetime, timedelta

BASE = "https://acme.meetone.io/api/v1"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}

def sync(last_updated_at):
    """Store every appointment changed since the last run; return the new last updated_at."""
    # A minute earlier than the last one we stored, to be safe.
    since = (datetime.fromisoformat(last_updated_at) - timedelta(minutes=1)).isoformat()
    url, params = f"{BASE}/appointments", {"updated_since": since, "per_page": 100}
    while url:
        response = requests.get(url, headers=HEADERS, params=params)
        response.raise_for_status()
        for appointment in response.json()["data"]:
            upsert(appointment)                              # idempotent, keyed by appointment["id"]
            last_updated_at = appointment["updated_at"]      # oldest change first: the last one is the newest
        url = response.links.get("next", {}).get("url")      # the cursor is inside this URL
        params = None                                        # ...which already carries every parameter
    return last_updated_at
```

Run it on a schedule, for example every few minutes, and persist the returned value after each run. An Organization API Token is rate limited, so poll at a moderate rate; see [Rate limits](https://help.meetone.io/developers/getting-started/rate-limits). If you need to react faster, subscribe to [webhooks](https://help.meetone.io/developers/scheduling-api/appointments-endpoints#webhooks-and-notifications) and use the list as the safety net that catches anything a webhook missed.

The same `updated_since` filter, with the same cursor paging, works on every other list of the Scheduling API, for example users and availabilities.

## Update an appointment

`PATCH /api/v1/appointments/{id}`

Updates only the fields that do not touch the time. To move an appointment, use [reschedule](https://help.meetone.io/developers/scheduling-api/appointments-endpoints#reschedule-an-appointment).

| Field | Type | Description |
|---|---|---|
| `external_id` | string | Your key for the appointment. |
| `message_to_patient` | string | A message for the patient. |
| `message_to_doctor` | string | A message for the practitioner. |

```bash
curl -X PATCH "https://acme.meetone.io/api/v1/appointments/c6e0b3a9-72d4-4f1b-9a58-0d3f8e2c6b17" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "message_to_doctor": "Patient asks for a sick note." }'
```

The response is `200 OK` with the updated appointment. An `external_id` that is already in use returns `422` with the code `taken`.

## Reschedule an appointment

`POST /api/v1/appointments/{id}/reschedule`

Moves the appointment to a new time, and optionally to another practitioner.

| Field | Type | Required | Description |
|---|---|---|---|
| `starts_at` | string (date-time) | Yes | The new start. |
| `doctor_id` or `doctor_external_id` | string | No | Move it to another practitioner. Default: the current one. Required for an appointment that has no practitioner (`422`, code `blank`, attribute `doctor_id`). |
| `ignore_availability` | boolean | No | Skip the slot check, as when booking. Default `false`. |
| `notify` | boolean | No | Tell the patient and the practitioner by email. Default `true`. |

```bash
curl -X POST "https://acme.meetone.io/api/v1/appointments/c6e0b3a9-72d4-4f1b-9a58-0d3f8e2c6b17/reschedule" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "starts_at": "2026-10-14T10:30:00+02:00" }'
```

- The new time has to be a **free slot of the appointment's type**, with the appointment itself not counting as taken, so you can move it by a few minutes into its own slot. Otherwise the response is `409` with the code `slot_unavailable`. With `ignore_availability: true` the slot check is skipped, but the practitioner still cannot be double-booked.
- Only a `confirmed` or `unconfirmed` appointment can be moved. A cancelled or completed one, or one without a time, returns `409` with the code `not_reschedulable`.
- Rescheduling needs appointment booking to be enabled for the organization (`403`, `feature_disabled`).
- The slot is checked and the appointment moved under the same lock as when booking, so concurrent requests cannot take the same slot.
- An appointment that was booked with `notify: false` stays silent: it sends no emails when it is rescheduled either.

The response is `200 OK` with the rescheduled appointment.

## Cancel an appointment

`POST /api/v1/appointments/{id}/cancel`

| Field | Type | Required | Description |
|---|---|---|---|
| `reason` | string | Yes | Why it was cancelled. Stored as `cancelled_reason`. |
| `notify` | boolean | No | Tell the patient and the practitioner by email. Default `true`. |

```bash
curl -X POST "https://acme.meetone.io/api/v1/appointments/c6e0b3a9-72d4-4f1b-9a58-0d3f8e2c6b17/cancel" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Practitioner is ill.", "notify": true }'
```

The response is `200 OK` with the appointment, now with `status` `cancelled`. Cancelling closes its video call. The patient and the practitioner are told by email only when the appointment still lies ahead and `notify` is not `false`; an appointment that was booked with `notify: false` stays silent. A missing `reason` returns `422` with the code `blank`; an appointment that is already cancelled or completed returns `409` with the code `not_cancellable`.

The cancelled appointment stays in the list and in your sync.

## Complete an appointment

`POST /api/v1/appointments/{id}/complete`

Records how the appointment went and closes its video call.

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | string (enum) | Yes | `success` or `no_success`, for example when the patient did not show up. Stored as `completed_status`. |
| `reason` | string | Yes | A short explanation. It is stored with the appointment and not returned. |

```bash
curl -X POST "https://acme.meetone.io/api/v1/appointments/c6e0b3a9-72d4-4f1b-9a58-0d3f8e2c6b17/complete" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": "no_success", "reason": "Patient did not show up." }'
```

The response is `200 OK` with the appointment, now with `status` `completed`. An unknown `status` or a missing `reason` returns `422`. Only a scheduled appointment that is neither cancelled nor completed can be completed; anything else returns `409` with the code `not_completable`.

## Webhooks and notifications

Booking, rescheduling, cancelling and completing through the API do exactly what the same steps do in the app: they send the emails, and they send the webhooks `appointment.created`, `appointment.reschedule`, `appointment.cancel` and `appointment.complete` to the endpoints that subscribed to them. `notify: false` holds back the emails only, never the webhooks. See [Webhook events reference](https://help.meetone.io/developers/webhooks/webhook-events-reference).

Webhook payloads use their own, older representation, not the objects of this API. Their `id`, where they have one, is not the UUID `id` you see here. The UUID is the payload's `public_id`, so match a webhook to the appointment in this API by `public_id`. If you give every appointment you book an `external_id`, that is part of the payload too and matches the webhook to the appointment in your own system. To read the current state, fetch the appointment through the API.

## Errors at a glance

| Status | Code | Meaning |
|---|---|---|
| `403` | `read_only_token` | A read-only token called an endpoint that changes data. |
| `403` | `feature_disabled` | Rescheduling needs appointment booking to be enabled. |
| `404` | `not_found` | The appointment, or a record you referenced, does not exist or belongs to another organization. |
| `409` | `slot_unavailable` | The new time is not free. |
| `409` | `not_reschedulable`, `not_cancellable`, `not_completable` | The appointment is not in a state that allows this. |
| `422` | `blank`, `inclusion`, `taken`, `invalid` | A required field is missing, a value is not allowed or a time cannot be read. |

## Related

- [Free slots and booking](https://help.meetone.io/developers/scheduling-api/availability-slots-and-booking)
- [Identifiers](https://help.meetone.io/developers/scheduling-api/identifiers)
- [Webhook events reference](https://help.meetone.io/developers/webhooks/webhook-events-reference)
- [Errors & pagination](https://help.meetone.io/developers/getting-started/errors-and-pagination)

Questions? Contact us at [support@meetone.io](mailto:support@meetone.io).
