# Pricing Resolver Service — Integration Guide for Voyager

> **Audience:** Alexander, integrating PRS into the Voyager journey-planner app.
> **Goal:** From zero to a working price-resolution flow in your code in under 30 minutes.

---

## TL;DR

You have a REST API at `https://pricingresolver.symbiofoundry.com`. Your Voyager itinerary builder calls it for every hotel line in a proposal. PRS picks one of two flows automatically based on group size, and returns the right shape for the agent to act on.

```
                                   ┌────────────────────────┐
Voyager request ──► POST /v1/resolutions ─►  pax >= 20  ──┤  Group flow:           │ ──► reconciliation +
                                            (group)        │  NOVA × Hotel × OTA   │     recommendation +
                                                           │  3-way triangulation  │     evidence
                                                           └────────────────────────┘
                                            pax <  20  ──┤  FIT flow:             │ ──► fit_prices.cheapest
                                            (FIT)          │  Priority cascade:    │     from first available
                                                           │  TBO → Hotelbeds      │     bedbank
                                                           └────────────────────────┘
```

Auth: `X-API-Key` header on every call. Idempotent. HMAC-signed webhooks. Async by default; sync mode available for chat-style UX.

**Top-level response field `flow_kind` tells you which path ran**: `"group"` or `"fit"`. The fields you read change accordingly (see §6 and §6a).

**Quick smoke test (group flow):**

```bash
curl -s -X POST 'https://pricingresolver.symbiofoundry.com/v1/resolutions?wait=true&wait_seconds=60' \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: <your_api_key>' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "tour_id": "voyager_test_001",
    "source": "itinerary_builder",
    "hotel": { "supplier_id": "SUP_LON_001" },
    "stay": { "check_in": "2026-06-15", "check_out": "2026-06-19" },
    "rooms": [{ "room_type": "DBL", "qty": 10, "meal_plan": "BB" }],
    "pax": { "adults": 20 }
  }' | jq '.flow_kind, .reconciliation.recommendation'
```

Expected: `"group"` then `"USE_FRESH_QUOTE"` with full deltas.

**Quick smoke test (FIT flow):**

```bash
curl -s -X POST 'https://pricingresolver.symbiofoundry.com/v1/resolutions?wait=true&wait_seconds=30' \
  -H 'Content-Type: application/json' \
  -H 'X-API-Key: <your_api_key>' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "tour_id": "voyager_fit_001",
    "source": "itinerary_builder",
    "hotel": { "supplier_id": "SUP_LON_002", "client_specified": false },
    "stay": { "check_in": "2026-06-15", "check_out": "2026-06-19" },
    "rooms": [{ "room_type": "DBL", "qty": 1, "meal_plan": "RO" }],
    "pax": { "adults": 2 }
  }' | jq '.flow_kind, .fit_prices.cheapest'
```

Expected: `"fit"` then a `cheapest.bedbank` of `"tbo"` (or `"hotelbeds"` if TBO returned no availability) with `rate_per_room_per_night` + `room_breakdown` in that bedbank's native currency.

---

## Table of contents

