API reference

Small enough to read in one sitting.

Fourteen routes, all JSON. Sign up, create a resource at day or minute granularity, flatten a total onto a range, read what is still free, and increment a count to book it.

Conventions

Every route answers JSON and nothing else. Send accept: application/json on every request; anything else is a 406 with an empty body. Check this first on any unexplained failure. Requests with a body also want content-type: application/json; charset=utf-8.
An account lives on a node; the node’s hostname is your base URL. No route takes account_id; the account is whatever the credential resolves to. There are four nodes, and they share nothing — no account, no token, no resource:

sydney.tendzin.com

Sydney, Australia.

virginia.tendzin.com

Northern Virginia, United States.

eemshaven.tendzin.com

Eemshaven, Netherlands.

singapore.tendzin.com

Singapore.
Pick the one nearest whatever will be calling it. All four run the same build and the same routes, so nothing below changes with the host — every example here uses Sydney only because it has to use something.
Sign up on the node you mean to usePOST /account/new creates the account on the node you send it to, and it is reachable at no other. Signing up again on another node gets you a second, unrelated account rather than another way in to the first. Nothing in any response names your node, because nothing needs to — it is the host you signed up against. Store it beside the token. tendzin.com is this website: it answers no API route at all, so a 404 from there means the host is wrong, not the route.
The one exemptionGET {node}/_health takes no credential and no headers, and answers {"status":"ok"}. It says the node booted and is serving, which is all an unauthenticated caller has any business learning — on your node, not tendzin.com. A node that answers here and 401s elsewhere means your token is the problem.

Authentication

A token (tz_secret_ + 43 chars) goes in Authorization: Bearer. A TOTP secret is never sent; derive a six-digit code from it and send in otp.
Enrolment also gives ten recovery codes (16 base32 chars). A recovery code goes in recovery_code, never otp. Reading that now costs nothing; later it costs the account.
The token authorizes use. The second factor authorizes credential changes. Issuing, revoking, and rotating all require a code and carry no Authorization header — the credential that cuts an attacker off must not be the one that leaked.

Someone takes your token

Revoke it from another machine. You still hold the TOTP secret, so nothing else is at risk.

Someone takes your TOTP secret

Then it is a race. Rotating the secret locks whoever moves second out permanently, so rotate the moment you suspect it. Keep the two in different places if you can.
There is no account recovery beyond the ten codesNo password, no email, no support queue. Lose the token and the secret with no recovery codes left, and the account is gone. That is the price of an API you can sign up for without a human.

Accounts and tokens

POST

/account/new

auth: none

Creates an account and returns a token. The body is optional and carries exactly one field, billing_email. account_id has no effect — the id is generated here.
no body
curl -X POST https://sydney.tendzin.com/account/new \
  -H 'accept: application/json'
201
{
  "account_id": "3f2a8c14-6c1e-11e7-80aa-03af88b051d5",
  "token_id":   "8c4e1d90-6c1e-11e7-80aa-03af88b051d5",
  "token":      "tz_secret_KJd9xR2vQpL7mN4bT8wY1cF6hA3sE5uZ0iO",
  "created_at": "2026-08-11T09:14:00Z"
}
body — optional
FieldTypeNotes
billing_emailstring | null — optionalOptional. Omit it, or send null, and the account is created without one — which is the ordinary case for an agent signing up unattended. Where an invoice would go; never verified, and not a credential. At most 254 bytes. Add or change it later with POST /account/update.
An account without an address works exactly like an account with one: nothing authenticates against it and no route reads it to decide anything.
with a billing address
curl -X POST https://sydney.tendzin.com/account/new \
  -H 'accept: application/json' \
  -H 'content-type: application/json; charset=utf-8' \
  -d '{"billing_email":"ops@example.com"}'
A malformed address is a 400, not a dropped fieldThe signup does not succeed with the address quietly discarded — the whole point of it is that an invoice arrives, and finding out it never landed would happen at the worst possible moment. It also costs nothing from the daily signup ceiling, so fixing a typo and retrying is free.
POST

/account/enroll

auth: bearer token

