Build on WoodSystems

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 account

The 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.

Get a key

  1. An admin opens Settings, Integrations in WoodSystems and creates an API key. They choose which user the key acts as and what it may do (its scopes).
  2. They give you the key and the account id. The key is shown once, when it is created.
  3. Keep the key on your server. Anyone who has it can act as the user it is assigned to, so never put it in browser code or a mobile app.

Live keys start wsk_live_. Sandbox keys start wsk_test_ and work only against the sandbox base URL.

Base URLs

EnvironmentREST APIMCP server
Livehttps://api.woodsystems.comhttps://mcp.woodsystems.com/mcp
Sandboxhttps://api-sandbox.woodsystems.comhttps://mcp-sandbox.woodsystems.com/mcp

Every path is under /v1.

Make your first call

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.

Authentication

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.

Who a call acts as

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.

Writes and idempotency

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"}]}'
  • The same key with the same body returns the first response again, with Idempotent-Replayed: true, instead of creating a second record. A timeout or an automatic retry cannot put the same customer in the pipeline twice.
  • The same key with a different body is refused (ERR_IDEMPOTENCY_MISMATCH).
  • A retry while the first request is still running gets ERR_IDEMPOTENCY_IN_PROGRESS.
  • Keys are remembered for 24 hours.

Pagination and sync

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.

Money, dates and versions

  • Money is a decimal string in major units ("1234.50") with a currency on the document. Never parse it as a float.
  • Timestamps are RFC 3339 in UTC (2026-10-06T14:22:05.412Z). Dates are YYYY-MM-DD.
  • Everything is under /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.

Errors

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.

Rate limits and usage

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.

Sandbox

The sandbox is a separate environment with its own data, for building and testing without touching the live account.

  • Sandbox keys start wsk_test_. An admin of the account creates them for you, as with live keys.
  • It starts from a copy of the account's setup (statuses, custom fields, forms, products, materials, vendors and the team), with no jobs, contacts, estimates, invoices or payments in it.
  • Nothing leaves it: emails and texts are recorded but never sent.
  • Webhooks from it carry "livemode": false and WoodSystems-Environment: sandbox.
  • Sandbox calls are not billed and have lower limits. A live key sent to the sandbox, or a sandbox key sent to live, is refused with ERR_WRONG_ENVIRONMENT.

Where next