Skip to main content

Projects

8 operations under /v1/projects (GET POST PATCH DELETE): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.

18 min read
View MarkdownEdit on GitHub

Base URL: https://api.codespar.dev

Every operation below requires a Bearer token. See Authentication.

The Projects API manages the second level of CodeSpar's tenancy model: Account -> Project. See the Projects concept for the full model.

Base URL: https://api.codespar.dev

All endpoints require authentication via Bearer token. See Authentication.

Project object

FieldTypeDescription
idstringProject ID in the form prj_<16chars>
org_idstringParent account ID
namestringDisplay name (free-form)
slugstringURL-safe identifier, unique per account
is_defaultbooleantrue for the account's default project (exactly one per account)
created_atstringISO 8601 timestamp

Slug rules

  • Lowercase alphanumeric characters plus _ and -
  • Max length: 64 characters
  • default is reserved (only the auto-created default project uses it)
  • Must be unique within the account

Violations return slug_invalid, slug_reserved, or slug_conflict (see Errors).


GET /v1/projects

Lists all projects in the authenticated account.

Auth required: Yes

Query parameters

ParameterTypeDefaultDescription
is_defaultboolean--Filter to only the default project (true) or non-default projects (false)
limitnumber50Results per page (max 100)
offsetnumber0Pagination offset

curl example

curl https://api.codespar.dev/v1/projects \
  -H "Authorization: Bearer csk_live_abc123..."

Response -- 200 OK

{
  "data": [
    {
      "id": "prj_a1b2c3d4e5f6g7h8",
      "org_id": "org_xyz789",
      "name": "Default",
      "slug": "default",
      "is_default": true,
      "created_at": "2026-04-01T10:00:00Z"
    },
    {
      "id": "prj_i9j0k1l2m3n4o5p6",
      "org_id": "org_xyz789",
      "name": "Staging",
      "slug": "staging",
      "is_default": false,
      "created_at": "2026-04-15T09:12:00Z"
    }
  ],
  "total": 2,
  "limit": 50,
  "offset": 0
}

GET /v1/projects/:id

Retrieves a single project by ID.

Auth required: Yes

curl example

curl https://api.codespar.dev/v1/projects/prj_a1b2c3d4e5f6g7h8 \
  -H "Authorization: Bearer csk_live_abc123..."

Response -- 200 OK

{
  "id": "prj_a1b2c3d4e5f6g7h8",
  "org_id": "org_xyz789",
  "name": "Default",
  "slug": "default",
  "is_default": true,
  "created_at": "2026-04-01T10:00:00Z"
}

POST /v1/projects

Creates a new project in the authenticated account.

Auth required: Yes

Request body

FieldTypeRequiredDescription
namestringYesDisplay name
slugstringYesURL-safe identifier (see slug rules)

New projects are always created with is_default: false. To promote a project to default, use PATCH /v1/projects/:id with {"is_default": true}.

curl example

curl -X POST https://api.codespar.dev/v1/projects \
  -H "Authorization: Bearer csk_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production",
    "slug": "prod"
  }'

Response -- 201 Created

{
  "id": "prj_q7r8s9t0u1v2w3x4",
  "org_id": "org_xyz789",
  "name": "Production",
  "slug": "prod",
  "is_default": false,
  "created_at": "2026-04-20T14:22:00Z"
}

PATCH /v1/projects/:id

Updates a project's name, slug, or default status.

Auth required: Yes

Request body

FieldTypeRequiredDescription
namestringNoNew display name
slugstringNoNew slug (subject to slug rules)
is_defaultbooleanNoSet to true to promote this project to default. Atomic: the previous default is demoted in the same transaction.

is_default can only be set to true. You cannot un-set it directly -- to change the default, promote a different project instead. Sending {"is_default": false} on the current default project is a no-op or error (exact behavior: TODO -- confirm).

curl example -- promote to default

curl -X PATCH https://api.codespar.dev/v1/projects/prj_q7r8s9t0u1v2w3x4 \
  -H "Authorization: Bearer csk_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{"is_default": true}'

Response -- 200 OK

{
  "id": "prj_q7r8s9t0u1v2w3x4",
  "org_id": "org_xyz789",
  "name": "Production",
  "slug": "prod",
  "is_default": true,
  "created_at": "2026-04-20T14:22:00Z"
}

