---
name: api-conventions
description: Response-body conventions for the Platypus backend API — the `error` key for failures, the `message` key for 2xx status messages, and when to throw a typed error instead of returning a response. Use when adding or changing a backend route or its tests.
---

# API Response Conventions

## The two keys

`error` carries a failure. `message` carries a 2xx status line for an
operation that has no resource to return.

```typescript
return c.json({ error: "Workspace not found" }, 404);
return c.json({ message: "Board deleted" });
```

Never `message` on a 4xx/5xx — that is the one crossing the codebase does not
make.

A delete or action with nothing to return answers `200 { message }` — never a
bodiless 204, and never `{ success: true }`.

## The error seam

Five cross-cutting failures are **thrown**, not returned. A single `onError`
seam maps each to its status and emits the same `{ error }` body, so a domain
rule states its message once instead of once per caller:

| Throw              | Becomes |
| ------------------ | ------- |
| `NotFoundError`    | 404     |
| `ValidationError`  | 400     |
| `LockedError`      | 403     |
| `ConflictError`    | 409     |
| `UnsupportedError` | 501     |

A Postgres unique violation also maps to 409 — detect it through the shared
helper rather than re-reading the driver's error shape. So does a foreign-key
violation, as a backstop only: a delete guarded by a `restrict` reference
should throw a `ConflictError` naming what still uses the row first.

```typescript
if (!row) throw new NotFoundError("Card not found");
throw new ValidationError("Invalid user assignee");
```

A resource referenced in the request body that isn't in this Organization, or
isn't Shared, is a `NotFoundError` (404) naming what couldn't be used — not a
422, and no extra detail fields beside `error`.

Throwing is what lets one rule serve more than one surface: the Kanban rules
answer both the HTTP routes and the Agent tool set from a single place. Reach
for the seam whenever the failure is one of those five; a 4xx that only one
route can produce still answers inline with `c.json({ error }, status)`. Both
are current and roughly equally common — the seam is not a migration you
should finish.

## Test assertions

Assert against the key the response actually uses:

```typescript
expect(body.error).toBe("Invalid user assignee");
expect(await res.json()).toEqual({ message: "Board deleted" });
```

A service test covering a thrown failure asserts the error, not a body:

```typescript
await expect(fn()).rejects.toThrow(NotFoundError);
```
