Apps built with Lovable, v0, Bolt or Replit can't connect to an MCP server while they run. They call HTTP. The Builder API gives them the same Workspace369 tools as the MCP server, over plain HTTPS and JSON.
It runs the same tools as MCP, so it has the same permissions, approvals, audit log and usage. A call made through it is billed exactly like the same call made over MCP.
Who can use it
- The Specialist plan and up (Specialist, Commander, Enterprise). On any lower plan (Cadet, Voyager) calls return
403withplan_required. MCP itself works on every plan. - An admin must turn on AI connections for the workspace.
- You need a personal access token (it starts with
mcp_pat_). Sign-in tokens from Claude or ChatGPT don't work here. - The token's scopes decide what it can do. A call outside its scopes returns
403withinsufficient_scope, anddetails.required_scopenames the scope it needs.
Keep the token on your server. Never put it in browser code or a public repo.
Base URL
https://mcp.workspace369.com/v1
The full spec is public (no token needed) at GET /v1/openapi.json. Point your code generator at it. The spec lists paths only; set the base URL https://mcp.workspace369.com in your generator.
Two ways to call
Any tool by name: POST /v1/tools/{name} with the tool's input as the JSON body. The answer is the tool's output. Keys the tool doesn't know are ignored. The tool names are in the MCP tools reference.
curl -s https://mcp.workspace369.com/v1/tools/clients_search \
-H "Authorization: Bearer $W369_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"acme"}'
Some tools run only inside the app and can't be called here (batch_apply, and the workflow tools automations_create_draft, automations_activate, automations_retry). They return 404 with tool_not_found.
Resource paths: shortcuts onto the same tools.
| Method and path | Tool | Scope |
|---|---|---|
GET /v1/clients, GET /v1/clients/{id} | clients_search, clients_get | clients:read |
POST /v1/clients, PATCH /v1/clients/{id} | clients_create, clients_update | clients:write |
GET /v1/proposals, GET /v1/proposals/{id} | proposals_search, proposals_get | proposals:read |
POST /v1/proposals, PATCH /v1/proposals/{id} | proposals_create_draft, proposals_update_draft | proposals:write |
POST /v1/proposals/{id}/send | proposals_send | proposals:write |
GET /v1/invoices, GET /v1/invoices/{id} | invoices_search, invoices_get | invoices:read |
POST /v1/invoices, PATCH /v1/invoices/{id} | invoices_create_draft, invoices_update_draft | invoices:write |
POST /v1/invoices/{id}/send | invoices_send | invoices:write |
GET /v1/contracts, GET /v1/contracts/{id} | contracts_search, contracts_get | contracts:read |
POST /v1/contracts | contracts_create_draft | contracts:write |
POST /v1/contracts/{id}/send | contracts_send | contracts:send |
GET /v1/projects, GET /v1/projects/{id} | projects_search, projects_get | projects:read |
POST /v1/projects, PATCH /v1/projects/{id} | projects_create, projects_update | projects:write |
GET /v1/tasks, GET /v1/tasks/{id} | tasks_search, tasks_get (also needs ?board_id=) | tasks:read |
POST /v1/tasks, PATCH /v1/tasks/{id} | tasks_create, tasks_update | tasks:write |
GET /v1/requests, GET /v1/requests/{id} | requests_search, requests_get | requests:read |
PATCH /v1/requests/{id} | requests_update_status | requests:write |
GET /v1/calendar/events | calendar_list_events | calendar:read |
POST /v1/calendar/events, PATCH /v1/calendar/events/{id} | calendar_create_event, calendar_update_event | calendar:write |
There is no PATCH for contracts, no POST for requests, and no DELETE anywhere. Use POST /v1/tools/{name} for any tool without a shortcut.
On GET, put the tool's arguments in the query string. Numbers and true/false are read as such, and a list is comma-separated. The {id} in the path always wins over an id in the body or query.
A successful call returns 200 with the tool's output as JSON. A get for an id that doesn't exist also returns 200, with the record as null, so check for it.
Sends wait for approval
Sending a proposal, contract, invoice or message returns 202 with the approval details. Nothing goes out until a person approves it in the app. A few other actions that reach a customer are held the same way (bulk messages, contract reminders and voids, replying to a request, pay links).
{
"held_for_approval": true,
"approval_id": "2b89220a51485f343f1ae00407384707",
"approval_url": "https://app.workspace369.com/settings/approvals/2b89220a...",
"approval_expires_at": "2026-10-21T06:24:27.070Z"
}
Show "awaiting approval" in your app for a 202. To check the result later, call the approvals_get tool with the approval_id. If the person who connected turned on "send without asking", the send runs at once and you get 200.
Pass a stable idempotency_key on sends so a retry doesn't send twice.
Errors
Every error has the same shape: {"error":{"code","message","details"}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json | The body isn't valid JSON. Some malformed bodies are rejected before they reach the API and get a plain 400 Bad Request with no JSON body, so treat any 400 as "fix the request". |
| 401 | unauthorized | Missing or invalid token. |
| 402 | usage_exhausted | The workspace is out of Tokens. |
| 403 | insufficient_scope | The token lacks a scope; details.required_scope names it. |
| 403 | forbidden | details.reason is permission_denied (the person lacks the permission), mcp_not_enabled (AI connections are off) or app_not_approved. |
| 403 | plan_required | The workspace is below the Specialist plan. |
| 404 | not_found | No such path. A record that doesn't exist is not an error: a get returns 200 with null (for example {"client":null}). |
| 404 | tool_not_found | No such tool, or one that only runs inside the app. |
| 409 | conflict | The record isn't in a state this call can act on. |
| 413 | payload_too_large | The body is over 1 MB. |
| 422 | invalid_request | The input failed validation; details.issues lists each problem. |
| 422 | tool_error | The input was valid but the tool refused it; message says why. |
| 429 | rate_limited | Too many requests. Wait for Retry-After seconds (also in details.retry_after_seconds). Also returned when too many approvals are waiting. |
| 500 | internal_error | Something failed on our side. Not charged. |
| 503 | service_unavailable, tool_disabled | Try again shortly. tool_disabled means that one tool is switched off for now. |
Rate limits: 120 requests a minute per token, 600 a minute per workspace. The limit is shared with your MCP calls.