---
title: "Projects, Keys and Environments"
description: "Ask the server which project a key acts in, read the environment from the project row instead of the key prefix, and tell an empty read apart from a wrong key."
---

<MetaStrip items={[
  { label: "TIME", value: "~20 min" },
  { label: "STACK", value: (<><ServerChip name="@codespar/sdk" accent /><ServerChip name="curl" /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>no LLM</span></>) },
  { label: "SERVERS", value: (<><ServerChip name="none" /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>platform API only</span></>) },
]} />

Resolve the organization, project and environment your key actually acts in, then use that answer to explain why a list came back empty, because the common cause is a live key reading a test project.

<EventPipeline
  title="RESOLVE THE CONTEXT"
  subtitle="Who is this key → where does it act → why was the read empty"
  nodes={[
    { tag: "1 · RESOLVE", name: "GET /v1/whoami", meta: "organization, project, key.environment, key.scopes", tone: "validation" },
    { tag: "2 · PLACE", name: "GET /v1/projects", meta: "every project in the org, default first, then oldest", tone: "event" },
    { tag: "3 · READ", name: "The list that came back empty", meta: "every data read filters on org_id and project_id", tone: "loop" },
  ]}
  loopSteps={[
    { tool: "wrong project", description: "The key is bound somewhere else. Only a different key moves it." },
    { tool: "empty project", description: "A project created over the API gets no sandbox seed, so it has no connections yet." },
    { tool: "unattributed rows", description: "Rows with a null project_id match no project at all." },
  ]}
  note="whoami is the only route here that costs no scope · no route in the served document takes a project parameter · x-codespar-project is inert on a csk_ key"
/>

## Prerequisites

```bash
npm install @codespar/sdk
```

The generic REST client used below (`cs.api`) landed in `@codespar/sdk` 0.12.0. Beyond a key, two things matter:

- `GET /v1/whoami` costs no scope. It is listed in the scope-exempt routes, so it answers for any key that authenticates at all.
- Everything else here does cost one. `/v1/projects` needs `projects:read` on the GET and `projects:write` on POST, PATCH and DELETE. The three settings routes need `projects:settings`. `GET /v1/organizations/:id` needs `projects:read`. A wildcard key, which is what every key created without an explicit scope list gets, passes before any of those are consulted.

<Callout>
Nothing in this recipe creates, rotates or reveals a key. `POST /v1/api-keys` sits in the service-auth subtree and is not in the served OpenAPI document. A `csk_` key calling it gets 401. Keys are a dashboard surface.
</Callout>

## 1. Ask the server who the key is

This is the first call, and the only one in the recipe that spends no scope. It answers with `organization` (id and name), `project` (id and name), `key` (id, environment and scopes), and optionally `user` (email and name).

```typescript title="resolve-context.ts"
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });

const who = await cs.api.response("get", "/v1/whoami");
if (!who.ok) {
  throw new Error(`whoami refused with ${who.status}`);
}

const organization = who.data.organization;
const project = who.data.project;
const key = who.data.key;

console.log("org:", organization.id, organization.name);
console.log("project:", project.id, project.name);
console.log("environment:", key.environment);
console.log("scopes:", key.scopes);
```

```bash title="the same call, with curl"
curl -s https://api.codespar.dev/v1/whoami \
  -H "authorization: Bearer $CODESPAR_API_KEY" \
  | jq '{ org: .organization.id, project: .project.id, environment: .key.environment }'
```

`cs.api.response(...)` returns `status`, `ok`, `data` and `response`, and never throws on a refusal. Use it throughout this recipe, because the refusals are the point.

<Callout type="warn">
**`whoami` declares exactly one response in the served document: 200.** It declares no 401 and no 403. Neither does any other operation in this recipe. A client generated from the document does not carry those two statuses in the discriminated union of `cs.api.response(...)`, so check `ok` and `status` rather than pattern-matching on a typed error shape.
</Callout>

<Callout type="warn">
`key.id` is typed as a string in the `Whoami` schema, and it comes back **null** with an OAuth token and with service auth. The real prefix of a key id is `ak_` followed by a 12-character id. The example published in [first call](/docs/first-call) still shows a `key_` prefix.
</Callout>

## 2. Read `key.environment`, not the prefix

The environment does not come from the key string. Authentication resolves the project the key is bound to and reads the `environment` column of that `projects` row. The prefix was stamped once, at creation, out of that same column, and is never compared to anything afterwards: the key lookup is by hash.

