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:
200andjob: nullwhen every cell was served;202andjob: { … }when some were not.cells_totalis the whole request,cells_servedwhat came back now. PollGET /jobs/{id}, then pageGET /jobs/{id}/cellsor fetchGET /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.