Examples
Code that runs as it is written.
A day-granularity client in the pieces you would actually paste: the shapes, one response envelope, and a function for each thing you need to do. Replace the token and the resource id and it works against your node.
The shapesThe clientApplying eventsRead what is freeOpen capacityBookCancelMove a bookingBranch on failure
The shapes
Four things come back from this API: a range, an inventory, a run of inventories, and an error.
contiguous-inventories groups adjacent stretches into unbroken runs, so a ContiguousInventory is one run and a read hands back a list of them.Result<T> wraps all of it so no call throws and every caller branches the same way. ApiError.inventories is empty except on invariant_violated, where it holds the points that refused the write. These are the codes these functions can return; the full list is on the API page.types.ts
export interface Range {
lower: string;
upper: string;
}
export interface Inventory {
readonly range: Range;
readonly count: number;
readonly total: number;
}
export interface ContiguousInventory {
readonly inventories: Inventory[];
}
export type ErrorCode =
| "unauthorized"
| "invalid_token"
| "invalid_request"
| "invariant_violated"
| "conflict"
| "rate_limited"
| "error";
export interface ApiError {
code: ErrorCode;
message: string;
inventories: Inventory[];
}
export interface Result<T> {
result: T | null;
errors: ApiError[];
status: number;
}
export interface ReservationQuery {
id: string;
start: string;
end: string;
transactionKey?: string;
}The client
The host, the credential, and the one function that unwraps a response. Every route answers the same envelope, so this is the only place that has to know its shape: a
204 carries no body, a failure carries errors, and anything else carries result.The transaction header is left off entirely rather than sent empty: an empty value is a
400 and not an absent one. Genuinely absent, the node generates an id of its own, so a caller that was never going to retry loses nothing.client.ts
const host = `https://${process.env.TENDZIN_NODE}.tendzin.com`;
const token = process.env.TENDZIN_TOKEN;
function headers(transactionKey?: string): HeadersInit {
return {
accept: "application/json",
authorization: `Bearer ${token}`,
"content-type": "application/json; charset=utf-8",
...(transactionKey ? { "tendzin-transaction-id": transactionKey } : {}),
};
}
export async function respond<T>(response: Response): Promise<Result<T>> {
try {
if (response.status === 204) {
return { result: null, errors: [], status: 204 };
}
const json = await response.json();
if (!response.ok) {
return { result: null, errors: json.errors, status: response.status };
}
return { result: json.result, errors: [], status: response.status };
} catch (cause) {
return {
result: null,
errors: [{
code: "error",
message: cause instanceof Error ? cause.message : String(cause),
inventories: [],
}],
status: 500,
};
}
}Applying events
Every write on a resource is this call. One
PATCH carries as many events as you like and applies all of them or none, and delta is never negative — direction is the operation’s job. Booking, cancelling and opening capacity differ only in the events they hand it, which is why each function below is about ten lines.The transaction id has to be a canonical hyphenated UUID or the write is a
400 — bare hex and urn:uuid: parse perfectly well and are refused anyway, because one id in two spellings is two transactions. Retrying with the same one is safe; reusing one for a different write is accepted and silently ignored, so mint a fresh id per distinct write.events.ts
export interface Event {
column: "count" | "total";
operation: "increment" | "decrement" | "flatten";
delta: number;
range: Range;
}
export async function applyEvents(
id: string,
events: Event[],
transactionKey: string = crypto.randomUUID(),
): Promise<Result<null>> {
const response = await fetch(`${host}/range/day/${id}`, {
method: "PATCH",
headers: headers(transactionKey),
body: JSON.stringify({ events }),
});
return respond<null>(response);
}Read what is free
Everything with room left, from today onwards.
contiguous-inventories returns runs already stitched, and total-minus-count-gte=1 is what makes it “bookable” rather than “everything”.upper-range-gte is a floor on each run’s own upper bound, not a window — a run that matches may extend years past the date you asked about, so close the far side yourself.search.ts
export async function searchDates(
{ id }: { id: string },
): Promise<Result<ContiguousInventory[]>> {
const query = new URLSearchParams({
"upper-range-gte": new Date().toISOString().slice(0, 10),
"total-minus-count-gte": "1",
});
const response = await fetch(
`${host}/range/day/${id}/contiguous-inventories?${query}`,
{ method: "GET", headers: headers() },
);
return respond<ContiguousInventory[]>(response);
}Open capacity
A new resource holds nothing until a
total is flattened onto it, and a point with no total is not bookable — so this is the call that has to happen before any of the ones below will work.Opening a season and changing capacity are the same call:
flatten sets total outright, and whatever is already booked against the range stays booked. One event can span years, so do not loop over days.availability.ts
export async function updateAvailability({
id,
total,
ranges,
transactionKey,
}: {
id: string;
total: number;
ranges: Range[];
transactionKey?: string;
}): Promise<Result<null>> {
const events = ranges.map((range) => ({
column: "total" as const,
operation: "flatten" as const,
delta: total,
range,
}));
return applyEvents(id, events, transactionKey);
}Book
One
increment on count, across the whole stay. Eleven nights is one event, not eleven rows you hope all landed.end is the last night and not the checkout date: both ends of a range are included, so a stay arriving on the 14th and leaving on the 18th is 2026-09-14..2026-09-17.reservations.ts
export async function createReservationDates({
id,
start,
end,
transactionKey,
}: ReservationQuery): Promise<Result<null>> {
return applyEvents(id, [{
column: "count",
operation: "increment",
delta: 1,
range: { lower: start, upper: end },
}], transactionKey);
}Cancel
The same event with
decrement in place of increment. Send the same range you booked — a cancel applied twice is a 400 reading duration with negative count, which is worth telling apart from a range that was never taken.reservations.ts
export async function cancelReservationDates({
id,
start,
end,
transactionKey,
}: ReservationQuery): Promise<Result<null>> {
return applyEvents(id, [{
column: "count",
operation: "decrement",
delta: 1,
range: { lower: start, upper: end },
}], transactionKey);
}Move a booking
Two events, one request. Doing it as a cancel followed by a book opens a window where the guest has no room and somebody else can take it.
Because both events land together the invariant is checked once against the final state: the old nights are released and the new ones taken with no moment in between, and a move that would overbook fails without having released anything.
reservations.ts
export async function moveReservationDates({
id,
from,
to,
transactionKey,
}: {
id: string;
from: Range;
to: Range;
transactionKey?: string;
}): Promise<Result<null>> {
return applyEvents(id, [
{ column: "count", operation: "decrement", delta: 1, range: from },
{ column: "count", operation: "increment", delta: 1, range: to },
], transactionKey);
}Branch on failure
The part most clients get wrong.
invariant_violated means somebody took it between your read and your write — a lost race, not a bug. Re-read and offer what is left; error.inventories holds the points that refused, so you can say which nights went.conflict means too many writers on the resource at once and the node gave up: nothing was written and nobody else won either, so back off with jitter and retry with the same transaction id. rate_limited is the same instruction. Everything else is configuration rather than contention, and retrying it only spends the budget.booking.ts
const booked = await createReservationDates({
id,
start: "2026-09-14",
end: "2026-09-17",
});
if (booked.errors.length === 0) {
return "booked";
}
const [error] = booked.errors;
switch (error.code) {
case "invariant_violated":
return "gone";
case "conflict":
case "rate_limited":
return "retry";
default:
throw new Error(`${error.code}: ${error.message}`);
}