Custos API Documentation

HTTP/JSON API exposed by cmd/server. All endpoints accept and return application/json and use UTF-8.

  • Base URL: http://<host>:<port> (default port 8080, configurable via HTTP_ADDR)
  • Auth: none currently enforced (deploy behind a trusted ingress / auth proxy)
  • Content-Type: application/json is required on every request that has a body
  • Unknown fields: request bodies with unknown JSON fields are rejected with 400

Conventions

Identifiers

  • id fields are server-generated UUIDs when omitted from a create request.
  • originated_id is an optional external identifier (e.g. ACCESS Record ID) — when supplied, it must be unique within its entity type.

Timestamps

All timestamps are RFC 3339 / ISO 8601 with timezone, e.g. 2026-05-16T12:34:56.789Z. The server emits UTC.

Error format

Errors are returned with an appropriate HTTP status code and a JSON body:

{ "error": "human-readable message" }
StatusMeaningTriggered by
400 Bad RequestMalformed JSON, unknown field, missing required field, or unknown foreign-key referencerequest body validation, service.ErrInvalidInput
404 Not FoundRequested record does not existservice.ErrNotFound
409 ConflictDuplicate email or duplicate originated_idservice.ErrAlreadyExists
500 Internal Server ErrorUnexpected server / database failure (driver message is logged, never returned)any other error

Health

GET /healthz

Liveness probe. Always returns 200 when the process is accepting connections.

Response 200

{ "status": "ok" }

Organizations

POST /organizations

Create a new organization.

Required fields: name Optional fields: id (auto-generated if omitted), originated_id

Request

{
  "name": "University of Example",
  "originated_id": "ACCESS-ORG-001"
}

Response 201

{
  "id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
  "originated_id": "ACCESS-ORG-001",
  "name": "University of Example"
}

Errors

  • 400 — name is required.
  • 409 — an organization with the supplied originated_id already exists.

Example

curl -s -X POST http://localhost:8080/organizations \
  -H 'Content-Type: application/json' \
  -d '{"name":"University of Example","originated_id":"ACCESS-ORG-001"}'

GET /organizations/{id}

Retrieve an organization by its ID.

Response 200

{
  "id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
  "originated_id": "ACCESS-ORG-001",
  "name": "University of Example"
}

Errors

  • 404 — no organization matches the supplied ID.

Users

POST /users

Create a new user.

Required fields: organization_id, email Optional fields: id, first_name, last_name, middle_name

The referenced organization_id must already exist; emails must be unique.

Request

{
  "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
  "first_name": "Ada",
  "last_name": "Lovelace",
  "email": "ada@example.edu"
}

Response 201

{
  "id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02",
  "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
  "first_name": "Ada",
  "last_name": "Lovelace",
  "email": "ada@example.edu"
}

Errors

  • 400 — email, organization_id missing, or organization_id does not exist.
  • 409 — a user with this email already exists.

Example

curl -s -X POST http://localhost:8080/users \
  -H 'Content-Type: application/json' \
  -d '{
        "organization_id":"8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
        "first_name":"Ada",
        "last_name":"Lovelace",
        "email":"ada@example.edu"
      }'

GET /users/{id}

Retrieve a user by its ID.

Response 200

{
  "id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02",
  "organization_id": "8c4a1b2e-7d4f-4b6a-9a0c-2f3b9d1c8e21",
  "first_name": "Ada",
  "last_name": "Lovelace",
  "email": "ada@example.edu"
}

Errors

  • 404 — no user matches the supplied ID.

Projects

POST /projects

Create a new project.

Required fields: title, project_pi_id Optional fields: id, origination, originated_id, created_time (defaults to current UTC time)

The referenced project_pi_id must be an existing user. originated_id, when supplied, must be unique across projects.

Request

{
  "title": "Climate Simulation 2026",
  "origination": "ACCESS",
  "originated_id": "ACCESS-PRJ-9000",
  "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02"
}

Response 201

{
  "id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1",
  "originated_id": "ACCESS-PRJ-9000",
  "title": "Climate Simulation 2026",
  "origination": "ACCESS",
  "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02",
  "created_time": "2026-05-16T17:21:04.512Z"
}

Errors

  • 400 — title, project_pi_id missing, or the PI user does not exist.
  • 409 — a project with this originated_id already exists.

Example

curl -s -X POST http://localhost:8080/projects \
  -H 'Content-Type: application/json' \
  -d '{
        "title":"Climate Simulation 2026",
        "origination":"ACCESS",
        "originated_id":"ACCESS-PRJ-9000",
        "project_pi_id":"f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02"
      }'

GET /projects/{id}

Retrieve a project by its ID.

Response 200

