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.
| 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. |
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. |
status |
string (enum) | requested, unconfirmed, confirmed, completed or cancelled. See 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. |
Medical history, private notes of the practitioner, and bank and tax data are never part of the object.
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.
{
"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. 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. |
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.
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"
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 whoseupdated_atis at or after that time, ordered byupdated_at, oldest change first.- Such a sync pages by cursor, not by page number. Follow the
nextURL of thelinkresponse header until there is none. Itspagevalue is an opaque cursor: do not build it or count it up. A response to a sync has nototal-countortotal-pagesheader, so the missingnextlink is the only signal that you reached the end. A cursor that is malformed returns422with the codeinvalidand the attributepage. - 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_atof the last appointment you processed asupdated_since, or a minute earlier to be safe. What comes again is simply stored again. - For the first run, send an
updated_sincefar in the past, for example2000-01-01T00:00:00+00:00. That reads every appointment, in the same order as later runs, and the lastupdated_atbecomes your first cursor.
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. If you need to react faster, subscribe to webhooks 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.
| 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. |
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. |
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
409with the codeslot_unavailable. Withignore_availability: truethe slot check is skipped, but the practitioner still cannot be double-booked. - Only a
confirmedorunconfirmedappointment can be moved. A cancelled or completed one, or one without a time, returns409with the codenot_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: falsestays 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. |
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. |
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.
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
Questions? Contact us at support@meetone.io.