For agents & developers

Connect your own software to Stacks. External integrations use the core API; built-in AI uses its own credits. Stacks-managed Agentic remains Coming Soon.

Use a separate scoped API key for each integration. Module, write, send and approval rules apply to every supported action. API usage is metered in weighted units against a monthly allowance per plan: 5,000 on Free and 50,000 on AI Pro per UTC calendar month. Once a workspace is above its allowance, charged calls answer 429 usage_limit until the 1st; usage inspection, discovery and idempotent replays are not charged. Technical request and resource limits still apply.

Run a read-only quick start

  1. In your workspace, open Settings → API Keys. Create a separate read-only key for this example, with only the crm module selected. Revoke it there when no longer needed. A browser login cookie is not an API key.
  2. Set STACKS_WORKSPACE_URL to your own HTTPS workspace URL, such as https://YOUR_WORKSPACE.stackscrm.com. On PowerShell: $env:STACKS_WORKSPACE_URL = 'https://YOUR_WORKSPACE.stackscrm.com'. Save the Python example below as stacks_read.py and run python stacks_read.py. It prompts for the key without echoing it; alternatively supply STACKS_API_KEY through your secret manager. Do not paste keys into shared prompts, source control or screenshots.
  3. The script reads one REST contacts page, initializes MCP, checks the negotiated version, sends the initialized notification, follows every tool-discovery cursor, and calls the read-only get_api_usage tool. It performs no writes or sends.
import getpass
import json
import os
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener

base = os.environ["STACKS_WORKSPACE_URL"].rstrip("/")
parts = urlsplit(base)
if parts.scheme != "https" or not parts.hostname or parts.path or parts.query or parts.fragment:
    raise SystemExit("Use the HTTPS origin of your own Stacks workspace")
key = os.environ.get("STACKS_API_KEY") or getpass.getpass("Read-only Stacks API key: ")
if not key:
    raise SystemExit("An API key is required")

class NoRedirect(HTTPRedirectHandler):
    def redirect_request(self, request, fp, code, msg, headers, newurl):
        return None

opener = build_opener(NoRedirect)

def request(path, payload=None, version=None, notification=False):
    is_mcp = path == "/mcp"
    headers = {
        "Authorization": "Bearer " + key,
        "Accept": "application/json, text/event-stream" if is_mcp else "application/json",
    }
    if payload is not None:
        headers["Content-Type"] = "application/json"
    if version:
        headers["MCP-Protocol-Version"] = version
    body = None if payload is None else json.dumps(payload).encode("utf-8")
    req = Request(base + path, data=body, headers=headers,
                  method="POST" if payload is not None else "GET")
    try:
        with opener.open(req, timeout=20) as response:
            raw = response.read()
            if notification:
                if response.status != 202 or raw:
                    raise RuntimeError("Expected HTTP 202 with no notification body")
                return None
            if response.status != 200:
                raise RuntimeError("Unexpected HTTP status: " + str(response.status))
            if response.headers.get_content_type() != "application/json":
                raise RuntimeError("This example expects a JSON response")
            return json.loads(raw)
    except HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
    except URLError as exc:
        raise RuntimeError("Connection failed: " + str(exc.reason)) from exc

def rpc(method, params, request_id, version=None):
    reply = request("/mcp", {"jsonrpc": "2.0", "id": request_id,
                             "method": method, "params": params}, version)
    if reply.get("jsonrpc") != "2.0" or reply.get("id") != request_id:
        raise RuntimeError("Invalid JSON-RPC response")
    if "error" in reply:
        raise RuntimeError("JSON-RPC error: " + json.dumps(reply["error"]))
    if "result" not in reply:
        raise RuntimeError("Missing JSON-RPC result")
    return reply["result"]

contacts = request("/api/v1/contacts?per_page=1")
print("REST contacts on first page:", len(contacts["data"]))

init = rpc("initialize", {"protocolVersion": "2025-06-18",
                          "capabilities": {},
                          "clientInfo": {"name": "stacks-read-example", "version": "1.0"}}, 1)
version = init["protocolVersion"]
if version != "2025-06-18":
    raise RuntimeError("Unsupported negotiated MCP version: " + str(version))
request("/mcp", {"jsonrpc": "2.0", "method": "notifications/initialized"},
        version, notification=True)

