Groundfloor Docs

M2M API

Confidential clients and the token exchange that mints a short Control Plane JWT for Coderunner and CI.

Machine login for workloads and CI. Create a client (secret shown once), then exchange client_id + secret for a short Control Plane JWT. Call Vault and Files on Control Plane. Never send a Dataplane key.

See also: Customer Portal — M2M tokens · Developers

POST …/m2m/token is unauthenticated. All other M2M client routes need an IdPlane (or already-minted) Bearer JWT. Do not put the secret in a browser bundle.

v1 M2M is Team Writer on that workspace: Vault (no collection ACL) and Files via Control Plane, plus Coderunner deploy/run (write). It cannot administer members, App authentication, Vault ACL, LLM virtual keys, or reserved secrets (DATAPLANE_SERVICE_API_KEY). It cannot read Secrets unless the client holds the secrets:read scope, and it can never write, delete, or promote Secrets. M2M is not an Access subject.

Exchange secret for a JWT

POST /v1/workspaces/{workspace_id}/m2m/token

Auth: none. Two independent rate limits, either of which returns 429: per workspace_id + client_id (default 20/minute) and per source IP (default 20/minute).

Request body

FieldTypeRequired
client_idstringYes (1–63)
client_secretstringYes (min 16)
export CP_URL="https://platform.groundfloor.cloud"
export WORKSPACE_ID="<workspace uuid>"

curl -sS -X POST "$CP_URL/v1/workspaces/$WORKSPACE_ID/m2m/token" \
  -H "Content-Type: application/json" \
  -d "{\"client_id\":\"$GROUNDFLOOR_M2M_CLIENT_ID\",\"client_secret\":\"$GROUNDFLOOR_M2M_CLIENT_SECRET\"}"

gf m2m token is the same exchange.

Response 200

{
  "access_token": "<jwt>",
  "token_type": "Bearer",
  "expires_in": 300,
  "m2m_client_id": "<uuid>",
  "workspace_id": "<workspace uuid>",
  "controlplane_url": "https://platform.groundfloor.cloud"
}

expires_in matches Control Plane JWT TTL (default 300 seconds). Use access_token on Control Plane:

curl -sS -H "Authorization: Bearer $ACCESS_TOKEN" \
  "$CP_URL/v1/workspaces/$WORKSPACE_ID/vault/collections"

Cache access_token for expires_in seconds — never mint per request. This endpoint is rate-limited per client (and per IP), default 20/minute each; calling it once per incoming request instead of reusing the JWT will exceed that limit under normal traffic and return 429 instead of a token. Store the token and its expiry, refresh proactively (e.g. at ~80% of expires_in, not on every call), and handle 429/401 from the mint call as retryable — a naive integration that lets this exception propagate unhandled can crash the whole process on every request once the limit is hit.

JWT claims include typ=m2m, aud=groundfloor-portal, sub=m2m:{client uuid}, workspace_id, m2m_client_id, client_id, and scopes (array, empty unless granted). Issuer is {controlplane}/v1/public/m2m.

Errors

StatusMeaning
401Unknown client_id, wrong secret, or client disabled
422Validation (secret too short, missing fields)
429Rate limit — usually means the caller is minting a token per request instead of caching it; see the caching note above

Invalid credentials always return the same 401 detail (Invalid client credentials).

List clients

GET /v1/workspaces/{workspace_id}/m2m/clients

Permission: read

Returns metadata only — no secrets.

{
  "clients": [
    {
      "id": "<uuid>",
      "workspace_id": "<workspace uuid>",
      "name": "catalog-bff",
      "client_id": "catalog-bff",
      "disabled": false,
      "scopes": [],
      "created_by": "<user uuid>",
      "created_at": "2026-09-11T12:00:00Z",
      "updated_at": "2026-09-11T12:00:00Z"
    }
  ]
}

Create a client

POST /v1/workspaces/{workspace_id}/m2m/clients

Permission: administer · 201

{ "name": "catalog-bff", "client_id": "catalog-bff" }
FieldTypeRequired
namestringYes (1–120)
client_idstringNo — slug from the name if omitted (^[a-z0-9][a-z0-9-]*$, max 63)
scopesstring[]No — default []. See Scopes

Response is the client plus client_secret (shown once). Store it as GROUNDFLOOR_M2M_CLIENT_ID / GROUNDFLOOR_M2M_CLIENT_SECRET. Control Plane stores a SHA-256 hash and a wrapped copy for deploy inject.

Get / update a client

GET /v1/workspaces/{workspace_id}/m2m/clients/{client_uuid}
PATCH /v1/workspaces/{workspace_id}/m2m/clients/{client_uuid}

Permission: read / administer

PATCH body (partial): name, disabled, scopes (replaces the list). Disabling a client makes POST …/m2m/token return 401.

Scopes

Scopes narrow what a client can do below its Team Writer grant; they never widen it. The only valid value is secrets:read. Unknown scopes are rejected with 422.

ScopeGrants
secrets:readSecrets list, compare, and reveal (plaintext value) — every environment in the workspace
(none)No Secrets access. List, compare, and reveal return 403

Secrets upsert, delete, and promote are 403 for every M2M client, with or without the scope. The reserved DATAPLANE_SERVICE_API_KEY stays blocked.

A scope change applies on the client's next minted token (tokens live expires_in seconds). Reveals by an M2M client are audited as secret.revealed with the M2M client id as actor.

Rotate secret

POST /v1/workspaces/{workspace_id}/m2m/clients/{client_uuid}/rotate

Permission: administer

Returns a new client_secret (shown once). Redeploy attached Coderunners so they pick up the injected secret.

Delete a client

DELETE /v1/workspaces/{workspace_id}/m2m/clients/{client_uuid}

Permission: administer · 204

Attach to a Coderunner

PUT /v1/workspaces/{workspace_id}/coderunners/{coderunner_id}/m2m

Permission: administer

{ "m2m_client_id": "<client uuid>" }

Set m2m_client_id to null to detach. After deploy, the workload receives GROUNDFLOOR_M2M_CLIENT_ID and GROUNDFLOOR_M2M_CLIENT_SECRET. It must call POST …/m2m/token, then Control Plane /vault and /files. Do not inject DATAPLANE_SERVICE_API_KEY.