Do this immediately — until you do, the token cannot replace itself. Once only; a second call is 409 already_enrolled. A token cannot make credential changes without a second factor.
201
{
  "secret":         "JBSWY3DPEHPK3PXP",
  "otpauth_uri":    "otpauth://totp/Tendzin:3f2a…?issuer=Tendzin&…",
  "recovery_codes": ["K7M2QX4RTB9WNC5A", "…nine more…"]
}
This response happens onceThe secret and the codes are on this body and never on another. There is no route that re-reads them. Write them down before you do anything else.
Standard TOTP: HMAC-SHA1, 6 digits, 30s period, base32 no padding. otpauth_uri for when a person is involved.
deriving a code
# python
import pyotp
otp = pyotp.TOTP(secret).now()

# elixir
otp = NimbleTOTP.verification_code(Base.decode32!(secret, padding: false))

# node
import { TOTP } from "otpauth";
const otp = new TOTP({ secret }).generate();
GET

/account

auth: bearer token

Returns account status.
200
{
  "account_id":          "3f2a…",
  "billing_email":       "ops@example.com",
  "enrolled_2fa":        true,
  "recovery_codes_left": 10
}
POST

/account/update

auth: OTP only

Sets or clears billing_email, and nothing else. Answers the same shape as GET /account, so you can read back what you set without a second request. Send null to clear it — there is no delete route.
bash
curl -X POST https://sydney.tendzin.com/account/update \
  -H 'accept: application/json' \
  -H 'content-type: application/json; charset=utf-8' \
  -d '{"account_id":"3f2a…","otp":"492057","billing_email":"billing@example.com"}'
OTP only, never a recovery code. The same rule as /account/enroll/rotate, for the same reason: if this address ever becomes a way back into an account, one a recovery code could set would be a permanent takeover.
The address is never verifiedNo confirmation mail is sent and nothing authenticates against it. It is not a credential, it decides nothing, and two accounts may carry the same one — one operator holding many accounts is the premise of unattended signup. At most 254 bytes.
Two 400s, both deliberateA malformed address is refused before the code is spent — send the same OTP again with the address fixed.Omitting billing_email is also a 400, not a no-op. A request that changes nothing has almost certainly misspelled the field, and finding that out by spending a single-use code is a bad trade.
POST

/account/tokens

auth: OTP or recovery code

Second factor only. A token cannot mint another token — a copied token cannot open a credential you would never look for.
bash
curl -X POST https://sydney.tendzin.com/account/tokens \
  -H 'accept: application/json' \
  -H 'content-type: application/json; charset=utf-8' \
  -d '{"account_id":"3f2a…","otp":"492057"}'
A recovery code does the same job when the secret is the thing that is gone. It goes in a field of its own, and is spent only when the request succeeds.
bash
curl -X POST https://sydney.tendzin.com/account/tokens \
  -H 'accept: application/json' \
  -H 'content-type: application/json; charset=utf-8' \
  -d '{"account_id":"3f2a…","recovery_code":"K7M2QX4RTB9WNC5A"}'
Field name mattersA recovery code sent in otp is a 401 — same as a wrong code. The route tells an anonymous caller nothing. Five failures lock every code route for fifteen minutes.If your codes are being refused, check the field name before you conclude the codes are bad — and stop after two attempts. Ten good codes spent one at a time in otp look exactly like ten bad ones, and end in a lockout.
Two things that will bite youClock skew. The server accepts the current thirty-second step and one either side — about ninety seconds of tolerance. A host more than a minute out of true fails every code. Run NTP.Single use. Two tokens inside one window fails the second time even though the code still looks valid. Wait for the next step.
GET

/account/tokens

auth: bearer token

The one credential route on the token — it changes nothing. Gating a read on a code would make “list, then revoke” cost two codes.
200
{
  "tokens": [
    { "id": "8c4e…", "preview": "tz_secret_…3sE5",
      "created_at": "2026-08-11T09:14:00Z", "revoked_at": null },
    { "id": "2b71…", "preview": "tz_secret_…9fT2",
      "created_at": "2026-07-02T11:02:00Z", "revoked_at": "2026-08-01T00:00:00Z" }
  ]
}
Previews, never values. Match by id or last four chars of preview. Revoked entries keep revoked_at.
POST

/account/tokens/revoke

auth: OTP or recovery code

Second factor only — including when revoking the token you hold. Returns changed entries. Recovery code works in recovery_code. Body validated before code spent.
bash
curl -X POST https://sydney.tendzin.com/account/tokens/revoke \
  -H 'accept: application/json' \
  -H 'content-type: application/json; charset=utf-8' \
  -d '{"account_id":"3f2a…","otp":"492057","token_ids":["8c4e…","2b71…"]}'