DELETE /v1/projects/:id

Deletes a project and all of its scoped resources (API keys, triggers, session history, connection records).

Auth required: Yes

Deletion cascades. There is no undo. Export audit logs first if you need to retain them.

curl example

curl -X DELETE https://api.codespar.dev/v1/projects/prj_q7r8s9t0u1v2w3x4 \
  -H "Authorization: Bearer csk_live_abc123..."

Response -- 200 OK

{
  "id": "prj_q7r8s9t0u1v2w3x4",
  "deleted": true
}

Deletion fails with cannot_delete_default or cannot_delete_last_project (see below).


Project members (RBAC overrides)

Every user with account membership inherits a base role (owner, admin, member) that applies across every project in that account. The four endpoints below let an account admin narrow or widen that role for a specific project — useful when a contractor should see exactly one project, or a member needs admin rights on staging without becoming a full account admin.

A user absent from project_members inherits their account-level role unchanged. Explicit rows are overrides, not the source of truth.

Not reachable with an API key — use the dashboard

The four member endpoints live in the API's service-auth subtree (requireServiceAuth), alongside api-keys, usage, billing and team. A csk_ Bearer key is not read at all there, so a Bearer call answers 401 {"error":"unauthorized"} — not a 403 and not a 404.

There is no key we can issue that changes this: the credential for that subtree is CodeSpar's own platform secret, and possession of it is the trust boundary for every account, so it never leaves our infrastructure. The dashboard is the supported surface. The request and response shapes below describe what it sends and renders.

GET /v1/projects/:id/members

Lists every user with effective access to the project, including account-level inherited members. The source field tells you whether the role came from a project override or from the account membership.

GET /v1/projects/prj_a1b2c3d4e5f6g7h8/members
Response — 200 OK
{
  "members": [
    {
      "user_id": "user_owner123",
      "role": "owner",
      "source": "org",
      "email": "founder@codespar.dev",
      "display_name": "Founder",
      "avatar_url": null,
      "added_at": "2026-04-15T10:00:00Z"
    },
    {
      "user_id": "user_contractor456",
      "role": "admin",
      "source": "project",
      "email": "ana@contractor.com",
      "display_name": "Ana",
      "avatar_url": null,
      "added_at": "2026-04-22T18:30:00Z"
    }
  ]
}

Sorted: owner first, then admin, then member — oldest first within each tier.

POST /v1/projects/:id/members

Adds or upserts a project-level override. Requires account-level admin or owner.

Request body
FieldTypeRequiredNotes
user_idstringYesMust already be a member of the account.
roleadmin | memberYesCannot override an account owner — they always retain full access.
POST /v1/projects/prj_.../members
Content-Type: application/json

{"user_id": "user_contractor456", "role": "admin"}

PATCH /v1/projects/:id/members/:user_id

Updates an existing override's role. Same gate (admin+ at account level).

PATCH /v1/projects/prj_.../members/user_contractor456
Content-Type: application/json

{"role": "member"}

DELETE /v1/projects/:id/members/:user_id

Removes the override. The user falls back to their account-level role for this project.

DELETE /v1/projects/prj_.../members/user_contractor456
StatusError codeWhen
401unauthorizedCalled with a Bearer key, or with no/invalid service key. Bare-string shape
403insufficient_rolePOST/PATCH/DELETE by a non-admin account member. Flat shape: {"error":"insufficient_role","required":"admin"}
404not_foundThe project id does not exist in this account, or a PATCH named a user with no override row
404user_not_in_orguser_id is not a member of this account
409cannot_override_ownerThe target user is the account owner; an owner's role cannot be narrowed per project

DELETE is idempotent: removing an override that does not exist answers 204, not 404 — the end state is the same, the user inherits their account role.


Errors

All endpoints follow the standard error format:

{
  "error": "error_code",
  "message": "Human-readable error description.",
  "status": 400
}

Project-specific error codes

StatusError codeWhen
400slug_invalidSlug contains disallowed characters, exceeds 64 chars, or is empty
400slug_reservedSlug is default (reserved for the auto-created default project)
409slug_conflictAnother project in the account already uses this slug
409cannot_delete_defaultDELETE on the default project. Promote another project first.
409cannot_delete_last_projectDELETE on the only remaining project. Every account must have at least one project.

