# `/v1/bookings`

Read, create, cancel, reschedule, and patch bookings. This is the write-heavy
surface — every write needs an
[`Idempotency-Key`](/help/api/idempotency), and `PATCH` needs an
[`If-Match`](/help/api/optimistic-locking) ETag.

A booking's UID is its UUID — every endpoint that takes `:uid` rejects
non-UUIDs with `404 booking_not_found`.

---

## GET `/v1/bookings`

List bookings in the authenticated account.

| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/bookings` |
| **Scope** | `bookings:read` |
| **Auth** | Required |

### Query parameters

| Param | Type | Notes |
|---|---|---|
| `limit` | integer | Default `20`, max `100`. |
| `cursor` | string | Opaque cursor from a prior `meta.next_cursor`. |
| `sort` | string | `start_at_desc` (default), `start_at_asc`, `created_at_desc`, `updated_at_asc`, `updated_at_desc`. |
| `event_type_id` | UUID | Filter to one event type. |
| `host_user_id` | UUID | Filter to one host. |
| `attendee_email` | string | Exact-match filter (case-sensitive). |
| `status` | string | Comma-separated. Typical values: `confirmed`, `canceled`. |
| `start_date` | ISO 8601 | Lower bound on `start_at` (inclusive). |
| `end_date` | ISO 8601 | Upper bound on `start_at` (inclusive). |
| `updated_since` | ISO 8601 | Only bookings whose `updated_at` is at or after this time. Pair with `sort=updated_at_asc` for catch-up reconciliation — see below. |
| `include_cancelled` | boolean | When `status` isn't given, set `false` to exclude `canceled`. Defaults to including them. |

### Response

```json
{
  "data": [
    {
      "uid": "01H…",
      "version": 1,
      "event_type_id": "01HXXX…",
      "event_type_slug": "intro-call",
      "title": "Intro call",
      "status": "confirmed",
      "start_at": "2026-05-20T10:00:00.000Z",
      "end_at": "2026-05-20T10:30:00.000Z",
      "timezone": "Europe/London",
      "host": { "user_id": "01H…", "username": "ada", "email": "ada@example.com" },
      "attendees": [{ "email": "bob@example.com", "name": "Bob Builder", "timezone": "Europe/Berlin" }],
      "guests": [{ "email": "carol@example.com" }],
      "location": { "type": "google_meet", "url": "https://meet.google.com/…", "value": null },
      "metadata": { "crm_id": "C-7" },
      "responses": null,
      "calendar_sync_status": "synced",
      "calendar_event_id": "abc123…",
      "rescheduled_from_uid": null,
      "cancelled_at": null,
      "cancellation_reason": null,
      "no_show_at": null,
      "no_show_reason": null,
      "created_at": "2026-05-14T09:00:00.000Z",
      "updated_at": "2026-05-14T09:00:00.000Z"
    }
  ],
  "meta": { "request_id": "req_…", "next_cursor": null, "has_more": false }
}
```

Field notes:

- `responses` (the booking-form answers) is `null` on list responses — fetch
  the detail to read them.
- `calendar_sync_status` is `synced` / `pending` / `failed` /
  `not_applicable` (no calendar accounts attached).
- `rescheduled_from_uid` references the prior booking when this one is the
  result of a reschedule.

#### Catch-up reconciliation

If a [webhook](/help/api/webhooks) was paused (auto-paused after repeated
delivery failures, or paused by you) you'll have missed `booking.*` events.
To resync without guessing, sweep by modification time:

```
GET /v1/bookings?updated_since=<last-seen>&sort=updated_at_asc&limit=100
```

then follow `meta.next_cursor` to the end. `updated_at_asc` keeps the order
stable while you page. One caveat: an internal calendar-sync retry also bumps
`updated_at`, so a booking can resurface in this sweep with no user-facing
change — make your reconciliation idempotent and that's harmless.

### curl

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "https://api.42min.us/v1/bookings?status=confirmed&start_date=2026-05-01T00:00:00Z&limit=100"

# Catch-up after a webhook pause
curl -H "Authorization: Bearer $TOKEN" \
  "https://api.42min.us/v1/bookings?updated_since=2026-05-18T00:00:00Z&sort=updated_at_asc&limit=100"
```

---

## GET `/v1/bookings/:uid`