Takes a list — one request for many. All or nothing: unknown id returns 404 unknown_token, same as non-existent, so no probing. Effect is immediate.
POST

/account/enroll/rotate

auth: OTP only

Replaces TOTP secret and issues ten fresh codes, returning the enrolment body. Old secret and codes die on success. Tokens untouched — this rotates the second factor only.
LockoutFive wrong codes in a row locks every route that takes a code — issuing, revoking and rotating — for fifteen minutes, on one counter for the account. Tokens you already hold keep working throughout: the lock stops you changing credentials, it does not sign you out. Anyone who knows your account_id can trigger it, so treat a sudden run of 401s here as possible interference rather than proof your secret is wrong.

Resources

A resource is one calendar. Granularity (day or minute) is fixed at creation and appears in the URL. day for nights, minute for intra-day.
POST

/range/{granularity}

auth: bearer token

Creates empty resource. No capacity until you flatten a total.
bash
curl -X POST https://sydney.tendzin.com/range/minute \
  -H "authorization: Bearer $TOKEN" \
  -H 'accept: application/json' \
  -H 'content-type: application/json; charset=utf-8'
201
{ "result": { "id": "b41d7c8e-6c1e-11e7-80aa-03af88b051d5" } }
Granularity fixed for life of resource. No conversion later. Nightly = day; intra-day = minute.
GET

/resources

auth: bearer token

Lists all resources and their granularity.
200
{
  "result": [
    { "id": "b41d7c8e…", "type": "day", "inserted_at": "2026-08-11T09:20:00Z" }
  ]
}
A resource has no name, and there is no deleteNo body on create. No name or metadata on resource — record the UUID yourself; GET /resources will not. Not recoverable from API.No DELETE. Retire by flattening total to 0.

Reading availability

Two reads, and the difference is what they do with adjacency. inventories gives you every distinct stretch. contiguous-inventories gives you those stretches already grouped into unbroken runs — which is what you want the moment a booking has to span more than one of them.
GET

/range/{granularity}/{id}/inventories

auth: bearer token

A flat list, merged where the numbers do not change.
200
{
  "result": [
    { "range": { "lower": "2026-09-01", "upper": "2026-09-13" }, "count": 0, "total": 12 },
    { "range": { "lower": "2026-09-14", "upper": "2026-09-17" }, "count": 1, "total": 12 },
    { "range": { "lower": "2026-09-18", "upper": "2027-03-31" }, "count": 0, "total": 12 }
  ]
}
upper is inclusive — last day in the booking, not first after it. Minute resources return timestamps.
200 — a minute resource
{
  "result": [
    { "range": { "lower": "2026-09-01T08:00", "upper": "2026-09-01T08:59" }, "count": 0, "total": 10 },
    { "range": { "lower": "2026-09-01T09:00", "upper": "2026-09-01T10:00" }, "count": 1, "total": 10 }
  ]
}
Bounds read back in the form writes accept — a read bound goes straight into the next write or filter.
GET

/range/{granularity}/{id}/contiguous-inventories

auth: bearer token

Same inventories grouped into runs. Each group is a contiguous window an agent can offer without client-side stitching.
bash
curl -X GET "https://sydney.tendzin.com/range/day/…/contiguous-inventories" \
  -H "authorization: Bearer $TOKEN" \
  -H 'accept: application/json'
200
{
  "result": [
    { "inventories": [
        { "range": { "lower": "2026-09-01", "upper": "2026-09-13" }, "count": 0, "total": 12 },
        { "range": { "lower": "2026-09-14", "upper": "2026-09-17" }, "count": 1, "total": 12 }
    ] }
  ]
}

Query parameters

Both reads take the same four filters. Pairs are exclusive — gte + gt is a 400.
FieldTypeNotes
upper-range-gtea boundupper bound on or after this point
upper-range-gta boundstrictly after; exclusive with gte
total-minus-count-gteintegerat least this much left (1 = bookable)
total-minus-count-gtintegerstrictly more; exclusive with gte
A filter bound is in the resource's own formatSame format as events: 2026-09-05 (day) or 2026-09-05T09:30 (minute). Wrong granularity is a 400. Read bounds are already in write shape.
upper-range-gte is not a window queryFilters by each entry’s own upper bound, not overlap. A run covering your window may extend years past it. No lower-range-* to close the other side. Use as a floor; compute coverage client-side.
Both reads return everything that matchesThere is no limit, no offset, no cursor and no ETag — a calendar with years of fragmented inventory hands you all of it, every time. Bound your reads with upper-range-gte rather than pulling the whole history, and cache on your side if you search several resources per request: availability across ten room types is ten reads, and nothing here will tell you they are unchanged.There is no change feed and no webhook either. If inventory also moves through a phone or an OTA, polling is how you notice.