Standard error codes

StatusError codeWhen
401unauthorizedInvalid or missing API key
403forbiddenAPI key lacks permission (e.g. a project-scoped key trying to manage a different project)
404not_foundProject ID does not exist or does not belong to the authenticated account
429rate_limitedToo many requests
500internal_errorServer error. Retry with exponential backoff.

Next steps

Every operation, from the spec

Generated from the published OpenAPI document, so it never drifts from what the API actually serves. The section above is written by hand and carries what a schema cannot: the object model, field rules, and the order to call things in.

GET /v1/projects

GEThttps://api.codespar.dev/v1/projects

List projects

Every project in the organization this credential resolves to, the default first and then oldest to newest.

SCOPE. A key created with an explicit scope list needs projects:read and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.

No role gate: any credential that resolves to the organization can read this.

Not paginated and not filtered: an organization is expected to hold a handful of projects, so there is no cursor, no limit and no query parameters. The response is always the whole set.

Responses

StatusBodyDescription
200objectOK

Response 200

FieldTypeRequiredDescription
projectsarray of objectyes—
Example request
curl -X GET https://api.codespar.dev/v1/projects \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/projects HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/projects",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/projects", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/projects");
Example response 200
application/json
{
  "projects": [
    {
      "id": "obj_0000000000000000",
      "org_id": "org_0000000000000000",
      "name": "Example",
      "slug": "example",
      "is_default": true,
      "environment": "live",
      "created_at": "2026-01-15T12:00:00.000Z"
    }
  ]
}

POST /v1/projects

POSThttps://api.codespar.dev/v1/projects

Create a project

Creates a project in the organization this credential resolves to.

ROLE. This is an administrative operation and it carries the admin role gate. On a credential of the kind this document describes — an API key or an OAuth access token — that gate does NOT refuse today. It resolves the user forwarded in the x-codespar-user header against this organization's members, records the outcome when no user was forwarded, the user is not a member of the organization, or the user ranks below admin, and then lets the request through. Refusal is behind a server-side rollout flag that is currently off. Forward an admin or owner user in x-codespar-user now: when the flag is turned on, a call that does not carry one starts being answered with 403 instead of being served.

SCOPE. A key created with an explicit scope list needs projects:write and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.

slug is unique per organization, lowercase alphanumerics plus _ and -, at most 64 characters, and default is reserved because the auto-seeded first project already holds it. A slug already taken in the organization is refused with 400 and code slug_conflict, not 409, and details.slug echoes the value that collided.

environment is test when omitted and is fixed once the row exists: the update operation has no field for it. live needs the organization to be approved for live by CodeSpar; until it is, a live create is refused with 403 and code org_not_approved_for_live before anything is written, whatever the credential and whatever its role or scopes. Approval is granted by CodeSpar, never through this API. Test projects need no approval. A new project is never the organization's default — the insert writes false — so promote it afterwards with PATCH /v1/projects/{id} if that is what you want.

settings seeds the settings sub-resource. Every key is checked against the settings registry, for the environment the project is about to be born with, BEFORE anything is inserted, so an unknown key, a wrong-typed value, or a key that does not apply to that environment refuses the whole call and leaves no project behind. The refusal is 400 invalid_body with details.key naming the offending setting. Settings are not part of the response body; read them back with GET /v1/projects/{id}/settings.

Request body

FieldTypeRequiredDescription
environment"live" | "test"no—
namestringyes—
settingsobjectno—
slugstringyes—

Responses

StatusBodyDescription
201objectCreated
400objectinvalid_body when the body or an initial setting fails validation (details.issues for the body, details.key for a setting). slug_conflict when the slug is already taken in this organization.
403objectorg_not_approved_for_live when environment is live and CodeSpar has not approved the organization for live. details.org_id names the organization. No project is created. The role and scope refusals the auth layer can also answer with 403 are not this body; see the ROLE and SCOPE notes.

Response 201

