API reference

Read a contractor's leads, customers, projects and invoices, record new enquiries, and hear about what happens through signed webhooks. The machine-readable description is at /api/v1/openapi.json.

Keys

The business owner issues a key in Settings → Developers and sees it once. Send it on every request:

Authorization: Bearer cos_…

A key acts with the permissions of the person who issued it, narrowed to its scopes, and stops working if that person leaves the business. Every refusal of a key is the same 401. A record belonging to another business is a 404, never a 403. Browsers cannot call this API: it is for servers, and it reads no cookies.

  • leads:read — Read enquiries
  • leads:write — Create enquiries
  • customers:read — Read customers
  • projects:read — Read projects
  • invoices:read — Read invoices

Nothing here can price, approve, send, invoice or take a payment. Those are decisions a person makes in the application.

Endpoints

MethodPathScope
GET/api/v1/leadsleads:read
POST/api/v1/leadsleads:write
GET/api/v1/leads/{id}leads:read
GET/api/v1/customerscustomers:read
GET/api/v1/customers/{id}customers:read
GET/api/v1/projectsprojects:read
GET/api/v1/projects/{id}projects:read
GET/api/v1/invoicesinvoices:read
GET/api/v1/invoices/{id}invoices:read
  • Pages are newest first, up to 100 at a time: pass limit, then the nextCursor you were given as cursor.
  • Money is a string of minor units (cents) beside its currency, so nothing is lost to floating point. "125050" is $1,250.50.
  • Rate: 120 requests a minute per key, with X-RateLimit-Remaining on every answer and Retry-After on a 429.
  • New leads need a first name and an email or phone. Their source is always API and their status NEW — triage stays with a person.
curl -X POST https://your-app/api/v1/leads \
  -H "Authorization: Bearer cos_…" -H "Content-Type: application/json" \
  -d '{"firstName":"Pat","lastName":"Caller","phone":"555-0101","requestSummary":"Gutters overflowing"}'

Webhooks

Add an https address in Settings → Developers and choose events. Within a minute or two of something happening we post a thinevent — what happened and the record's id, never the record. Read the details through the API.

{ "id": "cm…", "type": "invoice.issued", "createdAt": "2026-09-11T14:02:11.000Z",
  "data": { "object": "invoice", "id": "cm…" } }
  • Answer 2xx within ten seconds. Failures are retried for about a day, then kept as abandoned.
  • Twenty failures in a row switches the endpoint off, with the reason on the settings screen.
  • Redirects are not followed. Private network addresses are refused.
  • The same event id can arrive twice. Treat it as the same event.

Checking the signature

The ConstructionOS-Signature header is t=<unix seconds>,v1=<hex>: an HMAC-SHA256 of t.body with your endpoint's secret. Refuse anything older than 300 seconds.

import { createHmac, timingSafeEqual } from 'node:crypto';

// header: the ConstructionOS-Signature value, e.g. "t=1790000000,v1=5f2b…"
// body:   the raw request body, exactly as received — not re-serialised JSON
export function verify(secret, body, header, nowSeconds = Math.floor(Date.now() / 1000)) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(nowSeconds - t) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${body}`).digest('hex');
  const given = String(parts.v1 ?? '');
  return given.length === expected.length &&
    timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(given, 'hex'));
}

Events

  • lead.created — An enquiry was recorded — by a person, a form or the API.
  • lead.converted — An enquiry became a customer with a property.
  • customer.created — A customer record was created.
  • customer.updated — A customer record changed.
  • opportunity.won — A deal was closed as won.
  • opportunity.lost — A deal was closed as lost, with a reason.
  • proposal.sent — A proposal was published to the customer.
  • proposal.accepted — The customer accepted a proposal.
  • proposal.declined — The customer declined a proposal.
  • project.created — Work was awarded and a project exists.
  • project.status_changed — A project moved to a new stage.
  • change_order.approved — The customer approved a change order.
  • invoice.issued — An invoice was issued with its permanent number.
  • invoice.voided — An issued invoice was voided.
  • payment.succeeded — A payment settled.
  • payment.refunded — A payment was refunded.
  • review.submitted — A customer published a review of a finished job.