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.
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
| Field | Type | Description |
|---|---|---|
id | string | Project ID in the form prj_<16chars> |
org_id | string | Parent account ID |
name | string | Display name (free-form) |
slug | string | URL-safe identifier, unique per account |
is_default | boolean | true for the account's default project (exactly one per account) |
created_at | string | ISO 8601 timestamp |
Slug rules
- Lowercase alphanumeric characters plus
_and- - Max length: 64 characters
defaultis 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
| Parameter | Type | Default | Description |
|---|---|---|---|
is_default | boolean | -- | Filter to only the default project (true) or non-default projects (false) |
limit | number | 50 | Results per page (max 100) |
offset | number | 0 | Pagination 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name |
slug | string | Yes | URL-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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | No | New display name |
slug | string | No | New slug (subject to slug rules) |
is_default | boolean | No | Set 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/membersResponse — 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
| Field | Type | Required | Notes |
|---|---|---|---|
user_id | string | Yes | Must already be a member of the account. |
role | admin | member | Yes | Cannot 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_contractor456Member-related error codes
| Status | Error code | When |
|---|---|---|
401 | unauthorized | Called with a Bearer key, or with no/invalid service key. Bare-string shape |
403 | insufficient_role | POST/PATCH/DELETE by a non-admin account member. Flat shape: {"error":"insufficient_role","required":"admin"} |
404 | not_found | The project id does not exist in this account, or a PATCH named a user with no override row |
404 | user_not_in_org | user_id is not a member of this account |
409 | cannot_override_owner | The 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
| Status | Error code | When |
|---|---|---|
400 | slug_invalid | Slug contains disallowed characters, exceeds 64 chars, or is empty |
400 | slug_reserved | Slug is default (reserved for the auto-created default project) |
409 | slug_conflict | Another project in the account already uses this slug |
409 | cannot_delete_default | DELETE on the default project. Promote another project first. |
409 | cannot_delete_last_project | DELETE on the only remaining project. Every account must have at least one project. |
Standard error codes
| Status | Error code | When |
|---|---|---|
401 | unauthorized | Invalid or missing API key |
403 | forbidden | API key lacks permission (e.g. a project-scoped key trying to manage a different project) |
404 | not_found | Project ID does not exist or does not belong to the authenticated account |
429 | rate_limited | Too many requests |
500 | internal_error | Server 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
https://api.codespar.dev/v1/projectsList 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
| Status | Body | Description |
|---|---|---|
200 | object | OK |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
projects | array of object | yes | — |
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_KEYimport 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");{
"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
https://api.codespar.dev/v1/projectsCreate 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
| Field | Type | Required | Description |
|---|---|---|---|
environment | "live" | "test" | no | — |
name | string | yes | — |
settings | object | no | — |
slug | string | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
201 | object | Created |
400 | object | invalid_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. |
403 | object | org_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
| Field | Type | Required | Description |
|---|---|---|---|
created_at | string (date-time) | yes | — |
environment | "live" | "test" | yes | Fixed at creation. There is no field to change it afterwards. |
id | string | yes | prj_ followed by a 16 character id. |
is_default | boolean | yes | At 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. |
name | string | yes | — |
org_id | string | yes | — |
slug | string | yes | Unique within the organization. |
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);
}{
"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}
https://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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Project id. An id from another organization reads as absent. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | not_found. Also the answer for an id owned by another organization. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
created_at | string (date-time) | yes | — |
environment | "live" | "test" | yes | Fixed at creation. There is no field to change it afterwards. |
id | string | yes | prj_ followed by a 16 character id. |
is_default | boolean | yes | At 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. |
name | string | yes | — |
org_id | string | yes | — |
slug | string | yes | Unique within the organization. |
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_KEYimport 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"
}
});{
"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}
https://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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Project id. An id from another organization reads as absent. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
is_default | true | no | — |
name | string | no | — |
slug | string | no | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | invalid_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). |
404 | object | not_found. Also the answer for an id owned by another organization. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
created_at | string (date-time) | yes | — |
environment | "live" | "test" | yes | Fixed at creation. There is no field to change it afterwards. |
id | string | yes | prj_ followed by a 16 character id. |
is_default | boolean | yes | At 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. |
name | string | yes | — |
org_id | string | yes | — |
slug | string | yes | Unique within the organization. |
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
}
});{
"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}
https://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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Project id. An id from another organization reads as absent. |
Responses
| Status | Body | Description |
|---|---|---|
204 | — | No Content |
404 | object | not_found. Also the answer for an id owned by another organization. |
409 | object | The delete conflicts with an invariant. details.project_id on all three; details.blocked_by lists { table, rows } on the consumer-records refusal. |
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_KEYimport 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
https://api.codespar.dev/v1/projects/{id}/settingsRead 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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Project id. An id from another organization reads as absent. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | not_found. Also the answer for an id owned by another organization. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
settings | array of object | yes | — |
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_KEYimport 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"
}
});{
"settings": [
{
"key": "string",
"type": "boolean",
"description": "string",
"explicit": true,
"freshness": "request",
"payee_affecting": true,
"enum_values": [
"string"
]
}
]
}PATCH /v1/projects/{id}/settings
https://api.codespar.dev/v1/projects/{id}/settingsSet 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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Project id. An id from another organization reads as absent. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
settings | object | yes | — |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
400 | object | invalid_body: the body did not match the schema (details.issues), the patch was empty, or a key failed the registry check (details.key). |
404 | object | not_found. Also the answer for an id owned by another organization. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
settings | array of object | yes | — |
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: {}
}
});{
"settings": [
{
"key": "string",
"type": "boolean",
"description": "string",
"explicit": true,
"freshness": "request",
"payee_affecting": true,
"enum_values": [
"string"
]
}
]
}GET /v1/projects/{id}/settings/history
https://api.codespar.dev/v1/projects/{id}/settings/historyRead 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
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Project id. An id from another organization reads as absent. |
Responses
| Status | Body | Description |
|---|---|---|
200 | object | OK |
404 | object | not_found. Also the answer for an id owned by another organization. |
Response 200
| Field | Type | Required | Description |
|---|---|---|---|
history | array of object | yes | — |
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_KEYimport 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"
}
});{
"history": [
{
"setting_key": "string",
"actor": "string",
"explicit_before": true,
"explicit_after": true,
"created_at": "string"
}
]
}Organizations
1 operation under /v1/organizations/{id} (GET): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.
Fees
3 operations under /v1/fees (GET): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript.