BUILTCRM / DEVELOPERS
BUILTCRM API
Each BuiltCRM build runs on your own domain, so your base URL will be https://crm.yourcompany.com/api/v1 (your domain). API keys are created by an admin inside your CRM.
The API is meant for Zapier, Make and custom tools. All requests and responses use JSON (Content-Type: application/json). Times are ISO 8601 in UTC unless a field says it is local.
1. Authentication
Every request needs an API key. An Admin creates keys in Settings → API keys. The full key is shown only once, when it is created. Keys look like td_ followed by 48 hex characters.
Send the key in either header:
Authorization: Bearer td_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
x-api-key: td_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- Only a hash of each key is stored. A lost key can't be recovered; create a new one.
- An Admin can revoke a key at any time. Revoked keys stop working right away (
401 revoked_api_key). - Every request is logged with the key name, method, endpoint, status, IP and duration.
- Records changed through the API appear in the job activity feed as
API · <key name>.
CORS is open (Access-Control-Allow-Origin: *). Allowed methods are GET, POST, PATCH, OPTIONS. Allowed headers are Content-Type, Authorization, x-api-key.
2. Scopes (permissions)
Each key holds one or more scopes. A call to an endpoint outside the key's scopes returns 403 insufficient_scope.
| Scope | Allows | Available |
|---|---|---|
customers:read | Read / find customers | Yes |
customers:write | Create / update customers | Yes |
jobs:read | Read jobs, check availability | Yes |
jobs:create | Create jobs | Yes |
jobs:reschedule | Reschedule jobs | Yes |
jobs:cancel | Cancel jobs | Yes |
estimates:read | Read estimates | Yes |
estimates:create | Create estimates | Not yet |
invoices:read | Read invoices | Yes |
invoices:create | Create invoices | Not yet |
voice | Voice booking agent (all /api/public/voice/* endpoints) | Yes |
3. Rate limits
- 60 requests per minute per key (rolling 60-second window).
- Requests over the limit return
429 rate_limitedwith the headerRetry-After: 60.
4. Errors
Errors always use this format:
{ "error": "Human-readable message", "code": "machine_code" }
General errors
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_json | Request body isn't valid JSON |
| 400 | validation_error | A field is missing or invalid (message names the field) |
| 400 | invalid_id | id must be a UUID |
| 400 | invalid_phone | Not a valid US phone number / fewer than 7 digits in search |
| 400 | invalid_date | Date isn't ISO 8601 (or YYYY-MM-DD for availability) |
| 400 | invalid_status | Unknown job status filter |
| 400 | invalid_query | Invalid filter on estimates/invoices |
| 400 | invalid_update | Customer update was rejected |
| 401 | missing_api_key | No key sent |
| 401 | invalid_api_key | Key not recognised |
| 401 | revoked_api_key | Key has been revoked |
| 403 | insufficient_scope | Key lacks the scope for this endpoint |
| 403 | phone_mismatch | caller_phone doesn't match any phone on the job's customer |
| 404 | not_found | Record not found |
| 404 | customer_not_found | customer_id on job create not found |
| 404 | endpoint_not_found | Unknown path |
| 405 | method_not_allowed | Method not supported on this resource |
| 409 | duplicate_customer | A customer with this phone already exists (message includes its id) |
| 409 | phone_in_use | Phone belongs to a different customer |
| 429 | rate_limited | Over 60 requests/minute |
| 500 | internal_error | Unexpected server error |
Scheduling errors (create, reschedule, cancel)
| HTTP | code | Meaning |
|---|---|---|
| 400 | missing_zip | Scheduling needs a 5-digit ZIP (zip or a visit_address ending in one) |
| 400 | zip_not_serviced | No technician covers that ZIP |
| 400 | invalid_technician | technician_id isn't an active team member |
| 409 | outside_business_hours | Window is outside business hours |
| 409 | lead_time_violation | Window starts less than 60 minutes from now |
| 409 | window_full / fully_booked | No technician has room in that window |
| 409 | no_technician_working_this_day | Nobody covering the ZIP works that day |
| 409 | technician_not_covering_zip | Requested technician doesn't cover the ZIP |
| 409 | technician_not_working_this_day | Requested technician is off that day |
| 409 | technician_window_full | Requested technician already has 2 jobs in that window |
| 409 | already_cancelled | Job is already cancelled |
| 409 | already_completed | Completed jobs can't be cancelled or rescheduled |
| 409 | multi_technician_job | Job has several technicians; must be changed in the app or by the office |
Scheduling rules
- Business hours are 08:00–19:00 local (the business time zone).
- Arrival windows are 3 hours long. The last window starts at 16:00 local.
- A window must start at least 60 minutes from now.
- Each technician can have at most 2 jobs per window. A job shared by several technicians counts for each of them.
5. Pagination
List endpoints accept:
| Param | Default | Range |
|---|---|---|
limit | 50 | 1–200 |
offset | 0 | ≥ 0 |
List responses look like { "data": [...], "count": <total matching> }. Single-record responses look like { "data": { ... } }.
6. Endpoints
6.1 GET /me
Scope: any valid key. Checks that a key works.
curl https://crm.yourcompany.com/api/v1/me \
-H "Authorization: Bearer td_YOUR_API_KEY"
{ "data": { "key_name": "Zapier", "scopes": ["customers:read", "jobs:read"] } }
6.2 GET /customers
Scope: customers:read. Lists or searches customers, newest first.
| Query param | Description |
|---|---|
phone | Matches the last 10 digits against all of the customer's phones (formatting ignored; at least 7 digits) |
email | Exact match, case-insensitive |
name | Partial match |
q | Partial match on name, email, address or phone |
limit, offset | Pagination |
curl "https://crm.yourcompany.com/api/v1/customers?phone=555-010-0000" \
-H "x-api-key: td_YOUR_API_KEY"
{
"data": [
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "Jane Customer",
"phone": "+15550100000",
"email": "jane@example.com",
"address": "123 Example St, Phoenix, AZ 85001",
"notes": null,
"is_active": true,
"created_at": "2026-10-01T17:00:00.000Z",
"client_phones": [
{ "type": "cell", "phone_number": "+15550100000", "is_primary": true }
],
"client_addresses": [
{ "label": "Service", "address": "123 Example St, Phoenix, AZ 85001", "is_primary": true }
]
}
],
"count": 1
}
6.3 GET /customers/{id}
Scope: customers:read. Returns { "data": <customer> } in the same shape as above. Returns 404 not_found if the customer doesn't exist.
6.4 POST /customers
Scope: customers:write. Creates a customer. Returns 201.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string (1–200) | Yes | |
phone | string | No | US number, stored as E.164 |
phone_type | cell | home | alternative | No | Default cell |
email | string | No | Lower-cased |
address | string (≤500) | No | Saved as primary address |
address_label | string (≤60) | No | Default Service |
notes | string (≤5000) | No |
curl -X POST https://crm.yourcompany.com/api/v1/customers \
-H "Authorization: Bearer td_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Jane Customer","phone":"(555) 010-0000","email":"jane@example.com","address":"123 Example St, Phoenix, AZ 85001"}'
{ "data": { "id": "00000000-0000-0000-0000-000000000001", "name": "Jane Customer", "...": "customer fields" }, "created_by": "API · Zapier" }
Returns 409 duplicate_customer if the phone already belongs to a customer. Use PATCH on that customer instead.
6.5 PATCH /customers/{id}
Scope: customers:write. Send at least one field.
| Field | Type | Notes |
|---|---|---|
name | string | |
email | string | null | |
notes | string | null | |
is_active | boolean | Deactivate / reactivate |
add_phone | { phone, type?, is_primary? } | Adds a phone; primary also updates main phone |
add_address | { address, label?, is_primary? } | Adds an address |
{ "email": "jane.new@example.com", "add_phone": { "phone": "555-010-0001", "type": "home" } }
Response:
{
"data": { "id": "00000000-0000-0000-0000-000000000001", "...": "customer fields" },
"changes": ["email jane@example.com → jane.new@example.com", "phone +15550100001 added"]
}
Changes are logged on the customer's most recent job. Returns 409 phone_in_use if the phone belongs to another customer.
6.6 GET /jobs
Scope: jobs:read. Lists jobs, newest first.
| Query param | Description |
|---|---|
status | Comma list of new, scheduled, in_progress, completed, cancelled |
customer_id | UUID |
technician_id | UUID (lead technician) |
job_number | Exact |
scheduled_from, scheduled_to | ISO 8601 range on scheduled_at |
limit, offset | Pagination |
{
"data": [
{
"id": "00000000-0000-0000-0000-000000000010",
"job_number": "10001",
"client_id": "00000000-0000-0000-0000-000000000001",
"technician_id": "00000000-0000-0000-0000-000000000100",
"appliance_type": "Refrigerator",
"description": "Not cooling",
"status": "scheduled",
"scheduled_at": "2026-10-08T15:00:00.000Z",
"end_scheduled_at": "2026-10-08T18:00:00.000Z",
"is_anytime": false,
"arrival_window_hours": 3,
"visit_address": "123 Example St, Phoenix, AZ 85001",
"created_at": "2026-10-01T17:05:00.000Z",
"clients": { "id": "00000000-0000-0000-0000-000000000001", "name": "Jane Customer", "phone": "+15550100000", "email": "jane@example.com" },
"profiles": { "id": "00000000-0000-0000-0000-000000000100", "name": "Tech One" },
"technicians": [
{ "id": "00000000-0000-0000-0000-000000000100", "name": "Tech One", "is_lead": true },
{ "id": "00000000-0000-0000-0000-000000000101", "name": "Tech Two", "is_lead": false }
]
}
],
"count": 1
}
technician_id and profiles hold the lead technician. technicians lists the whole crew, lead first.
6.7 GET /jobs/{id}
Scope: jobs:read. Returns { "data": <job> }.
6.8 GET /availability
Scope: jobs:read. Lists open arrival windows for a ZIP and date. Uses the same logic as the voice agent.
| Query param | Required | Description |
|---|---|---|
date | Yes | YYYY-MM-DD |
zip | One of | 5-digit ZIP |
address | One of | Address containing a ZIP |
curl "https://crm.yourcompany.com/api/v1/availability?date=2026-10-08&zip=85001" \
-H "x-api-key: td_YOUR_API_KEY"
{
"data": {
"date": "2026-10-08",
"zip": "85001",
"serviced": true,
"reason": null,
"timezone": "America/Phoenix",
"now": "2026-10-07T20:00:00.000Z",
"today_local": "2026-10-07",
"current_local_time": "13:00",
"rules": {
"business_hours_local": "8:00–19:00",
"arrival_window_hours": 3,
"last_window_start_hour_local": 16,
"min_lead_time_minutes": 60,
"max_jobs_per_tech_per_window": 2
},
"total_technicians_covering_zip": 3,
"technicians_scheduled_on_date": 2,
"windows": [
{
"start": "2026-10-08T15:00:00.000Z",
"end": "2026-10-08T18:00:00.000Z",
"start_local_time": "08:00",
"end_local_time": "11:00",
"label": "Thu, Oct 8, 8:00 AM – 11:00 AM",
"available_technicians": [
{ "id": "00000000-0000-0000-0000-000000000100", "name": "Tech One", "service_region": "REGION" }
]
}
]
}
}
Pass a window's start as scheduled_at when creating or rescheduling a job.
6.9 POST /jobs
Scope: jobs:create. Creates a job. Returns 201.
| Field | Type | Required | Notes |
|---|---|---|---|
customer_id | UUID | Yes | |
appliance_type | string (≤40) | No | Normalised (e.g. "fridge" → Refrigerator) |
description | string (≤5000) | No | |
visit_address | string (≤500) | No | Matched to a saved address, or added. Default: primary address |
zip | string | No | Needed for scheduling if the address has no ZIP |
scheduled_at | ISO 8601 | No | Window start. Omit to create an unscheduled new job |
technician_id | UUID | No | Preferred technician. Otherwise one is assigned automatically |
notify_customer | boolean | No | Default true. Sends the booking text when scheduled |
{
"customer_id": "00000000-0000-0000-0000-000000000001",
"appliance_type": "Refrigerator",
"description": "Not cooling",
"scheduled_at": "2026-10-08T15:00:00.000Z"
}
The response is the job, plus Google Calendar and text results:
{
"data": { "id": "00000000-0000-0000-0000-000000000010", "job_number": "10001", "status": "scheduled", "...": "job fields" },
"calendar": { "status": "synced" },
"sms": { "ok": true }
}
6.10 POST /jobs/{id_or_job_number}/reschedule
Scope: jobs:reschedule. {id_or_job_number} can be the job's UUID or its job number.
| Field | Type | Required | Notes |
|---|---|---|---|
scheduled_at | ISO 8601 | Yes | New window start |
technician_id | UUID | null | No | Preferred technician |
notify | boolean | No | Default true. Texts the customer and technician(s) |
caller_phone | string | No | If sent, must match a phone on the job's customer (else 403 phone_mismatch) |
{ "scheduled_at": "2026-10-09T18:00:00.000Z", "caller_phone": "+15550100000" }
{
"data": { "id": "00000000-0000-0000-0000-000000000010", "scheduled_at": "2026-10-09T18:00:00.000Z", "...": "job fields" },
"previous_scheduled_at": "2026-10-08T15:00:00.000Z",
"arrival_window_label": "Fri, Oct 9, 11:00 AM – 2:00 PM",
"timezone": "America/Phoenix",
"technician_reassigned": false,
"notified": true,
"calendar": { "status": "synced" },
"sms": { "ok": true }
}
The scheduling rules are strict, the same as for the voice agent. Jobs with several technicians return 409 multi_technician_job.
6.11 POST /jobs/{id_or_job_number}/cancel
Scope: jobs:cancel. The body is optional.
| Field | Type | Notes |
|---|---|---|
reason | string (≤1000) | Logged in the activity feed |
notify | boolean | Default true. Sends cancellation texts |
caller_phone | string | Optional caller check, as for reschedule |
{ "reason": "Customer fixed it themselves" }
The response is the job (status: "cancelled") with calendar and sms results. Errors: already_cancelled, already_completed, multi_technician_job.
6.12 GET /estimates and GET /invoices
Scopes: estimates:read / invoices:read.
| Query param | Description |
|---|---|
job_id | UUID |
status | Comma list (e.g. draft,sent,approved for estimates; draft,sent,paid,void for invoices) |
doc_number | Exact |
limit, offset | Pagination |
Estimate example:
{
"data": [
{
"id": "00000000-0000-0000-0000-000000000020",
"doc_number": "E-1001",
"job_id": "00000000-0000-0000-0000-000000000010",
"status": "sent",
"line_items": [
{ "description": "Diagnostic", "quantity": 1, "unit_price": 89.5 }
],
"subtotal": 89.5,
"tax_rate": 0.086,
"tax_amount": 0,
"tax_jurisdiction": "Example County",
"total": 89.5,
"sent_at": "2026-10-08T19:00:00.000Z",
"responded_at": null,
"created_at": "2026-10-08T18:55:00.000Z",
"customer_url": "https://crm.yourcompany.com/estimate/PUBLIC_TOKEN"
}
],
"count": 1
}
Invoices have the same shape, plus estimate_id, invoice_type, amount_paid and paid_at. Their customer_url looks like https://crm.yourcompany.com/invoice/PUBLIC_TOKEN.
Internal part numbers and part links are always removed from line_items.
6.13 GET /estimates/{id} and GET /invoices/{id}
Returns { "data": <document> }, or 404 not_found.
6.14 Not supported (yet)
POST /estimates, POST /invoices, PUT/DELETE on any resource → 405 method_not_allowed.
7. Voice booking endpoints (scope voice)
The voice agent uses a separate set of endpoints under https://crm.yourcompany.com/api/public/voice/: availability, create-job, lookup-appointments, reschedule-appointment and cancel-appointment. They accept a key with the voice scope and follow the same scheduling rules and error codes listed in section 4. Their request formats are designed for the voice vendor and aren't covered in this reference.
8. Quick reference
| Method | Path | Scope |
|---|---|---|
| GET | /me | any |
| GET | /customers | customers:read |
| GET | /customers/{id} | customers:read |
| POST | /customers | customers:write |
| PATCH | /customers/{id} | customers:write |
| GET | /jobs | jobs:read |
| GET | /jobs/{id} | jobs:read |
| GET | /availability | jobs:read |
| POST | /jobs | jobs:create |
| POST | /jobs/{id_or_number}/reschedule | jobs:reschedule |
| POST | /jobs/{id_or_number}/cancel | jobs:cancel |
| GET | /estimates, /estimates/{id} | estimates:read |
| GET | /invoices, /invoices/{id} | invoices:read |