API and integrations

For Zapier, Make, n8n and your own code. Base URL: https://api.cabinetproject.app/api/v1/public

Authentication

Create a token in the app under Settings > API Settings (company owner or a member with company edit permission). Pick only the permissions the integration needs. The token is shown once.

Authorization: Bearer YOUR_TOKEN

Connection test (works with any valid token):

curl -H "Authorization: Bearer YOUR_TOKEN" https://api.cabinetproject.app/api/v1/public/me
{"success":true,"data":{"company":{"id":"...","name":"Oak Kitchens"},"token":{"name":"Zapier","permissions":["projects.read"]}}}

Permissions:

PermissionAllows
projects.readView projects
projects.writeCreate and update projects
clients.readView clients
clients.writeCreate and update clients
quotes.readView quotes
orders.readView orders
invoices.readView invoices and their payments
webhooks.manageSubscribe and unsubscribe REST hooks (Zapier, Make)

Errors: 401 token_invalid / token_revoked, 403 insufficient_scope, 422 validation, 429 rate limit.

Rate limit: 60 requests per minute and 1,000 per hour per token. Past it the API answers 429 with error_code: rate_limit_exceeded, a Retry-After header and retry_after (seconds) in the body; wait that long and retry.

Resources

Method and pathPermission
GET /projects, GET /projects/{id}projects.read
POST /projects, PUT /projects/{id}projects.write
GET /clients, GET /clients/{id}clients.read
POST /clients, PUT /clients/{id}clients.write
GET /quotes, GET /quotes/{id}quotes.read
GET /orders, GET /orders/{id}orders.read
GET /invoices, GET /invoices/{id} (balance, billed lines and payments)invoices.read

Lists return {"data":{"items":[...],"meta":{"current_page","last_page","per_page","total"}}}, newest first, per_page up to 100.

List filters

All lists accept page, per_page (default 25, up to 100), created_after, created_before and updated_after (ISO 8601). Each one also takes:

ListFiltersInclude
GET /projectsstage, status, client_id, assigned_to (user id), search (title and description)with_client=1
GET /clientssource, search (name, email, phone, account)
GET /quotesstatus, project_idwith_project=1, with_specs=1
GET /ordersstatus, order_status, payment_status, project_id, quote_idwith_specs=1
GET /invoicesstatus (or overdue), project_id, client_id, order_id, due_after, due_before
curl -X POST https://api.cabinetproject.app/api/v1/public/clients \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Pat Homeowner","email":"pat@example.com","phone":"+15555550100"}'

Polling triggers (Zapier "New ..." triggers)

Every list is newest first, so a polling trigger reads the first page and keeps the ids it has not seen. Use updated_after (ISO 8601) on any list to find records that changed after they were created, such as a project that moved stage.

TriggerRequest
New leadGET /projects?stage=lead
New project / project changedGET /projects, GET /projects?updated_after=2026-10-15T00:00:00Z
New clientGET /clients
New quote / quote acceptedGET /quotes, GET /quotes?status=accepted
New order / order changedGET /orders, GET /orders?updated_after=2026-10-15T00:00:00Z
New invoice / invoice paid / overdueGET /invoices, GET /invoices?status=paid, GET /invoices?status=overdue

REST hooks (instant triggers)

Needs the webhooks.manage permission. A hook is a regular webhook: signed, retried and listed under Settings > API Settings > Webhooks.

# subscribe (Zapier performSubscribe)
curl -X POST https://api.cabinetproject.app/api/v1/public/hooks \
  -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" \
  -d '{"target_url":"https://hooks.zapier.com/hooks/standard/123/abc","event":"install.scheduled"}'
# -> 201 {"data":{"id":"HOOK_ID","event":"install.scheduled","target_url":"...","secret":"whsec_..."}}

# unsubscribe (performUnsubscribe)
curl -X DELETE https://api.cabinetproject.app/api/v1/public/hooks/HOOK_ID -H "Authorization: Bearer YOUR_TOKEN"

# sample for the trigger editor (performList): the latest real record, same shape as the delivery's "data"
curl "https://api.cabinetproject.app/api/v1/public/hooks/sample?event=install.scheduled" -H "Authorization: Bearer YOUR_TOKEN"

# list
curl https://api.cabinetproject.app/api/v1/public/hooks -H "Authorization: Bearer YOUR_TOKEN"

The target URL must be public HTTPS. Make: use a "Custom webhook" module URL as target_url, or create the webhook in the app.

Webhook deliveries

Each event is a POST with a JSON body:

{
  "id": "evt_01J...",            // same id on retries and replays: use it to deduplicate
  "event": "install.scheduled",
  "api_version": "v1",
  "created_at": "2026-10-01T14:03:11+00:00",
  "company_id": "...",
  "data": { ... },               // the record, see the events table
  "previous": { ... }            // only on change events: the old values
}