cursor = None
names = set()
request_id = 2
while True:
    params = {} if cursor is None else {"cursor": cursor}
    page = rpc("tools/list", params, request_id, version)
    request_id += 1
    names.update(tool["name"] for tool in page["tools"])
    cursor = page.get("nextCursor")
    if not cursor:
        break
if "get_api_usage" not in names:
    raise RuntimeError("This key cannot discover get_api_usage")

result = rpc("tools/call", {"name": "get_api_usage", "arguments": {}},
             request_id, version)
if result.get("isError"):
    raise RuntimeError("Tool error: " + json.dumps(result.get("structuredContent", result)))
usage = result["structuredContent"]
print("MCP connected; commercial meter enforced:", usage["enforced"])

Install Python 3 to run the example; it uses only the standard library. REST returns JSON data and pagination. Your workspace’s /api/v1/openapi.json and Settings → API Reference describe the REST schema. Authentication errors return 401, denied permissions 403, missing owned records 404, validation errors 422, conflicts 409 and technical throttles 429.

MCP connection requirements

The MCP adapter supports stateless JSON HTTP with a custom Bearer header. Local protocol tests pass; hosted-client compatibility is unverified. Endpoint: https://YOUR_WORKSPACE.stackscrm.com/mcp. The example sends Authorization: Bearer, Content-Type: application/json and Accept: application/json, text/event-stream on each POST. After initialization it sends the negotiated MCP-Protocol-Version header. This adapter returns JSON; the example fails clearly if a different response type appears.

Supported protocol selections are 2025-03-26 and 2025-06-18. A client must reject a negotiated version it does not support. Tool discovery is permission-filtered and paginated with nextCursor; call tools with a JSON object for arguments. Check HTTP status, top-level JSON-RPC error, and tool isError separately. A tool can return an error inside HTTP 200.

get_api_usage reports this workspace's API units for the current UTC month: allowance, used, remaining and the reset date, plus operation weights, meter version and technical limits. Once usage is above the allowance, charged calls answer usage_limit until the 1st. External API/MCP access uses your own client and is separate from built-in AI credits. GET event streams and resumable sessions are not provided. The examples and checks below cover their named clients and workflows; they do not establish compatibility with every client.

Server-side and native MCP clients may omit Origin. If supplied, it must match the configured origin of the endpoint being called, including any non-default port. Foreign workspace origins, opaque null origins and malformed values are refused with HTTP 403 before authentication or tool execution. This endpoint does not provide cross-origin browser access; use your application's server-side MCP client.

Connect through OAuth

Hosted clients can use https://stackscrm.com/mcp with an OAuth access token. Discovery is available at /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource. The signed-in member chooses a workspace and approves access; the token is bound to that workspace and the central MCP resource. Workspace REST endpoints and workspace MCP endpoints continue to use scoped API keys, not these OAuth tokens.

  1. Ask the Stacks operator to register a separate OAuth client for your integration. Supply its exact callback URL. Registration is currently operator-managed; dynamic client registration and client ID metadata documents are not supported. Store the issued client secret in your client's secure credential settings.
  2. Select manual OAuth client setup and enter the issued client ID and secret. Use authorization code with S256 PKCE and client_secret_basic or client_secret_post. Discovery supplies the authorization, token and revocation URLs. Access tokens last one hour; clients should use the returned expiry and rotating refresh token. App-only client_credentials grants are not supported.
  3. Request only the scopes needed: crm, invoicing, scheduling, support, reviews, email, plus write or send when required. Start with explicit crm for read-only CRM access; omitting scopes requests the full catalog at consent. Scopes remain bounded by the member's permissions and workspace settings.
  4. Connect, then call get_workspace_context to verify the selected workspace and effective permissions. Follow every tools/list cursor. Validate the specific client and workflow before relying on it; OAuth discovery alone is not end-to-end client verification.

For Vercel Connect, register https://connect.vercel.com/callback, use the issuer above as the OAuth server URL and the central /mcp URL as the resource. Use manual registration and test a user token with the method's configured default scopes before submitting. A successful Vercel connection has not yet been verified.

Connect native Claude Code

For a local, session-scoped HTTP connection, set STACKS_API_KEY privately in your environment and save this JSON as a temporary file outside source control. Use your own HTTPS workspace origin. Claude Code expands ${STACKS_API_KEY} in the header; set it before starting the client. Do not put a literal key in the JSON, a prompt or a repository.

