# VistaLink: rates

Brief for a coding agent. Show the price each hotel charges on its own booking engine for the traveller’s dates.

## 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/rates

Rates for a stay.

```bash
curl -X POST 'https://api.vistalink.com/v2/rates' \
  -H "Authorization: Bearer $VISTALINK_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "hotels": [
    "43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
    {
      "website_url": "https://umilta36.com"
    }
  ],
  "stay": {
    "check_in": "2026-11-14",
    "check_out": "2026-11-16",
    "rooms": [
      {
        "adults": 2,
        "children_ages": []
      }
    ]
  },
  "channel": "direct",
  "freshness": "cached",
  "max_age_hours": 24,
  "currency": "EUR"
}'
```

```typescript
const res = await fetch("https://api.vistalink.com/v2/rates", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.VISTALINK_API_KEY}`,
    "Idempotency-Key": crypto.randomUUID(),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "hotels": [
      "43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
      {
        "website_url": "https://umilta36.com"
      }
    ],
    "stay": {
      "check_in": "2026-11-14",
      "check_out": "2026-11-16",
      "rooms": [
        {
          "adults": 2,
          "children_ages": []
        }
      ]
    },
    "channel": "direct",
    "freshness": "cached",
    "max_age_hours": 24,
    "currency": "EUR"
  }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
```

A live read answers `202` with a job: poll `GET /v2/jobs/{job_id}` (below) or subscribe to `rates.completed`.

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

## What comes back

- `results[]`, one per hotel: the `engine`, `observed_at`, and `rooms[].offers[]` with rate name, board, cancellation terms, nightly average and total.
- With `freshness: "live"` the call answers `202` with a job; poll `GET /v2/jobs/{job_id}` or subscribe to the `rates.completed` webhook.

## Rules that trip people up

- Cached is the default and answers straight away; `max_age_hours` sets how old a cached price may be.
- Live reads the hotel's engine while you wait on a job and is billed only when a price comes back.
- A hotel whose engine we can't read answers `unsupported` with a reason; the rest of the batch still prices.

## Price

- `rates.direct.cached`: $0.002 per hotel
- `rates.direct.live`: $0.040 per hotel

## 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.

## Jobs and webhooks

- Poll `GET /v2/jobs/{job_id}` every few seconds (start near `estimated_seconds`) until `status` is `succeeded`, `failed` or `canceled`; the result is in `result`.
- Or register a webhook once with `POST /v2/webhooks` (`url`, `events`) and keep the `secret` it returns.
- Verify each delivery: `X-VistaLink-Signature` is `t=<unix>,v1=<hex>`, where `v1` is HMAC-SHA256 of `"<t>.<raw body>"` with your secret. Compare in constant time and reject anything older than 5 minutes.

## Done when

- A cached read for a hotel and two dates shows offers with `observed_at`, or a clear "no price yet" for `unknown`.
- A live read shows a waiting state, then the offers when the job succeeds, and stops waiting on `failed`.
- Every offer you show keeps its `offer_id`, ready for booking within 30 minutes.

## 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: `get_rates_v2`, `get_job`. 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/rates
- OpenAPI: https://api.vistalink.com/v2/openapi.json
- Every brief: https://vistalink.com/developers/agents
