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:
| Permission | Allows |
|---|---|
projects.read | View projects |
projects.write | Create and update projects |
clients.read | View clients |
clients.write | Create and update clients |
quotes.read | View quotes |
orders.read | View orders |
invoices.read | View invoices and their payments |
webhooks.manage | Subscribe 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 path | Permission |
|---|---|
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:
| List | Filters | Include |
|---|---|---|
GET /projects | stage, status, client_id, assigned_to (user id), search (title and description) | with_client=1 |
GET /clients | source, search (name, email, phone, account) | |
GET /quotes | status, project_id | with_project=1, with_specs=1 |
GET /orders | status, order_status, payment_status, project_id, quote_id | with_specs=1 |
GET /invoices | status (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.
| Trigger | Request |
|---|---|
| New lead | GET /projects?stage=lead |
| New project / project changed | GET /projects, GET /projects?updated_after=2026-10-15T00:00:00Z |
| New client | GET /clients |
| New quote / quote accepted | GET /quotes, GET /quotes?status=accepted |
| New order / order changed | GET /orders, GET /orders?updated_after=2026-10-15T00:00:00Z |
| New invoice / invoice paid / overdue | GET /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
| Event | When | data is |
|---|---|---|
quote.created | A new quote was created | quote |
quote.sent | A quote was sent to the client | quote |
quote.accepted | A quote was accepted | quote |
quote.rejected | A quote was rejected | quote |
quote.expired | A quote expired | quote |
quote.deposit_paid | A quote deposit was paid | quote |
project.created | A new project was created | project |
project.stage_changed | A project moved to a new stage | project |
client.created | A new client was added | client |
client.updated | A client was updated | client |
invoice.paid | An invoice was marked paid | invoice |
payment.received | A payment was recorded | payment |
order.created | A new order was created (sale closed) | order |
order.status_changed | An order changed status | order |
order.purchase_status_changed | The order to the manufacturer changed status (ordered, confirmed, in transit, delivered) | order |
lead.created | A new lead came in (a job created at the lead stage) | project |
install.scheduled | An install was put on the calendar or moved to another date | install (calendar activity) |
install.completed | An install was marked completed | install (calendar activity) |
Payload fields
| Record | Fields |
|---|---|
| project / lead | id, 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 |
| quote | id, 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}] |
| client | id, type, name, account_name, email, phone, city, state, country, source, created_at, updated_at |
| invoice | id, number, project_id, client_id, invoice_type, status, currency, subtotal, tax, total, issue_date, due_date, sent_at, paid_at, created_at, updated_at |
| payment | id, number, invoice_id, project_id, quote_id, amount, refunded_amount, status, payment_method, payment_date, gateway, created_at, updated_at |
| order | id, 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 |
| install | id, 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.