Writing events

PATCH

/range/{granularity}/{id}

auth: bearer token

Applies events. 204 on success. Malformed event fails whole request with index of the bad one.
bash
curl -X PATCH https://sydney.tendzin.com/range/day/… \
  -H "authorization: Bearer $TOKEN" \
  -H "tendzin-transaction-id: e75ec5ed-9c7d-4479-a088-0d24be73cca5" \
  -H 'accept: application/json' \
  -H 'content-type: application/json; charset=utf-8' \
  -d @events.json
events.json
{
  "events": [
    { "column": "total", "operation": "flatten",   "delta": 12,
      "range": { "lower": "2026-09-01", "upper": "2027-03-31" } },
    { "column": "count", "operation": "increment", "delta": 1,
      "range": { "lower": "2026-09-14", "upper": "2026-09-17" } }
  ]
}
event
FieldTypeNotes
column"count" | "total"taken, or existing
operation"increment" | "decrement" | "flatten"add, subtract, or set outright
deltainteger >= 0always non-negative; direction is the operation's job
range.lowerYYYY-MM-DD or YYYY-MM-DDTHH:MMfirst unit in the booking
range.uppersamelast unit in the booking, inclusive. Must not be before lower; may equal it
upper is inclusive, and this is the thing to get right first2026-09-14..2026-09-17 is 4 days. 09:00..10:00 is 61 minutes.Book 09:00..09:59 + 10:00..10:59, not 09:00..10:00 + 10:00..11:00 — that double-books the 10:00 minute. Subtract one from every upper when converting from half-open.
A range has no maximum spanOne event can flatten a total across years, so opening a season is a single event rather than a loop over its days. What a range costs is in how many distinct runs it leaves behind, not in how long it is.
tendzin-transaction-idSend UUID header to identify write for safe retry. Omit and node generates one (fine for non-retried writes). Must be canonical hyphenated UUID — bare hex, urn: prefix, or custom key is 400. Hash your key into a UUID. No header = node generates; unreadable header refused. Case-insensitive.Replay ignores body. Id claimed before events read, so same id + different events returns 204 silently. Reuse only for identical retry; fresh id per distinct write.
On a minute resource the ranges are timestamps and everything else is identical — the same three operations, the same two columns, the same route with minute in place of day.

The invariant

0 ≤ count ≤ total always. Service enforces on write. Whole request fails on violation; error carries offending range in inventories. Three messages under 400 invariant_violated:
total lower than count
pushed count above total — the last unit went to somebody else
duration with negative count
released more than was taken; a cancel applied twice looks like this
duration with zero total
wrote a count onto a point with no capacity on it — usually a date past the end of the horizon you flattened
errors is a list; one bad range can fill multiple rows. Read inventories for which points failed.
This is what stops you overbooking, and it means you do not need a lockRead then write. Refused write on race = invariant_violated; treat as “someone beat me to it” and re-read. Reading first is for the human message, not correctness.Check runs once on final state. Moving a booking (dec + inc) is one request. Lower total below count: move bookings first.
A point with no total on it is not bookableCapacity is written. Until then the point is absent from reads; count touching it is refused with duration with zero total. Absence means zero, not unknown — horizon ends where total ends.flatten total of 0 removes points from reads; neighbouring runs close over the hole.

Errors

Every error body is the same shape. Branch on code; the message is for a human and may be reworded.
401
{
  "errors": [
    { "code": "invalid_token", "message": "invalid credentials", "inventories": [] }
  ],
  "status": 401
}
unauthorized
401
no Authorization: Bearer header
invalid_token
401
credential does not resolve, second factor refused, or the resource is not yours
unknown_token
404
an id in token_ids is not yours
invalid_request
400
the body or the query string is the wrong shape
invariant_violated
400
the write parsed, and would have taken count outside [0, total]
conflict
409
too many writers on one resource at once; the node gave up retrying
already_enrolled
409
enrolled twice
rate_limited
429
the limiter, or the signup ceiling
error
varies
anything not yet given a code
A write has a third failure, and it is not a 400409 conflict (conflict, retry): multiple writers on one resource; node gave up retrying. Nothing written, and unlike invariant_violated, no other booking succeeded. Back off and retry with same transaction id.
Code routes return identical 401 for every failure (wrong code, unknown account, not enrolled, already used, locked). Route must not reveal if guessed account exists. Check account id, clock, spent status.

