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 | |
|---|---|---|
| Reads | The minting organization's own workspace | The client workspaces the minting practice owns today |
| Routes | /v1/* and /v2/* tenant routes | /v2/partner/* only |
| Permissions | The tenant list (posture:read, risks:read, …) | The five partner:* permissions below, nothing else |
| On the other surface | 403 API_KEY_SCOPE_MISMATCH | 403 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):
{
"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:
| Permission | Route |
|---|---|
partner:clients:read | GET /v2/partner/clients |
partner:posture:read | GET /v2/partner/clients/:clientId/posture |
partner:controls:read | GET /v2/partner/clients/:clientId/controls |
partner:remediation:read | GET /v2/partner/clients/:clientId/remediation |
partner:receipts:read | GET /v2/partner/clients/:clientId/receipts |
Mint refusals:
- a
partner:*permission on a TENANT key, or a tenant permission on a PARTNER key: 400API_KEY_SCOPE_PERMISSION_MISMATCH(details.scope,details.permission); scope: "PARTNER"from an organization without the practice console: 403PARTNER_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).
| Route | Returns |
|---|---|
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).