{
  "mcpServers": {
    "stacks": {
      "type": "http",
      "url": "https://YOUR_WORKSPACE.stackscrm.com/mcp",
      "headers": {"Authorization": "Bearer ${STACKS_API_KEY}"}
    }
  }
}
claude --strict-mcp-config --mcp-config /path/to/stacks-mcp.local.json

This loads that MCP server for the session without adding a global connection. Start with a read-only key scoped to crm for simple CRM reads. get_contact_context includes invoice headers and requires both crm and invoicing read access, even if the contact has no invoices. Creating or completing a task needs a write-enabled key and the relevant task permission. Client tool allow/deny settings limit what that client may call; the server still enforces the key and account permissions on each request.

On 24 September 2026, native Claude Code 2.1.281 with claude-sonnet-5 connected to an authorized synthetic Stacks workspace. It read the public guide, created a contact, replayed the identical idempotent create to the same record, received a structured 422 for an empty task title, corrected it, completed a linked follow-up, and confirmed the saved task in both a task read and contact context. A separate read-only check confirmed that Claude Code blocked an unapproved tool under its client permission policy; this does not establish a server-side authorization test. This verifies the named workflow for that client, key and workspace only; it does not prove all tool operations, other clients, customer sends, workflow execution, provider delivery or payments.

Read the outcome before the next action

Tasks return is_completed and completed_at. After complete_task, read the task again to confirm completion. Repeating completion preserves its timestamp. Deals return status (open, won, or lost), plus won_at and lost_at; pipeline stage alone does not establish the outcome.

Contact tags is always an array, including [] when empty. Contact context includes at most 20 contact-record activity entries, newest first by created_at then id. These entries contain identifiers and action names, with no message bodies or metadata. This is a contact-record history, not every event across the workspace.

get_invoice (REST GET /api/v1/invoices/{id}) returns headers and totals. Use get_invoice_context (REST GET /api/v1/invoices/{id}/context) for line_items, payments, amount_paid and balance_due. Invoice and contact context require both CRM and invoicing access; a missing permission rejects the entire combined request.

Start with get_workspace_context (REST GET /api/v1/workspace): it returns the workspace, the credential you act as, the effective modules, the approval policy of each customer-facing send, which collections are empty, the technical limits and your remaining API units. Every tool definition carries annotations (read-only, destructive and idempotent hints), _meta.stacks (its REST twin, module, write/send requirement, side effect, retry rule and unit cost) and an outputSchema describing structuredContent.

Discover inputs with tools/list: check required, valid choices in enum, and conditional inputs in oneOf. A follow-up needs exactly one of contact_id or deal_id. Dates use YYYY-MM-DD. Omit assignee_id when calling assign_ticket to unassign it.

MCP business errors have isError: true and an error object in both text and structuredContent. It contains type, message, http_status, recovery and a request reference. HTTP 200 is not proof of business success. Correct invalid inputs; reload after an edit_conflict. Inspect the existing record and activity reference before retrying an uncertain write.

{"isError":true,"structuredContent":{"error":{"type":"invalid_params","message":"arguments.tag: required.","http_status":400,"recovery":"Correct the named input using the tool schema, then retry.","request_id":"EXAMPLE_REFERENCE"}}}

create_ticket creates the optional first message as an agent-authored public-thread entry (is_internal=false). Creation sends no email, whether or not it is linked to a contact. This tool does not create private notes or send customer replies. Words such as “internal” in the subject do not change visibility.

GET /api/v1/usage and get_api_usage expose API units and technical limits. Hosted AI credits are separate. Each plan has a monthly allowance of weighted units (5,000 on Free, 50,000 on AI Pro per UTC calendar month); once usage is above it, charged operations answer 429 usage_limit with the allowance, usage and reset date until the 1st. Rejected operations and successful idempotent replays consume zero units. The opt-in customer overview (GET /contacts/{id}/overview, get_contact_overview) returns only the requested sections, authorizes the whole section set before reading, pages each cohort with an opaque cursor bound to the calling credential, and refuses unsupported sections (communications, ordinary notes, remote review content) instead of returning empty lists; the legacy contact context is unchanged. API Activity shows safe error categories, status codes and a timezone label; an ok operation does not confirm email delivery.