Return one booking with `responses` populated. Sets `ETag` for the version.

| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/bookings/{uid}` |
| **Scope** | `bookings:read` |
| **Auth** | Required |

### Headers (response)

```
ETag: "3"
```

### Response

Same shape as list items, with `responses` filled in:

```json
{
  "data": {
    "uid": "01H…",
    "version": 3,
    "responses": { "phone": "+44 123 4567 8901", "company": "Acme" },
    "…": "…"
  },
  "meta": { "request_id": "req_…" }
}
```

### curl

```bash
curl -i -H "Authorization: Bearer $TOKEN" \
  https://api.42min.us/v1/bookings/01H…
```

### Common errors

- `404 booking_not_found` — unknown UID, or UID is not a UUID (collapsed
  into the same 404 to avoid leaking format details).

---

## POST `/v1/bookings`

Create a booking.

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/bookings` |
| **Scope** | `bookings:create` |
| **Auth** | Required |
| **Idempotency-Key** | Required |

### Headers

```
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: <unique-per-operation>
```

### Body

Either `event_type_id` **or** (`username` + `event_slug`) is required.

```json
{
  "event_type_id": "01HXXX…",
  "username": "ada",
  "event_slug": "intro-call",

  "start": "2026-05-20T10:00:00Z",
  "timezone": "Europe/Berlin",

  "attendee": {
    "email": "bob@example.com",
    "name": "Bob Builder",
    "first_name": "Bob",
    "last_name": "Builder",
    "phone": "+49 30 12345678",
    "timezone": "Europe/Berlin",
    "sms_opt_in": false
  },

  "guests": [{ "email": "carol@example.com" }],
  "responses": { "phone": "+49 30 12345678" },
  "metadata": { "crm_id": "C-7" },
  "utm_source": "web",
  "utm_medium": "cta",
  "utm_campaign": "spring-launch"
}
```

Field reference:

| Field | Type | Notes |
|---|---|---|
| `event_type_id` | UUID | One of {`event_type_id`} or {`username`+`event_slug`} required. |
| `username` / `event_slug` | string | Alternative to `event_type_id`. |
| `start` | ISO 8601 | Required. Booking length is `event_type.duration_minutes`. |
| `timezone` | IANA tz | Optional. Falls back to `attendee.timezone`, then `UTC`. |
| `attendee.email` | string | Required. Max 254 chars, RFC-shaped. |
| `attendee.name` | string | If omitted, built from `first_name` + `last_name`, else falls back to email. |
| `attendee.first_name` / `last_name` | string | Max 127 each. |
| `attendee.phone` | string | Max 64. |
| `attendee.sms_opt_in` | boolean | Defaults to `false`. |
| `guests[]` | array | Strings (emails) or `{email, name?}` objects. |
| `responses` | object | Booking-form answers, keyed by question id. |
| `metadata` | object | Free-form JSON. Stored alongside the booking; UTM params are merged in. |
| `utm_source` / `utm_medium` / `utm_campaign` | string | Max 255 each. Merged into `metadata`. |

### Response

`201 Created` with the full booking detail (same shape as `GET /v1/bookings/:uid`).
Initial `version` is `1`.

### curl

```bash
curl -X POST https://api.42min.us/v1/bookings \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "event_type_id": "01HXXX…",
    "start": "2026-05-20T10:00:00Z",
    "timezone": "Europe/Berlin",
    "attendee": { "email": "bob@example.com", "name": "Bob Builder" }
  }'
```

### Common errors

- `400 validation_error` — required field missing or malformed.
- `400 attendee_email_invalid` — bad email format / length > 254.
- `400 missing_idempotency_key` — header missing.
- `404 event_type_not_found` — bad id/slug.
- `409 event_type_inactive` — event type has `status != on`.
- `409 slot_in_past` — `start` is in the past.
- `409 slot_unavailable` — slot is taken or otherwise blocked.
- `409 idempotency_key_conflict` — same `Idempotency-Key` was used with a
  different body in the last 24h.
- `503 slot_lock_timeout` — couldn't acquire the per-slot lock. `Retry-After: 1`.

---

## POST `/v1/bookings/:uid/cancel`

