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:

Example
Authorization: Bearer td_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Example
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.

ScopeAllowsAvailable
customers:readRead / find customersYes
customers:writeCreate / update customersYes
jobs:readRead jobs, check availabilityYes
jobs:createCreate jobsYes
jobs:rescheduleReschedule jobsYes
jobs:cancelCancel jobsYes
estimates:readRead estimatesYes
estimates:createCreate estimatesNot yet
invoices:readRead invoicesYes
invoices:createCreate invoicesNot yet
voiceVoice 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_limited with the header Retry-After: 60.

4. Errors

Errors always use this format:

Example
{ "error": "Human-readable message", "code": "machine_code" }

General errors

HTTPcodeMeaning
400invalid_jsonRequest body isn't valid JSON
400validation_errorA field is missing or invalid (message names the field)
400invalid_idid must be a UUID
400invalid_phoneNot a valid US phone number / fewer than 7 digits in search
400invalid_dateDate isn't ISO 8601 (or YYYY-MM-DD for availability)
400invalid_statusUnknown job status filter
400invalid_queryInvalid filter on estimates/invoices
400invalid_updateCustomer update was rejected
401missing_api_keyNo key sent
401invalid_api_keyKey not recognised
401revoked_api_keyKey has been revoked
403insufficient_scopeKey lacks the scope for this endpoint
403phone_mismatchcaller_phone doesn't match any phone on the job's customer
404not_foundRecord not found
404customer_not_foundcustomer_id on job create not found
404endpoint_not_foundUnknown path
405method_not_allowedMethod not supported on this resource
409duplicate_customerA customer with this phone already exists (message includes its id)
409phone_in_usePhone belongs to a different customer
429rate_limitedOver 60 requests/minute
500internal_errorUnexpected server error

Scheduling errors (create, reschedule, cancel)

HTTPcodeMeaning
400missing_zipScheduling needs a 5-digit ZIP (zip or a visit_address ending in one)
400zip_not_servicedNo technician covers that ZIP
400invalid_techniciantechnician_id isn't an active team member
409outside_business_hoursWindow is outside business hours
409lead_time_violationWindow starts less than 60 minutes from now
409window_full / fully_bookedNo technician has room in that window
409no_technician_working_this_dayNobody covering the ZIP works that day
409technician_not_covering_zipRequested technician doesn't cover the ZIP
409technician_not_working_this_dayRequested technician is off that day
409technician_window_fullRequested technician already has 2 jobs in that window
409already_cancelledJob is already cancelled
409already_completedCompleted jobs can't be cancelled or rescheduled
409multi_technician_jobJob 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:

ParamDefaultRange
limit501–200
offset0≥ 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.

Example
curl https://crm.yourcompany.com/api/v1/me \
  -H "Authorization: Bearer td_YOUR_API_KEY"
Example
{ "data": { "key_name": "Zapier", "scopes": ["customers:read", "jobs:read"] } }

6.2 GET /customers

Scope: customers:read. Lists or searches customers, newest first.

Query paramDescription
phoneMatches the last 10 digits against all of the customer's phones (formatting ignored; at least 7 digits)
emailExact match, case-insensitive
namePartial match
qPartial match on name, email, address or phone
limit, offsetPagination
Example
curl "https://crm.yourcompany.com/api/v1/customers?phone=555-010-0000" \
  -H "x-api-key: td_YOUR_API_KEY"
Example
{
  "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.

FieldTypeRequiredNotes
namestring (1–200)Yes
phonestringNoUS number, stored as E.164
phone_typecell | home | alternativeNoDefault cell
emailstringNoLower-cased
addressstring (≤500)NoSaved as primary address
address_labelstring (≤60)NoDefault Service
notesstring (≤5000)No
Example
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"}'
Example
{ "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.

FieldTypeNotes
namestring
emailstring | null
notesstring | null
is_activebooleanDeactivate / reactivate
add_phone{ phone, type?, is_primary? }Adds a phone; primary also updates main phone
add_address{ address, label?, is_primary? }Adds an address
Example
{ "email": "jane.new@example.com", "add_phone": { "phone": "555-010-0001", "type": "home" } }

Response:

Example
{
  "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 paramDescription
statusComma list of new, scheduled, in_progress, completed, cancelled
customer_idUUID
technician_idUUID (lead technician)
job_numberExact
scheduled_from, scheduled_toISO 8601 range on scheduled_at
limit, offsetPagination
Example
{
  "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 paramRequiredDescription
dateYesYYYY-MM-DD
zipOne of5-digit ZIP
addressOne ofAddress containing a ZIP
Example
curl "https://crm.yourcompany.com/api/v1/availability?date=2026-10-08&zip=85001" \
  -H "x-api-key: td_YOUR_API_KEY"
Example
{
  "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.

FieldTypeRequiredNotes
customer_idUUIDYes
appliance_typestring (≤40)NoNormalised (e.g. "fridge" → Refrigerator)
descriptionstring (≤5000)No
visit_addressstring (≤500)NoMatched to a saved address, or added. Default: primary address
zipstringNoNeeded for scheduling if the address has no ZIP
scheduled_atISO 8601NoWindow start. Omit to create an unscheduled new job
technician_idUUIDNoPreferred technician. Otherwise one is assigned automatically
notify_customerbooleanNoDefault true. Sends the booking text when scheduled
Example
{
  "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:

Example
{
  "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.

FieldTypeRequiredNotes
scheduled_atISO 8601YesNew window start
technician_idUUID | nullNoPreferred technician
notifybooleanNoDefault true. Texts the customer and technician(s)
caller_phonestringNoIf sent, must match a phone on the job's customer (else 403 phone_mismatch)
Example
{ "scheduled_at": "2026-10-09T18:00:00.000Z", "caller_phone": "+15550100000" }
Example
{
  "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.

FieldTypeNotes
reasonstring (≤1000)Logged in the activity feed
notifybooleanDefault true. Sends cancellation texts
caller_phonestringOptional caller check, as for reschedule
Example
{ "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 paramDescription
job_idUUID
statusComma list (e.g. draft,sent,approved for estimates; draft,sent,paid,void for invoices)
doc_numberExact
limit, offsetPagination

Estimate example:

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

MethodPathScope
GET/meany
GET/customerscustomers:read
GET/customers/{id}customers:read
POST/customerscustomers:write
PATCH/customers/{id}customers:write
GET/jobsjobs:read
GET/jobs/{id}jobs:read
GET/availabilityjobs:read
POST/jobsjobs:create
POST/jobs/{id_or_number}/reschedulejobs:reschedule
POST/jobs/{id_or_number}/canceljobs:cancel
GET/estimates, /estimates/{id}estimates:read
GET/invoices, /invoices/{id}invoices:read