Timestamp fields such as created_at, completed_at and appointment scheduled_at use UTC in YYYY-MM-DD HH:MM:SS format. Treat them as UTC even though the string has no offset. Exception: the opt-in customer overview (GET /contacts/{id}/overview, get_contact_overview) serializes every instant — as_of, count_as_of, created_at, updated_at, won_at, completed_at, scheduled_at, sent_at and the rest — as ISO-8601 UTC with the offset explicit, YYYY-MM-DDTHH:MM:SSZ; its calendar fields (due_date, issued_date, paid_at) stay YYYY-MM-DD. Calendar fields such as due_date and issued_date are dates and must not be shifted. The workspace timezone controls CRM display; changing it preserves booked moments. An appointment's timezone is the guest preference, not the timezone of its stored timestamp.

Available resource operations

ResourceCore operationsAdditional actions
contacts list, get, create, update, delete tag, untag, request_review, send_email, send_sms
companies list, get, create, update, delete tag, untag
deals list, get, create, update win, lose
tasks list, get, create, update complete
invoices list, get, create, update record_payment, send_invoice
products list, get, create, update, delete —
tickets list, get, create assign, set_status, set_priority
users list, get —
pipelines list, get —
appointment_types list, get —
appointments list, get —

The catalog lists supported operations, not your key’s permissions. Combined context reads require every included module. Appointments are read-only through these API resources; availability lookup does not book or reschedule a slot. Sending also requires the provider, current send rights and the account’s approval policy.

Customer overview (opt-in)

GET /api/v1/contacts/{id}/overview?sections=deals,tasks,billing&limit=10 and the MCP tool get_contact_overview read one contact’s profile, tags and authorized custom fields plus only the sections you request: company, deals, tasks, appointments, billing, support, reviews, timeline. List sections return bounded cohorts (newest IDs first, 1–20 items each, default 10, at most 160 items per call) with exact counts and an opaque per-cohort next_cursor; continue one cohort with the same contact, that single section and the same limit within 15 minutes. tasks adds a deterministic next_action and appointments a next_appointment, both chosen from the full matching set, not from a page. Every requested section needs its own read grant (billing needs invoicing, appointments scheduling, support, reviews); one missing grant refuses the whole call with no partial data. Not available in version 1 and refused explicitly if requested: communications, ordinary notes and remote review content. billing means the customer’s invoices and recorded payments, never your Stacks subscription; a sent review request is not a review; the response is a bounded live read, not a full history. The older GET /contacts/{id}/context and get_contact_context are unchanged. The OpenAPI document and MCP discovery describe the exact fields.

Retries, outcomes and limits

Create/update of contacts, companies, deals, tasks and products support an idempotency_key; REST also accepts Idempotency-Key. Use a random 16–128-character key per intended write. Reuse it after a timeout to retrieve the original result, including across REST and MCP. Different input with the same key conflicts. Updates can use if_match with the returned _version; conditional custom-field updates are not supported.

Other actions have their own recovery rules. A pending approval is not a sent message, provider acceptance is not delivery, and an unavailable paid booking can require staff resolution. Inspect the existing result before resubmitting an uncertain external send or payment.

REST and MCP share 120 requests per minute per key, four concurrent requests per key and eight per workspace. JSON bodies are limited to 1 MiB, lists to 100 records per page and MCP discovery to 32 tools per page. Large jobs should use bounded pages and stop when permissions or limits reject a request.

Five local workflow examples

The repository’s automated workflow fixture is reproducible in isolated Docker and uses synthetic data. It demonstrates lead/company/deal/task creation, bounded relationship context, invoice ledger/status, calendar record inspection, and an internal follow-up that sends no message. Payment callbacks and booking conflicts have separate synthetic-provider regressions. Those local fixtures do not establish provider-sandbox or other-client compatibility; the bounded native Claude Code check above is separate.

Read the examples and their boundaries · Built-in AI credits · Export coverage

Connect your own supported email, SMS and payment providers. Customer SMS requires your own Twilio account and sender on every plan. Saving credentials does not verify a successful send or payment.

Private integrations use their own scoped keys; hosted OAuth integrations require an operator-registered client and member consent. Availability in a third-party directory or compatibility with a particular client requires separate verification. Start a Free workspace.