---
title: Create a booking
description: "Turns a free slot into a confirmed booking atomically: one SECURITY DEFINER transaction conflict-checks the assigned host (their organized bookings plus..."
api_method: POST
api_path: "/v1/bookings"
canonical_url: https://wiblo.app/docs/developers/api/bookings/create-booking
last_updated: 2026-07-28T18:08:25+02:00
md_url: https://wiblo.app/docs/developers/api/bookings/create-booking.md
---

# Create a booking

`POST /v1/bookings`

Turns a free slot into a confirmed booking atomically: one SECURITY DEFINER transaction conflict-checks the assigned host (their organized bookings plus co-hosted bookings, widened by the service's buffers, plus out-of-office), enforces booking/duration limits inside the transaction, and inserts the booking, its attendee, and the booking_hosts rows together. Round-robin services pre-order the host pool by the fairness math (weights, calibration, least-recently-booked) and retry the next candidate on conflict (up to 3 retries) — exhaustion is the documented 409 `BOOKING_CONFLICT`. `member_id` pins a specific assigned host (no retry); collective services book every host with the first-assigned as organizer. The idempotency key spans service, slot, and attendee email: the same attendee retrying the same slot returns 200 with `duplicate: true` and writes nothing, while a different attendee conflicts. Seated services (`seats_per_time_slot` set) JOIN an active booking on the same slot instead of conflicting — the first attendee creates the booking with its first seat, later attendees get seats until capacity answers 409 `BOOKING_SEATS_FULL`. Owners and admins only. Per-user rate-limited at 30/60s.

## Headers

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Wiblo-Workspace` | `string` | Yes | Workspace id the call is scoped to. The actor must hold an active membership in it; a foreign or unknown id collapses onto 404 WORKSPACE_NOT_FOUND. |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `service_id` | `string` | Yes | Id of the service being booked; retired services answer 404 `SERVICE_NOT_FOUND`. |
| `start_time` | `string` | Yes | Slot start as an RFC 3339 datetime; UTC and offset forms accepted. |
| `end_time` | `string` | Yes | Slot end as an RFC 3339 datetime; must be after `start_time`. |
| `title` | `string` | No | Booking title; stored as an empty string when omitted. |
| `description` | `string` | No | Free-text description stored on the booking (max 2000 characters). |
| `location` | `string` | No | Where the booking happens, as free text (max 500 characters). |
| `status` | `"accepted" \| "pending"` | No | Status the booking is written with — `accepted` (confirmed) or `pending` (awaiting host confirmation). Defaults to `"accepted"`. |
| `attendee` | `object` | Yes | The person the booking is for. |
| `attendee.name` | `string` | Yes | Attendee's display name (2–255 characters). |
| `attendee.email` | `string` | Yes | Attendee's email address. Also the attendee's idempotency identity — the same email retrying the same slot or seat dedupes. |
| `attendee.timezone` | `string` | Yes | Attendee's IANA time zone, e.g. `Europe/London`; unknown zones are rejected. |
| `attendee.phone_number` | `string` | No | Attendee's phone number (max 50 characters). |
| `member_id` | `string \| null` | No | Pins a specific assigned host — no round-robin retry; a member not assigned to the service answers 404 `HOST_NOT_FOUND`. `null` means the same as omitting it. |
| `override_hours` | `boolean` | No | Force-book a slot outside the resolved working hours (bypasses the 409 `OUTSIDE_WORKING_HOURS` check). Authed surface only. Defaults to `false`. |

## Request

**curl**

```bash
curl https://api.wiblo.app/v1/bookings \
  -X POST \
  -H "Authorization: Bearer $WIBLO_TOKEN" \
  -H "X-Wiblo-Workspace: $WIBLO_WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{
  "service_id": "3f8a2b9c-51d4-4e0b-9c6a-7d2e8f1a4b5c",
  "start_time": "2026-08-18T08:00:00.000Z",
  "end_time": "2026-08-18T08:30:00.000Z",
  "title": "Initial consultation with Priya Shah",
  "description": "Scoping call for the autumn rebrand project.",
  "location": "https://meet.example.com/acme",
  "attendee": {
    "name": "Priya Shah",
    "email": "priya@example.com",
    "timezone": "Europe/London",
    "phone_number": "+44 7700 900123"
  }
}'
```

**TypeScript**

```ts
import { createSdk, createBooking } from "@workspace/sdk"