{
  "id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1",
  "originated_id": "ACCESS-PRJ-9000",
  "title": "Climate Simulation 2026",
  "origination": "ACCESS",
  "project_pi_id": "f0c5a4d1-2b9e-4a7c-8d31-1c5b6e3d9f02",
  "created_time": "2026-05-16T17:21:04.512Z"
}

Errors

  • 404 — no project matches the supplied ID.

Compute Clusters

A compute cluster represents a physical or logical HPC resource (e.g. a Slurm cluster) where allocations can be provisioned.

POST /compute-clusters

Create a new compute cluster.

Required fields: name Optional fields: id (auto-generated if omitted)

name must be unique across compute clusters.

Request

{ "name": "Delta" }

Response 201

{
  "id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1",
  "name": "Delta"
}

Errors

  • 400 — name is required.
  • 409 — a compute cluster with this name already exists.

GET /compute-clusters

List all compute clusters.

Response 200

[
  { "id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1", "name": "Delta" },
  { "id": "1d4e6a3b-7c8f-49b2-bd34-7c1f9a4e5d10", "name": "Phoenix" }
]

GET /compute-clusters/{id}

Retrieve a single compute cluster by its ID.

Errors

  • 404 — no compute cluster matches the supplied ID.

Compute Allocations

A compute allocation grants a project a budget of Service Units (SUs) on a specific compute cluster for a bounded time window.

POST /compute-allocations

Create a new compute allocation.

Required fields: project_id, name, compute_cluster_id Optional fields: id, status (defaults to ACTIVE), initial_su_amount, start_time, end_time

Both project_id and compute_cluster_id must reference existing records. status is one of ACTIVE, INACTIVE, DELETED.

Request

{
  "project_id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1",
  "name": "Q2 2026 Climate Run",
  "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1",
  "initial_su_amount": 100000,
  "start_time": "2026-04-01T00:00:00Z",
  "end_time":   "2026-06-30T23:59:59Z"
}

Response 201

{
  "id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56",
  "project_id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1",
  "name": "Q2 2026 Climate Run",
  "status": "ACTIVE",
  "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1",
  "initial_su_amount": 100000,
  "start_time": "2026-04-01T00:00:00Z",
  "end_time":   "2026-06-30T23:59:59Z"
}

Errors

  • 400 — required field missing, or project_id / compute_cluster_id does not exist.

GET /compute-allocations/{id}

Retrieve a compute allocation by its ID.

Errors

  • 404 — no compute allocation matches the supplied ID.

Compute Allocation Resources

A compute allocation resource describes a hardware capability (e.g. GPU B200, CPU) that can be attached to one or more allocations.

POST /compute-allocation-resources

Create a new compute allocation resource.

Required fields: name, resource_type Optional fields: id, resource_amount

Request

{
  "name": "GPU B200",
  "resource_type": "GPU",
  "resource_amount": 8
}

Response 201

{
  "id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff",
  "name": "GPU B200",
  "resource_type": "GPU",
  "resource_amount": 8
}

Errors

  • 400 — name or resource_type is missing.

GET /compute-allocation-resources

List all compute allocation resources.

Response 200

[
  {
    "id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff",
    "name": "GPU B200",
    "resource_type": "GPU",
    "resource_amount": 8
  }
]

GET /compute-allocation-resources/{id}

Retrieve a compute allocation resource by its ID.

Errors

  • 404 — no resource matches the supplied ID.

Compute Allocation ↔ Resource Mappings

A many-to-many join: an allocation can have many resources attached, and a resource can be attached to many allocations. Mappings are unique per (allocation, resource) pair, and are cascade-deleted when either parent is removed.

POST /compute-allocations/{id}/resources

Attach an existing resource to a compute allocation.

Path parameters: {id} — the compute allocation ID. Required body fields: compute_allocation_resource_id

Request

{ "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff" }

Response 201

{
  "id": "7e1d2c3b-4a5f-4b6c-9d8e-0011223344ff",
  "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56",
  "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff"
}

Errors

  • 400 — compute_allocation_resource_id missing, or either the allocation or the resource does not exist.
  • 409 — this resource is already attached to the allocation.

DELETE /compute-allocations/{id}/resources/{resourceId}

Detach a resource from a compute allocation.

Response 204 — empty body on success.

Errors

  • 404 — no such mapping exists.

GET /compute-allocations/{id}/resources

List every compute allocation resource currently attached to the given compute allocation.

Response 200

[
  {
    "id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff",
    "name": "GPU B200",
    "resource_type": "GPU",
    "resource_amount": 8
  }
]

GET /compute-allocation-resources/{id}/allocations

List every compute allocation that has the given resource attached.

Response 200