What holds the prefix to the truth is the creation side, not the request side. Key creation refuses with 400 `env_mismatch` when the body's environment differs from the project's.

<Callout type="error">
**There is no 401 for "a test key in a live project".** Both [Authentication](/docs/concepts/authentication) and [Projects](/docs/concepts/projects) say a mismatch returns 401 unauthorized. No code produces it. A `csk_test_` key bound to a live project acts in live, and `whoami` reports `environment` of `"live"` next to that `csk_test_` prefix. Read the field, not the string.
</Callout>

## 3. Place the project inside the organization

`GET /v1/projects` returns every project in the organization, live and test together, the default first and then oldest to newest. It takes no query parameters and has no cursor. Match `project.id` from step 1 against a row: that row is what says whether the key acts in `test` or `live`, and whether the project is the default.

```typescript title="place-project.ts"
const list = await cs.api.response("get", "/v1/projects");
if (!list.ok) throw new Error(`projects refused with ${list.status}`);

const row = list.data.projects.find((p) => p.id === project.id);
if (!row) throw new Error("the key resolves to a project this organization does not list");

console.log(row.slug, row.environment, row.is_default);
console.log("other projects:", list.data.projects.map((p) => `${p.slug}:${p.environment}`));
```

```bash title="the same call, with curl"
curl -s https://api.codespar.dev/v1/projects \
  -H "authorization: Bearer $CODESPAR_API_KEY" \
  | jq '.projects[] | { id, slug, environment, is_default }'
```

<Callout type="warn">
**The list is not filterable and not paginated.** The [projects reference](/docs/api/reference/projects) documents `is_default`, `limit` and `offset` query parameters and a response carrying `data`, `total`, `limit` and `offset`. None of that exists. The handler reads no query at all and returns the whole set under `projects`. The `?is_default=true` curl on the Projects concept page filters nothing.
</Callout>

## 4. Close the identity

Two optional reads. `GET /v1/projects/{id}` returns one row, and the organization is inside the query itself, so an id from another organization is a 404 rather than a 403. `GET /v1/organizations/{id}` answers only for your own organization id.

```typescript title="close-identity.ts"
const one = await cs.api.response("get", "/v1/projects/{id}", {
  path: { id: project.id },
});
console.log(one.status, one.ok ? one.data.name : one.data.error.code);

const org = await cs.api.response("get", "/v1/organizations/{id}", {
  path: { id: organization.id },
});
console.log(org.status, org.ok ? org.data.name : org.data.error.code);
```

Pass any organization id other than your own and the answer is 404 `not_found`, including for an organization that exists. Nothing in this API lists or creates organizations.

## 5. Run the read that came back empty

Every data read filters on the `org_id` and `project_id` of the resolved context. `GET /v1/sessions`, `GET /v1/wallets` and `GET /v1/consumers/funding-sources` all do it the same way.

```typescript title="empty-read.ts"
const sessions = await cs.api.response("get", "/v1/sessions");
console.log(sessions.status, sessions.data.sessions.length);

const wallets = await cs.api.response("get", "/v1/wallets");
console.log(wallets.status, wallets.data.wallets.length);
```

An empty array with a 200 is a correct answer, not an error. It means the project you resolved in steps 1 to 3 holds nothing of that kind. Three separate things produce it, and only one of them is a wrong key. See step 9.

## 6. Read the project settings

```typescript title="read-settings.ts"
const settings = await cs.api.response("get", "/v1/projects/{id}/settings", {
  path: { id: project.id },
});
console.log(settings.status, settings.data.settings);
```

In a `live` project the answer today is an empty `settings` array, because all three declared settings (`test_autotopup_enabled`, `standin_account`, `standin_pix_key`) are registered for the `test` environment only. Empty here is normal and says nothing about the key.

`GET /v1/projects/{id}/settings` is the one route of the four under projects and settings with no role gate on it. The PATCH and the history read are administrative.

## 7. The header that does nothing

`x-codespar-project` is read only on the service-auth path. The `csk_` key path never touches it. Sending it changes nothing, and the only symptom is exactly the empty read this recipe is investigating.

<Callout type="error">
**The SDK makes this worse, not better.** `new CodeSpar({ projectId })` sends `x-codespar-project` on every single request, and the client reference documents the field that way. With a `csk_` key the API ignores it. You configure a project, you read another project's data, and nothing refuses. The fix for reading the wrong project is a different key. It is never a header and never a parameter: no operation in the served document declares a project parameter of any kind, in the path, the query or the headers.
</Callout>