Cancel a confirmed booking. Idempotent — calling it on an already-cancelled
booking returns the current state, not an error.

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/bookings/{uid}/cancel` |
| **Scope** | `bookings:cancel` |
| **Auth** | Required |
| **Idempotency-Key** | Required |

### Body

```json
{ "reason": "Schedule conflict" }
```

| Field | Type | Notes |
|---|---|---|
| `reason` | string | Optional. Max 1024 chars. Surfaced in calendar removals and notifications. |

### Response

`200 OK` with the updated booking. Version is bumped.

### curl

```bash
curl -X POST https://api.42min.us/v1/bookings/01H…/cancel \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"reason":"Schedule conflict"}'
```

### Common errors

- `404 booking_not_found` — unknown UID.
- `409 booking_in_past` — booking already started or finished.

---

## POST `/v1/bookings/:uid/reschedule`

Move a confirmed booking to a new start time. The duration is preserved.

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/bookings/{uid}/reschedule` |
| **Scope** | `bookings:reschedule` |
| **Auth** | Required |
| **Idempotency-Key** | Required |

### Body

```json
{
  "start": "2026-05-22T15:00:00Z",
  "timezone": "Europe/Berlin",
  "reason": "Invitee requested a later time"
}
```

| Field | Type | Notes |
|---|---|---|
| `start` | ISO 8601 | Required. Booking ends at `start + duration_minutes`. |
| `timezone` | IANA tz | Optional. Falls back to the booking's current timezone. |
| `reason` | string | Optional. Max 1024. |

### Response

`200 OK` with the updated booking. Version is bumped.

### curl

```bash
curl -X POST https://api.42min.us/v1/bookings/01H…/reschedule \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"start":"2026-05-22T15:00:00Z","timezone":"Europe/Berlin"}'
```

### Common errors

- `400 validation_error` — `start` missing or unparseable.
- `404 booking_not_found`.
- `409 booking_already_cancelled` — booking isn't `confirmed`.
- `409 booking_in_past` — original start is in the past.
- `409 slot_unavailable` — new slot is taken.
- `422 event_type_disallows_reschedule` — the event type opts out of
  rescheduling.
- `503 slot_lock_timeout` — could not acquire the new-slot lock.

---

## PATCH `/v1/bookings/:uid`

Patch a booking's mutable fields. Requires
[optimistic locking](/help/api/optimistic-locking).

| | |
|---|---|
| **Method** | `PATCH` |
| **URL** | `https://api.42min.us/v1/bookings/{uid}` |
| **Scope** | `bookings:update` |
| **Auth** | Required |
| **Idempotency-Key** | Required |
| **If-Match** | Required (`ETag` from the most recent GET) |

### Body

```json
{
  "metadata": { "stage": "qualified", "owner": "ada" },
  "responses": { "phone": "+49 …" },
  "attendee_name": "Bob D. Builder"
}
```

| Field | Type | Notes |
|---|---|---|
| `metadata` | object | Shallow-merged into the existing metadata. Pass `null` for a key to clear it on your side and re-send. |
| `responses` | object | Replaces the booking-form answers wholesale. |
| `attendee_name` | string | Max 255 chars; stored as the primary attendee's name. |

Any other field returns **`422 field_immutable`** with `details.fields` naming
the rejected keys. To change `start` use **reschedule**; to end the booking
use **cancel**.

### Response

`200 OK` with the updated booking detail. Response headers include
`ETag: "<new-version>"`.

### Fires a webhook

A successful PATCH emits the **`booking.updated`**
[webhook](/help/api/webhooks#event-names) (subscribe with `booking_updated`).
The payload is the full booking plus a **`changed_fields`** array naming
exactly which of `metadata` / `responses` / `attendee_name` this call
changed. This is the only event that covers these silent edits — no
`booking.rescheduled` or `booking.canceled` fires for a PATCH.

### curl

```bash
curl -X PATCH https://api.42min.us/v1/bookings/01H… \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "If-Match: \"3\"" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"metadata":{"stage":"qualified"},"attendee_name":"Bob D. Builder"}'
```

### Common errors

- `400 validation_error` — `metadata`/`responses` must be objects (not
  arrays/scalars); `attendee_name` must be ≤ 255 chars.
- `404 booking_not_found`.
- `409 version_conflict` — `If-Match` doesn't match the current `version`.
  Re-GET and retry.
- `422 field_immutable` — body included a field that can't be patched.
- `428 missing_if_match` — header missing.