Headers: X-CabinetProject-Event, X-CabinetProject-Delivery and X-CabinetProject-Signature: t=TIMESTAMP,v1=HEX, where v1 is HMAC-SHA256 of TIMESTAMP + "." + raw body with the endpoint secret (whsec_...). Reject timestamps older than 5 minutes.

// PHP
[$t, $v1] = [null, null];
foreach (explode(',', $_SERVER['HTTP_X_CABINETPROJECT_SIGNATURE']) as $part) {
    [$k, $v] = explode('=', $part, 2);
    $k === 't' ? $t = $v : ($k === 'v1' ? $v1 = $v : null);
}
$body = file_get_contents('php://input');
$ok = abs(time() - (int) $t) <= 300
    && hash_equals(hash_hmac('sha256', $t.'.'.$body, $secret), $v1);

// Node
const [t, v1] = header.split(',').map(p => p.split('=')[1]);
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const ok = Math.abs(Date.now() / 1000 - Number(t)) <= 300
  && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));

Answer with any 2xx within 10 seconds. Anything else is retried 5 times (1 min, 5 min, 30 min, 2 h, 5 h); redirects are not followed. After 15 failed deliveries in a row the endpoint is disabled and the company owner is notified. Every delivery, with the response we got, is in the app's delivery log, where it can be replayed. The Test button sends event ping with the company's latest record of the endpoint's first event.

Events

EventWhendata is
quote.createdA new quote was createdquote
quote.sentA quote was sent to the clientquote
quote.acceptedA quote was acceptedquote
quote.rejectedA quote was rejectedquote
quote.expiredA quote expiredquote
quote.deposit_paidA quote deposit was paidquote
project.createdA new project was createdproject
project.stage_changedA project moved to a new stageproject
client.createdA new client was addedclient
client.updatedA client was updatedclient
invoice.paidAn invoice was marked paidinvoice
payment.receivedA payment was recordedpayment
order.createdA new order was created (sale closed)order
order.status_changedAn order changed statusorder
order.purchase_status_changedThe order to the manufacturer changed status (ordered, confirmed, in transit, delivered)order
lead.createdA new lead came in (a job created at the lead stage)project
install.scheduledAn install was put on the calendar or moved to another dateinstall (calendar activity)
install.completedAn install was marked completedinstall (calendar activity)

Payload fields

RecordFields
project / leadid, client_id, client {id, type, name, account_name, email, phone, city, state, country, source}, stage, status, title, code, priority, source, estimated_value, quoted_at, approved_at, production_started_at, installed_at, completed_at, deadline, created_at, updated_at
quoteid, number, project_id, status, currency, subtotal, tax, total, deposit_required, deposit_paid, valid_until, accepted_at, accepted_by_name, accepted_by_email, sent_at, created_at, updated_at, lines[{sku, qty, hinge, room, name}]
clientid, type, name, account_name, email, phone, city, state, country, source, created_at, updated_at
invoiceid, number, project_id, client_id, invoice_type, status, currency, subtotal, tax, total, issue_date, due_date, sent_at, paid_at, created_at, updated_at
paymentid, number, invoice_id, project_id, quote_id, amount, refunded_amount, status, payment_method, payment_date, gateway, created_at, updated_at
orderid, number, name, project_id, quote_id, status, payment_status, currency, subtotal, tax, discount_amount, total, amount_paid, balance_due, sale_date, salesperson, lead_name, account_name, order_status, dealer_po, manufacturer_id, eta, sent_at, created_at, updated_at
installid, type, status, project_id, project_title, client {...}, scheduled_date (YYYY-MM-DD), scheduled_time (HH:MM, local), end_time, duration_minutes, timezone, address, assigned_to (name), notes, completed_at, created_at, updated_at

A quote's lines are the boxes an integrator can order: SKU, quantity, hinge, room and name. The list (GET /quotes) and the quote.* webhook use that shape. Prices and labor without a SKU stay off it. GET /quotes/{id} also returns the stored line_items.

GET /invoices adds order_id, discount_amount, amount_paid and balance_due to the invoice fields, so an invoice.paid zap can post the payment to Xero or QuickBooks Desktop. GET /invoices/{id} also returns lines[{sku, name, qty, unit_price, amount}] (what the client is billed, never your cost) and payments in the payment shape.

previous: quote.* status changes and order.status_changed carry {"status": old}; order.purchase_status_changed carries {"order_status": old}; a moved install.scheduled carries {"scheduled_date", "scheduled_time"}; install.completed carries {"status": old}; project.stage_changed carries {"stage": old}; client.updated carries the changed fields' old values.

New leads from forms (inbound)

Any form, Zapier or Make can create a lead without a token: in the app, Settings > Lead sources gives a URL like https://api.cabinetproject.app/api/v1/lead-intake/SOURCE_TOKEN that takes a POST with any fields (name, email, phone, address, message); unknown field names can be mapped in the app.