<Callout type="warn">
**The SDK's project id regex is narrower than the API's.** The SDK accepts `prj_` followed by 16 letters and digits, and the constructor throws a plain `Error` when it does not match. The API accepts underscore and hyphen too, and `POST /v1/projects` mints ids with an alphabet that includes both. A project created legitimately over the API can carry an id the SDK constructor refuses.
</Callout>

## 8. Separate the environments by creating a project

`POST /v1/projects` takes `name` (1 to 128, required), `slug` (lowercase letters, digits, underscore and hyphen, 1 to 64, required), `environment` (`live` or `test`, optional, default `test`) and `settings` (optional). It answers 201 with the project row.

```typescript title="create-project.ts"
const created = await cs.api.response("post", "/v1/projects", {
  body: {
    name: "Production",
    slug: "production",
    environment: "live",
  },
});

if (created.status === 400) {
  // invalid_body carries details.issues, or details.key for a rejected setting.
  // slug_conflict carries details.slug.
  throw new Error(`${created.data.error.code}: ${created.data.error.message}`);
}
if (!created.ok) throw new Error(`create refused with ${created.status}`);

console.log(created.data.id, created.data.environment, created.data.is_default);
```

```bash title="the same call, with curl"
curl -X POST https://api.codespar.dev/v1/projects \
  -H "authorization: Bearer $CODESPAR_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "name": "Production", "slug": "production", "environment": "live" }'
```

The write order matters when it fails. The handler validates the body, then validates **each initial setting** against the registry for the environment the project is about to be born in, then upserts the organization, then inserts the project, and only then writes the settings. A rejected setting refuses before the insert, so a bad `settings` object never leaves an orphan project behind.

<Callout type="warn">
**A test key can create a live project.** The environment of the calling context is never consulted in this handler. The `environment` from the body goes straight into the insert. Whoever holds `projects:write` in a sandbox project can mint the live one.
</Callout>

<Callout type="warn">
**The slug `default` is reserved, and it fails as `invalid_body` with the message "slug is reserved", not as `slug_conflict`.** Code that branches on `slug_conflict` to pick a new slug will not catch it.
</Callout>

A project created this way is always born with `is_default` false, and nothing in this API guarantees the organization has a default at all. At most one project carries true, held by a partial unique index. At least one is held by nothing.

## 9. Three causes of an empty read

Once the context is resolved, the empty list from step 5 has exactly three explanations, and two of them survive any key you swap in.

1. **The key acts in another project.** Steps 1 to 3 show it: `project.id` from `whoami` matches a row whose `environment` or `slug` is not the one you meant. The fix is a different key, issued from the dashboard for the project you want.
2. **The project is new and empty.** A project created by `POST /v1/projects` does not receive the shared sandbox seed. The seeding runs only inside the auto-created default project, and as a self-heal inside the meta-tools. A project created over the API starts with no connections, so the read is empty for a reason that has nothing to do with the key.
3. **The rows carry a null `project_id`.** In `consumer_funding_sources`, whatever the backfill could not attribute stays null and matches no project whatsoever. The route still returns `unattributed_count`, which counts exactly the rows it is hiding from everybody. No key reaches them.

## 10. Move the default, and write settings

`PATCH /v1/projects/{id}` accepts `name`, `slug` and `is_default`, and `is_default` only as the literal `true`. There is no `environment` field, in this route or anywhere else.

```bash title="promote a project to default"
curl -X PATCH https://api.codespar.dev/v1/projects/prj_abc123def456gh \
  -H "authorization: Bearer $CODESPAR_API_KEY" \
  -H "content-type: application/json" \
  -d '{ "is_default": true }'
```

The handler clears the previous default and marks the new one in the same transaction, so no committed state ever shows two defaults. An empty patch body is `invalid_body` with the message "empty patch".

Settings are written in batches, and the response is the **effective settings after the write**, not only the keys you touched.

```typescript title="write-settings.ts"
const written = await cs.api.response("patch", "/v1/projects/{id}/settings", {
  path: { id: project.id },
  body: { settings: { test_autotopup_enabled: true } },
});

if (written.status === 400) {
  // details.key names the offending setting when there is one.
  throw new Error(`${written.data.error.code}`);
}
console.log(written.data.settings);
```

A `null` value resets a key back to tracking the default. The whole batch is validated before any write happens, so one bad entry rejects everything and nothing changes.

