Recipes/Rate shopping, like for like
Code Feed15 min

Rate shopping, like for like

Cheapest rate per hotel is the comparison everyone builds first, and it fails without ever looking broken. On a real comp set it produced a 28% gap against a competitor that sells no flexible rate at all. Here is the grid that does not.

Rate shopping, like for like

What you'll build

A command that prints a product × hotel grid for one night, your rank on each product counting only the hotels that sell it, and an explicit note for every empty cell — plus a CSV for the spreadsheet.

Set this up first

Required

The endpoints don't take free text — they take your identifiers. This recipe assumes the things below already exist on your account; while one of them is missing the call answers empty, not with an error. Each point links to the recipe that walks it.

  1. 01

    Register the hotel in your dashboard

    Veetal resolves the property against Booking.com and assigns it a slug — and that slug, not the hotel name, is what every accommodation endpoint takes.

    Guide: Add your hotel and the comp set you price against →
  2. 02

    Fill in its comp set

    Up to ten competitors on the accommodation detail. Without them this recipe prints your own price list instead of a comparison, and the competitors block comes back empty.

  3. 03

    Run one import on the hotel

    A Feed dataset only contains what an import has written. Activate this API on the hotel, launch the first run by hand and read the credit estimate before you press — the cost grows with the comp set and with the OTAs.

    Guide: Import a hotel's reputation from Booking →

The same thing over the API, if you would rather not click:

cURL
curl -X POST "https://api.veetal.app/v2/account/accommodation" \
  -H "veetal-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ ... }'   # see the account reference for the body

To see whether an import has already finished, list them:

cURL
curl "https://api.veetal.app/v2/account/imports" \
  -H "veetal-api-key: YOUR_API_KEY"

Feed reads are not billed per request. The credits go on the imports that collect the data, which is why the estimate appears before you launch and not after.

How it works

  1. 01The report everyone builds first, and why it lies
  2. 02Get the rates flowing
  3. 03Read the right endpoint
  4. 04Define what "comparable" means
  5. 05Build the grid, and leave the holes open
  6. 06Run it
  7. 07What happens next

Step by step

By the end you will have a command that answers "where do I actually sit against my comp set tonight" — product by product, instead of the cheapest-rate-per-hotel table that quietly compares a flexible rate against a non-refundable one.

What you need: the hotel on your account with its comp set filled in, the Accommodation Rates feed active, and one finished import.

What it costs: nothing per run. Feed reads are not billed per request — the credits go on the imports that collect the rates.

The report everyone builds first, and why it lies

Cheapest rate per hotel, side by side. It is the obvious comparison and it fails without ever looking broken.

Here is one real night from the feed, laid out properly:

Room only · FlexBreakfast · FlexRoom only · NRBreakfast · NR
Your hotel411,40435,20
Competitor A567,40631,40
Competitor B1.010,901.041,702.220,902.251,70

Cheapest-per-hotel reads: 411,40 · 567,40 · 1.010,90. You are 28 % below Competitor A. Good news, and false.

Competitor A does not sell a flexible rate at all. That 567,40 is non-refundable. You are comparing your cancel-any-time rate against a rate the guest cannot cancel, and calling the difference a price gap. On the product you both actually sell, there is nothing to compare — which is itself the finding, and the one that never survives a single-number report.

Two more things visible in the grid and invisible in the list:

  • Competitor B's non-refundable costs more than its flexible — 2.220,90 against 1.010,90. "Non-refundable is the cheap one" is an assumption, not a rule, and here it is backwards.
  • You sell nothing in the non-refundable column. Deliberate or an oversight, that is a decision to make — and you cannot make it if the report averages the gap away.

1. Get the rates flowing

Add the hotel in Accommodations and fill in its comp set — up to ten competitors. Without them this recipe prints a price list, not a comparison.

Then launch an import in Accommodation Rates and set a schedule. The feed only answers for dates an import has already covered; ask for a night nobody imported and you get a clean 404.

2. Read the right endpoint

CODE
GET /v2/feed/accommodation/{slug}/rate/v7/{date}

Use v7. The older /rate path proxies to a legacy backend and answers a different shape — a trap worth knowing about before you build a parser around the wrong one.

What comes back is { report: { [slug]: [rate, ...] } }: your hotel and every competitor in the same payload, each rate carrying a _meta block with is_primary, accommodation_name, rate_date, visitor_type and device_type.

One rate looks like this:

JSON
{
  "room_type": "Habitación Doble Estándar",
  "price_per_night": 411.4,
  "price_total": 411.4,
  "currency": "EUR",
  "meal_plan": { "breakfast": false, "half_board": false, "full_board": false, "all_inclusive": false },
  "cancellation": { "free_cancellation": true, "free_cancellation_days": null, "no_refundable": false },
  "payment": { "no_prepayment": false },
  "is_genius": false,
  "available_rooms": 1,
  "minimum_nights": 1,
  "_meta": { "is_primary": true, "accommodation_name": "Your Hotel", "rate_date": "2026-08-21" }
}

meal_plan and cancellation are the two blocks that make the whole recipe possible. Everything else is detail.

There is also ?min_rates=true, which collapses each hotel to its single cheapest rate. It is convenient, and it is exactly the shortcut this recipe argues against: it is the parameter that throws away the product mix.

