# Errors and testing

URL: /code/sdk/guides/errors-and-testing · Id: `page:code/sdk/guides/errors-and-testing`

> Every error is an RFC 9457 problem whose type is a page explaining it, and every outcome, failures included, is reproducible on demand in test.

Every error from [api.sb](/code/platform/api-sb) is an RFC 9457 problem whose `type` is the URL of a page that explains it. The test environment reproduces every outcome on demand, failures included.

## The problem document
`tutorpool.com` is a [Licensed-human marketplace](/products/templates/licensed-marketplace) where parents book certified tutors. Refunding a lesson [Order](/business/nouns/order) that was never paid fails like this:

```json
{
  "type": "https://startupbuilder.dev/code/api/errors/refused",
  "title": "Refused",
  "status": 422,
  "detail": "Order order_1 is placed, and refund needs a paid Order.",
  "instance": "https://api.sb/tutorpool.com/actions/action_9",
  "requestId": "req_7Hk2",
  "fix": "Read the record, run the Verb that moves it to an allowed state, then retry.",
  "next": "https://api.sb/tutorpool.com/orders/order_1/pay"
}
```

`type` is stable: branch on it, never on `title` or `detail`. The [error catalog](/code/api/errors) page at that URL gives the cause and the fix. `fix` says what to change, `next` is the call that most often resolves it, and `requestId` finds the request in the [request log](/code/cli/guides/debugging).

The problem reads the same on every [Interface](/business/nouns/interface):

- The SDK throws it as an error with the same fields.
- The CLI prints it and exits `1` for an ended [Action](/business/nouns/action), or `2` for invalid input.
- MCP returns it as a tool result with `isError: true`, and an [Agent](/business/nouns/agent) reads `fix` as its next instruction.

## Two kinds of failure
Where a call fails decides what comes back, as [Actions](/code/sdk/guides/actions) explains in full.

A call rejected at the door never becomes an Action. Malformed input, an unknown [Verb](/business/nouns/verb), a revoked key or a key outside its scope returns a problem and nothing else. Nothing was recorded, so nothing needs undoing.

A call the record rejects is an Action born `refused`. Each refusal is recorded: an Order in the wrong state, a [Limit](/business/nouns/limit) raise whose [Approval](/business/nouns/approval) is denied, a [Mandate](/business/nouns/mandate) that falls short. `instance` links to it.

## Which errors to retry
| Error | Status | Retry |
| --- | --- | --- |
| [`invalid-input`](/code/api/errors/invalid-input), [`mode-not-allowed`](/code/api/errors/mode-not-allowed) | 400 | No: correct the input |
| [`unauthorized`](/code/api/errors/unauthorized), [`forbidden`](/code/api/errors/forbidden) | 401, 403 | No: use a key that reaches the call |
| [`payment-required`](/code/api/errors/payment-required) | 402 | After accepting the [Offer](/business/nouns/offer) in `next` |
| [`not-found`](/code/api/errors/not-found) | 404 | No: correct the namespace or id |
| [`conflict`](/code/api/errors/conflict) | 409 | After reading the record again |
| [`idempotency-mismatch`](/code/api/errors/idempotency-mismatch) | 409 | No: a new input needs a new key |
| [`refused`](/code/api/errors/refused), [`limit-exceeded`](/code/api/errors/limit-exceeded) | 422, 403 | After the state or the Limit changes |
| [`rate-limited`](/code/api/errors/rate-limited) | 429 | After `Retry-After` seconds |
| [`not-live`](/code/api/errors/not-live) | 503 | After the first deploy |
| Any other `5xx` | 500–599 | Yes, with the same `Idempotency-Key` |

Retry what time fixes; fix what time does not. A retry of a recorded call carries the original idempotency key, so a call that landed the first time never runs twice.

## A test plan
The [test environment](/code/cli/guides/environments) runs every Verb, [Permission](/business/nouns/permission) and Limit as live does, with no real money and no outside messages. Cover each path a [Customer](/business/nouns/customer) can take, the refusals as deliberately as the successes:

```bash
export STARTUP_BUILDER_API_KEY=sk_test_...

# The happy path: book a lesson, pay, deliver, confirm.
ORDER=$(npx startup-builder do Order.place --json '{"offer":"offer_algebra"}' | jq -r '.order["$id"] | split("/") | last')
npx startup-builder do Order.pay --json '{"order":"'"$ORDER"'"}'

# A refusal: refunding an unpaid Order returns refused.
UNPAID=$(npx startup-builder do Order.place --json '{"offer":"offer_algebra"}' | jq -r '.order["$id"] | split("/") | last')
npx startup-builder do Order.refund --json '{"order":"'"$UNPAID"'","reason":"Test"}'

# A preview: try runs every check and stores nothing.
npx startup-builder do Order.refund --try --json '{"order":"'"$ORDER"'","reason":"Test"}'
```

`npx startup-builder schema Order.refund` prints a Verb's input and output contract. A flow that never sees `refused` is untested exactly where Customers push hardest.

## Magic values
In `test`, every outside effect runs against a simulator. A magic value forces the outcome you want to test:

| Input | Outcome |
| --- | --- |
| [Card](/business/nouns/card) `4242 4242 4242 4242` | Succeeds |
| Card `4000 0000 0000 0002` | Declines |
| Card `4000 0000 0000 0259` | Succeeds, then arrives disputed one day later |
| Email to `bounce@simulator.test` | Bounces, and the address is suppressed |
| Phone `+1 555 0199` | Undeliverable |
| [Credential](/business/nouns/credential) number `FAIL-0000` | Verification fails |
| Payout account `000999999` | The payout fails and returns |

Every other value succeeds. The simulators emit the same [Events](/business/nouns/event), in the same order, as the outside services they stand in for.

## Test clocks
[Renewals](/business/nouns/renewal), dunning, delay windows and scheduled [Workflows](/business/nouns/workflow) all wait on time. A test clock moves time forward in `test` only:

```bash
npx startup-builder clock advance --by P30D
```

Everything due in those 30 days fires in order. The tutorpool monthly plan renews, the declined card enters dunning, and each refund past its delay window runs. `live` is never affected.

## Local receivers
A [Watch](/business/nouns/watch) needs a public URL; a laptop does not have one. `listen` streams test Events and forwards each one, signed, to a local server:

```bash
npx startup-builder listen 'Order.*' --forward-to http://localhost:3000/hooks
npx startup-builder trigger Order.disputed
```

`listen` prints a signing secret for the session, so the receiver's [signature check](/code/api/guides/webhooks) runs unchanged. `trigger` runs the Verbs that produce an Event in `test`, so the delivery is a real Event from a real Action.

## Next
- [Debugging](/code/cli/guides/debugging): follow a failed call from its `requestId`.
- [Action statuses](/code/api/guides/modes): the statuses an Action can end in.
- [Error catalog](/code/api/errors): every error type, its cause and its fix.