[
  {
    "id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56",
    "project_id": "3a8c2e7b-9d1f-4f5a-bc02-7a4d9e6c1bb1",
    "name": "Q2 2026 Climate Run",
    "status": "ACTIVE",
    "compute_cluster_id": "9b0a7f1c-2c5d-4e1b-9a0f-22e8a5c2dcb1",
    "initial_su_amount": 100000,
    "start_time": "2026-04-01T00:00:00Z",
    "end_time":   "2026-06-30T23:59:59Z"
  }
]

Compute Allocation Resource Rates

A rate captures how many Service Units (SUs) are charged per unit of a compute allocation resource over a bounded time window. Multiple rates can exist for the same resource; usage at any instant is charged using the rate whose [start_time, end_time) window contains that instant.

Rates are cascade-deleted when their parent resource is deleted.

POST /compute-allocation-resource-rates

Create a new rate for a compute allocation resource.

Required fields: compute_allocation_resource_id, rate, start_time, end_time Optional fields: id

Validation:

  • compute_allocation_resource_id must reference an existing resource.
  • rate must be ≥ 0.
  • start_time must be strictly before end_time.

Request

{
  "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff",
  "rate": 2.0,
  "start_time": "2026-01-01T00:00:00Z",
  "end_time":   "2026-12-31T23:59:59Z"
}

Response 201

{
  "id": "55aa66bb-77cc-88dd-99ee-001122334455",
  "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff",
  "rate": 2.0,
  "start_time": "2026-01-01T00:00:00Z",
  "end_time":   "2026-12-31T23:59:59Z"
}

Errors

  • 400 — required field missing, invalid time window, negative rate, or unknown compute_allocation_resource_id.

GET /compute-allocation-resource-rates/{id}

Retrieve a rate by its ID.

Errors

  • 404 — no rate matches the supplied ID.

GET /compute-allocation-resources/{id}/rates

List every rate ever defined for the given compute allocation resource, ordered by start_time ascending.

Response 200

[
  {
    "id": "55aa66bb-77cc-88dd-99ee-001122334455",
    "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff",
    "rate": 2.0,
    "start_time": "2026-01-01T00:00:00Z",
    "end_time":   "2026-12-31T23:59:59Z"
  }
]

GET /compute-allocation-resources/{id}/rates/effective

Return the rate currently in effect for the given resource. By default the server uses the current time; supply ?at=<RFC 3339 timestamp> to query an arbitrary instant.

A rate is “effective” at instant t when start_time <= t < end_time. If multiple rates overlap t, the one with the most recent start_time wins.

Examples

GET /compute-allocation-resources/c0a1.../rates/effective
GET /compute-allocation-resources/c0a1.../rates/effective?at=2026-05-16T12:00:00Z

Response 200

{
  "id": "55aa66bb-77cc-88dd-99ee-001122334455",
  "compute_allocation_resource_id": "c0a1b2c3-d4e5-46f7-8899-aabbccddeeff",
  "rate": 2.0,
  "start_time": "2026-01-01T00:00:00Z",
  "end_time":   "2026-12-31T23:59:59Z"
}

Errors

  • 400 — at query parameter is not a valid RFC 3339 timestamp.
  • 404 — no rate is effective for the resource at the supplied instant.

Compute Allocation Diffs

A diff is an append-only audit record of a change applied to a compute allocation — for example a usage update or a status transition. Diffs are cascade-deleted when their parent allocation is deleted.

POST /compute-allocation-diffs

Record a new diff against a compute allocation.

