directrate

Automation

Batch jobs & export

Hotels × nights × lengths of stay as cells, answered from the store now and filled by a job for the rest, then paged or exported as CSV or JSON lines.

POST /matrix

Pricing: served cells bill the cache-hit rate (0.25 × the per-cell price); the job quotes its own worst case, checked against the quota at creation, and bills what its tasks spend.

A matrix is hotels × check-in nights × lengths of stay, one cell per combination: the lowest cash rate (kind: "cash") or the lowest points (kind: "award") for that stay. Up to 200 hotel codes of one chain, an inclusive check-in range of up to 366 nights, and 1 to 3 los values (each 1 to 7), for at most 20,000 cells per request.

curl -s https://api.directrate.dev/v1/matrix \
  -H "x-api-key: $DIRECTRATE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "chain": "marriott",
    "hotel_codes": [
      "CHIRL",
      "CHIMK",
      "CHIDT"
    ],
    "nights": {
      "from": "2026-11-10",
      "to": "2026-11-16"
    },
    "los": [
      1
    ],
    "kind": "cash"
  }'
import os
import requests

r = requests.post(
    "https://api.directrate.dev/v1/matrix",
    headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
    json={
      "chain": "marriott",
      "hotel_codes": ["CHIRL", "CHIMK", "CHIDT"],
      "nights": {
        "from": "2026-11-10",
        "to": "2026-11-16",
      },
      "los": [1],
      "kind": "cash",
    },
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())
const res = await fetch("https://api.directrate.dev/v1/matrix", {
  method: "POST",
  headers: {
    "x-api-key": process.env.DIRECTRATE_API_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    "chain": "marriott",
    "hotel_codes": [
      "CHIRL",
      "CHIMK",
      "CHIDT"
    ],
    "nights": {
      "from": "2026-11-10",
      "to": "2026-11-16"
    },
    "los": [
      1
    ],
    "kind": "cash"
  }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("x-credits-charged"), await res.json());
Field Type Notes
chain a chain id one chain per request; an OTA takes its own hotel ids as the codes
hotel_codes 1 to 200 codes duplicates are dropped
nights { from, to } inclusive range of check-in dates, at most 366
los 1 to 3 ints, each 1 to 7 default [1]
kind cash or award default cash; award is refused on chains without award points
occupancy, currency as /shop
max_age seconds how stale a stored cell may be and still be served now; default the rate cache TTL (600 s); 0 serves nothing from the store
webhook_url URL POSTed once when the job finishes

The cell

Each cell is a MatrixCell:

{
  "hotel_code": "CHIRL", "night": "2026-11-10", "los": 1,
  "status": "available", "min_los": null,
  "lowest": { "bar": 189, "bar_after_tax": 221.49, "member": 179.55 },
  "lowest_points": null, "lowest_points_cash": null, "cash_copay": null, "cpp": null, "cpp_points_cash": null,
  "currency": "USD",
  "observed_at": "2026-10-11T09:02:14.000Z", "cache_age_sec": 412
}

lowest carries the same keys as ShopResponse.lowest on both kinds. On an award cell lowest_points is the lowest copay-free award per night, lowest_points_cash and cash_copay the cheapest points-plus-cash award (shop-sourced cells only), cpp the cents per point, and certs lists the free-night certificates that would cover the night when there are any. status is available, sold_out, closed or restricted_los (with min_los); an award cell is available when any award is sold, so it can be available with lowest_points null when only a points-plus-cash award exists. from_default_occupancy: true marks an award cell served from the default-occupancy cell (2 adults, 1 room) because the exact occupancy had none; points rarely vary by occupancy.

Now, or as a job

The store is read first. Every cell it holds within max_age comes back in cells at once, billed at the cache-hit rate. The rest becomes a job on the batch queue, and the response carries it:

  • 200 and job: null when every cell was served;
  • 202 and job: { … } when some were not. cells_total is the whole request, cells_served what came back now. Poll GET /jobs/{id}, then page GET /jobs/{id}/cells or fetch GET /jobs/{id}/export.
{
  "chain": "marriott", "kind": "cash", "los": [1], "occupancy": { "adults": 2, "children": [], "rooms": 1 }, "currency": "USD",
  "cells_total": 21, "cells_served": 9, "cells": [ "…9 cells…" ],
  "job": {
    "id": "0d4a7f2e-8c11-4b6a-b3f5-9e2c1d7a6b40", "kind": "matrix", "status": "queued",
    "cells_total": 12, "cells_done": 0, "tasks_total": 2, "tasks_done": 0, "tasks_failed": 0,
    "credits_reserved": 1.2, "credits_spent": 0,
    "created_at": "2026-10-11T09:02:14.000Z", "finished_at": null, "webhook_url": null
  },
  "shopped_at": "2026-10-11T09:02:14.000Z"
}

The job’s cells_total counts only the cells it has to fill, and tasks_total the upstream calls the planner split them into: fewer than cells where a chain has a native calendar (a window of nights per call) or a native batch endpoint (25 hotels per call).

credits_reserved and credits_spent

credits_reserved is the worst case quoted at creation: every cell of the job shopped live and billable. Before the job is accepted, the sum of the cells served now and this reserve is checked against the key’s monthly and daily ceilings; a request that would exceed one is refused with 429 quota_exceeded and nothing is queued or billed.

credits_spent is what the tasks have actually cost so far, and it is what you pay: a task whose cells came from cache bills the cache-hit rate, a task that failed bills nothing. The reserve is a guard, not a charge. The multiplier a key’s plan applies to a chain is frozen into the job at creation, so every task of a job bills alike.

Polling GET /jobs/{id}

Pricing: 0 credits.

curl -s https://api.directrate.dev/v1/jobs/{id} \
  -H "x-api-key: $DIRECTRATE_API_KEY"
import os
import requests

r = requests.get(
    "https://api.directrate.dev/v1/jobs/{id}",
    headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())
const res = await fetch("https://api.directrate.dev/v1/jobs/{id}", {
  headers: { "x-api-key": process.env.DIRECTRATE_API_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("x-credits-charged"), await res.json());
status Meaning
queued accepted; no task has reported yet
running tasks are reporting; cells_done and tasks_done grow
done every task accounted for and at least one succeeded; tasks_failed says how partial
failed every task accounted for and none succeeded, or the job could not be queued
cancelled listed in the status enum; no public endpoint sets it today

finished_at is set when the job reaches done or failed. A job is done with tasks_failed above zero when a chain refused part of the work; the cells of those tasks are simply absent from the pages below.

Paging GET /jobs/{id}/cells

Pricing: 0 credits; the job paid for them.

curl -s "https://api.directrate.dev/v1/jobs/{id}/cells?limit=100" \
  -H "x-api-key: $DIRECTRATE_API_KEY"
import os
import requests

r = requests.get(
    "https://api.directrate.dev/v1/jobs/{id}/cells",
    headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
    params={
      "limit": "100",
    },
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())
const url = new URL("https://api.directrate.dev/v1/jobs/{id}/cells");
url.searchParams.set("limit", "100");
const res = await fetch(url, {
  headers: { "x-api-key": process.env.DIRECTRATE_API_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("x-credits-charged"), await res.json());

Cells in hotel_code, night, los order, limit up to 1000 (default 200). The response is { job_id, cells, next_cursor }; pass next_cursor back as cursor until it is null. The cells are read from the store, so a page requested while the job is running returns what has been filled so far and nothing for the rest; read again after done for the full set.

curl -s "https://api.directrate.dev/v1/jobs/{id}/cells?limit=100&cursor=CHIMK%7C2026-11-13%7C1" \
  -H "x-api-key: $DIRECTRATE_API_KEY"
import os
import requests

r = requests.get(
    "https://api.directrate.dev/v1/jobs/{id}/cells",
    headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
    params={
      "limit": "100",
      "cursor": "CHIMK|2026-11-13|1",
    },
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())
const url = new URL("https://api.directrate.dev/v1/jobs/{id}/cells");
url.searchParams.set("limit", "100");
url.searchParams.set("cursor", "CHIMK|2026-11-13|1");
const res = await fetch(url, {
  headers: { "x-api-key": process.env.DIRECTRATE_API_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("x-credits-charged"), await res.json());

A job created by POST /hotels/ids/jobs has no cells; this endpoint answers 400 for it.

Files: GET /jobs/{id}/export and POST /export

Pricing: GET /jobs/{id}/export 0 credits, the job paid. POST /export bills exactly as POST /matrix bills served cells.

GET /jobs/{id}/export?format=csv|jsonl streams every cell of the job in hotel_code, night, los order as one file, Content-Disposition: attachment. The response carries X-Credits-Charged: 0 and X-Job-Status, which says whether the job had finished when the file was made.

curl -s "https://api.directrate.dev/v1/jobs/{id}/export?format=csv" \
  -H "x-api-key: $DIRECTRATE_API_KEY"
import os
import requests

r = requests.get(
    "https://api.directrate.dev/v1/jobs/{id}/export",
    headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
    params={
      "format": "csv",
    },
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())
const url = new URL("https://api.directrate.dev/v1/jobs/{id}/export");
url.searchParams.set("format", "csv");
const res = await fetch(url, {
  headers: { "x-api-key": process.env.DIRECTRATE_API_KEY },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("x-credits-charged"), await res.json());

POST /export is the /matrix request plus format (csv, the default, or jsonl). When the store holds every cell within max_age, the answer is the file (200, text/csv or application/x-ndjson), with X-Credits-Charged and X-Cells on it. When it does not, the same job is queued and the answer is the JSON MatrixResponse with 202 and X-Job-Id; fetch the file from GET /jobs/{id}/export once the job is done.

curl -s https://api.directrate.dev/v1/export \
  -H "x-api-key: $DIRECTRATE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "chain": "marriott",
    "hotel_codes": [
      "CHIRL",
      "CHIMK",
      "CHIDT"
    ],
    "nights": {
      "from": "2026-11-10",
      "to": "2026-11-16"
    },
    "los": [
      1
    ],
    "kind": "cash",
    "format": "csv"
  }'
import os
import requests

r = requests.post(
    "https://api.directrate.dev/v1/export",
    headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
    json={
      "chain": "marriott",
      "hotel_codes": ["CHIRL", "CHIMK", "CHIDT"],
      "nights": {
        "from": "2026-11-10",
        "to": "2026-11-16",
      },
      "los": [1],
      "kind": "cash",
      "format": "csv",
    },
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())
const res = await fetch("https://api.directrate.dev/v1/export", {
  method: "POST",
  headers: {
    "x-api-key": process.env.DIRECTRATE_API_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    "chain": "marriott",
    "hotel_codes": [
      "CHIRL",
      "CHIMK",
      "CHIDT"
    ],
    "nights": {
      "from": "2026-11-10",
      "to": "2026-11-16"
    },
    "los": [
      1
    ],
    "kind": "cash",
    "format": "csv"
  }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("x-credits-charged"), await res.json());

Nothing is stored server-side: a file is re-derived from the store on every fetch, so you can fetch it again at any time and it reflects the store as it is then.

Columns

CSV has one header row and one row per cell with RFC 4180 quoting. The columns, in order, are stable so a loader can key on them:

chain, hotel_code, night, los, kind, adults, children, rooms, currency,
status, min_los, lowest_bar, lowest_bar_after_tax, lowest_member,
lowest_points, lowest_points_cash, cash_copay, cpp, cpp_points_cash,
certs, from_default_occupancy, observed_at, cache_age_sec

children is the ages joined with ;, certs is cert:top_up pairs joined with ;, from_default_occupancy is 0 or 1, and an absent figure is an empty field. JSON lines has one object per line: the MatrixCell with chain and kind added.

webhook_url on completion

Give POST /matrix, POST /export or POST /hotels/ids/jobs a webhook_url and the finished job is POSTed to it once, when it reaches done or failed:

{ "event": "job.finished", "job": { "id": "0d4a7f2e-…", "kind": "matrix", "status": "done", "cells_total": 12, "cells_done": 12, "tasks_total": 2, "tasks_done": 2, "tasks_failed": 0, "credits_reserved": 1.2, "credits_spent": 0.3, "created_at": "…", "finished_at": "…", "webhook_url": "https://example.com/hooks/directrate" } }

The request is content-type: application/json with user-agent: directrate-webhook/1 and a 10-second timeout. It is a single attempt and carries no signature: a non-2xx answer is logged on our side and not retried. Treat it as a nudge and GET /jobs/{id} as the source of truth. The signed, retried deliveries described in Watches & webhooks are for watch events only.

POST /hotels/ids/jobs

Pricing: 1 credit per hotel that needed a live resolution, billed as the job runs; the worst case (1 per hotel) is checked against the quota at creation.

Up to 500 hotel codes of one chain, resolved to each hotel’s id on Google Hotels and the OTA sources as a paced background job that never bursts the sources. The answer is 202 with a JobResponse of kind: "ids": tasks_done counts hotels processed and cells_done ids found. Read the results from GET /hotels/{chain}/{code}/ids; this kind of job has no cells and no export. sources narrows the lookup to a subset of google, google_hotels, booking, expedia, hotelscom, ctrip, agoda; webhook_url is POSTed on completion as above. It is the way to fill the id map for a city or a comp set without a burst, before POST /parity or an undercut watch needs the ids.

curl -s https://api.directrate.dev/v1/hotels/ids/jobs \
  -H "x-api-key: $DIRECTRATE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "chain": "marriott",
    "hotel_codes": [
      "CHIRL",
      "CHIMK",
      "CHIDT"
    ]
  }'
import os
import requests

r = requests.post(
    "https://api.directrate.dev/v1/hotels/ids/jobs",
    headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
    json={
      "chain": "marriott",
      "hotel_codes": ["CHIRL", "CHIMK", "CHIDT"],
    },
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())
const res = await fetch("https://api.directrate.dev/v1/hotels/ids/jobs", {
  method: "POST",
  headers: {
    "x-api-key": process.env.DIRECTRATE_API_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    "chain": "marriott",
    "hotel_codes": [
      "CHIRL",
      "CHIMK",
      "CHIDT"
    ]
  }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("x-credits-charged"), await res.json());

Billing per cell

A cell has a live price, and everything else follows from it:

  • On a chain with a native calendar and los: 1, a cell is a calendar night: 0.1 credit on most chains, 0.25 on Wyndham. Cash cells wherever the chain has a priced calendar; award cells only where the calendar also carries points.
  • Cash cells on a chain with a native batch endpoint cost the compset per-hotel rate (0.25 on Marriott, Choice and Meliá; 0.5 on Radisson, Wyndham and Best Western).
  • Everything else costs the chain’s shop weight (1 on most chains; 3 on Radisson, 5 on Best Western, 6 on Wyndham; 1 on the OTAs).

A cell served from the store, now or inside a job, bills 0.25 × that price. credits_reserved is the live price of every cell the job has to fill; credits_spent is the real bill. Cells with status error or not_found are free. The example above, 3 Marriott hotels × 7 nights at los: 1, is 21 calendar nights: 2.1 credits if every cell is shopped live, 0.53 if every cell is in the store.

The planner refuses what would be a cost bug rather than running it: an award matrix on a chain with no award points, and a per-hotel fan-out on Best Western or Wyndham, where one shop costs at least 10 × the batch-hotel price (cash at los: 1 goes through their calendars; other lengths of stay through the batch endpoint).

Limits

Limit Value
hotel_codes 200 per request (500 for POST /hotels/ids/jobs)
nights 366 check-in dates, inclusive
los 3 values, each 1 to 7
cells 20,000 per request
GET /jobs/{id}/cells page 1000 (default 200)
webhook_url at most 2000 characters

Field-level detail is in the API reference under MatrixRequest, MatrixResponse, MatrixCell, JobResponse, JobCellsResponse and ExportRequest. For a place rather than a list of codes, POST /area resolves the hotels and then behaves exactly as /matrix.

As MCP tools, matrix, job and job_cells are the three calls above with the same inputs and the same cell shape; a model is told to size a matrix (hotels × nights × los) and read usage before calling, because the worst case is reserved up front. Export has no tool: page the cells, or fetch the file over HTTP.