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.
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.account_id; the account is whatever the credential resolves to. There are four nodes, and they share nothing — no account, no token, no resource:POST /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.GET {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.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.recovery_code, never otp. Reading that now costs nothing; later it costs the account.Authorization header — the credential that cuts an attacker off must not be the one that leaked./account/new
auth: none
billing_email. account_id has no effect — the id is generated here.curl -X POST https://sydney.tendzin.com/account/new \
-H 'accept: application/json'{
"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"
}| Field | Type | Notes |
|---|---|---|
| billing_email | string | null — optional | Optional. 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. |
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"}'/account/enroll
auth: bearer token
409 already_enrolled. A token cannot make credential changes without a second factor.{
"secret": "JBSWY3DPEHPK3PXP",
"otpauth_uri": "otpauth://totp/Tendzin:3f2a…?issuer=Tendzin&…",
"recovery_codes": ["K7M2QX4RTB9WNC5A", "…nine more…"]
}otpauth_uri for when a person is involved.# 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();/account
auth: bearer token
{
"account_id": "3f2a…",
"billing_email": "ops@example.com",
"enrolled_2fa": true,
"recovery_codes_left": 10
}/account/update
auth: OTP only
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.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"}'/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.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./account/tokens
auth: OTP or recovery code
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"}'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"}'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./account/tokens
auth: bearer token
{
"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" }
]
}id or last four chars of preview. Revoked entries keep revoked_at./account/tokens/revoke
auth: OTP or recovery code
recovery_code. Body validated before code spent.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…"]}'404 unknown_token, same as non-existent, so no probing. Effect is immediate./account/enroll/rotate
auth: OTP only
account_id can trigger it, so treat a sudden run of 401s here as possible interference rather than proof your secret is wrong.day or minute) is fixed at creation and appears in the URL. day for nights, minute for intra-day./range/{granularity}
auth: bearer token
total.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'{ "result": { "id": "b41d7c8e-6c1e-11e7-80aa-03af88b051d5" } }day; intra-day = minute./resources
auth: bearer token
{
"result": [
{ "id": "b41d7c8e…", "type": "day", "inserted_at": "2026-08-11T09:20:00Z" }
]
}GET /resources will not. Not recoverable from API.No DELETE. Retire by flattening total to 0.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./range/{granularity}/{id}/inventories
auth: bearer token
{
"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.{
"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 }
]
}/range/{granularity}/{id}/contiguous-inventories
auth: bearer token
curl -X GET "https://sydney.tendzin.com/range/day/…/contiguous-inventories" \
-H "authorization: Bearer $TOKEN" \
-H 'accept: application/json'{
"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 }
] }
]
}gte + gt is a 400.| Field | Type | Notes |
|---|---|---|
| upper-range-gte | a bound | upper bound on or after this point |
| upper-range-gt | a bound | strictly after; exclusive with gte |
| total-minus-count-gte | integer | at least this much left (1 = bookable) |
| total-minus-count-gt | integer | strictly more; exclusive with gte |
2026-09-05 (day) or 2026-09-05T09:30 (minute). Wrong granularity is a 400. Read bounds are already in write shape.lower-range-* to close the other side. Use as a floor; compute coverage client-side.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./range/{granularity}/{id}
auth: bearer token
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": [
{ "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" } }
]
}| Field | Type | Notes |
|---|---|---|
| column | "count" | "total" | taken, or existing |
| operation | "increment" | "decrement" | "flatten" | add, subtract, or set outright |
| delta | integer >= 0 | always non-negative; direction is the operation's job |
| range.lower | YYYY-MM-DD or YYYY-MM-DDTHH:MM | first unit in the booking |
| range.upper | same | last unit in the booking, inclusive. Must not be before lower; may equal it |
2026-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.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.minute in place of day.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:errors is a list; one bad range can fill multiple rows. Read inventories for which points failed.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.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.code; the message is for a human and may be reworded.{
"errors": [
{ "code": "invalid_token", "message": "invalid credentials", "inventories": [] }
],
"status": 401
}409 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.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.GET /account, GET /account/tokens, GET /resources and GET /_health are all outside it.code: "rate_limited".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.{
"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" }
]
}| Field | Type | Notes |
|---|---|---|
| code | string | usage_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. |
| message | string | Human-readable. Show it to whoever can act on it; do not parse it. |
| grace_period_ends_at | string | null | ISO 8601 UTC, or null where no grace applies. On a 402 this is in the past — it is what elapsed. |
errors: iterate, do not index. The key is absent when there is nothing to say, never an empty list.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.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.{
"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
}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.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.