FieldTypeRequiredDescription
created_atstring (date-time)yes—
environment"live" | "test"yesFixed at creation. There is no field to change it afterwards.
idstringyesprj_ followed by a 16 character id.
is_defaultbooleanyesAt most one project per organization carries true, held by a partial unique index on the table. At LEAST one is not guaranteed by any constraint, and nothing in this API creates one: a project created through this API is always false, and the organization's first project is seeded elsewhere.
namestringyes—
org_idstringyes—
slugstringyesUnique within the organization.
Example request
curl -X POST https://api.codespar.dev/v1/projects \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "name": "Example",
       "slug": "example",
       "environment": "live",
       "settings": {}
     }'
POST /v1/projects HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "name": "Example",
  "slug": "example",
  "environment": "live",
  "settings": {}
}
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/projects",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "name": "Example",
      "slug": "example",
      "environment": "live",
      "settings": {}
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/projects", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "Example",
    "slug": "example",
    "environment": "live",
    "settings": {}
  }),
});

const data = await res.json();
const r = await cs.api.response("post", "/v1/projects", {
  body: {
    name: "Example",
    slug: "example",
    environment: "live",
    settings: {}
  }
});
// r.status is one of the documented statuses (200, 403),
// each with its own body shape in r.data; nothing here throws on 403.
if (r.ok) {
  console.log(r.data);
}
Example response 201
application/json
{
  "id": "obj_0000000000000000",
  "org_id": "org_0000000000000000",
  "name": "Example",
  "slug": "example",
  "is_default": true,
  "environment": "live",
  "created_at": "2026-01-15T12:00:00.000Z"
}

GET /v1/projects/{id}

GEThttps://api.codespar.dev/v1/projects/{id}

Read one project

The same row the list returns, by id.

SCOPE. A key created with an explicit scope list needs projects:read and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.

No role gate.

An id belonging to another organization returns 404, never 403. The organization is part of the query rather than a check after it, so the handler cannot tell another tenant from absent, and it must not: a 403 would confirm the id exists, which is exactly what a caller sweeping ids is asking.

Path parameters

NameTypeRequiredDescription
idstringyesProject id. An id from another organization reads as absent.

Responses

StatusBodyDescription
200objectOK
404objectnot_found. Also the answer for an id owned by another organization.

Response 200

FieldTypeRequiredDescription
created_atstring (date-time)yes—
environment"live" | "test"yesFixed at creation. There is no field to change it afterwards.
idstringyesprj_ followed by a 16 character id.
is_defaultbooleanyesAt most one project per organization carries true, held by a partial unique index on the table. At LEAST one is not guaranteed by any constraint, and nothing in this API creates one: a project created through this API is always false, and the organization's first project is seeded elsewhere.
namestringyes—
org_idstringyes—
slugstringyesUnique within the organization.
Example request
curl -X GET https://api.codespar.dev/v1/projects/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/projects/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/projects/{id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/projects/{id}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/projects/{id}", {
  path: {
    id: "prj_0000000000000000"
  }
});
Example response 200
application/json
{
  "id": "obj_0000000000000000",
  "org_id": "org_0000000000000000",
  "name": "Example",
  "slug": "example",
  "is_default": true,
  "environment": "live",
  "created_at": "2026-01-15T12:00:00.000Z"
}

PATCH /v1/projects/{id}

PATCHhttps://api.codespar.dev/v1/projects/{id}

Rename a project, change its slug, or promote it to default

At least one field must be present; an empty body is refused with 400.

ROLE. This is an administrative operation and it carries the admin role gate. On a credential of the kind this document describes — an API key or an OAuth access token — that gate does NOT refuse today. It resolves the user forwarded in the x-codespar-user header against this organization's members, records the outcome when no user was forwarded, the user is not a member of the organization, or the user ranks below admin, and then lets the request through. Refusal is behind a server-side rollout flag that is currently off. Forward an admin or owner user in x-codespar-user now: when the flag is turned on, a call that does not carry one starts being answered with 403 instead of being served.

SCOPE. A key created with an explicit scope list needs projects:write and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.

is_default accepts true and nothing else. A project cannot be un-defaulted, and the only way to move the default is to promote a different project: the previous default is cleared and the new one set in one transaction, so no committed state ever shows two defaults. Promoting a project that is already the default is a no-op.

environment is absent on purpose and cannot be changed here or anywhere else. A cross-tenant id is 404, on the same reasoning as the read.

