On this page
REST API
The CommonLayer REST API serves the portal, project CI and integrations under /api/v1,
with JSON bodies, cookie or bearer-token authentication, RFC 9457 problem documents for
errors and opaque-cursor pagination. This page explains the conventions; the authoritative
list of operations and schemas is the generated OpenAPI 3.1 document.
The OpenAPI document
- A running API serves its document at
GET /api/v1/openapi.json. clctl openapiprints the same document without a database connection.- The committed copy is
docs/api/openapi.json, generated byclctl openapifrom the embedded default configuration (so it does not depend on the machine it is generated on) and checked for staleness bycargo test -p commonlayer-app --test it generated;make generaterewrites it. It documents every composed operation, including the development-only/api/v1/dev/payments/{session}/complete|failroutes that exist at runtime only with the simulated payment provider, and the/api/v1/dev/github/*routes that exist only with the development GitHub (github.provider = "development"). The portal generates its TypeScript types from the committed file (make generate-web). - Operation ids are unique
snake_casenames suitable as client method names. - At the time of writing the document has 330 operations under
/api/v1(81 of them public) and 5 outside it (webhooks, the registry token realm and its JWKS, registry notifications).
Every operation is tagged with its domain (auth, organizations, projects,
artifacts, registry, security, maintenance, funding, github,
notifications, users, platform). Operations that need no credentials also carry the tag public; the
authentication gate relies on it (ADR-0023).
Machine clients can use the gRPC API over the same services instead: see gRPC API.
Conventions
- Paths are under
/api/v1. Webhooks (/webhooks/github,/webhooks/payments/{provider}), the registry token realm (/registry/token,/registry/jwks.json) and registry notifications (/internal/registry/events) are outside it. - Field names are
snake_case; enumerations are lower-casesnake_casestrings; timestamps are RFC 3339 in UTC. - Money is integer minor units with an ISO 4217 code, for example
{"amount_minor": 50000, "currency": "USD"}ormonthly_target_minornext to acurrencyfield. There are no floating-point amounts anywhere in the API. - Digests are
sha256:<64 hex>. Artifacts are identified by digest; tags are mutable pointers and are always reported next to the digest they currently point to. - Request bodies are limited to 1 MiB (
server.body_limit_bytes); SBOM, VEX and scan report uploads to 8 MiB. Handlers time out after 30 seconds. - Every response carries
X-Request-Id; include it when reporting a problem.
Authentication
| Method | Used by | How |
|---|---|---|
| Session cookie | Browser | POST /api/v1/auth/login with {"login": "<email or username>", "password": "…"} sets __Host-cl_session (HttpOnly) and cl_csrf (readable). Outside development and test the __Host- prefix is required; development uses cl_session over plain HTTP on localhost. |
| API token | CLI, CI, integrations | Authorization: Bearer clt_<prefix>_<secret>, created with POST /api/v1/users/me/tokens and shown once. |
| GitHub Actions OIDC | Project CI submissions | Authorization: Bearer <OIDC JWT> with audience commonlayer on the submission endpoints (see below). |
| Registry token | docker, helm | The Docker token flow against /registry/token; clients do this automatically. |
| Checkout status token | A guest following their payment | X-Status-Token: <token> on GET /api/v1/funding/checkout/status only (see Funding). |
GET /api/v1/auth/session is public. It returns authenticated: false for anonymous
callers, and otherwise the user, the CSRF token, the authentication method, the effective
permissions per scope (platform, each organization, each project, self) and the
deployment environment. The portal uses it to hide controls; the server still decides.
CSRF
Unsafe methods (POST, PUT, PATCH, DELETE) authenticated by the session cookie must
send the value of the cl_csrf cookie in X-CSRF-Token, and their Origin (or
Referer) must be the portal, the API or an origin in server.cors_allowed_origins.
Otherwise the API answers 403 auth.csrf_failed. Bearer-token requests and
signature-verified webhooks are exempt.
API tokens and scopes
A token's effective permissions are the owner's role-derived permissions intersected with the union of the token's scopes, optionally restricted to one project or organization.
| Scope | Grants |
|---|---|
read | every read permission the owner holds |
projects:write | project.update, project.settings.manage, project.tasks.manage |
artifacts:submit | project.artifacts.submit |
artifacts:write | project.artifacts.manage, project.releases.manage |
security:write | project.security.triage, …exceptions.request, …advisories.manage |
funding:write | organization contribution permissions, platform.funding.contribute |
org:admin | every org.* permission |
full | everything the owner holds, except the never-by-token set |
What an API token can never do
Two rules limit tokens and automation, whatever the scopes and the owner's roles:
- Never by token (
Permission::NEVER_BY_TOKEN):platform.ledger.adjust,platform.payouts.approve,platform.users.manage,platform.settings.manage, and any change to credentials, sessions or tokens. These need an interactive session. - Human-only decisions (ADR-0035):
service accounts never hold
platform.maintainers.approve,platform.eligibility.override,platform.payouts.approve,platform.ledger.adjust,project.security.exceptions.decide,project.contributions.revieworproject.rewards.approve, whatever role they were given. The deciding operations also require a person:- a person with a session or a personal API token: exception decisions,
not_affectedconfirmations, contribution reviews, task waivers, reward approvals, abuse-report resolution (refused for others with403 authz.human_required); - a person in an interactive session, never a token: payout-run approval, payout
resolution, ledger adjustments, maintainer assignment approval and rejection, appeal
resolution, eligibility overrides, creating service accounts (otherwise
403 auth.interactive_session_required).
- a person with a session or a personal API token: exception decisions,
A CI token can therefore submit, read and propose, but never approve anything.
# Create a token with a session (cookie jar from login, CSRF value from the cl_csrf cookie)
curl -b jar -X POST "$API/api/v1/users/me/tokens" \
-H 'content-type: application/json' -H "x-csrf-token: $CSRF" -H "origin: $PORTAL" \
-d '{"name": "ci", "scopes": ["artifacts:submit"], "expires_in_days": 90}'
# Use it
curl -H "authorization: Bearer $TOKEN" "$API/api/v1/users/me"
The full permission matrix is generated from the code by clctl permissions into
the permission matrix, which also lists both sets above.
Submissions from GitHub Actions
POST /api/v1/projects/{slug}/submissions accepts an API token with artifacts:submit or
a GitHub Actions OIDC token whose repository_id matches a verified repository of the
project, and whose owner/name equals that repository's. The verified token becomes a
workload actor that holds only project.artifacts.submit on that project
(crates/commonlayer-app/src/workload.rs); it may poll GET /api/v1/submissions/{id} for
the result. The template's scripts/register.sh supports both forms. The OIDC path is
covered by the acceptance test of workflow A with the test kit's verifier and by unit
tests of the real verifier against locally generated keys; no token issued by GitHub has
been verified by a deployed platform yet.
Requested tags of the form <version>-r<revision> must equal the submission's own
release tag (otherwise 422 with the field error artifacts.reserved_tag).
Errors
Errors are application/problem+json (RFC 9457):
{
"type": "https://commonlayer.org/problems/funding.insufficient_funds",
"title": "Precondition failed",
"status": 412,
"detail": "…",
"code": "funding.insufficient_funds",
"request_id": "…"
}
A validation problem (422) additionally lists the invalid fields:
"errors": [{"field": "items.0.amount_minor", "code": "range", "message": "…"}].
code is stable and namespaced by domain (auth.invalid_credentials,
artifacts.verifier_not_configured, payments.not_configured). Clients should branch on
code, not on detail. errors appears for validation problems. Internal errors return
a generic detail; the cause is logged with the request id.
| Kind | HTTP status |
|---|---|
| Validation | 422 |
| Unauthenticated | 401 |
| Forbidden | 403 |
| Not found | 404 |
| Conflict | 409 |
| Precondition failed | 412 |
| Rate limited | 429 (with Retry-After) |
| Unavailable (dependency not configured or down) | 503 |
| Internal | 500 |
To avoid revealing that something exists, the API answers 404 rather than 403 when the
caller may not know the resource exists (non-public projects, private security reports,
embargoed findings, draft advisories). An anonymous request without any credential to an
operation not tagged public gets 401 auth.required before its body is parsed.
When an external dependency is not configured (payment provider, GitHub App, SMTP,
scanner or cosign binary), the API returns a specific *.not_configured code; it never
pretends the operation succeeded.
Pagination
List operations take ?limit=1..100 (default 25) and an opaque cursor, and return
{ "items": [ … ], "next_cursor": "…" }
next_cursor is null on the last page. Cursors are keyset positions, not offsets: there
are no page numbers and no total counts unless an operation documents them (project
search returns facets on its first page).
Idempotency
Financial requests require an Idempotency-Key header (1–255 visible ASCII characters;
at least 32 for guest checkouts, which share the anonymous scope): checkout (user and
organization), refund requests, reward approval and cancellation, ledger adjustments,
bank-transfer confirmation, payout-run creation and infrastructure expenses.
- The same key with the same request (method, path and body hash) replays the stored
response with
Idempotent-Replayed: true. - The same key with a different request answers
409 idempotency.key_reused; while the first request is still running,409 idempotency.in_progress. - Keys are scoped to the caller, so one principal never receives another's response. A failed operation releases the key so the client may retry. Keys expire after 24 hours.
Independently of the header, every ledger transaction carries its own idempotency key in the database (ADR-0005), so a retried job or redelivered webhook never posts twice.
Rate limits
| Bucket | Default |
|---|---|
| Login per account / per IP | 5 / 20 per 15 minutes, then exponential lockout |
| Registration, password reset, verification resend per IP | 5 per hour |
| Authenticated API per principal | 600 per minute |
| Anonymous API per IP | 120 per minute |
| Registry token per IP | 300 per minute |
| Submissions per project (only actors allowed to submit, keyed by the resolved project) | 60 per hour |
Refused submission attempts per principal (submissions_denied) | 60 per hour |
| Private security reports per user | 10 per day |
| Abuse reports per user | 5 per day (fixed in code) |
A limited request gets 429 with Retry-After. The client IP is taken from
X-Forwarded-For only when the direct peer is in server.trusted_proxies (default: none),
and then it is the right-most entry that is not itself a trusted proxy
(ADR-0040). The
limits except the abuse-report limit are configuration (rate_limits.*). The REST and gRPC
transports share the same buckets. During a Valkey outage each process applies the limits
on its own (ADR-0019).
Webhooks received by CommonLayer
| Endpoint | Sender | Verification |
|---|---|---|
POST /webhooks/github | GitHub App | X-Hub-Signature-256 HMAC over the raw body, before parsing; deliveries are stored by X-GitHub-Delivery and processed once, asynchronously |
POST /webhooks/payments/stripe | Stripe | Stripe-Signature (t=…,v1=…), 300 s tolerance (payments.webhook_tolerance_secs) |
POST /webhooks/payments/simulated | the development simulator | X-CommonLayer-Simulated-Signature, same scheme; refused in production |
POST /internal/registry/events | CNCF Distribution | bearer secret (registry.events_secret) |
Provider events are stored with a unique (provider, event_id) key; a duplicate is
acknowledged with 200 and counted, not applied again. Processing happens in a job, so
the endpoint answers quickly; a contribution becomes confirmed only when a verified
provider event says so, never because the browser returned from checkout.
Webhooks sent by CommonLayer
Users, organization admins (org.webhooks.manage) and project admins
(project.webhooks.manage) can register outbound webhook endpoints. Each delivery is a
POST with a JSON body and these headers:
| Header | Content |
|---|---|
X-CommonLayer-Event | event kind, for example artifact.release.published |
X-CommonLayer-Delivery | delivery id, stable across retries; deduplicate on it |
X-CommonLayer-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
The MAC is HMAC-SHA256 with the endpoint's secret (whsec_…, shown once at creation or
rotation with PATCH /api/v1/webhooks/{id} and {"rotate_secret": true}) over the bytes
<t>.<raw body>. Verify it like this:
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False # reject replays of old deliveries
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Deliveries are sent through the SSRF-safe client: the endpoint URL must resolve only to
public addresses, redirects are not followed, and HTTPS is required in production. Failed
deliveries are retried with exponential backoff (notifications.webhook.max_attempts,
default 8); an endpoint is disabled after 20 consecutive failures. Embargoed findings,
private reports, exception events and project review decisions are never sent to
webhooks.
Operations by area
The OpenAPI document is authoritative. As orientation:
| Area | Public reads | Authenticated operations |
|---|---|---|
| Identity | /auth/session, /auth/providers, /users/{username} | login, registration, sessions, tokens, profile; user administration |
| Organizations | /organizations/{slug} and /organizations/{slug}/funding (when public) | members, invitations, billing profile, notes, service accounts, contributions and funding summary |
| Abuse reports | — | report (POST /abuse-reports), own reports (/users/me/abuse-reports); administrators: queue, detail, assign, resolve (/admin/abuse-reports…) |
| Projects | /projects (search with facets), /projects/{slug}, documents, /categories, /migration-candidates, POST /manifest/validate | propose, update, repositories, members, manifest sync, follows; review queue |
| Artifacts and registry | artifacts, versions, /digests/{digest} with SBOM, attestation and signature downloads, /digests/{digest}/verify-instructions, releases, builds, pull statistics (/projects/{slug}/artifacts/{name}/pulls), /verification-policy | define artifacts, deprecate or yank versions, submissions, project verification policy, retention and preservation administration |
| Security | /security/overview, advisories (?project=) and their Atom feed, project security, findings (embargo-aware), digest vulnerabilities and scans, OpenVEX exports, public incidents | triage, assessments and their second-reviewer confirmation (/projects/{slug}/security/assessments, /security/assessments/{id}/confirm, …/reject), exceptions and their history (/projects/{slug}/exceptions), VEX import, manual scans, advisories, private reports, incidents |
| Maintenance | /maintainers, project maintainers, SLA, assignment history, eligibility summaries, contributions, the public task list (/projects/{slug}/maintenance-tasks), the current agreement text (/maintenance/agreement) | apply, assignments and their events, tasks and the own task history, appeals, overrides, contribution review, maintainer dashboard; administrators: agreements, the platform SLA, payout and tax status, suspensions |
| Funding | /funding/overview, /funding/providers, project funding, cycles, project ledger (public projection), policies, campaigns, rewards, /funding/infrastructure, /funding/checkout/status (with a status token) | checkout, own contributions and subscriptions, organization reporting, funding configuration, rewards, ledger, reconciliation, payout runs, payouts and their resolution |
| Platform | /stats | overview, audit log, jobs, runtime settings (/admin/settings, /admin/settings/{key}) |
| GitHub and notifications | /github/app, project pull requests, /notifications/event-kinds | repository linking, delivery log, notifications, preferences, outbound webhooks |
What the public reads may and may not contain is defined in privacy rules.
Guest payment status
POST /api/v1/funding/checkout and POST /api/v1/organizations/{slug}/checkout return a
status_token and its expiry (30 days). A guest sends it in the X-Status-Token header to
GET /api/v1/funding/checkout/status and receives the contribution's status, kind,
provider, amounts, items and, once confirmed, the receipt number; nothing about the payer.
Only the token's SHA-256 is stored, it is never part of the replayable idempotency record
(a replay gets a fresh token), and it should never be put in a URL. Unknown, empty and
expired tokens answer 404.
Runtime settings
GET /api/v1/admin/settings lists the registry of runtime settings: key, description,
type and bounds, default, value in force, whether it is overridden, version and who
changed it. PUT /api/v1/admin/settings/{key} changes one value with optimistic
concurrency; unknown keys and invalid values are refused with 422. Both need
platform.settings.manage, which no API token holds. The keys and what reads them are in
ADR-0038.
Payout resolution
A payout whose provider outcome is ambiguous (timeout, 5xx, 409, 429) is unknown; its
obligation stays scheduled. GET /api/v1/admin/payouts?status=unknown lists them and
POST /api/v1/admin/payouts/{id}/resolve records the outcome after checking the provider
(paid with the provider reference, or failed, which returns the obligation to
pending), in an interactive session
(ADR-0041).
Health and metrics
/healthz, /readyz and /metrics are served on the separate ops port
(server.ops_addr), not on the API port. Restrict that port to the monitoring network.