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/tokenAuth: 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
| Field | Type | Required |
|---|---|---|
client_id | string | Yes (1–63) |
client_secret | string | Yes (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
| Status | Meaning |
|---|---|
| 401 | Unknown client_id, wrong secret, or client disabled |
| 422 | Validation (secret too short, missing fields) |
| 429 | Rate 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/clientsPermission: 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/clientsPermission: administer · 201
{ "name": "catalog-bff", "client_id": "catalog-bff" }| Field | Type | Required |
|---|---|---|
name | string | Yes (1–120) |
client_id | string | No — slug from the name if omitted (^[a-z0-9][a-z0-9-]*$, max 63) |
scopes | string[] | 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.
| Scope | Grants |
|---|---|
secrets:read | Secrets 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}/rotatePermission: 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}/m2mPermission: 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.
Related
- Access API — site roles, not M2M
- Data Vault API
- Files API
- Coderunners API
- API Authentication