Required fields: compute_allocation_id, diff_type, status Optional fields: id, new_su_amount (defaults to 0), timestamp (defaults to the server's current UTC time), description

diff_type is a free-form short code such as USAGE_UPDATE or ALLOCATION_STATUS_CHANGE. status must be one of ACTIVE, INACTIVE, DELETED.

Request

{
  "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56",
  "diff_type": "USAGE_UPDATE",
  "new_su_amount": 90000,
  "status": "ACTIVE",
  "description": "Charged 10000 SUs for completed jobs"
}

Response 201

{
  "id": "44bb55cc-66dd-77ee-88ff-aabbccddeeff",
  "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56",
  "diff_type": "USAGE_UPDATE",
  "new_su_amount": 90000,
  "status": "ACTIVE",
  "timestamp": "2026-05-16T17:42:11.918Z",
  "description": "Charged 10000 SUs for completed jobs"
}

Errors

  • 400 — required field missing, or compute_allocation_id does not exist.

GET /compute-allocation-diffs/{id}

Retrieve a single diff by its ID.

Errors

  • 404 — no diff matches the supplied ID.

DELETE /compute-allocation-diffs/{id}

Remove a diff record. Intended for administrative cleanup; diffs are otherwise append-only.

Response 204 — empty body on success.


GET /compute-allocations/{id}/diffs

List every diff ever recorded against the given compute allocation, ordered by timestamp ascending.

Response 200

[
  {
    "id": "44bb55cc-66dd-77ee-88ff-aabbccddeeff",
    "compute_allocation_id": "2f6a8c1d-3e4b-4a7d-8c91-aa12bb34cc56",
    "diff_type": "USAGE_UPDATE",
    "new_su_amount": 90000,
    "status": "ACTIVE",
    "timestamp": "2026-05-16T17:42:11.918Z",
    "description": "Charged 10000 SUs for completed jobs"
  }
]

GET /compute-allocations/{id}/diffs/latest

Return the most recent diff (highest timestamp) for the given allocation.

Errors

  • 404 — the allocation has no diffs recorded.

End-to-end example

BASE=http://localhost:8080

ORG_ID=$(curl -s -X POST $BASE/organizations \
  -H 'Content-Type: application/json' \
  -d '{"name":"University of Example","originated_id":"ACCESS-ORG-001"}' \
  | jq -r .id)

USER_ID=$(curl -s -X POST $BASE/users \
  -H 'Content-Type: application/json' \
  -d "{\"organization_id\":\"$ORG_ID\",\"first_name\":\"Ada\",\"last_name\":\"Lovelace\",\"email\":\"ada@example.edu\"}" \
  | jq -r .id)

PROJ_ID=$(curl -s -X POST $BASE/projects \
  -H 'Content-Type: application/json' \
  -d "{\"title\":\"Climate Simulation 2026\",\"origination\":\"ACCESS\",\"originated_id\":\"ACCESS-PRJ-9000\",\"project_pi_id\":\"$USER_ID\"}" \
  | jq -r .id)

CLUSTER_ID=$(curl -s -X POST $BASE/compute-clusters \
  -H 'Content-Type: application/json' \
  -d '{"name":"Delta"}' | jq -r .id)

ALLOC_ID=$(curl -s -X POST $BASE/compute-allocations \
  -H 'Content-Type: application/json' \
  -d "{\"project_id\":\"$PROJ_ID\",\"name\":\"Q2 2026 Climate Run\",\"compute_cluster_id\":\"$CLUSTER_ID\",\"initial_su_amount\":100000}" \
  | jq -r .id)

RES_ID=$(curl -s -X POST $BASE/compute-allocation-resources \
  -H 'Content-Type: application/json' \
  -d '{"name":"GPU B200","resource_type":"GPU","resource_amount":8}' | jq -r .id)

# Attach the resource to the allocation.
curl -s -X POST $BASE/compute-allocations/$ALLOC_ID/resources \
  -H 'Content-Type: application/json' \
  -d "{\"compute_allocation_resource_id\":\"$RES_ID\"}" | jq

# Define a rate for the resource.
curl -s -X POST $BASE/compute-allocation-resource-rates \
  -H 'Content-Type: application/json' \
  -d "{
        \"compute_allocation_resource_id\":\"$RES_ID\",
        \"rate\":2.0,
        \"start_time\":\"2026-01-01T00:00:00Z\",
        \"end_time\":\"2026-12-31T23:59:59Z\"
      }" | jq

# Look up the currently-effective rate.
curl -s $BASE/compute-allocation-resources/$RES_ID/rates/effective | jq

# Record a usage diff against the allocation.
curl -s -X POST $BASE/compute-allocation-diffs \
  -H 'Content-Type: application/json' \
  -d "{
        \"compute_allocation_id\":\"$ALLOC_ID\",
        \"diff_type\":\"USAGE_UPDATE\",
        \"new_su_amount\":90000,
        \"status\":\"ACTIVE\",
        \"description\":\"Charged 10000 SUs for completed jobs\"
      }" | jq

# Inspect the diff history.
curl -s $BASE/compute-allocations/$ALLOC_ID/diffs | jq
curl -s $BASE/compute-allocations/$ALLOC_ID/diffs/latest | jq

# Bidirectional lookups.
curl -s $BASE/compute-allocations/$ALLOC_ID/resources | jq
curl -s $BASE/compute-allocation-resources/$RES_ID/allocations | jq

curl -s $BASE/projects/$PROJ_ID | jq

Running the server

export DATABASE_DSN='custos:secret@tcp(127.0.0.1:3306)/custos?parseTime=true&charset=utf8mb4'
# optional
export HTTP_ADDR=:8080
export DB_MAX_OPEN_CONNS=25
export DB_MAX_IDLE_CONNS=5

go run ./cmd/server
Environment variableDefaultPurpose
DATABASE_DSN(required)MySQL/MariaDB DSN. parseTime=true is mandatory.
HTTP_ADDR:8080Address the HTTP server binds to.
DB_MAX_OPEN_CONNS25Maximum open database connections.
DB_MAX_IDLE_CONNS5Maximum idle database connections.

Migrations from internal/db/migrations/ are applied automatically on startup.

The server handles SIGINT / SIGTERM gracefully, draining in-flight requests for up to 15 seconds before exiting.