---
url: https://saas.dev/docs/api
md: https://saas.dev/docs/api.md
---

# 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

[A renew request to the Leaseclock API and its 200 response](https://leaseclock.com/api)

- 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 `Authorization` header. A key acts with the role and the workspace of the person who created it.
- Every write is recorded as a version, returns an `undo` link and appears in the record's history.
- The OpenAPI file is at `https://leaseclock.com/openapi.json`, and every operation has a page at `leaseclock.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.

**API**

```bash title="Terminal"
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" }'
```

**CLI**

```bash title="Terminal"
npx leaseclock do Lease.renew 4b --endsOn 2027-11-30
```

**SDK**

```ts title="renew.ts"
import { Leaseclock } from 'leaseclock'

const $ = new Leaseclock({ token: process.env.LEASECLOCK_TOKEN })

await $.Lease.renew('4b', { endsOn: '2027-11-30' })
```

```json title="200 OK"
{
  "$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](https://saas.dev/docs/mcp.md).

## 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](#list-records) | `GET /api/leases` |
| [Get a lease](#get-a-record) | `GET /api/leases/{id}` |
| [Create a lease](#create-a-record) | `POST /api/leases` |
| [Update a lease](#update-a-record) | `PATCH /api/leases/{id}` |
| [Renew a lease](#run-a-verb) | `POST /api/leases/{id}/renew` |
| [End a lease](#run-a-verb) | `POST /api/leases/{id}/end` |
| [Preview a renewal](#preview-a-verb) | `GET /api/leases/{id}/renew` |
| [Undo the last write](#undo-and-history) | `POST /api/leases/{id}/undo` |
| [List every version](#undo-and-history) | `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.

```bash title="Terminal"
curl -G https://leaseclock.com/api/leases \
  -H "Authorization: Bearer $LEASECLOCK_TOKEN" \
  -d status=active \
  -d endsOn.before=2027-01-01
```

```json title="200 OK"
{
  "$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
}
```

| 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. |

## Get a record

```bash title="Terminal"
curl "https://leaseclock.com/api/leases/4b?expand=unit" \
  -H "Authorization: Bearer $LEASECLOCK_TOKEN"
```

```json title="200 OK"
{
  "$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

```bash title="Terminal"
curl https://leaseclock.com/api/units \
  -H "Authorization: Bearer $LEASECLOCK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "label": "6E", "building": "Birch House" }'
```

```json title="201 Created"
{
  "$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.

```bash title="Terminal"
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.

```bash title="Terminal"
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.

```bash title="Terminal"
curl -G https://leaseclock.com/api/leases/4b/renew \
  -H "Authorization: Bearer $LEASECLOCK_TOKEN" \
  -d endsOn=2027-11-30
```

```json title="200 OK"
{
  "$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.

```bash title="Terminal"
curl -X POST https://leaseclock.com/api/leases/4b/undo \
  -H "Authorization: Bearer $LEASECLOCK_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"
```

`history` lists every version of a record, newest first, with who made the change and from where.

```json title="GET /api/leases/4b/history"
{
  "$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.

```bash title="Terminal"
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**.

```bash title="Terminal"
export LEASECLOCK_TOKEN=…   # a Test key from Settings → API keys
curl https://leaseclock.com/api/leases -H "Authorization: Bearer $LEASECLOCK_TOKEN"
```

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.

## 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:

```json title="409 Conflict"
{
  "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`](https://saas.dev/docs/errors.md#invalid_json) | The body is not valid JSON |
| 400 | [`idempotency_key_required`](https://saas.dev/docs/errors.md#idempotency_key_required) | A Verb POST has no Idempotency-Key |
| 401 | [`invalid_key`](https://saas.dev/docs/errors.md#invalid_key) | The key is missing, revoked or mistyped |
| 402 | [`limit_reached`](https://saas.dev/docs/errors.md#limit_reached) | The write would pass a limit of the customer's Offer |
| 403 | [`not_allowed`](https://saas.dev/docs/errors.md#not_allowed) | The key's role cannot make this call |
| 404 | [`not_found`](https://saas.dev/docs/errors.md#not_found) | No record with that id in the key's workspace |
| 409 | [`wrong_state`](https://saas.dev/docs/errors.md#wrong_state) | The Verb does not run from the record's current state |
| 409 | [`idempotency_key_reused`](https://saas.dev/docs/errors.md#idempotency_key_reused) | The Idempotency-Key was used for a different request |
| 413 | [`body_too_large`](https://saas.dev/docs/errors.md#body_too_large) | The body is over 1 MB |
| 422 | [`invalid_field`](https://saas.dev/docs/errors.md#invalid_field) | A field is missing, unknown or the wrong type |
| 422 | [`field_set_by_verb`](https://saas.dev/docs/errors.md#field_set_by_verb) | A PATCH sends a field that only a Verb changes |
| 422 | [`verb_stopped`](https://saas.dev/docs/errors.md#verb_stopped) | The Verb's own function threw |
| 429 | [`rate_limited`](https://saas.dev/docs/errors.md#rate_limited) | The key sent more requests a second than its limit |
| 500 | [`server_error`](https://saas.dev/docs/errors.md#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](https://saas.dev/docs/blog.md) 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`](https://saas.dev/docs/saas-dev-cli.md) stores. See the [Quickstart](https://saas.dev/docs/quickstart.md).

### 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](https://saas.dev/docs/overrides.md).
