Skip to main content
Cookbooks

Projects, Keys and Environments

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.

1 min read
View MarkdownEdit on GitHub
TIME
~20 min
STACK
@codespar/sdkcurlno LLM
SERVERS
noneplatform API only

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.

RESOLVE THE CONTEXT
Who is this key → where does it act → why was the read empty
1 · RESOLVE
GET /v1/whoami
organization, project, key.environment, key.scopes
2 · PLACE
GET /v1/projects
every project in the org, default first, then oldest
3 · READ
The list that came back empty
every data read filters on org_id and project_id
STEP 1
wrong project
The key is bound somewhere else. Only a different key moves it.
STEP 2
empty project
A project created over the API gets no sandbox seed, so it has no connections yet.
STEP 3
unattributed rows
Rows with a null project_id match no project at all.
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

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.

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.

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

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

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.

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 still shows a key_ prefix.

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.

There is no 401 for "a test key in a live project". Both Authentication and 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.

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.

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}`));
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 }'

The list is not filterable and not paginated. The projects reference 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.

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.

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.

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

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.

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.

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.

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.

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

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.

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.

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.

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.

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.

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.

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.

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.

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 codeWhat already happenedRetry
401 unauthorizedNothing. 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 forbiddenNothing. 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_unmappedNothing. 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_roleNothing. 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_unresolvedNothing. The membership lookup itself failed.Yes. This is the transient one
403 insufficient_role with no flag involvedNothing. 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_bodyNothing. 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 setNothing. That slug is already used in the organization. It is a 400, not a 409.Never. Change the slug
400 slug_conflict, details.slug nullNothing. 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 PATCHNothing. 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_foundNothing. 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_recordsNothing 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_failedThe 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_missingThe credential resolved to an organization with no row. Ours, not yours.Never

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.

CLI

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.

codespar whoami prints the project name, and falls back to the id only when the name is null. The debugging page tells you to grab project_id from that command without mentioning it. Use --json, which returns the raw body.

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

Projects, Keys and Environments | CodeSpar