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

# Errors

Every error SaaS.Dev returns has a code, the line it refers to and the fix. Each code links from the error itself, so you or your agent land on the fix in one click.

Errors come from two places. [`npx saas.dev`](https://saas.dev/docs/saas-dev-cli.md) and `api.saas.dev` check your definition files and your account, and their errors link here. Your product's API, CLI, SDK and MCP server return the request codes to your customers in your product's names, linked to your own errors page, such as [leaseclock.com/docs/errors](https://leaseclock.com/docs/errors).

## Every code

| Status | Code | When |
| --- | --- | --- |
| 422 | [`invalid_yaml`](https://saas.dev/docs/errors.md#invalid_yaml) | The file does not parse |
| 422 | [`unknown_key`](https://saas.dev/docs/errors.md#unknown_key) | A key its place does not take |
| 422 | [`missing_key`](https://saas.dev/docs/errors.md#missing_key) | A key that must be there is not |
| 422 | [`invalid_name`](https://saas.dev/docs/errors.md#invalid_name) | A name with the wrong shape |
| 422 | [`duplicate_name`](https://saas.dev/docs/errors.md#duplicate_name) | Two things share one name |
| 422 | [`unknown_type`](https://saas.dev/docs/errors.md#unknown_type) | A field's type is not one SaaS.Dev has |
| 422 | [`unknown_noun`](https://saas.dev/docs/errors.md#unknown_noun) | A link or a limit names a Noun the product does not have |
| 422 | [`unknown_state`](https://saas.dev/docs/errors.md#unknown_state) | A Verb's from or to names a state status does not list |
| 422 | [`invalid_value`](https://saas.dev/docs/errors.md#invalid_value) | A key holds a value it cannot take |
| 409 | [`stripe_not_connected`](https://saas.dev/docs/errors.md#stripe_not_connected) | A push to Live sells an Offer, and no Stripe account is connected |
| 409 | [`version_changed`](https://saas.dev/docs/errors.md#version_changed) | Another push landed while this one waited |
| 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 |

## What an error looks like

Every error is the same JSON object, from `api.saas.dev` and from your product's API alike:

```json title="422 Unprocessable Content"
{
  "error": {
    "code": "invalid_name",
    "message": "field \"EndsOn\" must start lower-case.",
    "file": "Noun/Lease.mdx",
    "line": 6,
    "fix": "Rename it to \"endsOn\".",
    "docs": "https://saas.dev/docs/errors#invalid_name"
  }
}
```

| Field | What it holds |
| --- | --- |
| `code` | The code, in snake_case. It never changes, so branch on it |
| `message` | What is wrong, in one sentence for a person or an agent |
| `fix` | The change that clears the error |
| `docs` | The code's entry: on saas.dev for errors about your definition and account, on your own docs for errors your customers get |
| `file`, `line` | The file and line a definition error refers to |
| `field` | The field a request error refers to |

Some codes add what the fix needs, such as `limit` and `offer` on `limit_reached`.

`npx saas.dev` and your product's own CLI print the same error on one line, with the status, the place, the message and the fix, and exit with status 1 so a CI step fails on it:

```console title="Terminal"
$ npx saas.dev push
422  Lease.mdx line 6: field "EndsOn" must start lower-case. Rename it to "endsOn".
422  pro.mdx line 4: limits names "Units", and no Noun has that name. Use "Unit".
```

## A push returns every error

A push checks every file and returns every error at once, so your agent fixes them in one pass. Nothing is saved until the push passes. With `--json`, `npx saas.dev push` prints the same body `api.saas.dev` returns:

```json title="npx saas.dev push --json"
{
  "ok": false,
  "errors": [
    {
      "code": "invalid_name",
      "file": "Noun/Lease.mdx",
      "line": 6,
      "message": "field \"EndsOn\" must start lower-case.",
      "fix": "Rename it to \"endsOn\".",
      "edit": { "file": "Noun/Lease.mdx", "line": 6, "from": "EndsOn", "to": "endsOn" },
      "docs": "https://saas.dev/docs/errors#invalid_name"
    },
    {
      "code": "unknown_noun",
      "file": "Offer/pro.mdx",
      "line": 4,
      "message": "limits names \"Units\", and no Noun has that name.",
      "fix": "Use \"Unit\".",
      "edit": { "file": "Offer/pro.mdx", "line": 4, "from": "Units", "to": "Unit" },
      "docs": "https://saas.dev/docs/errors#unknown_noun"
    }
  ]
}
```

`edit` is the fix as a change to one line. `npx saas.dev push --fix` applies every `edit`, then previews. The command exits with status 1 on any error, and with status 2 when a push holds a breaking change and runs without `--yes`.

Your SDK throws it as a typed error with `status`, `code`, `message`, `fix` and `docs`. Your MCP server returns it as a tool error whose text holds the message, the fix and the link, so a customer's agent corrects the call and tries again.

## Handle errors in code

Branch on `code`. A message can be reworded to read better, while a code stays fixed. Treat a code your client does not know by its HTTP status.

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

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

try {
  await $.Lease.renew('5a', { endsOn: '2027-09-30' })
} catch (err) {
  if (!(err instanceof LeaseclockError)) throw err

  if (err.code === 'wrong_state') {
    // Hannah Cho's lease on 5A has ended, so she signs a new one.
    await $.Lease.create({ unit: '5a', renter: 'hannah.cho@example.com', endsOn: '2027-09-30' })
  } else {
    console.error(`${err.message} ${err.fix} ${err.docs}`)
    throw err
  }
}
```

Retry `rate_limited` and `server_error`. Every other code returns the same answer until the request, the record or the plan changes. The SDK and the CLI retry both for you, waiting as long as `Retry-After` says and resending the same `Idempotency-Key`, so a retried Verb runs once.

## Definition errors

`npx saas.dev push` checks every file before it saves anything. A file that breaks a rule returns 422 with the file, the line and the fix, and Test stays at its last version. The push reports every broken line in one response, so your agent fixes them all in one pass. [Constraints](https://saas.dev/docs/constraints.md) explains why each rule exists.

### `invalid_yaml`

The file does not parse, so nothing else in it can be checked. The usual cause is a brace, bracket or quote left open.

```text
422  Lease.mdx line 7: status is missing its closing "}". Add "}" at the end of the line.
```

Close it, and push again. The next push checks the rest of the file.

### `unknown_key`

A key that its place does not take. A Noun takes `name`, `fields` and `verbs`, a field takes the keys of its type, and an Offer's price takes `amount`, `currency` and `unit`.

```text
422  Unit.mdx line 3: a Noun takes name, fields and verbs. Rename "properties" to "fields".
422  pro.mdx line 3: price has no key "interval". Use "unit: month".
```

The fix names the key that does the job. Every surface reads the same keys, so a key outside them would reach one surface and miss the rest.

### `missing_key`

A key that must be there is not. Every field needs a `type`, every Noun a `name`, and every Offer a `price`.

```text
422  Unit.mdx line 4: field "label" has no type. Add one, such as "type: text".
```

Add the key on the line the error names.

### `invalid_name`

A name with the wrong shape. A Noun's name starts upper-case, and field keys and Verb names are camelCase and start lower-case, because each name becomes a URL, a JSON key, an SDK property and a CLI flag.

```text
422  Lease.mdx line 2: name "lease" must start upper-case. Rename it to "Lease".
422  Lease.mdx line 6: field "EndsOn" must start lower-case. Rename it to "endsOn".
```

Rename it as the fix says. The App's labels come from the same name, so `endsOn` still heads its column "Ends on".

### `duplicate_name`

Two Nouns share a name, or one Noun has two fields or Verbs with the same name. Each name is an address on every surface, so it can point at one thing only.

```text
422  Agreement.mdx line 2: name "Lease" is taken by Lease.mdx. Rename one of them.
```

Rename one, or merge the two files if they describe the same thing.

### `unknown_type`

A field's `type` is not one SaaS.Dev has. [Nouns](https://saas.dev/docs/nouns.md) lists every field type.

```text
422  Unit.mdx line 6: field "bedrooms" has no type "integer". Use "number".
```

Use the type the fix names. It is the closest one, and it brings its own input, check and JSON schema.

### `unknown_noun`

A `link` field's `target`, or an Offer's `limits`, names a Noun your product does not have. Names are singular and match the Noun's `name` exactly.

```text
422  Lease.mdx line 4: field "unit" links to "Units", and no Noun has that name. Use "Unit".
422  pro.mdx line 4: limits names "Units", and no Noun has that name. Use "Unit".
```

Use the Noun's name as its file writes it, or add the Noun if it is missing.

### `unknown_state`

A Verb's `from` or `to` names a state that the Noun's `status` field does not list.

```text
422  Lease.mdx line 12: renew runs from "Active", which is not a status of Lease. Use "active".
422  Lease.mdx line 13: verb "end" moves to "closed", which is not a status. Use "ended", or add "closed" to status.
```

Use one of the `status` options, or add the new state to `status` in the same push.

### `invalid_value`

A key holds a value it cannot take, such as a billing unit Stripe does not sell or a limit that is not a whole number.

```text
422  pro.mdx line 3: unit "monthly" is not a billing unit. Use "month" or "year".
```

Use a value the fix names.

## Push errors

These come from `npx saas.dev push` when every file is valid and the push still cannot land.

### `stripe_not_connected`

A push to Live holds an Offer, and the product has no Stripe account connected. In Test, checkout runs on a Stripe test account SaaS.Dev opens for you, and Live sells through your own.

```bash title="Terminal"
npx saas.dev push --live
# 409  Leaseclock Pro sells in Live, and leaseclock.com has no Stripe account. Run "npx saas.dev stripe connect".
```

Connect your Stripe account once, and push again. [Stripe](https://saas.dev/docs/stripe.md) walks through it.

### `version_changed`

Another push landed between the change you were shown and your yes, so the change you approved is out of date. It happens when you and your agent, or two agents, push to the same product at once.

```text
409  Test moved to version 4 while this push waited. Push again to see both changes.
```

Push again. The new preview shows the other push's change beside yours.

## Request errors

Your product's API returns these to anyone who calls it, and the CLI, SDK and MCP server pass them on unchanged. `api.saas.dev` returns the same codes for calls about your own products. A preview, a GET on a Verb's URL, returns the error the POST would, without changing anything.

### `invalid_json`

**400.** The body is not valid JSON. From a shell, the cause is nearly always quoting.

Wrap the body in single quotes, send `Content-Type: application/json`, and check the position the message names.

### `idempotency_key_required`

**400.** A Verb's POST arrived with no `Idempotency-Key` header. The key is what lets a retry run the Verb once.

The SDK, the CLI and the MCP server send one for you. From curl, add `-H "Idempotency-Key: $(uuidgen)"`, with a new key for each new run and the same key for a retry.

### `invalid_key`

**401.** The token is missing, revoked or mistyped.

On your product's API, create a key in the app under **Settings → API keys**, and read it from the environment as `$LEASECLOCK_TOKEN`. On `api.saas.dev`, run `npx saas.dev login`, or set `SAAS_DEV_TOKEN` in CI to a token from id.org.ai.

### `limit_reached`

**402.** The write would take a workspace past a limit in its Offer. Leaseclock Pro holds 200 Units, and Harbor Point Homes, another Leaseclock customer, already has 200:

```json title="402 Payment Required"
{
  "error": {
    "code": "limit_reached",
    "message": "Leaseclock Pro includes 200 Units, and this workspace holds 200.",
    "fix": "Delete a Unit this workspace no longer manages, or move to a plan with more Units.",
    "noun": "Unit",
    "limit": 200,
    "offer": "leaseclock.com/offers/pro",
    "docs": "https://leaseclock.com/docs/errors#limit_reached"
  }
}
```

Nothing is saved, and an import that would pass the limit is rejected whole. To sell more room, raise `limits` in the Offer, or add a larger Offer such as `Offer/team.mdx`, and the upgrade prompt in the app offers it. [Offers](https://saas.dev/docs/offers.md) covers limits.

### `not_allowed`

**403.** The key's role cannot make the call. A Viewer at Northgate Rentals can read leases, and renewing one needs a Member.

```text
403  A Viewer cannot run renew. Ask a workspace Admin for the Member role.
```

An Admin changes the role in the app under **Settings → Members**. Every surface checks the same rule, so a call the app would block is blocked in the API, the CLI and the agent's tools too.

### `not_found`

**404.** No record has that id in the key's workspace. A record in another customer's workspace answers the same way, so an id reveals nothing about anyone else's records.

List the records to find the id, as in `npx leaseclock show leases`. A record created in Test lives only in Test, so reach it with a Test key.

### `wrong_state`

**409.** The Verb does not run from the record's current state. Hannah Cho's lease on 5A ended on September 30, 2026, and `renew` runs only `from: [active]`:

```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"
  }
}
```

The App shows the same reason beside a disabled Renew button. Run a Verb the state allows, or create a new record.

### `idempotency_key_reused`

**409.** The `Idempotency-Key` was already used for a different request. A key stands for one request, so a retry with it returns the first response, and a new request under it is stopped before it runs.

Send a new key for each new request.

### `body_too_large`

**413.** The body is over 1 MB.

Send the records in smaller batches. Each batch is checked and saved on its own.

### `invalid_field`

**422.** A field is missing, unknown to the Noun, or the wrong type. The error names the field, and for an unknown one, the closest field the Noun has:

```json title="422 Unprocessable Content"
{
  "error": {
    "code": "invalid_field",
    "field": "endsOn",
    "message": "endsOn must be a date, and \"next year\" is not one.",
    "fix": "Send a date such as 2027-11-30.",
    "docs": "https://leaseclock.com/docs/errors#invalid_field"
  }
}
```

Send the field as its type reads in JSON: a date as `2027-11-30`, a link as the linked record's id, a `choice` as its option's key. The [API](https://saas.dev/docs/api.md) lists each type's form.

### `field_set_by_verb`

**422.** A PATCH sends a field that only a Verb changes. Lease's `status` moves only through `end`, so a PATCH with `"status": "ended"` stops here, and the fix names `POST /api/leases/2a/end`.

Run the Verb. It keeps the change in the record's history, with an undo.

### `verb_stopped`

**422.** The Verb's own function threw. The message is the text your function threw, word for word:

```text
422  endsOn must be later
```

The person or agent changes the input and runs it again. To change the words they read, change the `throw` in the Verb's `fn`. [Verbs](https://saas.dev/docs/verbs.md) shows a function that throws.

### `rate_limited`

**429.** The key sent more requests a second than its limit: 100 in Live and 25 in Test. `Retry-After` gives the seconds to wait, and every response carries `RateLimit-Remaining`, so a script slows down before it is stopped.

Wait and retry. The SDK and the CLI do both for you.

### `server_error`

**500.** Something failed inside SaaS.Dev. Every write either completes or leaves the record as it was, so nothing half-saves. Your customers read Leaseclock's name in the message, and nothing in it names SaaS.Dev.

Retry with the same `Idempotency-Key`, and the request runs once.

## Your customers' errors page

Every product gets its own errors page with every request code, in its own names and on its own domain. Leaseclock's is [leaseclock.com/docs/errors](https://leaseclock.com/docs/errors). When Priya Shah's import script at Northgate Rentals passes 200 Units, the error links her to Leaseclock's entry for `limit_reached`, and the fix there names Leaseclock Pro and its pricing page. Nothing on it names SaaS.Dev.