3. Define what "comparable" means

Two rates are comparable when they sell the same thing. That is a board level plus a cancellation policyBB/flex, RO/nr — and the rules live in one file so they can be argued with:

JAVASCRIPT
export function board(rate) {
  const meal = rate.meal_plan || {};
  if (meal.all_inclusive) return 'AI';
  if (meal.full_board) return 'FB';
  if (meal.half_board) return 'HB';
  if (meal.breakfast) return 'BB';
  return 'RO';
}

// free_cancellation decides; no_refundable is only the fallback, because a rate
// can arrive with neither flag set.
export function cancellation(rate) {
  const policy = rate.cancellation || {};
  if (policy.free_cancellation) return 'flex';
  return 'nr';
}

export const productKey = (rate) => `${board(rate)}/${cancellation(rate)}`;

Board is checked most-inclusive first because the flags stack: an all-inclusive rate also has breakfast: true, and testing breakfast first would file it as BB.

4. Build the grid, and leave the holes open

JAVASCRIPT
const matrix = buildMatrix(report);   // product × hotel
const standing = position(matrix);    // where you rank on each product

The one rule that matters: a missing cell stays null. Never zero, never averaged over, never filled with that hotel's price from another product. An empty cell means the competitor does not sell that product that night, and treating it as a zero is how a comp set report starts lying.

The ranking follows from it — the denominator only counts hotels that sell the product:

CODE
Breakfast · Free cancellation   #1 of 2 · you are the cheapest
Breakfast · Non-refundable      you do not sell this — 2 competitor(s) do
Room only · Free cancellation   #1 of 2 · you are the cheapest
Room only · Non-refundable      you do not sell this — 2 competitor(s) do

"#1 of 2" is a smaller and truer claim than "#1 of 3". The three-hotel comp set only has two hotels in that race.

5. Run it

BASH
node --env-file=.env index.mjs 2026-08-21
node --env-file=.env index.mjs 2026-08-21 --csv > rates.csv

The CSV gives one row per hotel and product, ready for a spreadsheet or a warehouse — still with the empty cells omitted rather than zeroed.

What happens next

  • Walk a month with /rate/v7/{year}/{month} and watch a competitor open or close its flexible inventory as the date approaches. That change of product mix usually shows up before the price does.
  • The same payload carries is_genius, available_rooms and minimum_nights: a competitor cheaper only on a Genius rate, or only with a three-night minimum, is not really cheaper.
  • Pair it with the parity feed. Rate shopping tells you what the market charges; parity tells you whether your own channels agree with each other.

Clone and run

Runnable

The whole recipe is one self-contained folder: Node 20, no build step, and a test suite that runs without a key.

Terminal
git clone https://github.com/Veetal-Connect/recipes.git
cd recipes/rate-shopping-like-for-like
cp .env.example .env          # your VEETAL_API_KEY and VEETAL_ACCOMMODATION_SLUG
node --env-file=.env index.mjs YYYY-MM-DD          # put the night you want to price
View the repository ↗ Stack: Node 20 · JavaScript, sin dependencias

Check your key first

One call, five seconds. If this answers, the recipe will run.

cURL
curl "https://api.veetal.app/v2/feed/accommodation/YOUR_ACCOMMODATION_SLUG/rate/v7/2026-08-21" \
  -H "veetal-api-key: YOUR_API_KEY"

# un mes entero:
curl "https://api.veetal.app/v2/feed/accommodation/YOUR_ACCOMMODATION_SLUG/rate/v7/2026/08" \
  -H "veetal-api-key: YOUR_API_KEY"

Questions

Why not just compare the cheapest rate of each hotel?

Because the cheapest rate of two hotels is often two different products. On a real comp set, one competitor sold no flexible rate at all, so its cheapest was non-refundable — comparing it against a flexible one produced a 28% "gap" that did not exist. Comparable means same board and same cancellation policy.

What does an empty cell mean?

That the competitor does not sell that product on that night. It is a finding, not a hole: it stays null, it is never averaged over, and it is never replaced by that hotel's price from another product. Treating empty cells as zeros is the fastest way to make a comp set report lie.

Isn't a non-refundable rate always cheaper than a flexible one?

No, and the data says so. On the night documented here, one competitor's non-refundable was 2.220,90 against 1.010,90 for its flexible — more than double. Price the assumption out of your model and read what the feed actually returns.

Should I use /rate or /rate/v7?

v7. The older /rate path proxies to a legacy backend and answers a different shape, so a parser built against one will break on the other. Everything in this recipe uses /rate/v7/{date}, and there is a /rate/v7/{year}/{month} for a whole month.

The endpoint answers 404 for the date I want

The feed only answers for dates an import has already covered. Check the Imports tab of the Accommodation Rates API to see which dates you actually have, and set a schedule so the window keeps moving forward.

Why is my comp set missing from the response?

The comp set comes from the accommodation itself, not from the request. Fill it in on the hotel's detail page in the dashboard — up to ten competitors — and run an import. Without it, the report contains one hotel and there is nothing to compare.

Build your own

Start free with 100 API credits. No credit card, no sales call.

Whatsapp