API
Leaseclock's API is at leaseclock.com/api. Every Noun has list, get, create and update operations, and every Verb has its own POST. Every response uses the same JSON shape, and every error links to the page that explains how to fix it.
In short
Request
POST /api/leases/4b/renew HTTP/1.1Host: leaseclock.comAuthorization: Bearer $LEASECLOCK_TOKENContent-Type: application/json{ "endsOn": "2027-11-30" }200 OK application/json
{ "$id": "https://leaseclock.com/api/leases/4b", "$type": "Lease", "unit": "4b", "renter": "dana.whitfield@example.com", "endsOn": "2027-11-30", "status": "active", "renewedBy": "priya@northgaterentals.com", "undo": "https://leaseclock.com/api/leases/4b/undo"}- The base URL is
https://leaseclock.com/api. Paths use each Noun's plural name:/api/leases,/api/units. - Requests and responses are JSON. A record is the same object in every response, from a list, a get, a write or a Verb.
- Every request carries a key in the
Authorizationheader. A key acts with the role and the workspace of the person who created it. - Every write is recorded as a version, returns an
undolink and appears in the record's history. - The OpenAPI file is at
https://leaseclock.com/openapi.json, and every operation has a page atleaseclock.com/docs, so your customers and their tools read the same reference you do.
Leaseclock over HTTP
Priya Shah, the leasing coordinator at Northgate Rentals, renews Dana Whitfield's lease on Unit 4B for another year. renew is a Verb in Lease.mdx, so it is a POST on the lease.
curl https://leaseclock.com/api/leases/4b/renew \
-H "Authorization: Bearer $LEASECLOCK_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "endsOn": "2027-11-30" }'{
"$id": "https://leaseclock.com/api/leases/4b",
"$type": "Lease",
"unit": "4b",
"renter": "dana.whitfield@example.com",
"endsOn": "2027-11-30",
"status": "active",
"renewedBy": "priya@northgaterentals.com",
"undo": "https://leaseclock.com/api/leases/4b/undo"
}The SDK and the CLI send this same request and get this same record back. So do the app's Renew button and an agent calling do over MCP.
Operations
name: Lease gives Lease its list, get, create and update operations at /api/leases. Each Verb under verbs: adds a POST, and a GET on the same URL previews it.
| Operation | Request |
|---|---|
| List leases | GET /api/leases |
| Get a lease | GET /api/leases/{id} |
| Create a lease | POST /api/leases |
| Update a lease | PATCH /api/leases/{id} |
| Renew a lease | POST /api/leases/{id}/renew |
| End a lease | POST /api/leases/{id}/end |
| Preview a renewal | GET /api/leases/{id}/renew |
| Undo the last write | POST /api/leases/{id}/undo |
| List every version | GET /api/leases/{id}/history |
Unit has the same operations at /api/units, without Verbs of its own. Add a Noun or a Verb to the definition and push it, and its operations answer on the next request.
List records
A list returns the records the key can see, sorted by the first indexed field. Lease sorts by endsOn, so the leases ending soonest come first.
| Parameter | What it does |
|---|---|
Any field, as status=active or unit=4b | Returns records whose field has that value. A choice field accepts only its options. |
endsOn.before, endsOn.after | Returns records before or after a date. Every indexed field takes both. |
sort | Sorts by an indexed field, as sort=endsOn or sort=-endsOn for newest first |
expand | Returns a linked record in place of its id, as expand=unit |
limit | Returns fewer records a page. A page holds up to 200. |
after | Starts the page after a record. next holds the full URL of the next page, and is null on the last one. |
curl -G https://leaseclock.com/api/leases \
-H "Authorization: Bearer $LEASECLOCK_TOKEN" \
-d status=active \
-d endsOn.before=2027-01-01{
"$id": "https://leaseclock.com/api/leases?status=active&endsOn.before=2027-01-01",
"items": [
{
"$id": "https://leaseclock.com/api/leases/4b",
"$type": "Lease",
"unit": "4b",
"renter": "dana.whitfield@example.com",
"endsOn": "2026-11-30",
"status": "active"
},
{
"$id": "https://leaseclock.com/api/leases/2a",
"$type": "Lease",
"unit": "2a",
"renter": "marcus.bell@example.com",
"endsOn": "2026-12-15",
"status": "active"
}
],
"next": null
}Get a record
curl "https://leaseclock.com/api/leases/4b?expand=unit" \
-H "Authorization: Bearer $LEASECLOCK_TOKEN"{
"$id": "https://leaseclock.com/api/leases/4b",
"$type": "Lease",
"unit": {
"$id": "https://leaseclock.com/api/units/4b",
"$type": "Unit",
"label": "4B",
"building": "Alder Court"
},
"renter": "dana.whitfield@example.com",
"endsOn": "2027-11-30",
"status": "active"
}A record's id comes from its name or label, so Unit 4B is 4b. Ids are stable: a later change to the label keeps the id.
Create a record
curl https://leaseclock.com/api/units \
-H "Authorization: Bearer $LEASECLOCK_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "label": "6E", "building": "Birch House" }'{
"$id": "https://leaseclock.com/api/units/6e",
"$type": "Unit",
"label": "6E",
"building": "Birch House",
"undo": "https://leaseclock.com/api/units/6e/undo"
}Required fields must be present, and every value must match its field's type. A missing label returns 422 invalid_field naming label.
Update a record
PATCH changes only the fields it sends.
curl -X PATCH https://leaseclock.com/api/leases/2a \
-H "Authorization: Bearer $LEASECLOCK_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "renter": "marcus.j.bell@example.com" }'A field a Verb moves cannot be patched. Lease's status changes only through end, so a PATCH that sends status returns 422 field_set_by_verb, with POST /api/leases/2a/end as the fix.
Run a Verb
A Verb is a POST on the record, with the Verb's input as the body. renew declares one input, endsOn, and runs only from: [active]. end takes no input.
curl -X POST https://leaseclock.com/api/leases/7c/end \
-H "Authorization: Bearer $LEASECLOCK_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"Every Verb POST requires an Idempotency-Key header. A retry with the same key returns the first response and runs nothing a second time, so a dropped connection never renews a lease twice.
Preview a Verb
A GET on a Verb's URL returns the record as the Verb would leave it, and changes nothing. The input goes in the query string.
curl -G https://leaseclock.com/api/leases/4b/renew \
-H "Authorization: Bearer $LEASECLOCK_TOKEN" \
-d endsOn=2027-11-30{
"$id": "https://leaseclock.com/api/leases/4b",
"$type": "Lease",
"unit": "4b",
"renter": "dana.whitfield@example.com",
"endsOn": "2027-11-30",
"status": "active",
"preview": true
}A preview checks everything the POST checks: the key's role, the lease's state and the input. A preview that would fail returns the same error the POST would.
Undo and history
Every write returns an undo link. A POST to it puts the record back as it was before that write, and records the undo as a version of its own.
history lists every version of a record, newest first, with who made the change and from where.
curl -X POST https://leaseclock.com/api/leases/4b/undo \
-H "Authorization: Bearer $LEASECLOCK_TOKEN" \
-H "Idempotency-Key: $(uuidgen)"{
"$id": "https://leaseclock.com/api/leases/4b/history",
"items": [
{
"version": 2,
"at": "2026-10-21T14:02:11Z",
"by": "priya@northgaterentals.com",
"via": "API",
"verb": "renew",
"changes": { "endsOn": ["2026-11-30", "2027-11-30"] }
},
{
"version": 1,
"at": "2026-08-21T09:40:03Z",
"by": "priya@northgaterentals.com",
"via": "App",
"verb": "create"
}
],
"next": null
}Keys
Your customers create keys in the Leaseclock app, under Settings → API keys. The key is shown once. Scripts read it from the environment as LEASECLOCK_TOKEN, and it can be revoked on the same screen at any time.
export LEASECLOCK_TOKEN=… # from Settings → API keys
curl https://leaseclock.com/api/leases -H "Authorization: Bearer $LEASECLOCK_TOKEN"Every key is an id.org.ai token. It acts with the role of the person who created it:
| Role | Can call |
|---|---|
| Viewer | List, get, preview and history |
| Member | Everything a viewer can, plus create, update, every Verb and undo |
| Admin | Everything a member can, plus creating and revoking keys for the workspace |
| Owner | Everything an admin can, plus the workspace's plan and billing |
A key reaches one workspace. A key from Northgate Rentals lists Northgate's leases and nobody else's, whatever the path or filter asks for. Your own team signs in as Leaseclock's admins and names a customer with the Workspace header, as in Workspace: northgate-rentals.
Test and Live
Every workspace has a Test copy with its own records, and checkout in Stripe's test mode. The key decides which copy a call reaches: a Test key reaches Test, and a Live key reaches Live, on the API, the SDK, the CLI and MCP alike. Switch the app to Test, then create the key under Settings → API keys.
The web surfaces of the Test copy answer at test.leaseclock.com, and at my-product.test.saas.dev before a product has a domain of its own. Test and Live share the definition and every operation, so a script that passes in Test runs unchanged in Live with a Live key.
export LEASECLOCK_TOKEN=… # a Test key from Settings → API keys
curl https://leaseclock.com/api/leases -H "Authorization: Bearer $LEASECLOCK_TOKEN"Limits
| Limit | Value | When it is reached |
|---|---|---|
| Requests | 100 a second per key in Live, 25 in Test | 429 rate_limited, with Retry-After in seconds |
| Records a page | 200 | A limit over 200 returns 200 |
| Request body | 1 MB | 413 body_too_large |
| The Offer's limits | Leaseclock Pro: 200 Units | 402 limit_reached, with the link to upgrade |
Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, so a script can slow down before it is stopped.
The Offer's limits come from Offer/pro.mdx. limits: { Unit: 200 } is the line the API checks before the 201st Unit is created, and the same line sets the pricing page and the upgrade prompt in the app.
Responses
Every response is one of three shapes.
A record has its $id, its $type and its fields. A write adds undo, and a Verb adds who ran it.
A list has its $id, the records in items, and next.
An error has a code, the field it refers to, a message, the fix and the page that explains it:
{
"error": {
"code": "wrong_state",
"message": "Lease 5a is ended, and renew runs only on an active lease.",
"fix": "Renew an active lease, or create a new lease for Unit 5A.",
"docs": "https://leaseclock.com/docs/errors#wrong_state"
}
}Field values in JSON follow their type in the definition:
| Field type | In JSON |
|---|---|
text, email | A string |
date | A string, as 2027-11-30 |
choice | The option's key, as "active" |
link | The linked record's id, as "4b", or the whole record with expand |
money | A number in the currency's units, as 1850 |
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON |
| 400 | idempotency_key_required | A Verb POST has no Idempotency-Key |
| 401 | invalid_key | The key is missing, revoked or mistyped |
| 402 | limit_reached | The write would pass a limit of the customer's Offer |
| 403 | not_allowed | The key's role cannot make this call |
| 404 | not_found | No record with that id in the key's workspace |
| 409 | wrong_state | The Verb does not run from the record's current state |
| 409 | idempotency_key_reused | The Idempotency-Key was used for a different request |
| 413 | body_too_large | The body is over 1 MB |
| 422 | invalid_field | A field is missing, unknown or the wrong type |
| 422 | field_set_by_verb | A PATCH sends a field that only a Verb changes |
| 422 | verb_stopped | The Verb's own function threw |
| 429 | rate_limited | The key sent more requests a second than its limit |
| 500 | server_error | Something failed inside SaaS.Dev |
Each code's docs link opens Leaseclock's own page for it at leaseclock.com/docs/errors, written in Leaseclock's names, so your customers fix the call without writing to you.
Questions
Does my product's API mention SaaS.Dev
No. The host, the paths, the headers, the keys and every error message use Leaseclock's names and Leaseclock's domain. A property manager calling the API sees Leaseclock.
What happens to my customers' scripts when I change the definition
Adding a field, a Noun or a Verb adds to the API, and every existing call keeps working. Renaming or removing one changes paths that scripts may call, so the push shows each changed path before anything goes Live, and the changelog lists the change for your customers.
Can a record be deleted
Yes. DELETE /api/units/6e removes the unit from every list and records the removal as a version, so its undo link brings it back with its history.
How do I call SaaS.Dev itself
api.saas.dev has the same shape for your own work: your products, their definitions and their versions. It reads $SAAS_DEV_TOKEN, which npx saas.dev login stores. See the Quickstart.
Can I change what an operation does
Yes. A Verb's logic is a function you write in its definition, and it runs inside the same checks, preview and undo. Any operation can be replaced with your own code. See Defaults and overrides.