<Callout type="error">
**This PATCH is not idempotent the way it looks.** Repeating a reset is a true no-op. Repeating a write is a no-op only if the key was **already explicit** and already held that value. On a key still tracking the default, which is the state of every key in a freshly created project, sending the default's own value is a real change: the key becomes explicit, enters the stored blob, and writes an audit entry whose `old_value` equals its `new_value`. The "harmless" retry is precisely the call that turns off default tracking.
</Callout>

<Callout type="warn">
`GET /v1/projects/{id}/settings/history` returns the **200 oldest** entries: ascending by creation time with the limit applied after. Past 200 changes it stops showing recent ones, and there is no cursor and no limit parameter to get past that. The `created_at` it returns does not parse either: the offset is rendered without minutes when they are zero, as in `2026-09-08T12:34:56.789+00`, and `new Date()` on that gives `Invalid Date`.
</Callout>

## 11. Delete a project

`DELETE /v1/projects/{id}` answers 204 with no body. It refuses in a fixed order: 404 first if the project is not in your organization, then `cannot_delete_default`, then `cannot_delete_last_project`, then the consumer-record probe, then the delete itself.

```typescript title="delete-project.ts"
const removed = await cs.api.response("delete", "/v1/projects/{id}", {
  path: { id: doomedProjectId },
});

if (removed.status === 409) {
  const code = removed.data.error.code;
  const blockedBy = removed.data.error.details?.blocked_by ?? [];
  for (const entry of blockedBy) {
    console.log(entry.table, entry.rows);
  }
  throw new Error(code);
}
if (removed.status !== 204) throw new Error(`delete refused with ${removed.status}`);
```

The probe is a single query over nine consumer tables with `UNION ALL`, so `cannot_delete_with_consumer_records` names **every** blocking table at once rather than one per attempt. There is one exception: when a row lands between the probe and the delete, the refusal carries a single table with `rows` of null, and `blocked_by` is empty when even that could not be identified.

## Refusals, and which one a retry fixes

| Status and code | What already happened | Retry |
|---|---|---|
| 401 `unauthorized` | Nothing. The body is raw: no `message`, no `request_id`. It covers a missing header, a scheme other than Bearer, a malformed key, an unknown key, a revoked key, and the corrupted case of a key row pointing at another organization's project. Uniform on purpose: nothing distinguishes "existed and was revoked" from "never existed". | Never |
| 403 `forbidden` | Nothing. The route is mapped and the key lacks the scope named in `message`. Applies today, behind no flag. | Never. Use another key or another scope |
| 403 `route_scope_unmapped` | Nothing. The route has no entry in the scope map **and** the deny-unmapped flag is on. The map carries 289 route entries and the flag is off by default in the code. | Never. No scope grants it |
| 403 `bearer_admin_role_missing`, `bearer_admin_user_not_member`, `insufficient_role` | Nothing. The role gate on the administrative routes: POST, PATCH and DELETE on projects, the settings PATCH, and the settings history read. With a bearer credential this sits behind an enforcement flag that is off by default in the code, which records the event and lets the request through. | Never |
| 403 `bearer_admin_role_unresolved` | Nothing. The membership lookup itself failed. | **Yes.** This is the transient one |
| 403 `insufficient_role` with no flag involved | Nothing. The tolerant path exists only for a bearer credential. An OAuth token falls through to the next branch and is refused right there unless `x-codespar-user` names an admin member. | Never |
| 400 `invalid_body` | Nothing. Either the body missed the schema, with `details.issues`, or an initial setting failed the registry, with `details.key`. This also covers the reserved slug `default` and an empty PATCH. | Never with the same body |
| 400 `slug_conflict`, `details.slug` set | Nothing. That slug is already used in the organization. It is a 400, not a 409. | Never. Change the slug |
| 400 `slug_conflict`, `details.slug` null | Nothing. Not a slug collision at all: the PATCH sent no slug. What collided was the partial unique index of one default per organization, which means two promotions ran at once. | **Yes.** This one a retry resolves |
| 400 `invalid_body` on the settings PATCH | Nothing. An unknown key, a wrong value type, a key that does not apply to the project's environment (`details.key` names it), an empty settings object with no details, or a blob over the ceiling. The batch is validated before any write. | Never with the same body |
| 404 `not_found` | Nothing. A nonexistent id and another organization's id are indistinguishable, because the organization is inside the same query. That is deliberate: a 403 would confirm the id exists. | Never |
| 404 on `GET /v1/organizations/{id}` | Nothing. Any id other than your own, including an organization that really exists. | Never |
| 409 `cannot_delete_default`, `cannot_delete_last_project`, `cannot_delete_with_consumer_records` | Nothing was deleted. All three carry `details.project_id`; the third also carries `details.blocked_by`. | Never. Nothing in this API deletes those records for you |
| 500 `project_resolution_failed` | The key authenticated and then neither its own project reference nor the organization default resolved. Raw body. A state defect on our side. | Never |
| 500 `organization_missing` | The credential resolved to an organization with no row. Ours, not yours. | Never |

