# VistaLink: room matching

Brief for a coding agent. Match one hotel’s rooms across its own site, its booking engine, booking sites and your own room list.

## 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/rooms/reconcile

Reconcile room types across sources.

```bash
curl -X POST 'https://api.vistalink.com/v2/rooms/reconcile' \
  -H "Authorization: Bearer $VISTALINK_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "hotel_id": "43d0be71-5f3c-4c8e-9a52-1d7e2b6f0a19",
  "sources": [
    {
      "kind": "website_url",
      "url": "https://umilta36.com/rooms"
    },
    {
      "kind": "rooms",
      "label": "partner_pms",
      "rooms": [
        {
          "key": "SUP-K",
          "name": "Superior King",
          "size_m2": 28,
          "description": "City view",
          "max_occupancy": 2,
          "bed_config": [
            {
              "type": "king",
              "count": 1
            }
          ],
          "images": [
            "https://partner.example/sup-k.jpg"
          ]
        }
      ]
    }
  ],
  "min_confidence": 0.5
}'
```

```typescript
const res = await fetch("https://api.vistalink.com/v2/rooms/reconcile", {
  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",
    "sources": [
      {
        "kind": "website_url",
        "url": "https://umilta36.com/rooms"
      },
      {
        "kind": "rooms",
        "label": "partner_pms",
        "rooms": [
          {
            "key": "SUP-K",
            "name": "Superior King",
            "size_m2": 28,
            "description": "City view",
            "max_occupancy": 2,
            "bed_config": [
              {
                "type": "king",
                "count": 1
              }
            ],
            "images": [
              "https://partner.example/sup-k.jpg"
            ]
          }
        ]
      }
    ],
    "min_confidence": 0.5
  }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
const data = await res.json();
```

With any URL source the call answers `202` with a job: poll `GET /v2/jobs/{job_id}` (below) or subscribe to `rooms.reconcile.completed`.

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

## What comes back

- `rooms[]`: each physical room once, with every listing tied to it and a `confidence` on the match.
- `shared[]` for a listing that sells several rooms under one name, and `unmatched` for listings below `min_confidence`.

## Rules that trip people up

- If every source is a room list you send (`kind: rooms`) and the `hotel_id` is known, the call answers at once; any URL source turns it into a job.
- Set `min_confidence` to the lowest match you would show a traveller.

## Price

- `rooms.reconcile.supplied`: $0.08 per reconciliation
- `rooms.reconcile.urls`: $0.25 per reconciliation

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

- Sending the hotel's own room list returns each physical room once, with a `confidence` on every match.
- Listings below your `min_confidence` show up as unmatched, not silently dropped.

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