Projects API
The Projects API covers the JWT-authenticated operations the dashboard uses to manage a single project — renaming it, configuring its retry policy, rotating or revoking its API key, listing/inviting team members, and changing or removing those members.
Base URL: https://api.zeridion.com/platform/v1
All endpoints require a valid JWT obtained from POST /platform/v1/auth/login.
Roles and the role hierarchy
Every team member's authority on a project is encoded as a role string. The server compares roles via RoleHelper.RoleRank, which yields a strict total order:
| Role | Rank | Typical permissions |
|---|---|---|
owner | 3 | Everything below, plus delete the project, change the retry policy, transfer/remove the owner. The user who created the project. There is exactly one owner per project. |
admin | 2 | Rename, rotate/revoke API keys, invite members, change member roles up to admin. |
member | 1 | Read-only on configuration; can use the project's API key for /flare/v1/* operations. |
viewer | 0 | Dashboard-only read access. |
Endpoints below indicate the minimum rank required. The general rule for any role-changing call is that the caller can never assign or invite a role equal to or higher than their own (role_exceeds_caller), and the owner is immutable from within the API (cannot_modify_owner / cannot_remove_owner).
PATCH /platform/v1/projects/{projectId}
Rename a project.
Authentication: JWT required. Role: owner only.
Request
PATCH /platform/v1/projects/{projectId}
Authorization: Bearer <jwt_token>
Content-Type: application/json
Body
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | New project name. Trimmed; max 200 characters; must be non-empty. |
{ "name": "Acme Production" }
Response
200 OK
{
"id": "prj_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"name": "Acme Production",
"plan": "starter"
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_body | Body is not valid JSON. |
| 401 | unauthorized | Missing or invalid JWT. |
| 403 | forbidden | Caller is not the project owner. |
| 404 | project_not_found | Project ID does not exist. |
| 422 | validation_error | name is missing, blank, or longer than 200 characters. |
DELETE /platform/v1/projects/{projectId}
Cascade-delete a project and everything attached to it (jobs, recurring jobs, alert settings, audit logs, daily usage, previous API keys, etc.).
Authentication: JWT required. Role: owner only.
Last project guard. A user must always have at least one project. If this is the user's only project the request fails with
409 last_project— create a replacement project first if you really want to delete this one.
Stripe customer. The Stripe customer record is not deleted. Cancel the subscription manually from the Customer Portal before deleting your last project.
Request
DELETE /platform/v1/projects/{projectId}
Authorization: Bearer <jwt_token>
No request body.
Response
200 OK
{
"deleted": true,
"id": "prj_01JAXBKM3N4P5Q6R7S8T9UVWXY"
}
Errors
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT. |
| 403 | forbidden | Caller is not the project owner. |
| 404 | project_not_found | Project ID does not exist. |
| 409 | last_project | This is the caller's only project; create another project first. |
PATCH /platform/v1/projects/{projectId}/retry-policy
Update the per-project default retry policy applied to new jobs that don't override these values explicitly.
Authentication: JWT required. Role: owner only.
All three body fields are optional; missing fields are unchanged. At least one field must be present.
Request
PATCH /platform/v1/projects/{projectId}/retry-policy
Authorization: Bearer <jwt_token>
Content-Type: application/json
Body
| Field | Type | Range | Description |
|---|---|---|---|
default_max_attempts | integer | 1–50 | Default max_attempts applied to a new job when the create call doesn't specify one. |
retry_backoff_policy | string | exponential | linear | fixed | Backoff curve between attempts. |
retry_backoff_seconds | integer | 1–3600 | Base backoff (in seconds) used by the curve. |
{
"default_max_attempts": 5,
"retry_backoff_policy": "exponential",
"retry_backoff_seconds": 30
}
Response
200 OK
{
"id": "prj_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"default_max_attempts": 5,
"retry_backoff_policy": "exponential",
"retry_backoff_seconds": 30
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_body | Body is not valid JSON. |
| 401 | unauthorized | Missing or invalid JWT. |
| 403 | forbidden | Caller is not the project owner. |
| 404 | project_not_found | Project ID does not exist. |
| 422 | validation_error | A field is the wrong type, out of range, or no fields were supplied. |
POST /platform/v1/projects/{projectId}/keys/rotate
Generate a new API key. The previous key remains accepted for a 24-hour grace window so deployed workers can be updated without a hard cutover. Each rotation resets the 24-hour grace window from the timestamp of the most recent rotation — back-to-back rotations refresh (and reuse) the same PreviousApiKeys row rather than stacking additional 24-hour windows on top of each other, so a project can never have more than one key in its grace period at any time.
Authentication: JWT required. Role: admin or higher.
:::warning Store the new key immediately The plaintext API key is returned exactly once in the response body. It cannot be retrieved again. Store it in your secrets manager (Azure Key Vault, AWS Secrets Manager, GitHub Actions secrets, etc.) before closing the response. :::
Request
POST /platform/v1/projects/{projectId}/keys/rotate
Authorization: Bearer <jwt_token>
No request body.
Response
200 OK
{
"api_key": "zf_live_sk_<new_full_key>",
"rotated_at": "2026-05-15T12:00:00.000Z"
}
Cache invalidation
Both the old prefix's tenant:{prefix} cache entry and the new prefix's entry are evicted from IDistributedCache so the next request resolves the project state straight from the DB. A Redis outage at this point fails open — rotation still succeeds, the worst-case window is one TTL tick of stale tenant state.
Errors
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT. |
| 403 | forbidden | Caller's role is below admin. |
| 404 | project_not_found | Project ID does not exist. |
| 409 | key_revoked | The key has been permanently revoked; rotation is no longer possible. |
DELETE /platform/v1/projects/{projectId}/keys/revoke
Permanently revoke the project's API key. After this call, neither the current key nor any grace-window key from a prior rotation will be accepted — old workers immediately start receiving 401 unauthorized.
Authentication: JWT required. Role: admin or higher.
:::danger No recovery Revocation is permanent. There is no undo. To restore API access you would need to delete the project and create a new one (or contact support). :::
Request
DELETE /platform/v1/projects/{projectId}/keys/revoke
Authorization: Bearer <jwt_token>
No request body.
Response
200 OK
{
"revoked_at": "2026-05-15T12:00:00.000Z"
}
Errors
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT. |
| 403 | forbidden | Caller's role is below admin. |
| 404 | project_not_found | Project ID does not exist. |
| 409 | already_revoked | The key is already revoked. |
GET /platform/v1/projects/{projectId}/members
List the accepted members of a project, ordered by invitation timestamp ascending (oldest first).
Authentication: JWT required. Role: any member of the project (viewer or higher).
Request
GET /platform/v1/projects/{projectId}/members
Authorization: Bearer <jwt_token>
Response
200 OK
{
"members": [
{
"user_id": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"email": "owner@example.com",
"role": "owner",
"invited_by": null,
"invited_at": "2026-01-01T00:00:00Z",
"accepted_at": "2026-01-01T00:00:00Z"
},
{
"user_id": "usr_01JAXBKM4N5P6Q7R8S9T0UVWXY",
"email": "engineer@example.com",
"role": "admin",
"invited_by": "usr_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"invited_at": "2026-02-01T10:00:00Z",
"accepted_at": "2026-02-01T11:30:00Z"
}
]
}
| Field | Type | Description |
|---|---|---|
user_id | string | Member's user ID. |
email | string | Member's email address. |
role | string | One of owner, admin, member, viewer. |
invited_by | string | null | User ID of the inviter, or null for the project owner. |
invited_at | string (ISO 8601) | When the invitation was issued. |
accepted_at | string (ISO 8601) | null | When the invitee accepted. Only accepted members are returned by this endpoint, so this is always populated in the response. |
Errors
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT. |
| 403 | forbidden | Caller is not a member of this project. |
POST /platform/v1/projects/{projectId}/invites
Issue a new email invitation to join the project. The invitee receives a transactional email with an accept link; they accept it via POST /platform/v1/invites/accept. Tokens are valid for 7 days.
Authentication: JWT required. Role: admin or higher.
The caller cannot invite someone to a role equal to or higher than their own (role_exceeds_caller).
Request
POST /platform/v1/projects/{projectId}/invites
Authorization: Bearer <jwt_token>
Content-Type: application/json
Body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Invitee's email address. Lower-cased server-side. |
role | string | Yes | One of admin, member, viewer. (owner cannot be assigned via invite.) |
{
"email": "engineer@example.com",
"role": "member"
}
Response
200 OK
{
"token": "01JAXBKM3N4P5Q6R7S8T9UVWXY",
"project_id": "prj_01JAXBKM3N4P5Q6R7S8T9UVWXY",
"email": "engineer@example.com",
"role": "member",
"expires_at": "2026-05-22T12:00:00.000Z"
}
| Field | Type | Description |
|---|---|---|
token | string | The invitation token. Also embedded in the accept link in the email. |
project_id | string | Project the invitation is for. |
email | string | Invitee's normalized email. |
role | string | Role the invitee will hold once they accept. |
expires_at | string (ISO 8601) | When the token stops being accepted (7 days after issue). |
Inviting an existing member is idempotent. If the invitee email already maps to an accepted membership on the project, the invite is still recorded with a fresh token and email is still sent, but accepting the token will not create a duplicate
ProjectMembershiprow — the existing membership is reused andPOST /platform/v1/invites/acceptreturns200 OKwith the membership's current role. The invite endpoint therefore never returns a409 already_member— re-inviting is silently a no-op at the membership layer.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_body | Body is missing or not valid JSON. |
| 400 | invalid_email | email is missing. |
| 400 | invalid_role | role is missing or not one of admin, member, viewer. owner cannot be assigned via invite — the project creator is the immutable owner. |
| 401 | unauthorized | Missing or invalid JWT. |
| 403 | forbidden | Caller's role is below admin. |
| 403 | role_exceeds_caller | Caller tried to invite someone to a role at or above their own. |
| 404 | project_not_found | Project ID does not exist. |
PATCH /platform/v1/projects/{projectId}/members/{memberId}
Change an existing member's role.
Authentication: JWT required. Role: admin or higher.
Constraints, all enforced server-side:
- Cannot change the owner — returns
409 cannot_modify_owner. - Cannot escalate beyond caller — assigning a role at or above the caller's own role returns
403 role_exceeds_caller.
Request
PATCH /platform/v1/projects/{projectId}/members/{memberId}
Authorization: Bearer <jwt_token>
Content-Type: application/json
Body
| Field | Type | Required | Description |
|---|---|---|---|
role | string | Yes | New role: admin, member, or viewer. |
{ "role": "admin" }
Response
200 OK
{
"user_id": "usr_01JAXBKM4N5P6Q7R8S9T0UVWXY",
"role": "admin"
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_body | Body is missing or not valid JSON. |
| 400 | invalid_role | role is missing or not a valid role string. |
| 401 | unauthorized | Missing or invalid JWT. |
| 403 | forbidden | Caller's role is below admin. |
| 403 | role_exceeds_caller | Caller tried to assign a role at or above their own. |
| 404 | member_not_found | The target user is not a member of this project. |
| 409 | cannot_modify_owner | The target user is the project owner. |
DELETE /platform/v1/projects/{projectId}/members/{memberId}
Remove a member from the project.
Authentication: JWT required. Role: admin or higher.
Constraints:
- Cannot remove the owner — returns
409 cannot_remove_owner. Transfer ownership first. - Only the owner can remove other admins — a non-owner admin trying to remove another admin gets
403 forbidden.
Request
DELETE /platform/v1/projects/{projectId}/members/{memberId}
Authorization: Bearer <jwt_token>
No request body.
Response
200 OK
{
"removed": true,
"user_id": "usr_01JAXBKM4N5P6Q7R8S9T0UVWXY"
}
Errors
| Status | Code | When |
|---|---|---|
| 401 | unauthorized | Missing or invalid JWT. |
| 403 | forbidden | Caller's role is below admin, or caller is a non-owner admin trying to remove another admin. |
| 404 | member_not_found | The target user is not a member of this project. |
| 409 | cannot_remove_owner | The target user is the project owner. |
See also
- Account API — read the authenticated user record and account-level controls
- Authentication — bearer-token, JWT, and API key formats used by this surface
- Billing API — link a project to a Stripe subscription and read its plan