<Callout type="warn">
**The 403s do not use the error envelope.** A scope refusal is flat: `error`, `message`, `status`. A role refusal is flat too: `error`, `required`, plus a message. The 400, 404 and 409 responses nest the code as `error.code` alongside `error.message`, `error.details` and a sibling `request_id`. Code that reads `err.error.code` on a 403 reads `undefined`. And `request_id` is never null on these routes, whatever the schema suggests: the server echoes an inbound request id or generates one.
</Callout>

## CLI

```bash
codespar login
codespar whoami
codespar whoami --json | jq -r '.project.id'
```

`codespar login` spends a `GET /v1/whoami` to prove the key works, and only then writes it to the config file with mode 0600. A key that fails is never persisted. `codespar whoami` runs the same single call and prints user, organization, project, key environment and scopes.

<Callout type="warn">
`codespar whoami` prints the project **name**, and falls back to the id only when the name is null. The [debugging page](/docs/debugging) tells you to grab `project_id` from that command without mentioning it. Use `--json`, which returns the raw body.
</Callout>

## What this does not do

- **There is no `GET /v1/orgs`.** The served document has 16 paths under the org-scoped subtree for mandates, audit, agents, approvals and data subjects, but that exact path is not one of them. No listing, no creating, no updating an organization. The only readable organization resource is `GET /v1/organizations/{id}`, and only with your own id.
- **No route takes a project parameter.** Across all 276 operations in the served document there is no path, query or header parameter named for a project. `x-codespar-project` shows up only in the security-scheme prose and applies to a service credential.
- **`GET /v1/projects` has no filter and no pagination**, despite the query parameters and the paged response shape documented on the [projects reference](/docs/api/reference/projects).
- **`slug_invalid` and `slug_reserved` are not error codes.** The reference page lists both. Neither string exists in the service. A reserved slug and a malformed slug both come back as `invalid_body`.
- **`slug_conflict` is not a 409.** The reference page says 409; the handler answers 400. [The error reference](/docs/errors) gets this one right, and the two pages contradict each other.
- **`environment` is missing from the Project object table** on the reference page, although it is a required column on the real row and the axis of this entire recipe.
- **No key creation, rotation or revocation over the API.** `POST /v1/api-keys` is service auth and is not in the served document, so there is no SDK method and no CLI command for it either. The same is true of the project members routes, which the Projects concept page shows as if they were callable.
- **No CLI command for projects, orgs or organizations.** The exclusion is deliberate and the reason is recorded in the CLI surface list: dashboard surface. Only `codespar login` and `codespar whoami` exist here.
- **No hand-written SDK methods.** There is no `cs.projects.list()` and no `cs.whoami()`. Everything goes through `cs.api`, the REST client generated from the document. `ApiClient.operations()` lists every reachable method and path if you want to check.
- **No way to change a project's environment.** PATCH does not accept the field, and a database trigger blocks the update. The path is a second project.
- **The scope numbers on [Authentication](/docs/concepts/authentication) are stale.** That page counts 14 route entries against roughly 198 routes. The map today carries 289 route entries and covers every reachable route, with the unmapped count held at zero.
- **The role gate and the unmapped-route gate are both off by default in the code.** What a given deployment has set for them is an environment question, not a code question.

## Next steps

<NextStepsGrid items={[
  { label: "CONCEPT", title: "Projects", description: "What a project separates, and where the 401-on-mismatch claim needs the correction above.", href: "/docs/concepts/projects" },
  { label: "REFERENCE", title: "Projects", description: "Field-by-field bodies for the five project routes, with a runnable request on the page.", href: "/docs/api/reference/projects" },
  { label: "CONCEPT", title: "Authentication", description: "How a key resolves to an organization, a project and a scope set.", href: "/docs/concepts/authentication" },
]} />
