Get a key
Keys belong to one company. An owner of a Blue Collar Lead account makes them:
- Sign in, open Connections in the menu, and find API and webhooks.
- Press Make an API key, name it after the app that will use it, and tick only the permissions it needs.
- Copy the key. It starts with
bcl_live_and is shown once; we keep only a fingerprint of it.
Make one key per app, so you can turn one off without breaking the others. A key that is turned off stops working at once.
| Permission | Lets the key |
|---|---|
contacts:read | List and read contacts. |
contacts:write | Create and update contacts. |
jobs:read | List and read jobs, their notes, the boards and stages, and the team. |
jobs:write | Create and update jobs, move them between stages, add notes. |
appointments:read | List and read appointments. |
calls:read | List and read calls, their summaries and transcripts. |
leads:write | Send in new leads. The AI follows up like it does for a website form. |
files:read | Read a job's photos, videos and documents, with download links. |
reports:read | Read photo reports and download their PDFs. |
webhooks:manage | Subscribe your own URLs to events. |
No key can reach a company's settings, billing, logins or other keys, and no key can see another company.
Authentication
Send the key in the Authorization header on every request. Keep it on your server: a key does not belong in a web page or a mobile app.
curl https://www.bluecollarlead.com/api/v1/me \ -H "Authorization: Bearer bcl_live_..."
{
"company": { "id": "client-7f3a91c2", "name": "Peachtree Roofing Co.", "timezone": "America/New_York" },
"api_key": { "name": "Office CRM sync", "prefix": "bcl_live_Xk3Q9a", "scopes": ["contacts:read", "jobs:write"] }
}
Basics
- Base URL:
https://www.bluecollarlead.com/api/v1. Always usewww; the bare domain redirects. - Send and receive JSON. Send
Content-Type: application/jsonwith a body. - Times are ISO 8601 in UTC, like
2026-10-19T18:05:00Z. Phone numbers come back in E.164, like+14045550101; send them in any common format. - Creating a contact or a job takes an
Idempotency-Keyheader (8 to 200 letters, digits, dots, dashes, colons or underscores). Retry with the same key and the record is created once. - The whole API is described in openapi.json. Import it into Postman or Insomnia to try every call.
Errors
Every error has the same shape, with a status code that matches:
{ "error": { "code": "missing_scope", "message": "This API key does not have the \"jobs:write\" permission." } }
| Status | Code | What happened |
|---|---|---|
| 400 | invalid_request | Something in the request is wrong. The message says what. |
| 401 | missing_api_key, invalid_api_key | No key, a mistyped key, or a key that was turned off. |
| 402 | subscription_inactive | The company's subscription has ended. |
| 403 | missing_scope, forbidden | The key lacks the permission for this call. |
| 404 | not_found | No such record in this company, or no such endpoint. |
| 409 | conflict | For example, the job belongs to a connected CRM and must be changed there. |
| 422 | invalid_request | A required field is missing. |
| 429 | rate_limited | Too many requests. Wait and retry. |
| 500 | server_error | Our fault. Retry; tell us if it keeps happening. |
Lists and syncing
Lists return newest first, 25 at a time (up to 100 with ?limit=). When has_more is true, ask again with ?cursor= set to the next_cursor you were given.
To keep another system in step, remember when you last synced and ask only for what changed since: ?updated_since=2026-10-19T00:00:00Z. Webhooks tell you the moment something happens; a sync on a schedule catches anything a webhook missed.
curl "https://www.bluecollarlead.com/api/v1/contacts?updated_since=2026-10-19T00:00:00Z&limit=100" \ -H "Authorization: Bearer bcl_live_..."
Rate limits
Each key may make 120 requests a minute. Every answer carries RateLimit and RateLimit-Policy headers; past the limit you get a 429 until the window resets. Page through big lists with limit=100 rather than fetching records one at a time.
Endpoints
| Call | What it does | Permission |
|---|---|---|
| GET/me | The company and key in use. | any |
| GET/contacts | List contacts. Filters: q, phone, email, updated_since. | contacts:read |
| GET/contacts/{id} | One contact. | contacts:read |
| POST/contacts | Create a contact. phone is required. | contacts:write |
| PATCH/contacts/{id} | Update name, email, address, company, notes, type or tags. | contacts:write |
| GET/jobs | List jobs. Filters: stage, status, pipeline_id, assigned_to, contact_id, job_number, q, updated_since. | jobs:read |
| GET/jobs/{id} | One job. | jobs:read |
| POST/jobs | Create a job. | jobs:write |
| PATCH/jobs/{id} | Update a job's details (not its stage). | jobs:write |
| POST/jobs/{id}/stage | Move a job to another stage. | jobs:write |
| GET/jobs/{id}/notes | A job's notes, newest first. | jobs:read |
| POST/jobs/{id}/notes | Add a note. | jobs:write |
| GET/pipelines | The boards and their stages (the stage keys to use). | jobs:read |
| GET/team | Active team members (ids to assign jobs to). | jobs:read |
| GET/jobs/{id}/files | Photos, videos and documents, each with a download link that works for 15 minutes. | files:read |
| GET/jobs/{id}/reports | A job's photo reports. | reports:read |
| GET/reports/{id} | One report. | reports:read |
| GET/reports/{id}/pdf | The report as a PDF. | reports:read |
| GET/reports/{id}/zip | The PDF plus every photo and video, in one ZIP. | reports:read |
| GET/appointments | List appointments. Filters: from, to, status, updated_since. | appointments:read |
| GET/appointments/{id} | One appointment. | appointments:read |
| GET/calls | List calls. Filters: phone, updated_since. | calls:read |
| GET/calls/{id} | One call. Add ?include=transcript for what was said. | calls:read |
| POST/leads | Send in a new lead for the AI to follow up. | leads:write |
| GET/webhooks/events | The events you can subscribe to. | webhooks:manage |
| GET/webhooks | Your subscriptions. | webhooks:manage |
| POST/webhooks | Subscribe a URL to events. Returns its signing secret once. | webhooks:manage |
| DELETE/webhooks/{id} | Remove a subscription. | webhooks:manage |
| POST/webhooks/{id}/test | Send a test event (ping). | webhooks:manage |
Every field of every record is in the OpenAPI spec.
Jobs, in practice
A job made through the API is made exactly like one typed in on the board: it is checked, linked to the contact with that phone number (or a new one), numbered when it passes the lead stage, and the assigned person is told.
curl -X POST https://www.bluecollarlead.com/api/v1/jobs \ -H "Authorization: Bearer bcl_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-58213" \ -d '{ "title": "Roof replacement", "contact": { "name": "Jordan Lee", "phone": "404-555-0101" }, "address": "412 Maple Ridge Dr, Roswell, GA", "value": 18500, "assigned_to": "emp-1", "note": "Wants GAF Timberline, charcoal." }'Move it when the contract is signed
curl -X POST https://www.bluecollarlead.com/api/v1/jobs/4229/stage \ -H "Authorization: Bearer bcl_live_..." \ -H "Content-Type: application/json" \ -d '{ "stage": "sold" }'
Use GET /pipelines for the stage keys of each board, and GET /team for the ids to put in assigned_to. Custom fields go in fields, by the field's key.
Leads
POST /leads is the same door as a website form. The lead is checked for bots, spam and the do-not-call list, a job is made on the board, and the AI calls or texts within the company's calling hours and daily limit. The same number twice within 10 minutes counts once.
curl -X POST https://www.bluecollarlead.com/api/v1/leads \ -H "Authorization: Bearer bcl_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Casey Morgan", "phone": "678-555-0144", "address": "9 Elm Rd, Marietta, GA", "service": "Roof repair", "message": "Leak over the kitchen after the storm." }'
Only send people who asked to be contacted. For a customer you already work with, use POST /contacts or POST /jobs, which never call anyone.
Webhooks
Subscribe an https URL on your system to the events you care about. We POST JSON to it within seconds of the event.
| Event | When |
|---|---|
job.created | A job was created: in the app, by the AI from a call or text, from a lead, or through the API. |
job.stage_changed | A job moved to another stage. Carries from_stage and to_stage. |
contact.created | A contact was created in the app or through the API. |
lead.received | A lead came in from a website form or the API. |
appointment.booked | The AI booked an appointment. |
optout.requested | Someone asked not to be contacted again, on a call or by texting STOP. data.channel says which. Stop calling and texting them. |
curl -X POST https://www.bluecollarlead.com/api/v1/webhooks \ -H "Authorization: Bearer bcl_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/hooks/bcl", "events": ["job.created", "job.stage_changed"] }'
The answer includes a secret (whsec_...) shown only then. Owners can also add webhooks from the app.
What arrives
POST /hooks/bcl
X-BCL-Event: job.stage_changed
X-BCL-Delivery: dlv_Q2x7mK0aP1sT
X-BCL-Signature: sha256=5f0c...
{
"id": "dlv_Q2x7mK0aP1sT",
"event": "job.stage_changed",
"created_at": "2026-10-19T18:05:02.114Z",
"data": { "job": { "id": 4229, "stage": { "key": "sold", "label": "Approved" } }, "from_stage": "quoted", "to_stage": "sold" }
}
- Answer with any 2xx within 8 seconds. Do the slow work after you answer.
- We retry up to 4 times over about 30 seconds after a timeout, a 408, a 429 or a 5xx. Other answers are not retried.
X-BCL-Deliveryis the same on every retry, so you can ignore a repeat.- After 100 failed deliveries in a row we turn the subscription off. Add it again once your end is fixed.
- Addresses that point at private networks are refused.
Verifying a webhook
Compute an HMAC-SHA256 of the raw request body with your secret, and compare it with X-BCL-Signature in constant time. Use the raw bytes, before any JSON parsing.
const crypto = require('crypto'); function verified(rawBody, header, secret) { const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); const a = Buffer.from(expected), b = Buffer.from(String(header || '')); return a.length === b.length && crypto.timingSafeEqual(a, b); }Python
import hmac, hashlib def verified(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header or "")
Rules worth knowing
- Stages move with their side effects. Moving a job to the company's finished stage can queue the customer's review request and push the job to their connected CRM, exactly like dragging the card. That is why the stage changes only through
POST /jobs/{id}/stage, neverPATCH. - Jobs from a connected CRM (GoHighLevel, JobTread) belong to that CRM. Moving them answers 409; change them there and they sync back.
- Phone numbers tie the system together. A contact's phone cannot be changed through the API, because calls, texts and jobs find a person by it.
- Appointments are read only in this version. The AI books them against the company's calendars and rules; send a lead or create a job and the company takes it from there.
- Download links expire. File links work for 15 minutes. Ask for the file list again to get fresh ones.
Versions and help
This is version 1. Within it we only add things: new fields, new endpoints, new events. We never rename or remove a field, so ignore fields you do not know. Anything that would break an integration gets a new version, and the old one keeps working while you move.
Questions, or something you need the API to do: [email protected].