Build on WoodSystems
The REST API, webhooks and the MCP server for WoodSystems, the ERP for custom cabinet and millwork shops.
The REST API, webhooks and the MCP server for WoodSystems, the ERP for custom cabinet and millwork shops.
Building an app for WoodSystems shops? A developer account is free and comes with a sandbox shop of its own, set up with demo data.
Create a developer accountThe WoodSystems API lets your own systems read and write a WoodSystems account: jobs and leads, contacts, tasks, calendar, estimates, invoices, files, work orders, materials, purchasing and products. Webhooks tell your systems when something changes, and the MCP server lets AI tools such as Claude, ChatGPT and Cursor work in the account as the person who connects them.
The API is part of the API & Integrations add-on, a separate charge for every account, Enterprise included. See limits and pricing.
Live keys start wsk_live_. Sandbox keys start wsk_test_ and work only against the sandbox base URL.
| Environment | REST API | MCP server |
|---|---|---|
| Live | https://api.woodsystems.com | https://mcp.woodsystems.com/mcp |
| Sandbox | https://api-sandbox.woodsystems.com | https://mcp-sandbox.woodsystems.com/mcp |
Every path is under /v1.
GET /v1/account is the cheapest way to check that a key works, that it points at the right account and which scopes it holds.
curl https://api.woodsystems.com/v1/account \
-H "X-Api-Key: $WOODSYSTEMS_API_KEY" \
-H "X-Tenant-Id: $WOODSYSTEMS_ACCOUNT_ID"
import os
import requests
response = requests.get(
"https://api.woodsystems.com/v1/account",
headers={
"X-Api-Key": os.environ["WOODSYSTEMS_API_KEY"],
"X-Tenant-Id": os.environ["WOODSYSTEMS_ACCOUNT_ID"],
},
timeout=30,
)
response.raise_for_status()
print(response.json())
const response = await fetch('https://api.woodsystems.com/v1/account', {
headers: {
'X-Api-Key': process.env.WOODSYSTEMS_API_KEY,
'X-Tenant-Id': process.env.WOODSYSTEMS_ACCOUNT_ID,
},
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
console.log(await response.json());
The answer names the account, the user the key acts as and the key's scopes:
{
"account_name": "Kelvin's Kustom Kabinets",
"actor": {
"money_visible": true,
"name": "Rob Owner",
"role": "owner",
"user_id": "e1d2c3b4-a596-4877-8695-a4b3c2d1e0f9"
},
"credential": "api_key",
"environment": "live",
"key_name": "Website contact form",
"scopes": ["leads:write", "meta:read"],
"tenant_id": "5c8d2e1f-3a4b-4c6d-8e9f-0a1b2c3d4e5f"
}
Next, look up the account's own ids under GET /v1/meta/* (statuses, types, departments, custom fields, users) before you write anything. Ids are UUID strings, and names are never accepted in their place.
Send the key one of two ways:
X-Api-Key: wsk_live_... together with X-Tenant-Id: <account id>. The account id is checked against the key, so pasting one account's key into another account's integration fails at once instead of filing records in the wrong place.Authorization: Bearer wsk_live_..., with no X-Tenant-Id.Connected apps that sign in through OAuth, such as an MCP client, send Authorization: Bearer wsa_... instead. They can never manage the account or delete anything.
Every call acts as the user the key is assigned to, with that user's current role. Money fields come back blank for roles that cannot see costs in the app, and a write the role may not make in the app is refused here too (ERR_ROLE_REQUIRED). If that user is deactivated the key stops working (ERR_ACTOR_INACTIVE) until an admin assigns it to someone else.
Every POST needs an Idempotency-Key header: any unique string per submission, such as a UUID or your own record id.
curl -X POST https://api.woodsystems.com/v1/jobs \
-H "X-Api-Key: $WOODSYSTEMS_API_KEY" \
-H "X-Tenant-Id: $WOODSYSTEMS_ACCOUNT_ID" \
-H "Idempotency-Key: 6f1d2c9a-website-enquiry-4410" \
-H "Content-Type: application/json" \
-d '{"name": "Whitfield kitchen remodel", "stage": "opportunity", "contacts": [{"first_name": "Dana", "last_name": "Whitfield", "email": "dana.whitfield@example.com"}]}'
Idempotent-Replayed: true, instead of creating a second record. A timeout or an automatic retry cannot put the same customer in the pipeline twice.ERR_IDEMPOTENCY_MISMATCH).ERR_IDEMPOTENCY_IN_PROGRESS.Lists return a page at a time:
{ "data": [], "next_cursor": "eyJ2IjoxfQ", "has_more": true }
Pass limit for the page size (1 to 200, default 50) and cursor with the previous page's next_cursor to continue. Cursors are opaque; do not build them yourself.
Most lists also take updated_since (RFC 3339). It returns what changed at or after that time, oldest first, which is how you keep another system in step without reading everything again. The reference shows which lists take it.
"1234.50") with a currency on the document. Never parse it as a float.2026-10-06T14:22:05.412Z). Dates are YYYY-MM-DD./v1, and changes to it are additive: new optional fields and new endpoints may appear, so ignore what you do not recognise. Nothing is removed from /v1. A change that cannot be made that way becomes /v2, and /v1 keeps working for at least a year after. See the changelog.Every error has the same shape:
{
"detail": "A sentence a person can read.",
"code": "ERR_SCOPE_MISSING",
"request_id": "the id to quote to support"
}
Branch on code, show detail to a person, and quote request_id to support. Validation errors add an errors list with one entry per field. Every code is listed on the errors page.
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Over the limit you get 429 with a Retry-After header; wait that many seconds and try again. Calls also count toward the account's monthly included calls (X-Quota-Limit, X-Quota-Used). See limits and pricing for the numbers and what counts as a call.
The sandbox is a separate environment with its own data, for building and testing without touching the live account.
wsk_test_. An admin of the account creates them for you, as with live keys."livemode": false and WoodSystems-Environment: sandbox.ERR_WRONG_ENVIRONMENT.