Skip to main content
CMDashCybersecurity Maturity Dashboard
DocsDevelopersPartner data API (/v2/partner/*)

Partner data API (/v2/partner/*)

A vCISO or MSP practice reads the client workspaces it owns from its own tooling (a PSA, a billing system, a client-reporting pipeline) with a PARTNER API key. The v2 API reference (/api/v2/reference, tag public-api/partner) is the authority for field-level shapes.

It is read-only. Nothing on this surface changes a client workspace.

Who can use it

  • The key is minted by the practice (the parent organization), not by a client.
  • The practice's plan must include the vCISO practice console (VCISO_PRACTICE, Command and Enterprise) and the Public API (read access, Starter and up). Both are checked when the key is minted and on every request, so a key stops working when the plan no longer includes them (PARTNER_PRACTICE_LAPSED, 403). The key itself is unchanged; restoring the plan restores it.
  • Only an OWNER or ADMIN of the practice can mint, rotate or revoke a key, with a fresh-auth proof (the same rules as a tenant key).

Two kinds of key

ApiKey.scope is TENANT or PARTNER. They address different surfaces, and neither works on the other's.

TENANT key (every key minted before v10.2)PARTNER key
ReadsThe minting organization's own workspaceThe client workspaces the minting practice owns today
Routes/v1/* and /v2/* tenant routes/v2/partner/* only
PermissionsThe tenant list (posture:read, risks:read, …)The five partner:* permissions below, nothing else
On the other surface403 API_KEY_SCOPE_MISMATCH403 API_KEY_SCOPE_MISMATCH

A PARTNER key never reads the practice's own workspace: use a TENANT key for that.

Minting a key

POST /api/api-keys (dashboard session, OWNER or ADMIN, X-Fresh-Auth proof):

json
{
  "name": "PSA sync",
  "scope": "PARTNER",
  "permissions": ["partner:clients:read", "partner:posture:read", "partner:remediation:read"],
  "expiresAt": "2027-04-01T00:00:00Z"
}

The response carries the key (cmdash_ek_…) once, plus id, prefix, expiresAt and scope. Expiry follows the tenant-key rules: 90 days by default, 365 at most. GET /api/api-keys lists keys with their scope. Revoke with DELETE /api/api-keys/:id; the next request with that key is a 401. Rotation (POST /api/api-keys/:id/rotate) re-mints a PARTNER key with the same permissions and needs the practice console on the plan at the time of rotation.

The permission list is closed:

PermissionRoute
partner:clients:readGET /v2/partner/clients
partner:posture:readGET /v2/partner/clients/:clientId/posture
partner:controls:readGET /v2/partner/clients/:clientId/controls
partner:remediation:readGET /v2/partner/clients/:clientId/remediation
partner:receipts:readGET /v2/partner/clients/:clientId/receipts

Mint refusals:

  • a partner:* permission on a TENANT key, or a tenant permission on a PARTNER key: 400 API_KEY_SCOPE_PERMISSION_MISMATCH (details.scope, details.permission);
  • scope: "PARTNER" from an organization without the practice console: 403 PARTNER_KEY_NOT_ENTITLED.

Routes

All routes take Authorization: Bearer cmdash_ek_… with a PARTNER key, and the global /api prefix (GET /api/v2/partner/clients).

RouteReturns
GET /v2/partner/clients{ clients: [{ id, name }], nextCursor }: active client workspaces whose parent is the key's organization, in id order, one page at a time. ?limit= sets the page size (1 to 200, default 200); pass the previous page's nextCursor as ?after= for the next page. nextCursor is null on the last page, so every client is reachable however large the book. A malformed after or limit is a 400.
GET /v2/partner/clients/:clientId/posture{ client, posture }: the latest posture snapshot (score, grade, delta, computedAt, signals), or null when the client has none.
GET /v2/partner/clients/:clientId/controls{ client, framework, truncated, controls }: one row per assessed control with status after the stale-evidence downgrade, degraded, and lastEvaluatedAt. At most 1000 rows; ?framework=<slug> filters.
GET /v2/partner/clients/:clientId/remediation{ client, truncated, tasks }: tasks that are not CLOSED, earliest due date first, with overdue. At most 200.
GET /v2/partner/clients/:clientId/receipts{ client, truncated, receipts }: receiptId, receiptDigest, claimId, status, assertionKind, issuedAt, freshUntil and verifyCommand, newest first, at most 200. Never the receipt payload, signature or evidence. Verify a receipt with its verifyCommand, which fetches it from the issuing deployment.

Which clients a key can read

A :clientId resolves only when all of these hold at the moment of the request:

  • the workspace's parent is the key's organization;
  • it is a vCISO client workspace;
  • it is active.

Anything else, whether the id does not exist, belongs to another practice, was detached from this practice, is suspended, or is not a UUID, gets the same answer: 404 with { "message": "Client not found", "details": { "code": "PARTNER_CLIENT_NOT_FOUND" } }. The route does not reveal which ids exist. Take ids from GET /v2/partner/clients.

Rate limits

Partner calls are metered per practice and client: each (practice, client) pair has its own hourly window at the practice's tier limit (the tenant table in public-api-reference.md), so one busy client integration does not use up the others. A call its client window admits also counts against one practice-wide window of 5 times that limit, shared by all of the practice's partner keys; this bounds a caller cycling through client ids. A call refused by its own client window does not count against the practice-wide window, so a runaway integration for one client cannot lock out the rest. The X-RateLimit-* headers describe whichever window binds; a refusal is the usual 429 with Retry-After.

GET /api/api-keys/:id/usage on a PARTNER key reports the practice-wide window and its ceiling (window: "PARTNER_PRACTICE"); on a TENANT key it reports the key's own window (window: "API_KEY").

Audit

Each client read writes an audit row into the client's audit trail (public_api.partner. client_read.posture, .controls, .remediation, .receipts), attributed to the key, with metadata.parentTenantId and metadata.clientTenantId. Listing clients writes public_api.partner.clients_listed, and a refused client id writes public_api.partner.client_refused (with the requested id), both in the practice's own trail.

Versioning

The partner routes are part of the current API version, v2, and exist only there: there is no /v1/partner/*. They carry no Deprecation or Sunset header.

The Python and TypeScript SDKs do not wrap the partner routes; call them over HTTP, or generate a client from openapi.json (tag public-api/partner).

This page is the product manual, published from the repository at docs/developers/partner-api.md. Customers read the same text inside CMDash under Help. Last updated October 9, 2026.

Partner data API (/v2/partner/*) — CMDash Docs