Skip to content
CommonLayer
Documentation menu
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 openapi prints the same document without a database connection.
  • The committed copy is docs/api/openapi.json, generated by clctl openapi from the embedded default configuration (so it does not depend on the machine it is generated on) and checked for staleness by cargo test -p commonlayer-app --test it generated; make generate rewrites it. It documents every composed operation, including the development-only /api/v1/dev/payments/{session}/complete|fail routes 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_case names 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-case snake_case strings; timestamps are RFC 3339 in UTC.
  • Money is integer minor units with an ISO 4217 code, for example {"amount_minor": 50000, "currency": "USD"} or monthly_target_minor next to a currency field. 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

MethodUsed byHow
Session cookieBrowserPOST /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 tokenCLI, CI, integrationsAuthorization: Bearer clt_<prefix>_<secret>, created with POST /api/v1/users/me/tokens and shown once.
GitHub Actions OIDCProject CI submissionsAuthorization: Bearer <OIDC JWT> with audience commonlayer on the submission endpoints (see below).
Registry tokendocker, helmThe Docker token flow against /registry/token; clients do this automatically.
Checkout status tokenA guest following their paymentX-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.

ScopeGrants
readevery read permission the owner holds
projects:writeproject.update, project.settings.manage, project.tasks.manage
artifacts:submitproject.artifacts.submit
artifacts:writeproject.artifacts.manage, project.releases.manage
security:writeproject.security.triage, …exceptions.request, …advisories.manage
funding:writeorganization contribution permissions, platform.funding.contribute
org:adminevery org.* permission
fulleverything 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.review or project.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_affected confirmations, contribution reviews, task waivers, reward approvals, abuse-report resolution (refused for others with 403 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 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.

KindHTTP status
Validation422
Unauthenticated401
Forbidden403
Not found404
Conflict409
Precondition failed412
Rate limited429 (with Retry-After)
Unavailable (dependency not configured or down)503
Internal500

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

BucketDefault
Login per account / per IP5 / 20 per 15 minutes, then exponential lockout
Registration, password reset, verification resend per IP5 per hour
Authenticated API per principal600 per minute
Anonymous API per IP120 per minute
Registry token per IP300 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 user10 per day
Abuse reports per user5 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

EndpointSenderVerification
POST /webhooks/githubGitHub AppX-Hub-Signature-256 HMAC over the raw body, before parsing; deliveries are stored by X-GitHub-Delivery and processed once, asynchronously
POST /webhooks/payments/stripeStripeStripe-Signature (t=…,v1=…), 300 s tolerance (payments.webhook_tolerance_secs)
POST /webhooks/payments/simulatedthe development simulatorX-CommonLayer-Simulated-Signature, same scheme; refused in production
POST /internal/registry/eventsCNCF Distributionbearer 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:

HeaderContent
X-CommonLayer-Eventevent kind, for example artifact.release.published
X-CommonLayer-Deliverydelivery id, stable across retries; deduplicate on it
X-CommonLayer-Signaturet=<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:

AreaPublic readsAuthenticated 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/validatepropose, update, repositories, members, manifest sync, follows; review queue
Artifacts and registryartifacts, versions, /digests/{digest} with SBOM, attestation and signature downloads, /digests/{digest}/verify-instructions, releases, builds, pull statistics (/projects/{slug}/artifacts/{name}/pulls), /verification-policydefine 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 incidentstriage, 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/statsoverview, audit log, jobs, runtime settings (/admin/settings, /admin/settings/{key})
GitHub and notifications/github/app, project pull requests, /notifications/event-kindsrepository 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.