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 enquiriesleads:write— Create enquiriescustomers:read— Read customersprojects:read— Read projectsinvoices:read— Read invoices
Nothing here can price, approve, send, invoice or take a payment. Those are decisions a person makes in the application.
Endpoints
| Method | Path | Scope |
|---|---|---|
| GET | /api/v1/leads | leads:read |
| POST | /api/v1/leads | leads:write |
| GET | /api/v1/leads/{id} | leads:read |
| GET | /api/v1/customers | customers:read |
| GET | /api/v1/customers/{id} | customers:read |
| GET | /api/v1/projects | projects:read |
| GET | /api/v1/projects/{id} | projects:read |
| GET | /api/v1/invoices | invoices:read |
| GET | /api/v1/invoices/{id} | invoices:read |
- Pages are newest first, up to 100 at a time: pass
limit, then thenextCursoryou were given ascursor. - 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-Remainingon every answer andRetry-Afteron a 429. - New leads need a first name and an email or phone. Their source is always
APIand their statusNEW— 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.