TWO THINGS ARE ANSWERED WITH slug_conflict, and only one of them is about a slug. A slug already taken in the organization is 400 slug_conflict with details.slug carrying the value you sent. A patch that names NO slug can also get slug_conflict, and then details.slug is null — but the collision there is not a slug at all: the only other unique index on the table is the partial one that permits a single default per organization, so what you hit is two promotions racing. The handler labels both the same way. Read a null details.slug as retry the promotion, not as a slug collision to go hunting for.

The 200 body is the project as it stands after the update.

Path parameters

NameTypeRequiredDescription
idstringyesProject id. An id from another organization reads as absent.

Request body

FieldTypeRequiredDescription
is_defaulttrueno—
namestringno—
slugstringno—

Responses

StatusBodyDescription
200objectOK
400objectinvalid_body when the patch is empty or a field fails validation (details.issues). slug_conflict when the slug is taken, or when a concurrent promotion collided (details.slug null).
404objectnot_found. Also the answer for an id owned by another organization.

Response 200

FieldTypeRequiredDescription
created_atstring (date-time)yes—
environment"live" | "test"yesFixed at creation. There is no field to change it afterwards.
idstringyesprj_ followed by a 16 character id.
is_defaultbooleanyesAt most one project per organization carries true, held by a partial unique index on the table. At LEAST one is not guaranteed by any constraint, and nothing in this API creates one: a project created through this API is always false, and the organization's first project is seeded elsewhere.
namestringyes—
org_idstringyes—
slugstringyesUnique within the organization.
Example request
curl -X PATCH https://api.codespar.dev/v1/projects/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "name": "Example",
       "slug": "example",
       "is_default": true
     }'
PATCH /v1/projects/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "name": "Example",
  "slug": "example",
  "is_default": true
}
import os
import requests

res = requests.patch(
    "https://api.codespar.dev/v1/projects/{id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "name": "Example",
      "slug": "example",
      "is_default": True
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/projects/{id}", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "name": "Example",
    "slug": "example",
    "is_default": true
  }),
});

const data = await res.json();
const result = await cs.api.patch("/v1/projects/{id}", {
  path: {
    id: "prj_0000000000000000"
  },
  body: {
    name: "Example",
    slug: "example",
    is_default: true
  }
});
Example response 200
application/json
{
  "id": "obj_0000000000000000",
  "org_id": "org_0000000000000000",
  "name": "Example",
  "slug": "example",
  "is_default": true,
  "environment": "live",
  "created_at": "2026-01-15T12:00:00.000Z"
}

DELETE /v1/projects/{id}

DELETEhttps://api.codespar.dev/v1/projects/{id}

Delete a project

A hard delete, not a soft one: the project row is removed and most of what hangs off it goes with it. 204 with no body on success.

ROLE. This is an administrative operation and it carries the admin role gate. On a credential of the kind this document describes — an API key or an OAuth access token — that gate does NOT refuse today. It resolves the user forwarded in the x-codespar-user header against this organization's members, records the outcome when no user was forwarded, the user is not a member of the organization, or the user ranks below admin, and then lets the request through. Refusal is behind a server-side rollout flag that is currently off. Forward an admin or owner user in x-codespar-user now: when the flag is turned on, a call that does not carry one starts being answered with 403 instead of being served.

SCOPE. A key created with an explicit scope list needs projects:write and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.

Three refusals, all 409 because the request is well formed and conflicts with an invariant the organization relies on:

  • cannot_delete_default: promote another project to default first.
  • cannot_delete_last_project: an organization always keeps at least one project for new resources to attach to.
  • cannot_delete_with_consumer_records: the project still owns consumer records, which are kept rather than dragged along by the delete.

All three carry details.project_id. The third also carries details.blocked_by, a list of { table, rows }. A pre-flight probe names EVERY blocking table with its row count, so one refusal tells you everything to clear rather than making you rediscover the next blocker on each retry. A record inserted between that probe and the delete is caught by a backstop that can only name the one table that raised, and reports rows: null for it; an empty blocked_by means even that table was not identifiable. Nothing in this API deletes those records for you.

A cross-tenant id is 404, not 403.

Path parameters

