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 - The raw key is shown once at creation; only a SHA-256 hash + short prefix are stored.
- Keys are scoped per action. The six data tools each require a scope (below); the control-plane and content-engine tools need only a valid, non-revoked key acting on its own account.
- Data & brain tools require an active entitlement. Every tool that reads or writes your data or brain (the six data verbs plus
emit_telemetry,sync_brain,start_run,run_heartbeat,connect_integration, and thestudio_*tools) is callable only while the account holds at least one active paid entitlement (a plugin, bundle, suite, membership, or team seat). Without one they returnnot_entitled(MCP error-32003/ HTTP403) pointing you to /pricing. The onboarding tools stay open (see below). This is additive to the scope check, not a replacement. - Every call writes a row to your audit log (
api_key_calls): which key, which tool, ok/error/denied, duration, sizes. - Revoke any time from the same panel; the next call from a revoked key returns 401 immediately.
- Keys are tenant-scoped: a key issued by one account can never touch another's data.
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>).
- Discovery.
GET /.well-known/oauth-authorization-server(RFC 8414) advertises the endpoints andS256PKCE;GET /.well-known/oauth-protected-resource(RFC 9728) points a client at this authorization server. - Authorize.
GET /api/v1/oauth/authorize?response_type=code&client_id=…&redirect_uri=…&scope=…&code_challenge=…&code_challenge_method=S256&state=…. The consent step uses your logged-in Sales as Code session (you'll be sent to log in first if needed), shows the requested scopes, and on Allow redirects back with a one-timecode. - Token.
POST /api/v1/oauth/tokenwithgrant_type=authorization_code,code,redirect_uri,client_id, and the PKCEcode_verifier. Returns{ "access_token": "sak_…", "token_type": "Bearer", "scope": "…" }. Use that token exactly like any pasted key.
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.
log_capture·capture.write: log a free-form note or transcript; optionally attach to an account or prospect.update_prospect·prospect.write: create or update a prospect (omitidto create).add_to_plan·plan.write: append to a named section of an account plan (next_steps,risks,champion_notes, …).mark_meeting_outcome·meeting.write: record a meeting outcome (advance/stall/lostor free-form) with notes + next step.read_account·account.read: read an account; optionally include its plan, recent meetings, or captures.pull_brief·brief.read: a structuredtoday/weeksummary: accounts active, recent captures + meetings, open plan sections.
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.
verify_session: returns the account, plan, and entitled plugins. Every skill calls this first and stops if it errors.check_entitlement: is this account licensed for a named plugin? Stops and asks the user to buy/connect if not.start_run: declare that a skill run has started so Activity can show it live. Returns arun_refcorrelation id (server-generated unless you supply one): pass it torun_heartbeatwhile working, toemit_telemetryasrun_idwhen done, and tosync_brainasrun_ref. Optional; skills that only report at the end keep working.run_heartbeat: signal a started run is still working. At most one per 60s, and only needed for runs longer than a few minutes; a started run silent for 30 minutes is flaggedstale. Returnsrunning: false(not an error) when the run already finished.emit_telemetry: record a run (plugin, skill, counts, outcome, duration). Whenrun_idcarries therun_reffromstart_run, this settles the declared run (running→ok/error) instead of recording a second row. Optional error taxonomy:error_code(one ofauth·entitlement·rate_limit·provider·skill_logic·unknown) +error_detail(≤2000 chars) categorize failures for Activity's run drill-down; free-formoutcomestrings keep working. The full payload is stored for the drill-down, capped at 64KB; oversized calls are rejected withpayload_too_large, never silently truncated. Optionalaccount_ref(≤200 chars) tags the run with the account it worked (see below). Metadata only. Powers Activity.sync_brain: push brain state for a scope (signal-weights, icp-registry, decision-log, funnel-brain, content calendar, …). Optionalrun_refattributes the sync to the exact run that produced it (otherwise Activity falls back to a time-window match). Optionalaccount_ref(≤200 chars) tags the brain state with the account it is about. No raw contact PII by default; 256KB cap.pull_config: plan, entitled plugins, and feature flags so a skill can adapt at run start.
Content-engine tools
Used by the Content Engine plugin to connect channels and drive the studio. Also no extra scope.
connect_integration: write back an OAuth token the connector obtained for a channel (linkedin, linkedin_company, youtube, reddit, google_drive), stored encrypted so server-side publish/ingest can run.studio_list_drafts: list the Content Studio draft queue, newest first, with an optional status filter.studio_review_draft: approve, schedule, or hold a draft. Approving hands it to the publisher. It can't markpublished; only the publisher does that.
Deal evidence (scoped)
set_meddpicc·meddpicc.write: set MEDDPICC evidence on an opportunity. Pass any of the eight dimensions as short text; the ones you pass are merged, the rest are left alone. Feeds the curiosity engine that finds the gaps.
GTM company-OS tools (scoped)
The scoring, enrichment and list-building substrate the GTM Engine runs on, callable directly.
gtm_score·account.read: score a subject with the shared substrate.roleis one ofawareness,deal-risk,deal-risk-team,technical-risk,health,ecosystem-fit,efficiency. Returns a 0 to 100 value, a band, and the contributing factors.gtm_enrich·prospect.write: run the enrichment waterfall for one contact across the configured providers, cheapest first, validating email MX. Optionally persists what it finds.gtm_build_tam·prospect.write: materialize a target account list, tagged with the list name and ready for enrichment and scoring.gtm_crm_write·prospect.write: write structured facts onto a record (prospect, account, target account, opp, contact). Merges into the existing payload; omitidto create.gtm_signal_query·account.read: read the stored signals and recorded scores for an account, so an agent can see the state before it acts.gtm_run_play·prospect.write: enrich, then score, then draft outreach, for one contact. Chains the three above.
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.
answer_about_account·account.read: answer a question about one account from what the organization has established.answer_across_org·account.read: the same, across every account the calling seat may see, over the organization fact store.
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.
team_rollup: team-wide run rollup for a team owner. Per-member runs, actions and rank, per-plugin totals, week over week, inside a trailing window (1 to 90 days, 30 by default).team_member_summary: one member's run summary, for 1:1 prep. Per-plugin counts and the most recent runs with plugin, skill, status, outcome counts and account label.
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).
- Start. Call
start_run(afterverify_session/check_entitlement) with the plugin + skill. Keep the returnedrun_reffor the rest of the run. The run appears on Activity as running immediately. - Heartbeat. For runs longer than a few minutes, call
run_heartbeatwith therun_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. - Brain syncs. Pass the same
run_refon everysync_braincall the run makes. That upgrades the run drill-down from "syncs that landed around this run" (time-window guess) to exact attribution. - Final. End with one
emit_telemetrycarrying therun_refasrun_id, plus counts, outcome, and the error taxonomy on failure. This settles the declared run (running→ok/error) in place, one run, one row. Omitduration_msand 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.
- Pass it where the skill knows the account. A skill working a specific account (researching
acme.com, prepping the Acme deal) should setaccount_refto a stable label, prefer the domain, since it de-duplicates cleanly. A skill with no single account (a cross-account digest) should omit it. - Use one consistent label per account. Activity groups by exact string match, so
"acme.com"and"Acme Corp"are two different accounts. Pick one convention (domain is recommended) and reuse it across skills. - Fully optional, fully back-compatible. Omit it and everything works exactly as before: the account filter on /rollup and the team deconfliction card on /team-admin/rollup simply show their honest "no account labels yet" empty state until skills start sending it.
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:
Deprecation: trueon responses from the affected endpoint.Sunset: <HTTP-date>naming the earliest date it can stop working, at least 180 days ahead.Link: <url>; rel="deprecation"pointing at what to do instead.- The same, marked
deprecated: trueon the operation in the spec.
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.