# Create a booking

`POST /api/v1/bookings`

Books seats on a departure, exactly as a manual booking made in Awrora does: the same capacity check (including seats held in someone else's checkout), the same customer record, the same timeline entry and the same confirmation email. The booking is recorded with source "api". Prices come from the experience's price tiers — the body carries counts, never amounts. Requires the "bookings:write" scope and an "Idempotency-Key" header.

Scope: `bookings:write`
Idempotency-Key: required

## Parameters

- `Idempotency-Key` (header, string, required) — A unique key for this request, 8-200 characters (a UUID is a good choice). Retrying with the same key replays the first response verbatim — same status, same body, plus "Idempotent-Replayed: true" — instead of acting twice. Reusing a key with a different body answers 422.

## Request body

- `experience_date_id` (string, required) — The departure to book, from GET /v1/availability. A departure that belongs to another organization answers 404, exactly like one that does not exist.
- `guests` (object[], required) — At least one guest line. Several lines may use different price tiers.
- `add_ons` (object[]) — Optional add-ons. Requires the "Add-ons" app to be enabled for the organization.
- `customer` (object, required) — Who the booking is for.
- `note` (string) — Free-text note stored on the booking and shown to staff.
- `payment` ("pay_on_site" | "invoice" | "external_paid", required) — How the booking is paid. "pay_on_site": confirmed, the guest pays the operator on arrival (the platform on-site fee is accrued, exactly as for a checkout booking). "invoice": confirmed and flagged for invoicing by staff. "external_paid": confirmed and already paid somewhere else — no card is charged and no invoice is created. There is no card option: this API never takes payment.
- `send_confirmation_email` (boolean) — Send the organization's booking confirmation email to the guest. Set false when your own system already told them.
- `notify_staff` (boolean) — Send the organization's internal "new booking" notification email to its staff, the same one a checkout booking triggers. Honors the organization's own notification settings: if they have switched that notification off, nothing is sent either way. Set false when your integration already tells staff itself.

## Responses

- `200` — The booking that was created — the same shape GET /v1/bookings/{id} returns. Returned when nothing new was created — the resource already existed, or this is a replay of an earlier request with the same Idempotency-Key.
- `201` — The booking that was created — the same shape GET /v1/bookings/{id} returns.
- `400` — Invalid request — codes "validation_failed" or, on an endpoint that requires one, "idempotency_key_required".
- `401` — Missing, invalid, revoked or expired API key — codes "api_key_missing", "api_key_invalid", "api_key_revoked", "api_key_expired".
- `403` — The API app is off, the plan does not include the API, or the key lacks the required scope — codes "app_not_enabled", "plan_upgrade_required", "insufficient_scope" (the last carries "required_scope").
- `404` — No such resource. The same response is returned for a resource that belongs to another organization — code "not_found".
- `409` — The request conflicts with the current state — codes "insufficient_capacity" (carries "seats_available"), "conflict", or "idempotency_in_progress" when another request with the same Idempotency-Key is still running.
- `422` — The request was understood but cannot be fulfilled as asked — codes "validation_failed" or "idempotency_key_reused" (the same Idempotency-Key was already used with a different body).
- `429` — Rate limit exceeded — code "rate_limited". See the x-ratelimit-* headers.
- `500` — Something went wrong on our side — code "internal_error".

## Response `200`

- `id` (string, required) — Awrora id (UUID) for the booking.
- `booking_number` (integer | null, required) — Sequential booking number within the organization, or null for legacy rows.
- `status` ("pending" | "confirmed" | "cancelled", required) — pending = created, awaiting payment; confirmed = active; cancelled = cancelled.
- `source` (string | null, required) — Where the booking came from: online, admin, agency, ai or api.
- `created_at` (string, required) — ISO 8601 timestamp with offset.
- `updated_at` (string, required) — ISO 8601 timestamp with offset.
- `experience` (object, required) — The experience that was booked.
- `departure` (object, required) — The departure that was booked.
- `customer` (object, required) — The guest details captured at checkout. Not the customer record — see /v1/customers.
- `guests` (object[], required) — Guest lines. May be empty for legacy rows.
- `add_ons` (object[], required) — Add-on lines. Empty when none were bought.
- `totals` (object, required) — Money totals for the booking.
- `payment` (object, required) — How the booking is paid, and where that payment stands.
- `note` (string | null, required) — Free-text note from the buyer or staff, or null.
- `manage_url` (string | null, required) — Link where the guest can open and finish their own booking. Only returned to keys with the "bookings:write" scope — read-only keys and webhook/event payloads always get null, because anyone holding the link can see the booking and complete it. Also null when no valid link exists (no token, or the token has expired). Treat it as a secret.

## Example

```bash
curl -X POST "https://your-site.awrora.app/api/v1/bookings" \
  -H "Authorization: Bearer $AWRORA_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "experience_date_id": "3f6c1f0e-8a2b-4f4e-9d7a-2b1c5e0a9f11",
  "guests": [
    {
      "tier_id": "tier_8f2k3n",
      "count": 1
    }
  ],
  "add_ons": [
    {
      "id": "3f6c1f0e-8a2b-4f4e-9d7a-2b1c5e0a9f11",
      "count": 1
    }
  ],
  "customer": {
    "name": "string",
    "email": "anna@example.com",
    "phone": "string"
  },
  "note": "string",
  "payment": "pay_on_site",
  "send_confirmation_email": true,
  "notify_staff": true
}'
```