# VistaLink: bookings

Brief for a coding agent. Book an offer on the hotel’s own engine, with the guest paying the hotel.

## Before you write code

- The API key comes from the environment as `VISTALINK_API_KEY`. Never hard-code it, never send it to a browser: call VistaLink from server code only.
- The user creates the key at https://vistalink.com/developers/onboarding. Test keys hit the real catalogue and spend credit; they never create Stripe charges.
- Live rates, batch rates, room reconciliation and bookings need Build or above. On Free they answer `403` `forbidden` with `details.reason: "tier_required"` and `details.required_tier: "build"`.
- Base URL: https://api.vistalink.com. Every call sends `Authorization: Bearer $VISTALINK_API_KEY`.

## The call: POST /v2/bookings

Create a booking.

```bash
curl -X POST 'https://api.vistalink.com/v2/bookings' \
  -H "Authorization: Bearer $VISTALINK_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "hotel_id": "43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
  "offer_id": "off_t3c8e1a9b4f2d7e6a5c0b",
  "stay": {
    "check_in": "2026-11-14",
    "check_out": "2026-11-16",
    "rooms": [
      {
        "adults": 2,
        "children_ages": []
      }
    ]
  },
  "guests": [
    {
      "room": 0,
      "lead": {
        "first_name": "Ada",
        "last_name": "Rossi",
        "email": "ada@example.com",
        "phone": "+390600000000"
      },
      "occupants": []
    }
  ],
  "payment": {
    "mode": "handoff",
    "locale": "en"
  },
  "special_requests": "Late arrival"
}'
```

```typescript
const res = await fetch("https://api.vistalink.com/v2/bookings", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.VISTALINK_API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "hotel_id": "43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
    "offer_id": "off_t3c8e1a9b4f2d7e6a5c0b",
    "stay": {
      "check_in": "2026-11-14",
      "check_out": "2026-11-16",
      "rooms": [
        {
          "adults": 2,
          "children_ages": []
        }
      ]
    },
    "guests": [
      {
        "room": 0,
        "lead": {
          "first_name": "Ada",
          "last_name": "Rossi",
          "email": "ada@example.com",
          "phone": "+390600000000"
        },
        "occupants": []
      }
    ],
    "payment": {
      "mode": "handoff",
      "locale": "en"
    },
    "special_requests": "Late arrival"
  }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
```

The `offer_id` comes from `POST /v2/rates` (`results[].rooms[].offers[].offer_id`) and is valid for 30 minutes.

Follow the booking with `GET /v2/bookings/{booking_id}` or the `booking.updated` webhook. On `requires_user_action`, send the guest to `handoff.url`; on `requires_payment`, show `payment.checkout_url`.

Cancel with `POST /v2/bookings/{booking_id}/cancel` while it hasn't been submitted to the hotel.

It ends in `confirmed` (with the engine's code in `confirmation`), or `confirmation_pending`, `failed`, `expired` or `canceled`.

Make one new `Idempotency-Key` per operation, and reuse it only to retry that same operation.

## What comes back

- A booking that moves through `queued`, `processing`, `requires_user_action` or `requires_payment`, then `confirmed` with the engine's own confirmation code.
- Every status change as a `booking.updated` webhook.

## Rules that trip people up

- Send an `Idempotency-Key` header on every create, so a retry never books twice.
- The offer is checked again before booking; `revalidation.price_changed` says if the price moved.
- `payment.mode` decides how the guest pays: `handoff` (they finish on a hosted page), `checkout` (they pay the property through a link) or `external`. The payee is always the hotel.
- There is no booking sandbox yet: a booking submitted with any key, test or live, goes to the hotel. Try the flow on a hotel you control, or stop before submitting.

## Price

- `booking.confirmed`: Free

## When it fails

- Errors come back as `{ "error": { "code", "message", "request_id", "details" } }`. Log `request_id`; support needs it.
- Retry `rate_limited` (429) after `X-RateLimit-Reset`, and `upstream_error` (502) or `unavailable` (503) with backoff. Don't retry other 4xx: fix the request.
- `insufficient_credit` (402) means the account needs a top-up; `forbidden` (403) with `details.reason: "tier_required"` means the call needs a higher tier.

## Done when

- One booking reaches `confirmed` and the confirmation code is shown to the guest.
- Retrying the same create with the same `Idempotency-Key` returns the same booking, not a second one.
- `failed`, `expired` and `canceled` each show the guest what happened.

## If you are an agent runtime rather than writing code

Connect the MCP server instead: `claude mcp add --transport http vistalink https://api.vistalink.com/mcp --header "Authorization: Bearer $VISTALINK_API_KEY"` (the shell expands the key into the stored config). Tools for this task: `book_hotel`, `get_booking_v2`. The tools ending in `_v2` share a name with a v1 tool (`search_hotels`, `get_rates`, `get_booking`, `chat_about_hotels`), so they carry the suffix. It stays until v1 is retired, so agents built on v1 keep working.

## Full reference

- Docs: https://vistalink.com/developers/docs/bookings
- OpenAPI: https://api.vistalink.com/v2/openapi.json
- Every brief: https://vistalink.com/developers/agents
