Skip to main content
CMDashCybersecurity Maturity Dashboard
DocsDevelopersPublic API — Versioning Policy

Public API — Versioning Policy

Last updated: September 2026

Docs portal: /api/v1/reference · /api/v2/reference (ReDoc) · /api/v1/docs · /api/v2/docs (Swagger UI) · /api/v1/docs-json · /api/v2/docs-json (raw OpenAPI 3.0) Machine-generated reference: public-api-reference.md SDK: @cmdash/api-sdk — v2 under client.<namespace> (the default) and client.v2.<namespace>; v1 under client.v1.<namespace> (see "SDK version targeting" below)

API v1 deprecation (effective 2026-08-24; v2 is now live)

This is the entry the "What happens to v1 when v2 ships" section below said would go here once a v2 actually existed. It now does.

  • What changed, and why it required a new version. Two things this repo committed to never changing silently on /v1/*:
    1. GET /v1/compliance/frameworks/:slug/controls and GET /v1/integrations/:id/health return 200 { error: '...' } for an unknown slug/id instead of a real 404. Fixing that is itself a breaking change under this policy (a status-code-meaning change) — see "Error shapes" below. v2 fixes both; v1 keeps the 200.
    2. Seven of the fourteen declared API-key scopes had no route enforcing them (compliance:write, integrations:write, reports:read, reports:generate, webhooks:read, webhooks:write, audit-logs:read). Wiring a route to check a previously-unenforced scope silently expands what an already-issued key can do the moment that route ships — also a breaking change under this policy (see "What forces a v2" below). v2 enforces six of the seven with a real route each; the seventh, compliance:write, was removed from VALID_PERMISSIONS entirely rather than carried forward unenforced — see "Per-scope resolution" below for why.
  • v1 status: deprecated, not removed. Every /v1/* response now carries Deprecation: Mon, 24 Aug 2026 00:00:00 GMT and Sunset: Wed, 24 Feb 2027 00:00:00 GMT headers (RFC 8594), plus Link: </api/v2/reference>; rel="successor-version". Nothing about a /v1/* route's behavior changed to produce this entry — the headers are purely additive (see "What v1 guarantees" below).
  • Sunset date: 2027-02-24 (6 months out). There are zero live API keys in production as of this writing (verified 2026-08-24), so this window is conservative by policy rather than by any real integration's migration timeline — there is nothing to time it against yet. If a real integrator shows up before the sunset date, extend it; do not let the calendar force a removal nobody asked for.
  • Where to go instead: /v2/* — same auth, same rate limits, same response envelope, same pagination conventions as v1 (see the sections below; none of that changed). Only the two fixed endpoints and the newly- enforced scopes behave differently. packages/sdk-js and packages/sdk-python both now cover v2 under a v2 sub-namespace (see "SDK version targeting", ROADMAP-V100 I4) — the SDKs' unprefixed namespace IS v2, so a new integration gets it by default.

This is the policy prose a generated doc can't produce on its own — see framework-coverage.mjs's own header for why: classification and commitments are a judgment call, written once by a human, not a fact parsed out of source. Everything factual about the v1 surface (which endpoints exist, which permission each requires) lives in the generated reference doc instead, precisely so this file doesn't have to restate it and can't drift from it.

v2 CSV export format change (2026-09-16, v8.13.0 — ROADMAP-V90 H4/H5/H6)

GET /v2/audit-logs/export?format=csv is the one public-API route that returns a CSV file, and its BYTES changed. This is recorded here rather than shipped silently because a file format is part of the contract even when the JSON envelope is not involved.

What changed

  1. A UTF-8 BOM (EF BB BF) now leads the file. Without it, Excel on Windows reads the file as the system code page and mis-renders every non-ASCII user name — the reason auditors were getting mojibake in the exact artifact they file. A parser that reads the first column of the first row without stripping the BOM will now see \ufeffID instead of id; csv.reader in Python needs encoding='utf-8-sig', and papaparse/csv-parse strip it by default.
  2. Records end with CRLF, not LF (RFC 4180 §2). Most parsers already accepted both.
  3. Headers are Title Case: ID, Timestamp, User Email, User Name, Action, Resource, IP Address, Metadata — was id,timestamp,user_email,user_name,action,resource,ip_address,metadata. Column ORDER and column MEANING are unchanged. A consumer reading by INDEX is unaffected; one reading by header name must update its map.
  4. Content-Disposition is cmdash-<org-slug>-audit-log-<YYYY-MM-DD>.csv — was audit-log-export-<timestamp>.csv. A non-ASCII org slug is sent as both filename= (ASCII fallback) and filename*=UTF-8''… (RFC 6266).
  5. An empty date range returns 200 with a header-only file. It used to return 404 No audit logs found for the specified date range, which made "nothing happened in this window" indistinguishable from a broken export. Clients that treated that 404 as "no data" should now read zero data rows.
  6. The 10,000-row cap is now stated. Responses carry X-Total-Count (rows matching the range), X-Truncated and X-Ceiling, and a capped file ends with a trailer row "Showing the first N of M rows — narrow the date range or filters to export the rest". X-Total-Records (rows in the file) is unchanged and still sent. format=json carries the same three facts as totalAvailable, truncated and ceiling fields.

Why this was not a /v3. Items 1–4 are byte-level presentation of a file, not the response-field contract items 2 and 5 of "What v1 guarantees" cover; item 5 is a status-code change that makes the route more conformant with the rest of v2 (the 404-for-empty-collection shape is the same defect v2 already fixed on two other routes); item 6 is additive. It is recorded here anyway, because a consumer parsing the file by header name or asserting on the filename has to be told.

What "v1" is, mechanically

Every Public API v1 route is mounted at a path starting with v1/ — there is no unversioned Public API route to migrate away from. The documentation portal at /api/v1/* is built by filtering the live OpenAPI document down to exactly the paths matching that prefix, so the v1 docs cannot advertise a route that isn't real, or silently include something that shouldn't be public.

The usage endpoints (/api-usage/*) and the key-management endpoints (/api-keys/*) are not part of the versioned surface — they're JWT-authenticated dashboard endpoints for administering keys and viewing usage, not data the v1 SDK/API-key surface exposes. The webhook management endpoints (/webhooks/*) are the same: JWT-authenticated, unversioned, out of scope for this document.

v2 is the same mechanism. Every v2 route is mounted at a path starting with v2/, and the /api/v2/* documentation is built by the same filter-the-live-spec technique, so the same guarantee holds there. Where a v2 route behaves identically to its v1 counterpart (posture, risks, gate, and two of the three compliance and integration routes each), it returns the same shape; every v2 route is declared explicitly in the generated reference, the Postman collection and both SDKs.

What v1 guarantees

For every endpoint currently documented at /api/v1/reference:

  1. The route keeps responding at the same path with the same HTTP method. GET /v1/posture/latest will not become GET /v1/postures/latest or a POST.
  2. Response fields are not removed or repurposed. A field present in a v1 response today keeps the same name and the same meaning. New optional fields may be added (clients must ignore unknown fields — this is standard REST/OpenAPI practice and nothing in the current SDK or any known integration depends on strict/closed object shapes).
  3. Existing query/body parameters keep working the same way. A parameter that's optional today stays optional; a parameter that accepts a given value keeps accepting it.
  4. Auth, permission scopes, and rate-limit semantics don't tighten silently. A key with risks:read today keeps being able to call GET /v1/risks tomorrow. If a scope's behavior needs to change, that's itself a breaking change (see below), not a patch.
  5. The error envelope shape stays what HttpExceptionFilter documents — { success: false, requestId, timestamp, statusCode, error, message, details? }. See "Error shapes" below for two endpoints that don't follow this consistently — that inconsistency is called out explicitly rather than glossed over. Now fixed in v2 (a real 404 like every other :id lookup here); v1 keeps the inconsistent 200 on purpose, since fixing it in place would itself be the breaking change this policy exists to prevent.

Additive, backward-compatible changes (new endpoints, new optional fields/params, new permission scopes, new webhook event types) ship inside v1 with no version bump — that's the entire point of versioning by commitment rather than by "anything that changed gets a new number."

What forces a v2

Any of the following is a breaking change and requires a new version prefix (/v2/...), never a silent change to /v1/...:

  • Removing or renaming a response field, or changing what an existing field means (units, encoding, nullability going from "always present" to "sometimes null").
  • Removing an endpoint, or changing its HTTP method.
  • Making a previously-optional parameter required, or narrowing what values an existing parameter accepts.
  • Changing the meaning of an existing status code (e.g. an endpoint that returns 200 with an error-shaped body today starts returning 404 for that same case — again, note the two known instances of this above are the reverse direction: fixing them to return proper 404s is the breaking change, not the bug itself).
  • Changing the pagination shape ({ total, risks: [...] } today for GET /v1/risks) or the rate-limit header contract (X-RateLimit-Limit/Remaining/Reset).
  • Retiring a permission scope a key may already have been granted, in a way that changes what that key can still do.

What happens to v1 when v2 ships: v1 keeps running, unannounced-silent-break-free, for a stated deprecation window before any v1 route is removed. v2 now exists (see the "v1.0.0 deprecation" entry at the top of this document) — that window is live: v1 is deprecated as of 2026-08-24 and sunsets 2027-02-24. Every /v1/* response carries the Deprecation/Sunset response headers (applied per route, so they never appear on a v2 response). "We changed it, sorry" after the fact is not an acceptable way to run a versioned API that appears in procurement conversations.

Per-scope resolution (v2)

For each of the seven scopes flagged unenforced below, v2 made one of two honest choices — enforce it with a real route, or remove it from VALID_PERMISSIONS entirely rather than leave a grant that does nothing:

Scopev2 resolutionWhy
integrations:writeEnforced — POST /v2/integrations/:id/syncTriggers the same sync as the dashboard's "Sync now" button. No user attribution is needed.
reports:readEnforced — GET /v2/reports, GET /v2/reports/:idThe same report list and content the dashboard shows. Read-only and tenant-scoped.
reports:generateEnforced — POST /v2/reports/generateGenerates a report. The creator attribution is optional (some callers have no user identity), so an API key is a supported caller.
webhooks:readEnforced — GET /v2/webhooks, GET /v2/webhooks/:id/logsThe same registrations and delivery logs the dashboard shows.
webhooks:writeEnforced — POST/PUT/DELETE /v2/webhooks, POST /v2/webhooks/:id/rotate-secret, POST /v2/webhooks/:id/testCreate, update, delete, secret rotation and test-event send need no user identity. Delivery-log replay is deliberately not exposed on v2: it requires a real user id for its audit trail, the same reason as compliance:write below.
audit-logs:readEnforced — GET /v2/audit-logs, GET /v2/audit-logs/exportThe same audit-log list and export the dashboard offers. Read-only.
policies:readEnforced — GET /v2/policies, GET /v2/policies/:idA1009 G8. Read-only, paginated (limit default 50, max 500; offset), filterable by status and category. The detail adds the active version's Markdown and the published version history. The access-code hash and public-portal access window are never returned.
vendors:readEnforced — GET /v2/vendors, GET /v2/vendors/:idA1009 G8. Read-only, paginated, filterable by status, tier and criticality. The point-of-contact and internal-owner fields are not returned.
evidence:readEnforced — GET /v2/evidence, GET /v2/evidence/:idA1009 G8. Read-only, paginated, filterable by status, collectionMethod, frameworkSlug and controlId. Raw integration payloads (metadata, diff) and reviewer notes are not returned. Distinct from evidence:write (POST /v2/evidence).
assets:readEnforced — GET /v2/assets, GET /v2/assets/:idA1009 G8. Read-only, paginated, filterable by assetType and criticality. The FAIR dollar value is not returned.
incidents:readEnforced — GET /v2/incidents, GET /v2/incidents/:idA1009 G8. Read-only, paginated, filterable by status and severity; each incident lists its linked controls.
compliance:writeRemoved from VALID_PERMISSIONSThe one operation it would have covered, updating a control assessment, requires a real user id for its audit trail and its compliance.milestone webhook. An API key has no user identity to supply, so this ships as a removal instead of a grant that does nothing. Creating a key with it is now rejected with a 400. This changes the unversioned key-management surface (/api-keys/*) only — no v1 or v2 data route's behavior changes, since no route ever checked this scope.

SDK version targeting

Both @cmdash/api-sdk (JavaScript) and cmdash-api-sdk (Python) cover v1 AND v2 as separate sub-namespaces. #575 introduced them with v1 on the bare namespace and v2 under client.v2.*; I4 swapped which one is the default, because the bare namespace is what every quickstart, README and copy-pasted sample shows, and that was pointing new integrators at the version carrying a Sunset header.

before I4after I4
client.<resource>v1v2
client.v2.<resource>v2v2 (unchanged — the same object the bare name resolves to)
client.v1.<resource>—v1
  • v1 is not removed or weakened. Every v1 method keeps its exact path, parameters and return type at client.v1.*, including the two documented 200 { error: ... } quirks on compliance.getFrameworkControls / integrations.getHealth.
  • The swap is deliberately loud, not silent. In TypeScript it surfaces as a compile error at exactly the call sites that care: v2 drops those two union return types, so code that branched on 'error' in result stops compiling. In Python the client raises one DeprecationWarning per client the first time a /v1/ path is called, naming the sunset date. Both SDKs accept an opt-out (suppressDeprecationWarnings / suppress_deprecation_warnings) for the warning, and client.v1.<resource> is a mechanical, type-checked migration back to the previous behaviour.
  • Nothing else differs between the two versions on a shared resource. The five resources that exist in both (posture, compliance, risks, integrations, gate) require the same key scopes in both; the six previously-unenforced scopes v2 wired up all belong to v2-ONLY resources (webhooks, reports, audit-logs, the sync trigger), which have no v1 counterpart to break.

Both SDKs are checked against the served API specification for both namespaces independently. v2-only resources with no v1 equivalent (webhooks, reports, audit-logs, collector/evidence pushes) are reachable both bare (client.webhooks) and as client.v2.webhooks.

Authentication

  • Bearer token, prefix cmdash_ek_: Authorization: Bearer cmdash_ek_....

  • Keys are SHA-256 hashed at rest; the full key is shown exactly once, at creation/rotation.

  • Keys carry an explicit, whitelisted set of permission scopes chosen at creation time. Every v1 route requires one specific scope — see the generated reference doc for the exact scope per route. Seven scopes in that whitelist had no v1 route that checked them (compliance:write, integrations:write, reports:read, reports:generate, webhooks:read, webhooks:write, audit-logs:read) — corrected from an earlier draft of this document, which undercounted this as six. That was not a security hole (nothing is unlocked by holding an unused scope), but a key issued with one of those grants could exercise none of it. v2 resolved all seven — see "Per-scope resolution (v2)" below — six got a real enforcing route, and compliance:write was removed from the whitelist entirely. v1 itself is unchanged: none of its routes check any of these seven scopes today, and none ever will (enforcing one on a v1 route would itself be the breaking change this policy exists to prevent — see "What forces a v2" above).

  • Keys expire: 90-day default, 365-day maximum, enforced at creation and rotation.

  • Creating/listing/revoking/rotating keys is a JWT-authenticated, Enterprise-gated dashboard operation (Owner or Admin role) — separate from the v1 data API's own auth.

  • The entitlement is re-checked on every request, not only when the key was minted. An earlier revision of this page recorded the opposite as an accepted audit finding ("a key minted while a tenant held that entitlement keeps working … even if the tenant's current plan changes afterward"). That is not the case: the calling tenant's effective entitlements are resolved on every /v1 and /v2 request, and the request is refused when PUBLIC_API is gone. What a caller sees:

    Tenant's plan grantsResult
    PUBLIC_APIevery method the key's scopes allow
    PUBLIC_API_READ only (Starter and up, V81 D2)GET/HEAD/OPTIONS only; any other method is 403, even if the key carries a :write scope minted under a previous plan
    neither403 on every route — "This API key is valid, but your plan no longer includes Public API access."

    The 403 is deliberate: the key itself authenticated, so a 401 would send a customer off re-minting credentials that would fail identically.

    The entitlement is the union of the plan's features and any paid add-ons, so a tenant whose PUBLIC_API came from an add-on rather than the Enterprise bundle is not wrongly locked out.

    Propagation. The verdict is cached per tenant for 60 seconds so the check costs at most one resolve per tenant per minute on a path that serves up to 20,000 requests/hour for one Enterprise tenant. Every plan change that goes through the product — Stripe checkout, plan change, cancellation, an AWS Marketplace tier move, a staff tier change, or the staff add-on editor — evicts that entry as it writes, so a downgrade takes effect on the next request. The 60 seconds is the backstop for a row changed some other way (a direct database edit, say). A key that must stop working immediately should be revoked, which does not go through this cache at all: the key is checked on every request.

Rate limits

Per API key, fixed 1-hour window, resolved from the calling tenant's plan:

TierRequests / hour
Starter500
Essentials2,000
Command10,000
Enterprise20,000
(tenant/tier unresolvable)1,000 (flat fallback)

An operator-set API_RATE_LIMIT env var overrides the tier map entirely (applies the same flat number to every key) — used for tests/staging, not expected to be set in production. Every response from a rate-limited route carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (Unix seconds). A 429 carries the standard error envelope. A1009 P4: all of a workspace's keys also share one window of 5 times the limit above, and X-RateLimit-Reset is epoch seconds on every limiter (see limits.md).

Fails OPEN on Redis outage or connection failure — checkLimit() returns "allowed" with the full limit rather than blocking every Public API call because the rate limiter's own backing store is unavailable. This is a deliberate availability-over-strict-enforcement tradeoff (mirrors AiBudgetService), not an oversight — worth stating plainly since "what happens when the limiter itself is down" is exactly the kind of thing a procurement/security review will ask.

That fail-open state is no longer permanent: the limiter probes Redis again after a 5-second cooldown and resumes metering as soon as it answers (A1009 P4; limits.md).

NODE_ENV=test skips the real Redis dial entirely (a mock is injected via _setRedisForTest) — this only affects the test suite; nothing about this changes production behavior, and no production deploy should ever run with NODE_ENV=test.

Pagination (verified per-endpoint — there is no single global convention)

  • GET /v1/risks — offset/limit: ?limit=50&offset=0 (defaults shown), response is { total, risks: [...] }. This is the only paginated list endpoint in v1 today.
  • Every other list endpoint (GET /v1/compliance/frameworks, GET /v1/integrations, GET /v1/compliance/frameworks/:slug/controls, GET /v1/risks/:id/controls) returns the full array, unpaginated — these are tenant-scoped collections that are small by construction (a handful of frameworks/integrations/controls-per-risk, not an unbounded log). Introducing pagination on one of these later would itself be a breaking change per the policy above (the response shape changes from a bare array to an envelope object) — noted here so it's a deliberate v2 decision if it ever comes up, not an accidental v1 patch.

Error shapes

Standard envelope for every error (auth failures, rate limits, validation, not-found, unhandled 500s):

json
{
  "success": false,
  "requestId": "...",
  "timestamp": "2026-08-21T12:00:00.000Z",
  "statusCode": 404,
  "error": "Not Found",
  "message": "Risk 123 not found"
}

Two known exceptions that do NOT follow this shape, found while auditing the real behavior (not previously documented):

  • GET /v1/compliance/frameworks/:slug/controls — an unknown slug returns HTTP 200 with body { "error": "Framework not found" }, not a 404 with the standard envelope.
  • GET /v1/integrations/:id/health — an unknown id returns HTTP 200 with body { "error": "Integration not found" }, not a 404. (The OpenAPI document for this route declares a 404 response that the route never actually produces.)

Every other :id/:slug lookup in v1 (GET /v1/risks/:id/loss-exposure, PUT /v1/risks/:id, GET /v1/risks/:id/controls) returns a real 404 with the standard envelope. The two endpoints above are inconsistent with the rest of the surface and with what their own OpenAPI document promises. This is documented as the actual, current v1 behavior — still true today, unchanged — rather than silently "fixed" to match the rest, since changing response semantics on a live v1 route is itself a breaking change under the versioning policy above.

Fixed in v2. GET /v2/compliance/frameworks/:slug/controls and GET /v2/integrations/:id/health return a real 404 (the standard envelope) for the same unknown-slug/unknown-id case. v1 keeps its documented behavior.

Insufficient scope: 401 on v1, 403 on v2

An API key that authenticated but does not carry the scope a route requires (API key lacks required permission: <scope>) is answered differently by the two versions, on purpose:

VersionStatusWhy
/v2/*403 ForbiddenThe key is valid; it is not allowed to do this. A client that treats 401 as "re-authenticate" (Zapier's connection test did, and looped on reconnect) must not be told to re-authenticate.
/v1/*401 Unauthorized (unchanged)Changing the meaning of a status code on a frozen, deprecated surface is itself a breaking change under "What v1 guarantees" above. v1 keeps its 401 until it is sunset (2027-02-24).

The message, the envelope and the scope named are identical in both. The other 403s on this surface (a plan without Public API access, a read-only plan attempting a write, a sandbox key attempting a write) were already 403 on both versions.

Query-string validation (A1009 P2)

On GET /v1|v2/risks, GET /v2/reports, GET /v2/webhooks/:id/logs, POST /v2/webhooks/:id/rotate-secret and GET /v1|v2/posture/history, a query value that is not the shape its parameter names is a 400 with the field errors under details (limit=abc, status=FOO, minScore=x, a repeated ?status=A&status=B). An empty value (?status=) still means "not filtered". limit is a positive whole number and is clamped to 500 on the risk list (and to the endpoint's own ceiling elsewhere): an unbounded page was a defect, not a feature, so this tightening applies to v1 as well. Values that used to fail with a 500 (a NaN reaching the database) are the only inputs whose outcome changed from "works" to "400"; status accepts exactly the RiskStatus values OPEN, IN_PROGRESS, MITIGATED, ACCEPTED, CLOSED (the OpenAPI enum listed a non-existent TRANSFERRED, which always failed).

Success envelope

Every non-error response is wrapped by the global ResponseInterceptor:

json
{ "success": true, "requestId": "...", "timestamp": "...", "data": { ... } }

The SDK (packages/sdk-js) unwraps this automatically — every client method resolves to data, not the outer envelope.

meta is an OPTIONAL fifth key, present only on responses that read a row-capped list — see "List truncation" below.

Asynchronous jobs (ROADMAP-V100 I7)

Exports, evidence packages and generated reports are jobs: you start one, then poll. Four things are true of every such surface, and the same four values are used everywhere:

statusmeaning
PENDINGaccepted, not started
PROCESSINGrunning
COMPLETEfinished; output available
FAILEDfinished; no output

Alongside status, every status read carries three derived fields so a client never has to hard-code which strings mean "keep going":

json
{ "status": "PROCESSING", "ready": false, "terminal": false, "retryAfterSeconds": 5 }
  • ready — the output can be fetched. Only ever true for COMPLETE.
  • terminal — the status will not change again. Stop polling.
  • retryAfterSeconds — advisory wait before the next poll; null once terminal.

A status read is always 200 while the job exists. 404 means no such job. Nothing else is an error — an in-flight job is not a client mistake.

A download of an unfinished job answers 409 with details.code = "JOB_NOT_READY", details.status (the live status) and a Retry-After header. Retry it; do not treat it as fatal.

Changed in v9.5.0. GET /v2/reports/{id} previously answered 400 "Report is not ready for download (status=PROCESSING)" for the whole life of a running report, which made the documented generate-then-poll loop return a client error on every poll. It now returns 200 with the block above and content: null until ready is true. If your client treated that 400 as "not finished yet", switch to checking ready; if it treated it as fatal, it now works. The download routes that used to answer 400 (report / evidence-package) or 404 (the three data-export signed-URL routes) for an unfinished job now all answer 409 JOB_NOT_READY.

List truncation (ROADMAP-V100 B7 + I11)

Some list endpoints are capped at 500 rows server-side. A capped response discloses itself twice, from one recorded fact — you may read either:

  • header X-List-Truncated: <surface>;cap=500, present ONLY when rows were actually dropped, and
  • meta in the body, present on every capped read:
json
{
  "success": true, "data": [ /* … */ ],
  "meta": {
    "lists": [ { "surface": "tags", "cap": 500, "returned": 500, "hasMore": true, "totalCount": null } ],
    "hasMore": true, "totalCount": null, "returned": 500
  }
}

meta.hasMore: false is a positive statement that the rows you have are the complete set — which is why the body signal exists at all: an absent header cannot distinguish "complete" from "you never heard about it".

totalCount is exact or null, never an estimate:

  • complete list → the exact count, for free (no extra query);
  • truncated list → null, unless you ask for it with ?totalCount=true (or the X-List-Total-Count: true header), which costs one COUNT(*) over the same tenant-scoped filter the page used.

The flat hasMore/totalCount/returned fields appear only when the request read exactly one capped list; meta.lists is always authoritative.

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

Public API — Versioning Policy — CMDash Docs