const sdk = createSdk({ baseUrl: "https://api.wiblo.app" })

const { data, error } = await createBooking({
  client: sdk,
  headers: { "X-Wiblo-Workspace": "..." },
  body: {
    "service_id": "3f8a2b9c-51d4-4e0b-9c6a-7d2e8f1a4b5c",
    "start_time": "2026-08-18T08:00:00.000Z",
    "end_time": "2026-08-18T08:30:00.000Z",
    "title": "Initial consultation with Priya Shah",
    "description": "Scoping call for the autumn rebrand project.",
    "location": "https://meet.example.com/acme",
    "attendee": {
      "name": "Priya Shah",
      "email": "priya@example.com",
      "timezone": "Europe/London",
      "phone_number": "+44 7700 900123"
    }
  },
})
```

## Example response

```json
{
  "booking": {
    "booking_id": "a7c3e9f1-2b5d-4e8a-9c1f-6d3b8a5e2c7f",
    "booking_uid": "f2b8d4a6-9e1c-4c7b-8f3a-5d0e9b2c6a4e",
    "attendee_id": "c9e5a1d7-3f6b-4a2e-b8d4-1c7f5e3a9b6d",
    "member_id": "5f2d8c1b-7e4a-4b9d-a6c3-1d8e5f2a7b4c",
    "status": "accepted",
    "duplicate": false,
    "message": "Booking created"
  }
}
```

## Responses

**`200`** — Idempotent replay — the identical booking already existed (`duplicate: true`); nothing was written. Returns `CreateBookingResponse`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `booking` | `object` | Yes | The creation outcome. |
| `booking.booking_id` | `string` | Yes | Unique id of the booking. |
| `booking.booking_uid` | `string` | Yes | Public uid of the booking, used in booking route paths and signed links. |
| `booking.attendee_id` | `string \| null` | No | Id of the attendee row created with the booking; `null` on idempotent replays (`duplicate: true`). |
| `booking.member_id` | `string \| null` | Yes | The assigned organizer's workspace member id; `null` for hostless resource bookings. On a replay it reports the existing booking's organizer. |
| `booking.status` | `"accepted" \| "pending"` | Yes | `accepted` (confirmed) or `pending` (awaiting host confirmation). |
| `booking.duplicate` | `boolean` | Yes | `true` when the identical booking already existed (same service, slot, and attendee email) — nothing was written. |
| `booking.message` | `string` | Yes | Human-readable outcome message. |

**`201`** — The booking was created: its row, attendee, and booking_hosts landed in one transaction. Returns `CreateBookingResponse`.

**`400`** — Body failed Zod validation or the `X-Wiblo-Workspace` header was missing (`VALIDATION_FAILED`). Returns `ApiErrorEnvelope`.

**`401`** — No valid session cookie or `wbl_*` bearer was present. Returns `ApiErrorEnvelope`.

**`403`** — The actor is a plain member (`ADMIN_REQUIRED` — owners and admins only). Returns `ApiErrorEnvelope`.

**`404`** — The workspace or service collapsed (`WORKSPACE_NOT_FOUND` / `SERVICE_NOT_FOUND` — retired services included) or `member_id` is not an assigned host (`HOST_NOT_FOUND`). Returns `ApiErrorEnvelope`.

**`409`** — The slot conflicts (`BOOKING_CONFLICT` — host busy, out of office, or every round-robin candidate taken), the window falls outside the resolved working hours (`OUTSIDE_WORKING_HOURS` — retry with `override_hours: true` to force-book), a booking/duration limit is full (`BOOKING_LIMIT_REACHED`), or a seated slot is at capacity (`BOOKING_SEATS_FULL`). Returns `ApiErrorEnvelope`.

**`422`** — The service has no assigned hosts (`SERVICE_HAS_NO_HOSTS`). Returns `ApiErrorEnvelope`.

**`429`** — Rate limit exceeded. The body's `error.code` is `RATE_LIMITED` and `error.details.retry_after` is the same number of seconds as the `Retry-After` header. Returns `ApiErrorEnvelope`.
