---
title: Create a booking (no auth)
description: The attendee-facing booking creation.
api_method: POST
api_path: "/v1/public/bookings"
canonical_url: https://wiblo.app/docs/developers/api/public/create-public-booking
last_updated: 2026-07-28T18:08:25+02:00
md_url: https://wiblo.app/docs/developers/api/public/create-public-booking.md
---

# Create a booking (no auth)

`POST /v1/public/bookings`

The attendee-facing booking creation. Same atomic guarantees as the authed create (conflicts with buffers, out-of-office, limits inside the transaction, attendee-aware idempotency, round-robin fairness with retry, seated joins, resource assignment) via a delegating service_role-only RPC; the booking always lands `pending` (the host confirms) and carries the service's title/description. Answers 201 on create and 200 with `duplicate: true` on an idempotent replay by the same attendee — both carry HMAC-signed cancel/reschedule link tokens expiring at the booking start. No credentials of any kind; aggressively rate-limited per IP at 10/60s and wrapped by Vercel BotID.

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `service_id` | `string` | Yes | Id of the service to book. Hidden, retired, and unknown services all answer the same `SERVICE_NOT_FOUND` 404. |
| `start_time` | `string` | Yes | Start of the requested slot (ISO 8601 UTC datetime). |
| `end_time` | `string` | Yes | End of the requested slot (ISO 8601 UTC datetime); must be after `start_time`. |
| `location` | `string` | No | Free-text location for the appointment, carried onto the booking. |
| `attendee` | `object` | Yes | The booker's contact details (name, email, IANA time zone, optional phone number). |
| `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` | No | Pre-selects one assigned host by workspace member id; a non-host answers `HOST_NOT_FOUND` 404. Omitted lets the scheduling orchestration (round-robin fairness) choose. |

## Request

**curl**

```bash
curl https://api.wiblo.app/v1/public/bookings \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
  "service_id": "3f8a2b9c-51d4-4e0b-9c6a-7d2e8f1a4b5c",
  "start_time": "2026-08-17T10:00:00.000Z",
  "end_time": "2026-08-17T10:30:00.000Z",
  "attendee": {
    "name": "Priya Shah",
    "email": "priya@example.com",
    "timezone": "Europe/London",
    "phone_number": "+44 7700 900123"
  }
}'
```

**TypeScript**

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

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

const { data, error } = await createPublicBooking({
  client: sdk,
  body: {
    "service_id": "3f8a2b9c-51d4-4e0b-9c6a-7d2e8f1a4b5c",
    "start_time": "2026-08-17T10:00:00.000Z",
    "end_time": "2026-08-17T10:30:00.000Z",
    "attendee": {
      "name": "Priya Shah",
      "email": "priya@example.com",
      "timezone": "Europe/London",
      "phone_number": "+44 7700 900123"
    }
  },
})
```

## Example response

```json
{
  "booking": {
    "booking_id": "e7c4a9d2-8b1f-4e6a-9d3c-5a8f2b7e1c4d",
    "booking_uid": "a1f8c3e6-2d7b-4c9a-8e5f-1b4d7a2c9e6f",
    "attendee_id": "d2b7e4a1-9c6f-4b3d-8a2e-7f5c1d8b3a6e",
    "member_id": "5f2d8c1b-7e4a-4b9d-a6c3-1d8e5f2a7b4c",
    "status": "pending",
    "duplicate": false,
    "message": "Booking created"
  },
  "links": {
    "cancel_token": "v1.cancel.1786960800.3fQ8yWvKpLm2XcJ9RtBz6HdN4kGaU7oEs1MiZP5vYqA",
    "reschedule_token": "v1.reschedule.1786960800.9kTb2XqRfW7pJcV4NmZa8LdH3yGeS6uK1oQiPB5wMnE",
    "expires_at": "2026-08-17T10:00:00.000Z"
  }
}
```

## Responses

**`200`** — Idempotent replay: the same attendee already holds this slot (`duplicate: true`); links are re-minted for the existing booking. Returns `PublicCreateBookingResponse`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `booking` | `object` | Yes | The created booking — public bookings always land `pending` (a host confirms). On an idempotent replay by the same attendee (200, not 201) it is the existing booking with `duplicate: true`. |
| `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. |
| `links` | `object` | Yes | Signed cancel/reschedule link tokens for the booking; a replay re-mints them for the existing booking. |
| `links.cancel_token` | `string` | Yes | HMAC-signed single-action token (`v1.cancel.<exp>.<sig>`) authorizing cancellation of this one booking; opaque to clients. |
| `links.reschedule_token` | `string` | Yes | HMAC-signed single-action token (`v1.reschedule.<exp>.<sig>`) authorizing rescheduling of this one booking; opaque to clients. |
| `links.expires_at` | `string` | Yes | When both tokens expire (ISO 8601 UTC datetime); never later than the booking's start time. |

**`201`** — The created booking plus its signed link tokens. Returns `PublicCreateBookingResponse`.

**`400`** — Body failed Zod validation (`VALIDATION_FAILED`) or the RPC rejected the times (`BAD_REQUEST`). Returns `ApiErrorEnvelope`.

**`404`** — The service collapsed (`SERVICE_NOT_FOUND` — hidden, retired, and unknown ids look identical) or the pre-selected `member_id` is not an assigned host (`HOST_NOT_FOUND`). Returns `ApiErrorEnvelope`.

**`409`** — The slot conflicts on every candidate host (`BOOKING_CONFLICT`), the window falls outside working hours (`OUTSIDE_WORKING_HOURS` — the public surface can never override), the period's booking limit is reached (`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`.