NameTypeRequiredDescription
idstringyesProject id. An id from another organization reads as absent.

Responses

StatusBodyDescription
204—No Content
404objectnot_found. Also the answer for an id owned by another organization.
409objectThe delete conflicts with an invariant. details.project_id on all three; details.blocked_by lists { table, rows } on the consumer-records refusal.
Example request
curl -X DELETE https://api.codespar.dev/v1/projects/{id} \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
DELETE /v1/projects/{id} HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.delete(
    "https://api.codespar.dev/v1/projects/{id}",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/projects/{id}", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.delete("/v1/projects/{id}", {
  path: {
    id: "prj_0000000000000000"
  }
});

GET /v1/projects/{id}/settings

GEThttps://api.codespar.dev/v1/projects/{id}/settings

Read a project's effective settings

Every setting declared for the project's environment, with its effective value, the declared default, and whether the value was set explicitly or is still tracking that default. explicit: false means the key will follow the default if the default ever moves.

SCOPE. A key created with an explicit scope list needs projects:settings and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.

This read alone carries NO role gate. The write and the history read below both do, and what that gate currently does is described on each of them.

The list is filtered by environment: a setting declared test-only does not appear on a live project at all, so an empty list is a normal answer rather than an error. A stored value whose key is no longer declared is omitted too. Every setting the registry declares as this is written applies to test only, which makes an empty list what a live project returns — a fact about the current declarations, not about this route.

A cross-tenant id is 404.

Path parameters

NameTypeRequiredDescription
idstringyesProject id. An id from another organization reads as absent.

Responses

StatusBodyDescription
200objectOK
404objectnot_found. Also the answer for an id owned by another organization.

Response 200

FieldTypeRequiredDescription
settingsarray of objectyes—
Example request
curl -X GET https://api.codespar.dev/v1/projects/{id}/settings \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/projects/{id}/settings HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/projects/{id}/settings",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/projects/{id}/settings", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/projects/{id}/settings", {
  path: {
    id: "prj_0000000000000000"
  }
});
Example response 200
application/json
{
  "settings": [
    {
      "key": "string",
      "type": "boolean",
      "description": "string",
      "explicit": true,
      "freshness": "request",
      "payee_affecting": true,
      "enum_values": [
        "string"
      ]
    }
  ]
}

PATCH /v1/projects/{id}/settings

PATCHhttps://api.codespar.dev/v1/projects/{id}/settings

Set or reset a project's settings

The body is { "settings": { <key>: <value> } }. A null value RESETS that key to default-tracking; it does not store null. Multiple keys in one call are applied together.

ROLE. This is an administrative operation and it carries the admin role gate. On a credential of the kind this document describes — an API key or an OAuth access token — that gate does NOT refuse today. It resolves the user forwarded in the x-codespar-user header against this organization's members, records the outcome when no user was forwarded, the user is not a member of the organization, or the user ranks below admin, and then lets the request through. Refusal is behind a server-side rollout flag that is currently off. Forward an admin or owner user in x-codespar-user now: when the flag is turned on, a call that does not carry one starts being answered with 403 instead of being served.

SCOPE. A key created with an explicit scope list needs projects:settings and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.

The whole batch is validated against the settings registry before anything is written, so one bad entry rejects the entire call and nothing changes. An unknown key, a value of the wrong type, or a key that does not apply to the project's environment is 400 invalid_body with details.key naming the offender. An empty settings object is 400 as well, with no details.

Each key that actually changes appends one audit entry in the same transaction as the write, so the change and its record cannot come apart.

REPEATING A CALL IS NOT ALWAYS FREE, and the asymmetry is worth reading before you build a retry on it. A reset of a key already tracking its default is a true no-op: nothing is written and nothing is recorded. A WRITE is not symmetric with that. The shortcut that skips a write applies only to a key that is ALREADY explicit and already holds the value you sent. On a key still tracking its default — the state every key is in on a fresh project — sending exactly the default's own value IS a change: the key becomes explicit, it appears in the stored blob, and one audit entry is appended whose old_value and new_value are equal. The SECOND identical call is the one that does nothing. So a retry that looks idempotent can still be the call that flips a key off default-tracking, which matters because an explicit key stops following the default if the default later moves.