1. [Where PRS fits in Voyager](#1-where-prs-fits-in-voyager)
2. [Authentication](#2-authentication)
3. [The TypeScript client (drop-in)](#3-the-typescript-client-drop-in)
4. [Sync vs async — pick the right pattern](#4-sync-vs-async--pick-the-right-pattern)
5. [Webhook integration (production pattern)](#5-webhook-integration-production-pattern)
6. [Handling each recommendation (group flow)](#6-handling-each-recommendation)
6a. [FIT flow — bedbank pricing for small groups](#6a-fit-flow--bedbank-pricing-for-small-groups)
7. [Any hotel anywhere (cold-hotel pattern)](#7-any-hotel-anywhere-cold-hotel-pattern) — including `address` / `postal_code` / `geo`
8. [Error handling](#8-error-handling)
9. [Idempotency](#9-idempotency)
10. [Audit timeline & state machine](#10-audit-timeline--state-machine)
11. [Rate limits](#11-rate-limits)
12. [Pre-warming the cache](#12-pre-warming-the-cache)
13. [Date validation](#13-date-validation)
14. [Webhook receiver templates](#14-webhook-receiver-templates)
15. [Production checklist](#15-production-checklist)
16. [Common scenarios with code](#16-common-scenarios-with-code)
17. [Troubleshooting](#17-troubleshooting)

---

## 1. Where PRS fits in Voyager

```
┌──────────────────┐       ┌────────────────────────────────────┐
│  Voyager         │       │  Pricing Resolver Service          │
│  ┌────────────┐  │       │  ┌──────────────────────────────┐  │
│  │ Itinerary  │  │ POST  │  │ Identity → NOVA rate         │  │
│  │ Builder    │──┼──────►│  │     │                        │  │
│  └────────────┘  │       │  │     ├─ Direct outreach (eml) │  │
│                  │       │  │     │                        │  │
│  ┌────────────┐  │ POST  │  │     ├─ Public OTA scrape     │  │
│  │ Inquiry    │──┼──────►│  │     │   (SerpAPI live)       │  │
│  │ Intake Form│  │       │  │     ▼                        │  │
│  └────────────┘  │       │  │ 3-way Reconciliation Engine  │  │
│                  │       │  │     ▼                        │  │
│  ┌────────────┐  │ webhook│  │ Recommendation +             │  │
│  │ Quotation  │◄─┼───────│  │ Evidence + Sources           │  │
│  │ Agent      │  │       │  └──────────────────────────────┘  │
│  └────────────┘  │       │                                    │
└──────────────────┘       └────────────────────────────────────┘
```

You call PRS once per **(hotel, stay window, room mix)** tuple. PRS returns one of 8 recommendations and the evidence. Your UI shows it; your inquiry agent picks; you record their choice and proceed to the quotation agent.

**Key insight:** PRS doesn't decide for you. It produces the *evidence* and a *recommendation*. The inquiry agent always has the final say.

---

## 2. Authentication

Every `/v1/*` request needs an `X-API-Key` header. The `/healthz`, `/readyz`, `/openapi.json`, `/docs`, `/redoc`, `/v1/stats`, and `/` routes are public.

```http
X-API-Key: <your_api_key>
```

**Get your key from Nikola** (sent over a secure channel, not in this doc). Treat it like a production secret: keep it server-side, never bundle into browser assets, rotate quarterly. The same key works for the demo and the live integration; we'll issue you a separate production-tier key when Voyager goes live.

**If your auth fails** you'll get:

```http
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "type": "about:blank",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Missing or invalid X-API-Key header. See /docs for authentication details."
}
```

---

## 3. The TypeScript client (drop-in)

Copy `examples/voyager-fetch.ts` from the repo into your Voyager codebase. Pure `fetch` + `node:crypto`, no extra deps.

```typescript
// in your Voyager backend
import { createPrsClient, verifyPrsWebhook } from './lib/prs/voyager-fetch';

export const prs = createPrsClient({
  baseUrl: process.env.PRS_BASE_URL!,        // e.g. https://pricingresolver.symbiofoundry.com
  apiKey:  process.env.PRS_API_KEY!,         // server-side env, NEVER bundled
});
```

The client surfaces 5 methods:

| Method | Use for |
|---|---|
| `prs.resolveAsync(req)` | Production async — returns 202 acceptance, terminal event delivered to your webhook |
| `prs.resolveSync(req, waitSeconds?)` | Chat-UX where the agent waits — blocks 2–15s, returns full ResolutionDetail |
| `prs.getResolution(id)` | Poll an in-flight resolution |
| `prs.getEvents(id)` | Audit trail for an id |
| `prs.cancel(id, reason?)` | Abort an in-flight resolution |
| `prs.hotelLookup(input)` | Pre-flight identity lookup without starting a workflow |

All methods auto-generate idempotency keys, throw `PrsApiError` on 4xx/5xx, and have a 120s timeout you can override.

---

## 4. Sync vs async — pick the right pattern

| | Sync `?wait=true` | Async + webhook |
|---|---|---|
| **When** | Chat UX where agent is staring at a spinner | Production batch — itinerary builder generates 8 hotel lines, fans out, displays as they arrive |
| **HTTP** | `POST /v1/resolutions?wait=true&wait_seconds=60` | `POST /v1/resolutions` (no query params) |
| **Returns** | Full `ResolutionDetail` JSON immediately | `{resolution_id, status: "ACCEPTED"}` 202 |
| **Latency** | 2–15s (live SerpAPI takes time on cache miss) | <300ms enqueue + webhook arrives later |
| **Failure mode** | If wait_seconds expires before completion → returns current state (still resolving) | Webhook retries on non-2xx |

**Sync example — agent is waiting:**

```typescript
const result = await prs.resolveSync({
  tour_id: 'tour_123',
  source: 'itinerary_builder',
  hotel: { supplier_id: 'SUP_LON_001' },
  stay: { check_in: '2026-06-15', check_out: '2026-06-19' },
  rooms: [{ room_type: 'DBL', qty: 3, meal_plan: 'BB' }],
  pax: { adults: 6 },
}, 60);

console.log(result.reconciliation?.recommendation);   // 'USE_FRESH_QUOTE'
console.log(result.public_prices?.min_comparable);    // 545
console.log(result.public_prices?.per_ota.booking);   // { rate, currency, source_url, ... }
```

**Async example — production proposal generation:**

```typescript
// 1. Fan out 10 resolutions in parallel, return immediately
const acceptances = await Promise.all(
  itinerary.hotels.map(hotel => prs.resolveAsync({
    tour_id: tour.id,
    source: 'itinerary_builder',
    hotel: { supplier_id: hotel.supplier_id },
    stay: { check_in: hotel.check_in, check_out: hotel.check_out },
    rooms: hotel.rooms,
    pax: tour.pax,
    webhook_url: `https://your-voyager.com/api/prs-events`,
  })),
);

// 2. Each resolution_id is now in-flight. Store them, redirect agent to the
//    proposal-builder UI which subscribes (websocket / SSE) to your DB updates.
await db.tour_resolutions.bulkCreate(
  acceptances.map(a => ({ tour_id: tour.id, resolution_id: a.resolution_id, status: 'IN_PROGRESS' })),
);

// 3. When PRS finishes a resolution (~2–15s later), webhook fires; see §5.
```

---

## 5. Webhook integration (production pattern)

You provide a `webhook_url` in the request body. PRS POSTs **HMAC-signed** events to that URL on terminal transitions.

### Event envelope

```json
{
  "event_id": "evt_…",
  "event_type": "prs.resolution.reconciled",
  "event_version": "1.0",
  "occurred_at": "2026-04-30T14:32:11.432Z",
  "resolution_id": "res_…",
  "tour_id": "tour_…",
  "data": { /* full reconciliation + nova_rate + fresh_quote + public_prices */ }
}
```

### Headers

```http
POST /api/prs-events
Content-Type: application/json
X-PRS-Signature: sha256=<hex_hmac_of_raw_body>
X-PRS-Event-Type: prs.resolution.reconciled
X-PRS-Event-Id: evt_…
```

### Event types

| Event | When | Notes |
|---|---|---|
| `prs.resolution.reconciled` | Terminal success — every resolution that completes fires this | Always fires last |
| `prs.resolution.undercut_detected` | Public OTA materially below NOVA — fires *in addition to* `reconciled` | Voyager UI should show red flag with `data.evidence` |
| `prs.resolution.failed` | Terminal failure (no contact, ambiguous identity, etc.) | Inquiry agent gets fallback suggestion |

### Express handler (Node)

**Critical:** capture the **raw body** for HMAC verification. Don't re-stringify after `JSON.parse`.

```typescript
import express from 'express';
import { verifyPrsWebhook, type PrsWebhookEvent } from './lib/prs/voyager-fetch';

const app = express();

// Use express.raw, NOT express.json, on this route — we need the exact bytes
app.post('/api/prs-events',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const raw = (req.body as Buffer).toString('utf8');
    const sig = req.headers['x-prs-signature'] as string | undefined;

    if (!verifyPrsWebhook(raw, sig, process.env.PRS_WEBHOOK_SECRET!)) {
      return res.status(401).json({ error: 'invalid signature' });
    }

    const event = JSON.parse(raw) as PrsWebhookEvent;

    // Idempotency: PRS retries on non-2xx. Dedupe on event_id.
    if (await alreadyHandled(event.event_id)) {
      return res.status(200).json({ ok: true, deduped: true });
    }

    switch (event.event_type) {
      case 'prs.resolution.reconciled': {
        await handleReconciled(event);
        break;
      }
      case 'prs.resolution.undercut_detected': {
        await raiseRedFlag(event);   // your UI work
        break;
      }
      case 'prs.resolution.failed': {
        await markFailed(event);
        break;
      }
    }
    return res.status(200).json({ ok: true });
  });
```

### What to put in `PRS_WEBHOOK_SECRET`

Whatever Nikola has set on the PRS server's `WEBHOOK_HMAC_SECRET` env var. For demo: `demo_secret_rotate_before_prod`. For production: ask Nikola for the rotated value.

### Webhook retry behavior

PRS treats anything that's not 2xx as a delivery failure. Today it's fire-and-forget (best effort) but production will add retries with exponential backoff. **You should be idempotent** — see §9.

---

## 6. Handling each recommendation

### How NOVA pricing is fetched (group flow only)

As of 2026-05-14, the group-flow NOVA path is **always-fresh / no-curation**:

- PRS calls NOVA's `/accommodations/search` on **every** group resolution. There is no PRS-side supplier-table gate and no PRS-side cache of NOVA responses.
- PRS hands NOVA the identifiers Voyager passed: `hotel_name` + `city_code` + `country_code`, plus any of `address` / `postal_code` / `geo.{latitude,longitude}`. **NOVA does its own hotel discovery** — PRS no longer maintains a curated `supplier_id` → NOVA mapping for pricing.
- NOVA returns up to 20 candidates per query. PRS applies a local picker (postcode-then-IDF-weighted-name; see below) to pick one, then applies a **Contracted → Guideline → Estimated** cascade to choose the best priced supplier inside that hotel's response.
- The tier that won is surfaced on `nova_rate.source`. `nova_rate.staleness_days` is always `0` (always-fresh).
- If no candidate has a priced supplier with a finite positive amount, `nova_rate` is `null` and the orchestrator falls through to OTA + direct outreach.

**The local `suppliers` table still exists** (12 demo rows) but is no longer a gate for NOVA pricing. It is consulted only for outreach contact info on the email / voice branch — see §1.

#### NOVA price-tier cascade

| NOVA `price_type` | `nova_rate.source` value | Meaning | Suggested Voyager UI |
|---|---|---|---|
| `Contracted`    | `pre_agreed_negotiated` | Signed contract for this specific property — authoritative, bookable | Primary badge, "Contracted" |
| `Guideline`     | `guideline` *(NEW)*     | Category-tier benchmark, e.g. "3* in London Outskirts" — indicative, **not a property-specific contract** | Amber badge, "Guideline / category benchmark" |
| `Estimated`     | `estimated`             | NOVA-computed indicative rate for the real named property | Amber badge, "Estimated indicative" |
| _(none priced)_ | `nova_rate` is `null`   | NOVA has no priced supplier — orchestrator falls through to OTA + outreach | Hide NOVA row |

**The cascade does not affect reconciliation logic.** Cases A–F in `src/reconciliation.ts` treat any non-null `nova_rate` as "NOVA found a rate"; `nova_rate.source` is informational. Voyager can branch UI on `nova_rate.source` to differentiate Contracted (high trust) from Guideline / Estimated (advisory).

#### Disambiguators forwarded to NOVA

PRS forwards these from `hotel.*` when present — NOVA narrows server-side and the PRS picker uses them locally too:

- `hotel.address` → NOVA `address` (free-text narrow)
- `hotel.postal_code` → first picker strategy: parsed-postcode equality → confidence 0.95. When multiple NOVA candidates share the postcode, the picker falls through to name matching over that narrowed subset.
- `hotel.geo.{latitude,longitude}` → NOVA `gps_location` + `distance: 5km` (server-side). No client-side geo step in PRS.
- `pax.infants` → NOVA `occupancy.fco` ("Free Charge Occupant"). **Working assumption — undocumented in NOVA partner guide.** Flagged for verification with NOVA team; one-line swap in `src/nova-api.ts` if they correct the field.

#### Local NOVA picker

After NOVA returns candidates, PRS applies a two-strategy ladder:

| Order | Method            | Fires when                                                                 | Confidence |
|-------|-------------------|----------------------------------------------------------------------------|------------|
| 1     | `postcode_name`   | `postal_code` matches a candidate's parsed address postcode (single match) | 0.95       |
| 2     | `name_only`       | IDF-weighted Jaccard with plain-Jaccard floor; auto-pick at top ≥ 0.55 **OR** top ≥ 0.35 with top-vs-second gap ≥ 0.15 | = top score |

If neither strategy clears its threshold, PRS returns `nova_rate: null` rather than guessing — safer than picking the wrong chain hotel. **Note:** plain Jaccard acts as a floor because NOVA can return very small candidate pools (1–3) where IDF degenerates to ~0; the implementation takes the max of the IDF score and plain Jaccard.

#### Currency

NOVA request uses `EUR` by default (NOVA converts server-side from its contract currency). Voyager-side currency override is a future enhancement; orchestrator does not currently pass through your request currency.

#### Example response payloads per tier

```jsonc
// Contracted hit — authoritative price
"nova_rate": {
  "source": "pre_agreed_negotiated",
  "rate_per_room_per_night": 540,
  "currency": "GBP",
  "meal_plan_included": "BB",
  "last_updated_at": "2026-04-12T08:14:00Z",
  "staleness_days": 0
}
```

```jsonc
// Guideline hit — category benchmark, not a property contract
"nova_rate": {
  "source": "guideline",
  "rate_per_room_per_night": 185,
  "currency": "EUR",
  "meal_plan_included": "BB",
  "last_updated_at": "2026-05-10T00:00:00Z",
  "staleness_days": 0
}
```

```jsonc
// Estimated hit — indicative for the real property
"nova_rate": {
  "source": "estimated",
  "rate_per_room_per_night": 210,
  "currency": "EUR",
  "meal_plan_included": "BB",
  "last_updated_at": "2026-05-13T22:00:00Z",
  "staleness_days": 0
}
```

```jsonc
// No priced supplier → orchestrator falls through to OTA + direct outreach
"nova_rate": null
```

#### Staging data caveat

NOVA's staging-UAT London catalogue currently exposes 352 accommodations but only **4 with priced suppliers** (3 Guideline + 1 Estimated, zero Contracted). Most staging queries therefore return `nova_rate: null` and exercise the OTA / direct-outreach branches. In production, once EIH loads contracts, expect the cascade to land on `pre_agreed_negotiated` for the bulk of group queries. **Not a PRS-side bug** — flagged with the NOVA data-loading workstream.

---

This is the most important table in the doc. Decide your UI for each value:

| Recommendation | What it means | Voyager UI action |
|---|---|---|
| **`USE_FRESH_QUOTE`** | All 3 sources align within tolerance. Fresh hotel quote is the best price. | ✅ Default to fresh_quote.rate. Show all 3 sources for context. Proceed to quote. |
| **`USE_NOVA_RATE`** | Hotel didn't reply, but NOVA + public OTA agree within 5%. NOVA rate is fine to use. | ✅ Default to nova_rate.rate. Mark "based on stored rate; hotel did not respond." |
| **`UNDERCUT_DETECTED_RENEGOTIATE`** ⚡ | **The money case.** Public OTA is materially below NOVA. We may be quoting above market. | 🚨 **Red flag** in UI. Show `undercut_evidence` panel. Action buttons: <br>• "Manual call to renegotiate" (logs intent, agent calls hotel themselves)<br>• "Auto-renegotiate" (triggers voice bot — Phase 2)<br>• "Quote at OTA price anyway" (use public_prices.min_comparable, flag for review)<br>• "Override and quote NOVA" (proceed despite warning) |
| **`HUMAN_REVIEW_REQUIRED`** | No usable price source. All branches failed. | ⚠️ Block auto-quote. Show "manual pricing required" + audit timeline so agent can investigate. |
| **`HUMAN_REVIEW_FRESH_ABOVE_PUBLIC`** | Cold hotel; the hotel quoted us *above* public OTA — possible mistake or anti-DMC pricing. | ⚠️ Show comparison side-by-side. Suggest going back to hotel for clarification. |
| **`NO_BASELINE_USE_FRESH`** | Cold hotel (no NOVA contract); only direct quote. | ✅ Default to fresh_quote.rate. UI flags as "new supplier — first booking". |
| **`NO_FRESH_USE_NOVA_FLAGGED`** | Hotel did not respond. NOVA is sole source. | ⚠️ Default to nova_rate.rate but show warning: "NOVA rate is X days old, no fresh confirmation". |
| **`PUBLIC_PRICE_FALLBACK`** | Cold hotel + silent + voice failed. Only OTA-scraped data available. | ⚠️ Yellow flag. Show OTA evidence + median price. Recommend re-trying outreach before quoting. |

### Code pattern

```typescript
function renderRecommendation(detail: ResolutionDetail) {
  const rec = detail.reconciliation?.recommendation;
  switch (rec) {
    case 'USE_FRESH_QUOTE':
      return <PriceCard primary={detail.fresh_quote!} sources={detail.public_prices} />;

    case 'UNDERCUT_DETECTED_RENEGOTIATE':
      return <UndercutAlert
        evidence={detail.reconciliation!.undercut_evidence!}
        actions={['manual_call', 'auto_renegotiate', 'quote_at_ota', 'override']}
      />;

    case 'PUBLIC_PRICE_FALLBACK':
      return <FallbackCard
        bundle={detail.public_prices!}
        warning="No supplier contract; price based on public OTA only"
      />;

    case 'HUMAN_REVIEW_REQUIRED':
      return <ManualPricing audit={detail.audit_events} />;

    // ... etc
  }
}
```

### Capturing the agent's choice

When the inquiry agent picks an action, write it back to PRS so the supplier-relationship analytics layer learns:

```typescript
// example: agent chose "quote at OTA price anyway"
await db.price_resolution_history.create({
  resolution_id: detail.resolution_id,
  used_rate: { value: detail.public_prices!.min_comparable, currency: detail.public_prices!.per_ota.booking.currency, source: 'public_ota' },
  used_at: new Date(),
});
```

---

## 6a. FIT flow — bedbank pricing for small groups

Triggered automatically when `pax.adults + pax.children + pax.infants < 20`. Threshold is configurable server-side via `FIT_THRESHOLD_PAX`.

For FIT, PRS **skips** NOVA + email RFQ + voice escalation + SerpAPI scrape. Instead it runs a **priority cascade** across bedbanks and returns the first available quote:

1. **TBO Holidays** — priority. Tried first.
2. **Hotelbeds** — fallback. Called only when TBO returns no usable quote (no city match, no hotel match, no inventory, or error).

The cascade is sequential, not parallel: as soon as TBO returns an available quote with at least one room rate, the loop short-circuits and Hotelbeds is skipped. Both adapters surface their **native currency** — TBO returns whatever currency its credential profile carries (USD on staging, GBP on the live Europe Incoming profile), Hotelbeds returns EUR. PRS does not convert; Voyager handles any FX based on `BedbankQuote.currency`.

### Response shape

```typescript
interface ResolutionDetail {
  flow_kind: 'group' | 'fit';        // top-level discriminator
  // ... group-flow fields (nova_rate, fresh_quote, public_prices, reconciliation, prices)
  //     are NULL when flow_kind === 'fit'

  fit_prices: {                       // populated only when flow_kind === 'fit'
    group_size: number;               // adults + children + infants
    threshold: number;                // FIT_THRESHOLD_PAX (20)
    bedbanks_attempted: BedbankName[]; // every adapter we called
    bedbanks_returned:  BedbankName[]; // adapters that returned a usable quote (available=true)
    quotes: BedbankQuote[];           // every quote — available + unavailable + errors (good for diagnostics)
    cheapest: {                        // cheapest comparable across all bedbanks (per-room-per-night)
      bedbank: BedbankName;
      rate_per_room_per_night: number;
      currency: string;
      room_breakdown: BedbankRoomRate[];
    } | null;                          // null if no bedbank returned a quote
  } | null;
}

interface BedbankRoomRate {
  room_type: 'SGL'|'TWN'|'DBL'|'TRP'|'QUAD'|'SUITE'|'FAMILY';
  qty: number;
  meal_plan: 'RO'|'BB'|'HB'|'FB'|'AI';
  rate_per_room_per_night: number;
  total_for_stay: number;
  currency: string;
  refundable: boolean;
  cancellation_deadline: string | null;   // ISO timestamp
  rate_key: string;                        // opaque, used later when we wire the booking endpoint
  raw_room_name: string;                   // bedbank's exact room label, e.g. "Classic Double"
  num_guests: number;
}
```

### Code pattern

```typescript
function renderResolution(detail: ResolutionDetail) {
  if (detail.flow_kind === 'fit') {
    if (!detail.fit_prices?.cheapest) {
      return <Empty>No bedbank availability for this hotel/dates. Show "Manual pricing required".</Empty>;
    }
    const c = detail.fit_prices.cheapest;
    return <FitPriceCard
      provider={c.bedbank}                       // 'tbo' or 'hotelbeds' badge
      rate={c.rate_per_room_per_night}
      currency={c.currency}
      rooms={c.room_breakdown}
      otherProviders={detail.fit_prices.quotes.filter(q => q.bedbank !== c.bedbank && q.available)}
    />;
  }

  // detail.flow_kind === 'group' — render reconciliation as in §6
  return renderRecommendation(detail);
}
```

### Multi-bedbank comparison

Each `quote` in `fit_prices.quotes[]` is a single bedbank's response (available, unavailable, or errored — kept for diagnostics). In the current priority cascade, `quotes` will typically contain **one** entry when TBO answers successfully, or **two** when TBO returned no availability and Hotelbeds was tried as a fallback. RateHawk and Travellanda adapters are reserved for future inclusion in the cascade.

### Capturing the agent's choice (FIT)

```typescript
// example: agent picked the cascade winner (TBO when available, else Hotelbeds)
await db.price_resolution_history.create({
  resolution_id: detail.resolution_id,
  used_rate: {
    value: detail.fit_prices!.cheapest!.rate_per_room_per_night,
    currency: detail.fit_prices!.cheapest!.currency,
    source: `bedbank:${detail.fit_prices!.cheapest!.bedbank}`,
  },
  rate_key: detail.fit_prices!.cheapest!.room_breakdown[0].rate_key,  // for later booking
  used_at: new Date(),
});
```

### Why no `reconciliation` for FIT?

FIT prices are publicly available rates from wholesale partners — there's no NOVA pre-agreed rate to undercut against, and no need to triangulate. The cheapest live bedbank quote IS the price. The `reconciliation.recommendation` enum from §6 simply doesn't apply.

If a hotel returns no inventory across all bedbanks, `cheapest === null` and `bedbanks_returned === []`. Treat this as "manual pricing required" — same UX as `HUMAN_REVIEW_REQUIRED` in group flow.

---

## 7. Any hotel anywhere (cold-hotel pattern)

Voyager users don't pre-load hotels — they type whatever the client asks for. PRS handles this for both flows:

```typescript
const result = await prs.resolveSync({
  tour_id: 'tour_456',
  source: 'intake_form',
  hotel: {
    hotel_name: 'Hotel Adlon Kempinski Berlin',          // free text from inquiry form
    city_code: 'BER',                                     // optional, improves matching
    country_code: 'DE',                                   // ISO 3166-1 alpha-2 — drives currency + Google geo-targeting
    address: 'Unter den Linden 77, 10117 Berlin',         // OPTIONAL — strong tiebreaker for FIT bedbank matching
    postal_code: '10117',                                 // OPTIONAL — exact-match disambiguator
    geo: { latitude: 52.5163, longitude: 13.3805 },       // OPTIONAL — best disambiguator (100m radius)
    client_specified: true,                               // affects path classification
  },
  stay: { check_in: '2026-08-15', check_out: '2026-08-19' },
  rooms: [{ room_type: 'DBL', qty: 2, meal_plan: 'BB' }],
  pax: { adults: 4 },
}, 30);
```

### What `address` / `postal_code` / `geo` do

These are all optional but **strongly recommended for FIT requests**, because bedbank catalogs contain many "Hilton London"-style chain duplicates (15+ in London alone). Without a disambiguator the matcher refuses to silently pick — better an empty result than a wrong-hotel quote.

For **group flow** (`pax >= 20`), only `hotel_name + country_code` is consumed by the SerpAPI scrape — extra fields are stored on the resolution but don't change behavior today.

### Hotel disambiguation (TBO matcher)

As of 2026-05-14 the TBO adapter (`src/bedbanks/tbo.ts`, `findHotelCode`) uses a **multi-factor strategy ladder**. Each strategy is tried in order; the first one that auto-picks wins. The catalogue side carries `Latitude` / `Longitude` / `Address` per hotel (fetched once per city via `/TBOHotelCodeList` with `IsDetailedResponse=true`, cached 24h in process memory). Postcodes are regex-extracted from the address field once at cache load.

Name comparison is **IDF-weighted Jaccard** — common tokens like "Hotel" / "London" carry little weight, rare tokens like "Pancras" / "Renaissance" / "Strand" dominate. The per-city IDF table is computed once when the city catalogue is loaded.

| Order | Method            | Fires when                                                | Min name similarity                                       | Confidence stamped |
|-------|-------------------|-----------------------------------------------------------|-----------------------------------------------------------|--------------------|
| 1     | `postcode_name`   | `hotel.postal_code` matches a catalogue hotel's postcode  | ≥ 0.15                                                    | 0.95               |
| 2     | `geo_name`        | `hotel.geo.{latitude,longitude}` within **100m**          | ≥ 0.15                                                    | 0.95               |
| 3     | `geo_name_loose`  | geo within **500m** (but not 100m)                        | ≥ 0.30                                                    | 0.85               |
| 4     | `name_only`       | nothing else fired                                        | top ≥ 0.55 **OR** top ≥ 0.35 with top-vs-second gap ≥ 0.15 | = top score        |

If none of the four passes, the adapter returns:

```jsonc
{
  "bedbank": "tbo",
  "available": false,
  "rooms": [],
  "error": "no TBO hotel name match for \"<your hotel_name>\" in city <TBO city code>"
}
```

This is **deliberate** — refusing is safer than picking the wrong chain hotel. Voyager should retry with an extra disambiguator (postcode > geo > address) when this happens.

#### Three concrete request bodies

**(a) Name-only happy path — works for unambiguous, non-rebranded properties.** IDF helps when the name has rare tokens (e.g. "Strand", "Sacher").

```json
{
  "tour_id": "voyager_fit_001",
  "source": "itinerary_builder",
  "hotel": {
    "hotel_name": "Strand Palace",
    "city_code": "LON",
    "country_code": "GB",
    "client_specified": false
  },
  "stay": { "check_in": "2026-06-15", "check_out": "2026-06-19" },
  "rooms": [{ "room_type": "DBL", "qty": 1, "meal_plan": "RO" }],
  "pax": { "adults": 2 }
}
```
→ matcher fires strategy 4 (`name_only`), picks "Strand Palace Hotel" against ~13k London hotels because both rare tokens hit.

**(b) Name + postcode — the recommended pattern.** Single highest-value disambiguator. Works for rebranded hotels and chain hotels alike.

```json
{
  "hotel": {
    "hotel_name": "St. Pancras Renaissance London",
    "city_code": "LON",
    "country_code": "GB",
    "postal_code": "NW1 2AR"
  },
  "stay": { "check_in": "2026-06-15", "check_out": "2026-06-19" },
  "rooms": [{ "room_type": "DBL", "qty": 1, "meal_plan": "BB" }],
  "pax": { "adults": 2 }
}
```
→ TBO's catalogue actually calls this hotel "St. Pancras London, Autograph Collection" (Marriott rebranded the Renaissance brand). Name-only fails because the rare token "Renaissance" doesn't exist in TBO's name. Strategy 1 (`postcode_name`) fires off `NW1 2AR` and picks the right hotel at confidence 0.95.

**(c) Name + geo — when postcode isn't available.** Within 100m fires the tight strategy; 100–500m fires the loose strategy (needs a slightly stronger name overlap).

```json
{
  "hotel": {
    "hotel_name": "Strand Palace",
    "city_code": "LON",
    "country_code": "GB",
    "geo": { "latitude": 51.510845, "longitude": -0.120859 }
  },
  "stay": { "check_in": "2026-06-15", "check_out": "2026-06-19" },
  "rooms": [{ "room_type": "DBL", "qty": 1, "meal_plan": "RO" }],
  "pax": { "adults": 2 }
}
```
→ strategy 2 (`geo_name`) fires; tight 100m radius + low name-similarity floor disambiguates among chain duplicates.

#### What about `address`?

`hotel.address` is forwarded to TBO but **not currently consumed** by the TBO matcher (only the Hotelbeds matcher reads it). Still safe to send — it's harmless and improves Hotelbeds fallback accuracy. Postcode + geo are the two TBO can use today.

#### Known label mismatch on the response

`src/bedbanks/tbo.ts` (in `fetchQuote`, ~line 577) currently hard-codes `resolution_method: 'name_city'` on the returned quote regardless of which strategy actually fired. So a response built off `postcode_name` or `geo_name` will still show:

```jsonc
{
  "bedbank": "tbo",
  "available": true,
  "resolution_method": "name_city",   // ← always this string today
  "resolution_confidence": 0.95,      // ← reflects the actual strategy's confidence
  ...
}
```

The **matcher itself works correctly** — the right hotel is picked, the confidence number is right. Only the audit label is misleading. Don't branch Voyager logic on `resolution_method` until this is fixed; use `resolution_confidence` if you need a quality signal. (Flagged for fix server-side; not actionable on the Voyager side.)

#### Reference: legacy field-strength table

For quick mental model — what each combination buys you in matcher confidence:

| What you pass               | Matcher strategy fired             | Confidence | Notes                                          |
|-----------------------------|------------------------------------|------------|------------------------------------------------|
| `name + city_code` only     | `name_only` (IDF Jaccard)          | top score  | Works for unambiguous / rare-token names; refuses on chain duplicates |
| `+ postal_code`             | `postcode_name`                    | 0.95       | **Recommended.** Unblocks rebranded hotels too |
| `+ geo` (within 100m)       | `geo_name`                         | 0.95       | Best when you have GPS, no postcode            |
| `+ geo` (100–500m)          | `geo_name_loose`                   | 0.85       | Looser radius; needs name overlap ≥ 0.30       |
| `+ address`                 | (TBO ignores; Hotelbeds uses)      | n/a        | Forward anyway — helps fallback                |
| `+ giata_code` (any of 4)   | not implemented yet                | —          | Reserved for future short-circuit              |

### `country_code` matters (group flow only)

It drives:
1. Which Google Hotels region to query (`gl=de` for Germany, `gl=uk` for UK, etc.)
2. Which currency to expect back (EUR, GBP, JPY, USD, …)

50+ countries are mapped out of the box. Anything not mapped defaults to `gl=us` + EUR.

### Lazy bedbank catalog resolution (FIT only)

PRS resolves `hotel_name` → bedbank hotel code lazily, but the two bedbanks do it differently:

**TBO (priority):**
- First request for a `(country, city)` pair PRS hasn't seen before calls `/CityList` + `/TBOHotelCodeList` and pulls that city's entire hotel list (e.g. ~13,391 hotels for London). Total extra latency: ~3–5s.
- The list is cached in **process memory** under the `(country, city)` key for `TBO_CITY_CACHE_TTL_MS` (default 24h). Subsequent requests for any hotel in that city skip both static calls and go straight to `/Search` — typically 1–2s end-to-end.
- The cache is cleared on every process restart by design. **There is no DB seed and no sync script for TBO.**

**Hotelbeds (fallback):**
- Uses a CSV-backed mapping plus the DB-backed `bedbank_hotels` table populated by `npm run sync:hotelbeds`. This is a separate, deliberate offline workflow.

Behavior:
- Concurrent requests for the same un-cached city against TBO are not coalesced today — the first wins the cache, subsequent in-flight requests will issue duplicate `/CityList` calls. Not a problem at current volume.
- Concurrent requests for the same un-synced city are serialised — only one sync runs at a time.
- If a bedbank's API errors during sync (e.g. quota exhausted), PRS records the failure and uses whatever rows it managed to fetch. Subsequent requests retry the failed sync until it succeeds.

**Pending supplier review:**

When path = `B_CLIENT_COLD` or `C_CLIENT_PREF_TRANSFER`, PRS creates a row in its `pending_suppliers` table. You should:
1. Surface this in your "supplier admin" view
2. After the tour closes, the inquiry agent reviews and either approves (promotes to main supplier catalog) or archives.

---

## 8. Error handling

PRS uses **Problem Details** (RFC 7807) error envelopes:

```json
{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Idempotency-Key header is required.",
  "resolution_id": "res_…"
}
```

Status codes you'll see:

| Status | Meaning | Retry? |
|---|---|---|
| **200** | Sync mode success, returned full ResolutionDetail | n/a |
| **202** | Async mode acceptance, resolution started | n/a |
| **400** | Validation error — fix the request | No |
| **401** | Bad/missing API key | No |
| **404** | Resolution / hotel not found | No |
| **409** | Idempotency key conflict, or invalid state transition (e.g. cancel after complete) | No |
| **5xx** | PRS internal error | Yes — exponential backoff, max 3 attempts |

### Wrap the client

```typescript
import { PrsApiError } from './lib/prs/voyager-fetch';

async function safeResolve(req: ResolutionRequest): Promise<ResolutionDetail | null> {
  try {
    return await prs.resolveSync(req, 60);
  } catch (err) {
    if (err instanceof PrsApiError) {
      if (err.httpStatus >= 500) {
        // log + retry
        Sentry.captureException(err);
        return retryWithBackoff(() => prs.resolveSync(req, 60));
      }
      if (err.httpStatus === 400) {
        // validation — show user-friendly error
        showToast(`Invalid input: ${err.problem.detail}`);
        return null;
      }
      throw err;
    }
    // network / timeout — let your UI's error boundary handle it
    throw err;
  }
}
```

---

## 9. Idempotency

`POST /v1/resolutions` requires an `Idempotency-Key` header (any UUID). PRS stores it under a `UNIQUE` constraint and replays the existing response on any collision within 48h.

**Recommended: fresh random UUID per call.**

```typescript
const idem = crypto.randomUUID();   // voyager-fetch.ts does this for you
```

This is the only flow today, and it only invokes wholesaler read-side endpoints (TBO `/Search`, Hotelbeds search) — no `/PreBook`, no `/Book`. Re-running the orchestrator is safe; you always get the latest pricing and adapter cascade. **Do not derive the key from request content** (e.g. tour+hotel+dates): you will pin yourself to the first response forever and miss every server-side improvement. See `ALEXANDER_NOTE_idempotency.md` for the full incident write-up.

If you need crash-safe retry of an in-flight job, store one UUID per logical job in your queue row and reuse it across retries of *that job* — not across distinct user requests for the same tuple.

**On re-submission:** PRS returns the existing resolution with header `X-PRS-Idempotent-Replay: true`. Treat this as success — your job already happened.

**Future:** when PRS exposes a write endpoint (e.g. `POST /v1/bookings`), *that* endpoint will require a content-derived `Idempotency-Key` to prevent double-booking on HTTP retry. Different endpoint, different strategy — by design.

For webhook handling, dedupe on `event_id` — PRS may retry on non-2xx delivery.

---

## 10. Audit timeline & state machine

Every state transition is recorded. Useful for:
- Showing the agent why a recommendation came out a certain way
- Compliance reviews
- Debugging "why is this resolution stuck"

```typescript
const events = await prs.getEvents(resolution_id);
// → [
//   { type: 'RESOLUTION_CREATED', timestamp, payload },
//   { type: 'STATE_CHANGED', payload: { state: 'IDENTIFYING_HOTEL' } },
//   { type: 'HOTEL_IDENTIFIED', payload: { path: 'D_DMC_DEFAULT', ... } },
//   { type: 'NOVA_RATE_FETCHED', payload: { rate: 540, currency: 'GBP' } },
//   { type: 'STATE_CHANGED', payload: { state: 'FANOUT' } },
//   { type: 'EMAIL_SENT', payload: { to: 'reservations@…', subject: '…' } },
//   { type: 'PUBLIC_PRICES_CAPTURED', payload: { source_count: 4, min: 545 } },
//   { type: 'EMAIL_REPLIED', payload: { kind: 'quote' } },
//   { type: 'STATE_CHANGED', payload: { state: 'RECONCILING' } },
//   { type: 'RECONCILED', payload: { recommendation: 'USE_FRESH_QUOTE', confidence: 0.86 } },
//   { type: 'WEBHOOK_DELIVERED', payload: { event_type: 'prs.resolution.reconciled' } },
// ]
```

State machine reference: see `pricing-resolver-service-design-v1.1.md` Section 6 in the repo, or the `figure-6-state-machine-v1.1.png` diagram.

---

## 11. Rate limits

PRS enforces **100 requests/minute per API key** (or per IP for unauthenticated public endpoints). Health checks (`/healthz`, `/readyz`, `/openapi.json`) are exempt.

Every response carries:

```http
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1777512000     ← Unix timestamp when the bucket resets
```

When you exceed the cap:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 23
Content-Type: application/problem+json

{
  "type": "about:blank",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "Rate limit exceeded: 100 requests per minute per API key. Retry in 23s.",
  "retry_after_seconds": 23
}
```

**Pattern:** read `X-RateLimit-Remaining` and self-throttle when it drops below 10. Or just back off on 429 with `retry_after_seconds`.

For higher limits (production-tier API key), ask Nikola.

## 12. Pre-warming the cache

For predictable demos or daily batch flows, hit `/v1/admin/warmup` to populate the OTA cache for many hotels in one call:

```bash
# Warm the 3 P0 demo scenarios
curl -X POST 'https://pricingresolver.symbiofoundry.com/v1/admin/warmup' \
  -H 'X-API-Key: <your_api_key>' \
  -H 'Content-Type: application/json' \
  -d '{ "preset": "p0" }'

# Warm 8 famous European hotels
curl -X POST '...' -d '{ "preset": "famous_european" }'

# Warm all 11 seeded hotels
curl -X POST '...' -d '{ "preset": "all_seeded" }'

# Or pass an explicit list
curl -X POST '...' -d '{
  "hotels": [
    { "hotel_name": "Hotel Sacher Vienna", "city_code": "VIE", "country_code": "AT", "client_specified": true,
      "stay": { "check_in": "2026-08-15", "check_out": "2026-08-19" },
      "rooms": [{ "room_type": "DBL", "qty": 2, "meal_plan": "BB" }],
      "pax": { "adults": 4 } }
  ]
}'
```

Returns a summary with `total / succeeded / failed / duration_ms / items[]` for each warmed entry. Cache TTL is 24h; run as a nightly cron in your Voyager infrastructure for whichever hotels you anticipate.

## 13. Date validation

`POST /v1/resolutions` rejects unrealistic stay windows with HTTP 400:

| Rule | Message |
|---|---|
| `check_out <= check_in` | "check_out (...) must be after check_in (...) — minimum 1 night" |
| `check_in` in the past | "check_in must not be in the past (got X, today is Y)" |
| `check_in` more than 24 months ahead | "check_in is too far in the future — max 24 months ahead" |
| Stay longer than 60 nights | "stay too long (N nights) — cap is 60" |
| Wrong format | "check_in must be ISO date YYYY-MM-DD (got ...)" |

Voyager UI should validate client-side too (better UX), but the server is the source of truth.

## 14. Webhook receiver templates

Drop-in examples for popular stacks live in `examples/webhook-receivers/`:

| File | Stack |
|---|---|
| `nextjs.ts` | Next.js App Router (place at `app/api/prs-events/route.ts`) |
| `express.ts` | Express (uses `express.raw()` for HMAC) |
| `hono.ts` | Hono (Node / Bun / Deno / Cloudflare Workers) |
| `nestjs.controller.ts` | NestJS (with `main.ts` rawBody capture) |

Each is self-contained: HMAC verification, type-safe event dispatch, idempotency stub. Pick yours, set `PRS_WEBHOOK_SECRET`, done.

## 15. Production checklist

Before going live in Voyager:

- [ ] **Server-side env vars** in Voyager:
  - `PRS_BASE_URL=https://pricingresolver.symbiofoundry.com`
  - `PRS_API_KEY=<production key, not the demo one>`
  - `PRS_WEBHOOK_SECRET=<HMAC secret matching PRS server>`
- [ ] **Webhook URL** registered with Nikola — should be HTTPS, server-side, public reachable
- [ ] **HMAC verification** in your webhook handler (see §5)
- [ ] **Idempotency** on webhook handler — dedupe on `event_id`
- [ ] **Error envelopes** — handle 400/401/409/5xx per §8
- [ ] **Resolution ID storage** — link to your tour records so the inquiry agent can navigate to the audit timeline
- [ ] **Recommendation UI** for all 8 enum values per §6
- [ ] **Pending supplier admin view** for cold hotels (see §7)
- [ ] **Manual override workflow** — agent always has final say, especially for `UNDERCUT_DETECTED_RENEGOTIATE`
- [ ] **Audit log** of agent decisions (which rate they used, why) for supplier-relationship analytics
- [ ] **Rate limit** on your side — PRS is OK with 500 concurrent resolutions but you don't want to fan out 10k at once
- [ ] **Monitoring**: alert on `prs.resolution.failed` events and on webhook 5xx responses

---

## 16. Common scenarios with code

### Scenario A — Known hotel happy path (group flow, ≥20 pax)

```typescript
const result = await prs.resolveSync({
  tour_id: 'tour_001',
  source: 'itinerary_builder',
  hotel: { supplier_id: 'SUP_LON_001' },        // we have a NOVA contract
  stay: { check_in: '2026-06-15', check_out: '2026-06-19' },
  rooms: [{ room_type: 'DBL', qty: 10, meal_plan: 'BB' }],
  pax: { adults: 20 },
}, 60);

// result.flow_kind === 'group'
// result.reconciliation.recommendation === 'USE_FRESH_QUOTE'
// → display price card with all 3 sources, agent confirms quote
```

### Scenario B — Undercut detected (group flow)

```typescript
const result = await prs.resolveSync({
  tour_id: 'tour_002',
  source: 'itinerary_builder',
  hotel: { supplier_id: 'SUP_ROM_002' },
  stay: { check_in: '2026-09-10', check_out: '2026-09-13' },
  rooms: [{ room_type: 'DBL', qty: 10, meal_plan: 'BB' }],
  pax: { adults: 20 },
}, 60);

// result.flow_kind === 'group'
// result.reconciliation.recommendation === 'UNDERCUT_DETECTED_RENEGOTIATE'
// result.reconciliation.undercut_evidence === {
//   nova_rate: 360, public_min: 298, delta_percent: -17.22,
//   ota_sources: ['booking', 'hotels_com', 'expedia', 'trivago']
// }
// → red flag UI, show evidence, offer renegotiation actions
```

### Scenario C — Cold hotel, group size ≥ 20 → group flow with public-price fallback

```typescript
const result = await prs.resolveSync({
  tour_id: 'tour_003',
  source: 'intake_form',
  hotel: {
    hotel_name: 'The Savoy',
    city_code: 'LON',
    country_code: 'GB',
    client_specified: true,
  },
  stay: { check_in: '2026-07-04', check_out: '2026-07-07' },
  rooms: [{ room_type: 'DBL', qty: 10, meal_plan: 'BB' }],
  pax: { adults: 20 },
}, 60);

// result.flow_kind === 'group'
// result.hotel_identity.path === 'B_CLIENT_COLD'
// result.hotel_identity.pending_supplier_id === 'psup_…'
// result.reconciliation.recommendation === 'PUBLIC_PRICE_FALLBACK'
// → fallback price card with OTA evidence, supplier admin queue gets a new entry
```

### Scenario F — FIT booking (small group < 20)

```typescript
const result = await prs.resolveSync({
  tour_id: 'tour_006',
  source: 'itinerary_builder',
  hotel: {
    hotel_name: 'Strand Palace',
    city_code: 'LON',
    country_code: 'GB',
    geo: { latitude: 51.510845, longitude: -0.120859 },   // strong disambiguator
    client_specified: false,
  },
  stay: { check_in: '2026-06-15', check_out: '2026-06-19' },
  rooms: [{ room_type: 'DBL', qty: 1, meal_plan: 'RO' }],
  pax: { adults: 2 },                                     // < 20 → FIT branch
}, 30);

// result.flow_kind === 'fit'
// result.reconciliation === null
// result.fit_prices.cheapest === {
//   bedbank: 'tbo',           // 'hotelbeds' if TBO returned no availability
//   rate_per_room_per_night: 211.50,
//   currency: 'USD',          // TBO native (USD on staging, GBP live); 'EUR' if Hotelbeds answered
//   room_breakdown: [{
//     room_type: 'DBL', qty: 1, meal_plan: 'RO',
//     rate_per_room_per_night: 261.04,
//     total_for_stay: 1044.14,
//     refundable: false,
//     cancellation_deadline: '2026-05-05T23:59:00+01:00',
//     rate_key: '20260615|20260619|...',     // opaque, used later for booking
//     raw_room_name: 'Classic Double',
//   }]
// }
// → render FitPriceCard, agent confirms or picks alternative from fit_prices.quotes[]
```

### Scenario D — Async + webhook (production batch)

```typescript
// 1. Fan out
const acceptances = await Promise.all(
  proposal.hotelLines.map(line => prs.resolveAsync({
    tour_id: proposal.tour_id,
    inquiry_id: proposal.inquiry_id,
    source: 'itinerary_builder',
    hotel: line.hotel,
    stay: line.stay,
    rooms: line.rooms,
    pax: proposal.pax,
    webhook_url: 'https://voyager.your-domain.com/api/prs-events',
  })),
);
// All return 202 in under a second total. Resolutions run async.

// 2. Track in your DB
await db.proposal_lines.bulkUpdate(
  acceptances.map((a, i) => ({
    line_id: proposal.hotelLines[i].id,
    prs_resolution_id: a.resolution_id,
    prs_status: 'IN_PROGRESS',
  })),
);

// 3. Voyager UI subscribes (websocket / SSE) to your DB updates and renders
//    each line card as the webhook fires. Total wall time ≈ slowest single
//    resolution (~12s), not sum of all.
```

### Scenario E — Pre-flight identity lookup

Useful for showing the agent the path classification *before* committing to a workflow:

```typescript
const lookup = await prs.hotelLookup({
  hotel_name: 'Le Bristol Paris',
  city_code: 'PAR',
  country_code: 'FR',
});
// → { path: 'B_CLIENT_COLD' OR 'A_DMC_KNOWN' / 'D_DMC_DEFAULT', supplier_id?, contact? }

if (lookup.path === 'B_CLIENT_COLD') {
  showWarning('No NOVA contract for this hotel — pricing will fall back to public OTA');
}
```

---

## 17. Troubleshooting

### "I'm getting 401 Unauthorized"

Check the `X-API-Key` header is set, has no leading/trailing whitespace, and matches the key Nikola gave you. Old demo keys (anything starting with `prs_demo_…`) have been retired and will return 401.

### "I'm getting 400 — Idempotency-Key header is required"

You forgot the `Idempotency-Key` header. The TS client adds one automatically — make sure you're using it.

### "Resolution stuck IN_PROGRESS forever"

Check `GET /v1/resolutions/{id}/events` — the audit timeline tells you which state it's stuck in. Usually one of:
- `EMAIL_SENT` waiting for a reply (8s in demo mode, 3h in production) — fast-forward via `POST /v1/admin/fast-forward/{id}`
- `INITIATING_VOICE` — voice provider may be stubbed
- `SCRAPING_PUBLIC_PRICES` — SerpAPI may be slow on cache miss

### "Webhook isn't being delivered"

Check from the PRS side: the audit events should include `WEBHOOK_DELIVERED` (or `webhook_failed` payload). Common issues:
- Your endpoint isn't HTTPS (PRS allows http://localhost only for testing)
- Your endpoint isn't publicly reachable (NAT / firewall)
- Your endpoint returns non-2xx (signature mismatch counts as 401 → no retry)

For local dev: use [webhook.site](https://webhook.site) or [ngrok](https://ngrok.com) to expose a tunnel.

### "I'm getting `PUBLIC_PRICE_FALLBACK` for a hotel I have a NOVA contract for"

Means PRS didn't match your `supplier_id` to the seeded NOVA catalog. Either:
- The supplier_id is wrong — check `/v1/hotels/lookup` first
- The NOVA simulator doesn't have that supplier seeded (in demo, only 11 are seeded — see `data/suppliers.csv`)

### "Real OTA prices look weird (currency mismatch, prices way off)"

Check the `country_code` in your request — it drives the SerpAPI region + currency. For Italian hotels, send `country_code: 'IT'` (returns EUR). For UK, `'GB'` (returns GBP). 50+ ISO codes are mapped.

### "Latency is too high"

Cache hit is <100ms. Cache miss is 2–15s for sync mode (live SerpAPI scrape + Claude email parse).
- Pre-warm the cache for known hotels via a nightly job
- Use async + webhook pattern for non-interactive flows
- Send `?wait_seconds=120` on sync calls to be safe; default is 60

### "How do I know if PRS is healthy?"

```bash
curl https://pricingresolver.symbiofoundry.com/readyz
# expect: {"ok":true,"demo_mode":true,"llm_enabled":true}
```

Alert on `ok: false`.

---

## Appendix — Endpoints quick reference

| Method | Path | Notes |
|---|---|---|
| POST | `/v1/resolutions` | Create (sync via `?wait=true` or async). Requires `Idempotency-Key`. |
| GET | `/v1/resolutions` | List, filter by `tour_id`, `status`, `since` |
| GET | `/v1/resolutions/{id}` | Full detail + audit events |
| POST | `/v1/resolutions/{id}/cancel` | Abort in-flight resolution |
| GET | `/v1/resolutions/{id}/events` | Audit timeline |
| GET | `/v1/resolutions/{id}/public-prices` | OTA snapshot only |
| POST | `/v1/resolutions/{id}/renegotiate` | Trigger renegotiation flow (when undercut detected) |
| POST | `/v1/hotels/lookup` | Pre-flight identity classification |
| POST | `/v1/admin/simulate/email-reply` | Demo-only: inject simulated email reply |
| POST | `/v1/admin/simulate/voice-outcome` | Demo-only: inject simulated voice outcome |
| POST | `/v1/admin/fast-forward/{id}` | Demo-only: skip silence timer |
| GET | `/v1/stats` | Public aggregate counters |
| GET | `/healthz`, `/readyz` | Health checks |
| GET | `/openapi.json` | OpenAPI 3.1 spec |
| GET | `/docs/` | Swagger UI |
| GET | `/redoc/` | Scalar API reference |

Full schemas live in `/openapi.json`. Try-it-out works in `/docs/`.

---

## Need help?

- Browse the API: <https://pricingresolver.symbiofoundry.com/docs/>
- Live counters: <https://pricingresolver.symbiofoundry.com/>
- Postman collection: `docs/prs-demo.postman_collection.json` in the repo
- The TypeScript client: `examples/voyager-fetch.ts` in the repo
- Ping Nikola for production keys, webhook secret rotation, or any blocker.

Happy integrating. 🚀
