StartConceptsPlaybooksFeature docsAPI & MCPChangelog

API & MCP

Your account is an MCP server. Issue a Bearer key at Settings → API Keys, point any MCP-compatible agent at /api/v1/mcp, and it can call the tools below against your account. Every tool is also a plain REST endpoint if you'd rather not speak MCP. This is a power-user path; the products themselves run on our servers and need none of it.

Authentication

Issue a key at /settings/api-keys. Keys are Bearer-style:

Authorization: Bearer sak_abc1…xyz

Two ways to call

Same tools, same keys, same scopes, same audit log: pick the transport that fits your client.

MCP (JSON-RPC)

Point an MCP client at https://salesascode.com/api/v1/mcp. It calls tools/list to introspect, then tools/call to invoke. This is what the Sales as Code plugins use.

REST

POST JSON to /api/v1/tools/<name> with a Bearer header:

curl -X POST https://salesascode.com/api/v1/tools/log_capture \
  -H 'authorization: Bearer sak_…' \
  -H 'content-type: application/json' \
  -d '{"source":"scheduled","text":"Acme champion confirmed Q3 budget"}'

Advanced: connect any MCP client

HTTP is the canonical transport. Any MCP-compatible client can connect to /api/v1/mcp over HTTP with your Bearer key: same tools, same scopes, same audit log, no server credentials required beyond the key. This is a power-user path; the products themselves run on our servers and need none of it.

Point your client at https://salesascode.com/api/v1/mcp and set the Authorization: Bearer header (or the environment variable your client reads, e.g. SALES_AS_CODE_API_KEY, with an optional SALES_AS_CODE_URL). The endpoint holds all the logic; auth, scopes, rate limits, and the audit log live on the server, exactly as with any direct HTTP client.

Per-user and per-team keys

Every key belongs to the user who issues it. A team owner can instead issue a team key (a toggle in the New key dialog): it still writes into the owner's workspace, but the control-plane tools resolve entitlements against the team's plan, the union of the owner's grants and every plugin the team's seat packs license. A per-user key is unchanged; it authorizes against that user's own plan. verify_session reports scope: "team" or "user" and, for team keys, the team id.

OAuth-provider mode (optional)

Instead of pasting a key, an MCP client can obtain access through a standard OAuth 2.0 authorization-code + PKCE flow. This is env-gated: off unless the deployment sets MCP_OAUTH_ENABLED=true; when off, the endpoints below return 404 and pasted Bearer keys are the only path.

The honest trick that keeps it small: the OAuth access token is a managed API key. The token endpoint mints a scoped api_keys row under the hood, so an OAuth-obtained token revokes, audits, and rate-limits exactly like a hand-issued key; you'll see it in Settings → API keys named OAuth (<client>).