The 200 body is the same shape the read returns: the effective settings AFTER the write, not only the keys this call touched.

A cross-tenant id is 404.

Path parameters

NameTypeRequiredDescription
idstringyesProject id. An id from another organization reads as absent.

Request body

FieldTypeRequiredDescription
settingsobjectyes—

Responses

StatusBodyDescription
200objectOK
400objectinvalid_body: the body did not match the schema (details.issues), the patch was empty, or a key failed the registry check (details.key).
404objectnot_found. Also the answer for an id owned by another organization.

Response 200

FieldTypeRequiredDescription
settingsarray of objectyes—
Example request
curl -X PATCH https://api.codespar.dev/v1/projects/{id}/settings \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "settings": {}
     }'
PATCH /v1/projects/{id}/settings HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "settings": {}
}
import os
import requests

res = requests.patch(
    "https://api.codespar.dev/v1/projects/{id}/settings",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "settings": {}
    },
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/projects/{id}/settings", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "settings": {}
  }),
});

const data = await res.json();
const result = await cs.api.patch("/v1/projects/{id}/settings", {
  path: {
    id: "prj_0000000000000000"
  },
  body: {
    settings: {}
  }
});
Example response 200
application/json
{
  "settings": [
    {
      "key": "string",
      "type": "boolean",
      "description": "string",
      "explicit": true,
      "freshness": "request",
      "payee_affecting": true,
      "enum_values": [
        "string"
      ]
    }
  ]
}

GET /v1/projects/{id}/settings/history

GEThttps://api.codespar.dev/v1/projects/{id}/settings/history

Read a project's settings audit log

The ordered change history for this project's settings, oldest first.

ROLE. This is an administrative operation and it carries the admin role gate. On a credential of the kind this document describes — an API key or an OAuth access token — that gate does NOT refuse today. It resolves the user forwarded in the x-codespar-user header against this organization's members, records the outcome when no user was forwarded, the user is not a member of the organization, or the user ranks below admin, and then lets the request through. Refusal is behind a server-side rollout flag that is currently off. Forward an admin or owner user in x-codespar-user now: when the flag is turned on, a call that does not carry one starts being answered with 403 instead of being served.

SCOPE. A key created with an explicit scope list needs projects:settings and is answered with 403 without it. A wildcard key passes: the wildcard is what a key gets when it is created without an explicit list, which is every key by default.

One entry per key that actually changed, so a call that wrote a value an already-explicit key was already holding leaves no trace here. Note what does leave a trace: writing a key's own default value while it is still tracking that default is a change, and it lands here with old_value equal to new_value. explicit_before and explicit_after are what separate a reset from a write: a reset ends with explicit_after: false and a new_value equal to the declared default.

Capped at the 200 OLDEST entries and not paginated. Read that carefully: the order is ascending and the limit is applied after it, so once a project has recorded more than 200 changes this endpoint stops showing the most RECENT ones. There is no cursor and no limit parameter to page past it.

created_at on each entry is not a parseable RFC 3339 timestamp; see the field's own description.

A cross-tenant id is 404.

Path parameters

NameTypeRequiredDescription
idstringyesProject id. An id from another organization reads as absent.

Responses

StatusBodyDescription
200objectOK
404objectnot_found. Also the answer for an id owned by another organization.

Response 200

FieldTypeRequiredDescription
historyarray of objectyes—
Example request
curl -X GET https://api.codespar.dev/v1/projects/{id}/settings/history \
  -H "Authorization: Bearer $CODESPAR_API_KEY"
GET /v1/projects/{id}/settings/history HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
import os
import requests

res = requests.get(
    "https://api.codespar.dev/v1/projects/{id}/settings/history",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
)
res.raise_for_status()
data = res.json()
const res = await fetch("https://api.codespar.dev/v1/projects/{id}/settings/history", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
  },
});

const data = await res.json();
const result = await cs.api.get("/v1/projects/{id}/settings/history", {
  path: {
    id: "prj_0000000000000000"
  }
});
Example response 200
application/json
{
  "history": [
    {
      "setting_key": "string",
      "actor": "string",
      "explicit_before": true,
      "explicit_after": true,
      "created_at": "string"
    }
  ]
}
Projects | CodeSpar