Rate limits

A global sign-up ceiling and a per-account lock. A per-IP limit is not in force at present and is expected to return.

Every POST under /account

new, enroll, tokens, tokens/revoke and enroll/rotate: the five-failure account lock on the routes that take a code. new also has a global per-day ceiling, which is not about you and is the 429 you are most likely to see.

Everything else

No limit today. Reads, writes, GET /account, GET /account/tokens, GET /resources and GET /_health are all outside it.
The per-IP bucket that stops code guessing is currently off. Do not tune a client to its absence — it can return without notice, and a client that retries tightly will start collecting 429s the day it does.
Do not build a booking client around the limiterBooking client work (search, write, list) is unlimited. Limited routes are signup-only. Token buckets and jitter wrappers solve a problem you do not have.Handle 429 as unexpected 5xx, not something to design around. When the per-IP budget is in force it is per address, so a fleet behind one NAT is one caller.
Back off on your own schedule, with jitterSign-up ceiling resets daily. The per-IP limit, when on, is a 10-minute window and a 30-minute ban.Enforced at the load balancer — a fleet behind one NAT shares the budget. 429 returns JSON with code: "rate_limited".

Usage and billing

Not yet in effectNothing on this page is live. No response carries warnings today and no route answers 402. It is published now so a client can be built to tolerate it before it appears — a client that starts handling this the day it ships is a client that breaks the day it ships. Treat it as a contract being committed to, not as behaviour to test against.
An account that has gone past its included usage, or that needs a payment method before it can continue, is told on the responses it is already making. There is no new route and nothing to poll.
While the account is in grace, every authenticated route answers normally — same status, same body, same fields — with one array added.
200
{
  "result": [ … as always … ],
  "warnings": [
    { "code":                 "usage_exceeded",
      "message":              "usage for this period is above the included allowance",
      "grace_period_ends_at": "2026-09-01T00:00:00Z" }
  ]
}
warnings[]
FieldTypeNotes
codestringusage_exceeded — the meter has passed what the plan includes. payment_required — there is no usable payment method. Separate because the fix is different: pay for the overage or move plan, versus supply an instrument at all.
messagestringHuman-readable. Show it to whoever can act on it; do not parse it.
grace_period_ends_atstring | nullISO 8601 UTC, or null where no grace applies. On a 402 this is in the past — it is what elapsed.
A list, exactly like errorsOne entry today and usually one, but an account can be both over its allowance and without a usable payment method, and a shape that could only carry one of those would have to pick. Read it the way you read errors: iterate, do not index. The key is absent when there is nothing to say, never an empty list.
Warnings are not errorsThey appear alongside result, never instead of it, and the request they ride on has succeeded in full. A client that treats an unrecognised top-level key as a failure, or that fails a schema check on one, will break when this ships. That is the single thing this section exists to prevent.
When the grace period ends and nothing has changed, the same condition stops being advisory. Every authenticated route answers 402 Payment Required, carrying the ordinary error shape and the same warnings array — so a client that already reads the warnings reads them identically here.
402
{
  "errors": [
    { "code":        "payment_required",
      "message":     "payment required; usage for this period is above the included allowance",
      "inventories": [] }
  ],
  "warnings": [
    { "code":                 "usage_exceeded",
      "message":              "usage for this period is above the included allowance",
      "grace_period_ends_at": "2026-08-14T00:00:00Z" }
  ],
  "status": 402
}
What a 402 does not doIt is not permanent and it is not a lockout. The 402 continues until the account is fixed and stops on the next request after it is. Nothing is deleted, no resource is released, and no token is revoked.GET /account and the credential routes keep working. An account that cannot read its own state or rotate a leaked secret cannot fix anything. Being unable to pay must not also mean being unable to respond to a breach.GET /_health is unaffected — it carries no credential and therefore no account to bill.
Retry a 402 the way you would a 503: with backoff, not immediately. A tight loop fixes nothing and is the behaviour most likely to earn a rate limit on top of it.