Honest scope: PKCE (S256) is required. Registered clients only. There is no dynamic client registration and no self-serve path: a client_id we do not hold, or a redirect_uri that is not an exact match for one registered against it, is refused inline with invalid_request rather than redirected. The consent screen shows the registered name, never caller-supplied text. No refresh tokens either (the minted key doesn't expire; revoke it in Settings), and codes are single-use and expire in 10 minutes.

To connect a client, ask us to register it with its name and exact redirect URIs. Until then, use a Bearer key from Settings → API keys, which needs no registration and reaches the same tools with the same scopes.

Machine-readable spec

If you are writing a client (or you are an agent reading this), start with the spec rather than this page: https://salesascode.com/openapi.json. It is OpenAPI 3.1, generated from the same tool registry the server dispatches against, so it cannot describe an endpoint that does not exist. Every tool is one operation, with its name as the operationId, its input schema as the request body, and its required scope as x-required-scope. That is enough to generate function-calling definitions without reading any prose.

Also machine-readable: /llms.txt and /llms-full.txt for what the products are, /sitemap.xml for every indexable page.

The site as markdown

Every public page answers at its path plus .md. /docs/api and /docs/api.md are the same document, one rendered for a browser and one for a reader that would rather not parse HTML. Each page also advertises its twin in the head:

<link rel="alternate" type="text/markdown" href="/docs/api.md">

The markdown is converted from the page's own HTML, so it cannot drift from what a person sees. Prerendered pages get a real file written at build time; the server-rendered ones (the homepage, /pricing, the product landers) are converted per request and also honour Accept: text/markdown on their ordinary URL. /markdown-index.md lists the whole set.

The tools

27 tools. The groups below are how they are organised; the spec is the complete, current list.

Data verbs (scoped)

Thin facades over the same store the app uses, so external writes and in-app data converge on one record shape. Each needs the scope shown.

Control-plane tools

The bridge every plugin uses: verify the licensed session and gate on entitlement first, then report telemetry and brain state last. No extra scope: a valid key on its own account is enough.

Onboarding stays open; the rest need entitlement. A brand-new account with a key but no purchase can always call verify_session, check_entitlement, and pull_config; that's how a plugin connects and discovers it needs to buy. The telemetry and brain tools in this group (start_run, run_heartbeat, emit_telemetry, sync_brain) write your brain/run state, so they require an active entitlement just like the data verbs and return not_entitled otherwise.

Content-engine tools

Used by the Content Engine plugin to connect channels and drive the studio. Also no extra scope.

Deal evidence (scoped)

GTM company-OS tools (scoped)

The scoring, enrichment and list-building substrate the GTM Engine runs on, callable directly.

Reasoning tools (scoped)

The questions a table export cannot answer. Both return the facts the answer rests on and who established each one, and both return an honest "nothing recorded" rather than a guess.

Team reads (team owners)

Metadata only: run counts and outcomes, never email bodies, CRM records, or brain payloads. Both error unless the acting account owns the team.

Telemetry lifecycle for plugin skills

What a well-behaved skill emits, in order. Everything here is optional beyond the final emit_telemetry (old skills keep working), but the more of it a skill sends, the better Activity gets (live "Running now" strip, honest durations, exact run ↔ brain correlation).

  1. Start. Call start_run (after verify_session / check_entitlement) with the plugin + skill. Keep the returned run_ref for the rest of the run. The run appears on Activity as running immediately.
  2. Heartbeat. For runs longer than a few minutes, call run_heartbeat with the run_ref, at most once every 60 seconds (cadence floor; heartbeats multiply rows of noise if spammed, and once per minute is all the staleness rule needs). A started run with no heartbeat for 30 minutes is flagged stale on the dashboard, so a crashed skill never looks "running" forever.
  3. Brain syncs. Pass the same run_ref on every sync_brain call the run makes. That upgrades the run drill-down from "syncs that landed around this run" (time-window guess) to exact attribution.
  4. Final. End with one emit_telemetry carrying the run_ref as run_id, plus counts, outcome, and the error taxonomy on failure. This settles the declared run (runningok/error) in place, one run, one row. Omit duration_ms and it is computed from the declared start.

Report silent work too. If a scheduled run did exploratory or advisory work that never triggered a discrete skill (research, Q&A, drafting that wasn't saved), emit a session summary before it ends: emit_telemetry with skill: "session-summary" and honest counts (e.g. {"questions_answered": 4, "accounts_researched": 2}). Otherwise that work is invisible in Activity and the user can't see what their plugins actually did for them.

start_run        {"plugin":"gtm-engine","skill":"daily-signals"}      → {"run_ref":"run_kJ2…"}
run_heartbeat    {"run_ref":"run_kJ2…"}                               (every ≥60s while working)
sync_brain       {"scope":"signal-weights","payload":{…},"run_ref":"run_kJ2…"}
emit_telemetry   {"plugin":"gtm-engine","skill":"daily-signals",
                  "run_id":"run_kJ2…","outcome":"ok","counts":{"signals_sensed":12}}

Account scoping: account_ref

Both emit_telemetry and sync_brain accept an optional account_ref: a caller-supplied string label for the account a run worked or a brain snapshot is about, a domain ("acme.com") or an account name ("Acme Corp"). It is capped at 200 characters and otherwise unvalidated, an honest free-text label, not a foreign key and never synced CRM records or contact PII. It preserves the metadata-only trust posture: the same label the seller already sees, nothing more.

Where it surfaces: the per-account filter on your Activity scopes the run history + by-plugin summary to one account; the team Activity shows per-account, per-member activity and a "heads up, teammates worked the same account this week" deconfliction card. Both are metadata-only reporting over these labels; no account data is ever synced to render them.

Error envelope

Non-2xx responses carry a consistent JSON shape. It is nested: error is an object, not a string. (H106, 2026-08-25: this page showed a flat { "error": "unauthorized" } for months. That is the shape the browser-facing app routes use, and no /api/v1 response has ever looked like it.)

{
  "error": {
    "code": "invalid_key",
    "message": "Bearer token is unknown or revoked.",
    "hint": "Check the key is still listed at /settings/api-keys, then issue a new one."
  }
}

Branch on code; it is stable. message is for a person to read and may be reworded. hint says what to do about it and is present when there is a useful answer, so treat it as optional.

A request to a path under /api/ that matches no route returns this same envelope with code not_found, never an HTML page.

Status codes: 200 ok · 400 invalid input · 401 missing/revoked key · 403 key lacks the required scope or the account has no active entitlement (not_entitled, buy at /pricing) · 404 unknown tool / resource · 429 rate-limited · 500 server error. Over MCP JSON-RPC the entitlement denial is error code -32003.

Every POST needs a JSON content-type

Send content-type: application/json on every POST. A POST with no content-type at all, or a form-encoded one, is rejected as a cross-site form submission before it reaches the route, and that rejection is plain text rather than the JSON envelope below. It looks like this, and it is not an auth failure however much it resembles one:

$ curl -X POST https://salesascode.com/api/v1/tools/log_capture
Cross-site POST form submissions are forbidden        # 403, text/plain

A client generated from the spec sends the header automatically, since every operation declares a JSON request body. Hand-written curl is where this bites.

Rate limits

Every key carries a per-minute limit (60 by default; a team owner can raise or clear it per key). You do not have to discover it by hitting it: every authenticated response carries the budget.

RateLimit-Limit: 60
RateLimit-Remaining: 57
RateLimit-Reset: 60
RateLimit-Policy: 60;w=60

The same numbers repeat as X-RateLimit-* for clients that read the older spelling. Over the limit you get 429 with Retry-After: 60 and the code rate_limited. Pace against RateLimit-Remaining rather than retrying into the wall.

Versioning & deprecation

The API is versioned in the path: everything public lives under /api/v1. A change that would break a working client gets a new version prefix rather than being made in place.

Within v1 we treat these as additive and safe, so a client must tolerate them: a new tool, a new optional field in a request, a new field in a response, a new error code, a new optional header. Match on the fields you use and ignore the rest.

These are breaking, and get a new version: removing or renaming a tool, making an optional request field required, removing a response field, or changing the type of one.

When something is on the way out you will see it on the wire before it goes, not only in a changelog:

Nothing in v1 is deprecated today, so you will not see these headers yet. They are the contract for when it happens.

Audit & limits

Every call (MCP or REST) writes to your audit log, visible to the issuing user. Keys are rate-limited per key; a misbehaving client on one key can't 429 another. See the API Keys & MCP feature page for the issuing flow and the security model, and Concepts → the MCP contract for how the plugins use these tools.