Automation
Watches & webhooks
Watch one hotel's nights for a change and get a signed POST when it happens, with retries, a replay log, and a bill per refresh.
What a watch is
A watch is one hotel × a range of check-in nights × one length of stay, refreshed on a schedule
you pick. Each check-in night in the range is one cell, the same cell POST /matrix fills; a watch
re-reads its cells every refresh_sec and compares each one with what that watch last saw. When the
comparison meets the watch’s condition, an event is created and POSTed to your webhook_url.
The range covers at most 90 nights, los is 1 to 7, and kind is award (points, the default)
or cash. refresh_sec is one of six tiers: 900, 1800, 3600, 10800, 21600 (the default) or
86400 seconds. The sweep that refreshes due watches runs every 15 minutes, which is why 900 s is
the floor. A Free key refreshes at most daily: a refresh_sec under 86400 is raised to 86400 on
creation, and the response shows the refresh_sec actually set.
Many watches on the same cell share one upstream call, which is why a refresh bills at the cache-hit rate rather than the live one (see Billing).
The five conditions
condition |
Fires when | Needs |
|---|---|---|
available |
a night turns available (the first observation counts if it already is) |
nothing else |
points_lte |
a night is available and its lowest_points is at or under threshold |
threshold, kind: "award" |
any |
any compared field of a night moves; also once per night on first observation | nothing else |
rebook |
the direct rate (kind: "cash") or the award (kind: "award") drops under what the guest paid by at least rebook.min_saving_pct |
rebook, a single stay (nights.from equals nights.to, los is the stay) |
undercut |
the OTA channel named in undercut sells the same night under the direct public rate by at least undercut.min_pct |
undercut, kind: "cash", a hotel chain (the OTA is the channel) |
Event type is met for available, points_lte, rebook and undercut, and initial or
changed for any. A condition fires on the transition, once: a night that already met it and still
does is not reported again, and a night that stops meeting it and later meets it again is.
rebook takes paid_rate (per night) or paid_total (divided by los) for a cash watch, or
paid_points (per night) for an award watch. compare is bar (default) or member;
paid_includes_tax: true compares against lowest.bar_after_tax instead of lowest.bar. The member
comparison is pre-tax only, so compare: "member" with paid_includes_tax: true is refused.
min_saving_pct defaults to 5; 0 fires on any drop. A figure the cell lacks (no after-tax number
on that chain, no member rate on an anonymous shop) never fires.
undercut takes channel (one of booking, expedia, hotelscom, ctrip, agoda), the
hotel’s id on that channel as hotel_code (from GET /hotels/{chain}/{code}/ids), min_pct
(default 2; 0 fires on any undercut) and compare: after_tax (default, lowest.bar_after_tax on
both sides; a chain without tax data never fires) or native (each side’s own lowest.bar). Both
sides must be available and carry the figure. The channel’s cells refresh with the watch and are on
its bill. POST /parity is the one-off version of this comparison.
Create a watch
Pricing: 0 credits to create; the key’s quota is checked against one refresh, so a key with no room cannot create a watch that would never run.
curl -s https://api.directrate.dev/v1/watches \
-H "x-api-key: $DIRECTRATE_API_KEY" \
-H "content-type: application/json" \
-d '{
"chain": "marriott",
"hotel_code": "CHIRL",
"nights": {
"from": "2026-11-10",
"to": "2026-11-20"
},
"los": 1,
"kind": "award",
"condition": "points_lte",
"threshold": 40000,
"webhook_url": "https://example.com/hooks/directrate",
"refresh_sec": 3600
}'import os
import requests
r = requests.post(
"https://api.directrate.dev/v1/watches",
headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
json={
"chain": "marriott",
"hotel_code": "CHIRL",
"nights": {
"from": "2026-11-10",
"to": "2026-11-20",
},
"los": 1,
"kind": "award",
"condition": "points_lte",
"threshold": 40000,
"webhook_url": "https://example.com/hooks/directrate",
"refresh_sec": 3600,
},
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())const res = await fetch("https://api.directrate.dev/v1/watches", {
method: "POST",
headers: {
"x-api-key": process.env.DIRECTRATE_API_KEY,
"content-type": "application/json",
},
body: JSON.stringify({
"chain": "marriott",
"hotel_code": "CHIRL",
"nights": {
"from": "2026-11-10",
"to": "2026-11-20"
},
"los": 1,
"kind": "award",
"condition": "points_lte",
"threshold": 40000,
"webhook_url": "https://example.com/hooks/directrate",
"refresh_sec": 3600
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("x-credits-charged"), await res.json());The response is 201 with the watch:
{
"id": "6b2d6a1e-3f6c-4d1a-9a0b-2f0c8a9e1d44",
"chain": "marriott", "hotel_code": "CHIRL",
"nights": { "from": "2026-11-10", "to": "2026-11-20" }, "los": 1, "kind": "award",
"occupancy": { "adults": 2, "children": [], "rooms": 1 }, "currency": "USD",
"condition": "points_lte", "threshold": 40000, "rebook": null, "undercut": null,
"webhook_url": "https://example.com/hooks/directrate", "refresh_sec": 3600,
"cells": 11, "credits_per_refresh": 0.28,
"status": "active", "next_refresh_at": "2026-10-11T09:15:00.000Z", "last_refreshed_at": null,
"created_at": "2026-10-11T09:15:00.000Z",
"secret": "9f3c…64 hex characters…b71e"
}
secret is the HMAC key for this watch’s deliveries. It is returned once, here, and never again:
GET /watches/{id} omits it. Store it with the watch id.
cells is the number of nights the watch covers and credits_per_refresh is what one refresh bills.
GET /watches lists the key’s watches and GET /watches/{id} returns one; both are free.
What a delivery looks like
Every event is one POST to webhook_url with a JSON body and these headers:
| Header | Value |
|---|---|
content-type |
application/json |
user-agent |
directrate-webhook/1 |
x-directrate-signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> (below) |
x-directrate-event |
the event type: initial, changed or met |
x-directrate-watch |
the watch id |
x-directrate-event-id |
the event id, unique per event; the same id is sent again on a retry |
The body is a WatchEvent: the night’s state before and after, as MatrixCell objects.
{
"event_id": "c1f0a2d4-7b3e-4f9a-8e21-5d6c0b9a3f10",
"watch_id": "6b2d6a1e-3f6c-4d1a-9a0b-2f0c8a9e1d44",
"type": "met",
"chain": "marriott", "hotel_code": "CHIRL", "night": "2026-11-14", "los": 1, "kind": "award",
"prev": { "hotel_code": "CHIRL", "night": "2026-11-14", "los": 1, "status": "available", "min_los": null,
"lowest": {}, "lowest_points": 46000, "lowest_points_cash": null, "cash_copay": null, "cpp": null,
"cpp_points_cash": null, "currency": "USD", "observed_at": "2026-10-11T08:15:02.000Z", "cache_age_sec": 3600 },
"next": { "hotel_code": "CHIRL", "night": "2026-11-14", "los": 1, "status": "available", "min_los": null,
"lowest": {}, "lowest_points": 38000, "lowest_points_cash": null, "cash_copay": null, "cpp": null,
"cpp_points_cash": null, "currency": "USD", "observed_at": "2026-10-11T09:15:04.000Z", "cache_age_sec": 0 },
"created_at": "2026-10-11T09:15:04.000Z",
"delivered_at": null,
"attempts": 0
}
prev is null on an initial event. A rebook event adds
rebook { basis, paid, current, saving, saving_pct, los, saving_total }; an undercut event adds
undercut { channel, channel_hotel_code, basis, direct, channel_rate, diff, diff_pct, channel_observed_at },
where diff is channel_rate minus direct (negative when the channel undercuts). delivered_at and attempts
are as they were when the body was written, before the first attempt; GET /events shows the current
values.
Answer with any 2xx within 10 seconds. Anything else, including a timeout, counts as a failed
attempt.
Verifying the signature
The signature header is t=<t>,v1=<hex> where t is a Unix timestamp in seconds and hex is
HMAC-SHA256 over the string <t>.<body>, keyed with the watch’s secret as UTF-8 bytes (the
64-character hex string itself, not its decoded bytes), encoded as lowercase hex. body is the raw
request body exactly as received. Because t is inside the signed string, a captured delivery cannot
be replayed later with a different timestamp; reject a t more than a few minutes from your clock.
Compute the HMAC over the raw bytes of the body. Re-serialising the parsed JSON changes key order or whitespace and breaks the match.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body as a string or Buffer, untouched. header: x-directrate-signature.
export function verifyDirectrate(secret, rawBody, header, toleranceSec = 300) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
if (!m) return false;
const t = Number(m[1]);
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));
}
import hashlib
import hmac
import re
import time
def verify_directrate(secret: str, raw_body: bytes, header: str, tolerance_sec: int = 300) -> bool:
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
if not m:
return False
t = int(m.group(1))
if abs(int(time.time()) - t) > tolerance_sec:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, m.group(2))
In Express, read the body with express.raw({ type: "application/json" }) on the webhook route and
parse it only after the check passes. In Flask or FastAPI, use request.get_data() or
await request.body() rather than the parsed JSON.
Retries
A delivery that does not get a 2xx is retried on a fixed schedule: the next attempt follows after
60 s, then 300 s, 1800 s, 7200 s, 21600 s and 43200 s, about 24 hours in all. After the last of those
six retries the event stays in the log as undelivered, with its last error, and is not sent again;
read it from GET /events. Retries carry the same x-directrate-event-id, so an endpoint that
answered 2xx late and then sees the id again can drop the duplicate.
Failed deliveries never pause the watch: it keeps refreshing and keeps billing. An event whose watch was deleted before it was delivered is dropped.
When a watch stops on its own
expired: the sweep marks a watchexpiredonce its last night (nights.to) is earlier than yesterday UTC, the same rule that refuses a past check-in on a request. Expired is final; resuming an expired watch is refused with400 invalid_request. Create a new one.pausedby the system: a watch whose API key is gone (revoked or deleted) is paused at the next sweep instead of refreshing unpaid.- Skipped at the quota: when a refresh would carry the key past its monthly or daily ceiling, the
watch is not refreshed that cycle; it is moved forward one
refresh_secand tried again. It staysactiveand nothing is billed for the skipped cycle.
Pause, resume, delete
Pricing: 0 credits each.
POST /watches/{id}/pause stops refreshes and billing and returns the watch with status: "paused".
curl -s https://api.directrate.dev/v1/watches/{id}/pause \
-H "x-api-key: $DIRECTRATE_API_KEY" \
-H "content-type: application/json" \
-d '{}'import os
import requests
r = requests.post(
"https://api.directrate.dev/v1/watches/{id}/pause",
headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
json={},
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())const res = await fetch("https://api.directrate.dev/v1/watches/{id}/pause", {
method: "POST",
headers: {
"x-api-key": process.env.DIRECTRATE_API_KEY,
"content-type": "application/json",
},
body: JSON.stringify({}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("x-credits-charged"), await res.json());POST /watches/{id}/resume sets it active; it refreshes at the next sweep, and the first refresh
after a pause fires like any other (the baseline is what the watch last saw, not the time elapsed).
curl -s https://api.directrate.dev/v1/watches/{id}/resume \
-H "x-api-key: $DIRECTRATE_API_KEY" \
-H "content-type: application/json" \
-d '{}'import os
import requests
r = requests.post(
"https://api.directrate.dev/v1/watches/{id}/resume",
headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
json={},
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())const res = await fetch("https://api.directrate.dev/v1/watches/{id}/resume", {
method: "POST",
headers: {
"x-api-key": process.env.DIRECTRATE_API_KEY,
"content-type": "application/json",
},
body: JSON.stringify({}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
console.log(res.headers.get("x-credits-charged"), await res.json());DELETE /watches/{id} removes the watch and its baseline and answers { "deleted": true }.
Events already in the log stay readable from GET /events.
curl -s -X DELETE https://api.directrate.dev/v1/watches/{id} \
-H "x-api-key: $DIRECTRATE_API_KEY"
Auditing deliveries: GET /events
Pricing: 0 credits.
curl -s "https://api.directrate.dev/v1/events?since=2026-10-11T00:00:00Z&limit=100" \
-H "x-api-key: $DIRECTRATE_API_KEY"import os
import requests
r = requests.get(
"https://api.directrate.dev/v1/events",
headers={"x-api-key": os.environ["DIRECTRATE_API_KEY"]},
params={
"since": "2026-10-11T00:00:00Z",
"limit": "100",
},
)
r.raise_for_status()
print(r.headers.get("x-credits-charged"), r.json())const url = new URL("https://api.directrate.dev/v1/events");
url.searchParams.set("since", "2026-10-11T00:00:00Z");
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());Every event on the key’s watches, delivered or not, in creation order. Each item is the exact body
that was (or will be) POSTed, with delivered_at and attempts as they stand now: delivered_at
null and attempts at 7 means the schedule ran out. since is an ISO-8601 timestamp; limit is up
to 500 (default 100); pass next_cursor back as cursor for the next page. This is the replay path
when your endpoint was down: read what you missed here, then process it as if it had arrived.
Billing
A refresh is billed when the sweep picks the watch up, before the upstream calls run, under the
usage line watch, at credits_per_refresh:
credits_per_refresh = cells × the chain's per-cell price × 0.25
The per-cell price is what one cell costs POST /matrix live: on a chain with a native calendar and
los: 1, the calendar night price (0.1 credit on most chains, 0.25 on Wyndham; award nights only
where the calendar carries points); on a chain with a native batch endpoint, the compset per-hotel
rate for cash cells; otherwise the chain’s shop weight. The 0.25 is the cache-hit multiplier, every
time: one upstream call behind a refresh is shared by every watch on the same cell, so each watch
pays the cached rate. The example above, 11 Marriott award nights at los: 1, bills 0.28 credits a
refresh, 6.72 a day at refresh_sec: 3600. An undercut watch adds the channel side at the OTA’s
shop weight: 11 nights on Booking.com add 11 × 1 × 0.25 = 2.75 a refresh.
The key’s quota is checked before every refresh. A refresh that would exceed the monthly or daily
ceiling is skipped for that cycle (the watch stays active and moves forward one refresh_sec); a
key with no room for one refresh cannot create the watch in the first place
(429 quota_exceeded). Creating, listing, reading, pausing, resuming and deleting a watch, and
reading GET /events, are free.
Limits
| Limit | Value |
|---|---|
| nights per watch | 90 (inclusive check-in range) |
los |
1 to 7 |
refresh_sec |
900, 1800, 3600, 10800, 21600, 86400; Free keys 86400 |
webhook_url |
an http(s) URL of at most 2000 characters |
| delivery timeout | 10 s per attempt |
| retries | 6, over about 24 hours |
GET /events page |
up to 500 (default 100) |
| watches per key | no cap is enforced; each refresh is checked against the quota |
A watch is planned exactly as a matrix of one hotel is: an award watch on a chain with no points, or a cash watch that would fan out one shop per night on Best Western or Wyndham, is refused at creation with the planner’s reason.
Field-level detail is in the API reference under
WatchRequest, WatchResponse, WatchEvent and EventsResponse.
As MCP tools, watches lists them, watch_create is POST /watches (the secret
comes back once, in the result) and watch_delete is DELETE /watches/{id}; pause and resume are
dashboard actions. The tool descriptions carry the per-refresh price, and a model is told to create a
watch only when asked for an alert, since it bills for as long as it stays active.