Developers
Connect CourtBuildr to anything
Two ways in. The API lets your CRM, your spreadsheet or your own code read your leads and jobs, and create or move jobs. Webhooks have us tell your system the moment something happens, so nothing polls. Both are switched on per builder in the dashboard under Integrations, and both only ever see that builder's own data.
Which path is yours
Not a developer? Connect your CRM is the same thing as a step-by-step guide with annotated screenshots: Zapier, GoHighLevel, jobs, and handing a key to a developer.
- No developer, using Zapier or Make (HubSpot, Pipedrive, Jobber, Salesforce, Zoho, JobTread, a Google Sheet): you only need Webhooks and the Send sample lead button. Twenty to forty minutes. No API key.
- GoHighLevel: its own Inbound Webhook trigger takes our events directly. See the GoHighLevel recipe. No API key.
- Your own code, or a developer you hired: everything below. Start at Authentication.
One warning for everybody: the Customise tab has an older single “Send leads to another system” URL. It still works, but it fires on every lead alongside any endpoint here. Use one or the other for the same system, not both, or each lead arrives twice.
Authentication
Create a key in your dashboard (Integrations → Create key). It starts with cb_live_ and is shown once. Send it on every request:
curl https://courtbuildr.com/api/v1/me \
-H "Authorization: Bearer cb_live_…"{
"data": {
"id": "…", "slug": "you", "name": "Your Courts",
"plan": "growth", "currency": "USD", "portal_enabled": true,
"events": ["lead.created", "job.created", …]
}
}Every response is JSON: { "data": … } on success, { "error": { "code", "message" } } otherwise. Lists carry meta.next_cursor, an opaque token; pass it back as ?cursor= for the next page (it is not a date). Every list also takes ?email=; jobs take ?external_ref= and ?lead_id= too. Revoke a key from the same screen the moment you think it has leaked.
Leads
A lead is a person who left their details in your designer, with the court they built. Newest first.
since filters by creation time; status is one of new, contacted, quoted, won, lost; email finds one person.{ "status": "quoted", "note": "Sent proposal 14 Sep" }. Your dashboard shows the same.The lead object
{
"id": "5b1f…",
"created_at": "2026-09-14T02:11:09Z",
"updated_at": "2026-09-14T02:11:09Z",
"name": "Sarah Mitchell",
"first_name": "Sarah",
"last_name": "Mitchell",
"email": "[email protected]",
"phone": "+1 512 555 0199",
"status": "new",
"note": "",
"reason": "quote",
"sport": "Basketball",
"sport_key": "basketball",
"size": "26 × 31 ft",
"width_m": 7.92,
"length_m": 9.45,
"hoop": "72″ adjustable",
"estimated_value": "$24,500",
"estimated_value_cents": 2450000,
"estimated_value_amount": 24500,
"currency": "USD",
"design_url": "https://courtbuildr.com/design?tenant=you&d=…",
"ar_url": "https://courtbuildr.com/ar?tenant=you&d=…"
}Jobs
A job is a court you are building for a customer, with its own page in the customer portal. Create one when a deal is won and the customer gets a branded page for their build; move it along and they are emailed at every step.
POST /jobs/{id}/updates accepts, in order, with the labels your customer sees.external_ref, or by the lead they came from.customer.email is required; everything else is optional. Pass "invite": true to email the customer their sign-in link straight away, lead_id to mark the lead won, and external_ref (your CRM's id or quote number) so you can find the job again. No twins: if that lead already has an open job you get it back with meta.existing: true (send force: true to create another), and an Idempotency-Key header replays the first response for 24 hours, so a re-fired workflow cannot create a second job or send a second invite.{
"customer": { "name": "Sarah Mitchell", "email": "[email protected]", "phone": "+1 512 555 0199" },
"sport": "basketball",
"size": "Half court · 26 × 31 ft",
"design_url": "https://courtbuildr.com/design?tenant=you&d=…",
"amount_cents": 2450000,
"show_price": true,
"inclusions": [{ "label": "Hoop", "detail": "72in tempered glass, in-ground" }],
"key_dates": { "start": "2026-09-22", "install": "2026-10-06", "handover": "2026-10-17" },
"site": { "line1": "14 Ridge Road", "city": "Austin", "state": "TX", "postcode": "78701", "country": "US", "notes": "Side gate code 4421" },
"lead_id": "5b1f…",
"invite": true
}customer, sport, size, design_url, amount_cents, paid_cents, show_price, inclusions, key_dates or site. Only the keys you send change; a partial site leaves the other address lines alone. Nothing is required. Changing customer.email hands the job to that address and signs the previous person out of it.{ "stage": "site_prep", "note": "Excavation starts Monday" }. Stage keys come from GET /stages. Takes an Idempotency-Key header too.The job object
{
"id": "c622…",
"number": "RIC-MWYPV",
"external_ref": "CRM-12345",
"lead_id": "5b1f…",
"created_at": "2026-09-14T02:00:00Z",
"updated_at": "2026-09-14T03:10:00Z",
"stage": { "key": "base", "label": "Base / slab", "customer_label": "Base going in", "terminal": false },
"customer": { "name": "Sarah Mitchell", "email": "[email protected]", "phone": "+1 512 555 0199" },
"sport": "basketball",
"size": "Half court · 26 × 31 ft",
"design_url": "https://courtbuildr.com/design?tenant=you&d=…",
"inclusions": [{ "label": "Hoop", "detail": "72in tempered glass, in-ground" }],
"key_dates": { "start": "2026-09-22", "install": "2026-10-06", "handover": "2026-10-17" },
"price": { "amount_cents": 2450000, "paid_cents": 0, "currency": "USD", "shown_to_customer": true },
"site": { "line1": "14 Ridge Road", "line2": "", "city": "Austin", "state": "TX", "postcode": "78701", "country": "US", "notes": "Side gate code 4421" },
"customer_invited_at": "2026-09-14T02:05:00Z",
"customer_signed_in": true,
"portal_url": "https://courtbuildr.com/portal/jobs/c622…?tenant=you"
}Webhooks
Register an HTTPS URL and pick the events you want. We POST a signed JSON message per event and expect any 2xx back within 10 seconds. We do not follow redirects, so register the final URL. Anything else is retried by our hourly scheduler, no sooner than 1 h, 6 h, 1 day, 3 days after the last attempt, then marked failed in your delivery log. An endpoint that fails 50 times in a row is paused until you re-enable it.
{ "url": "https://…", "events": ["lead.created", "job.*"], "description": "HubSpot" }. Returns the endpoint with its secret, shown once. Leave events empty for everything; job.* and lead.* are wildcards.{ "active": false } to pause, or change url, events, description.{ "event": "lead.created" } (or any event name) to get a realistic sample in the real shape, with test: true on the envelope, so your tool can learn the fields without a live lead. No body sends a plain ping.Events
lead.created· A visitor left their details in the designer (name, contact, the court they designed).job.created· A job was created for a customer, from a lead or from scratch.job.stage_changed· A job moved to a new stage of the builder's pipeline.job.updated· The builder posted a note or photos on a job without moving the stage.job.message.created· The customer wrote a message on their job page.job.document.added· The builder attached a document to a job.
What data holds, per event
lead.created· the lead objectjob.created·{ job, lead_id }(the job object;lead_idnull unless it came from a lead)job.stage_changed·{ job, previous_stage, update: { id, stage_key, note, photo_count, created_at } }job.updated·{ job, update: { id, stage_key: null, note, photo_count, created_at } }job.message.created·{ job_id, job_number, customer: { name, email }, message: { id, body, photo_count, created_at } }job.document.added·{ job_id, document: { id, title, kind, created_at } }
Photos and documents are private files on the customer's page, so events carry counts and titles, never file links.
What a delivery looks like
POST https://your-system.example/courtbuildr
Content-Type: application/json
X-CourtBuildr-Event: lead.created
X-CourtBuildr-Delivery: 9d3a…
X-CourtBuildr-Signature: t=1789351606,v1=5f2c…
{
"id": "9d3a…",
"event": "lead.created",
"created_at": "2026-09-14T02:11:09Z",
"api_version": "2026-09-14",
"data": { …the lead, as above… }
}Deliveries can arrive more than once if your first response was slow; the id (also in X-CourtBuildr-Delivery) is stable across retries, so de-duplicate on it.
Verifying signatures
The X-CourtBuildr-Signature header is t=<unix seconds>,v1=<hex> where v1 is HMAC-SHA256 of <t>.<raw body> using your endpoint's secret. Reject anything older than 5 minutes. Use the raw request body, not a re-serialised object.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyCourtBuildr(secret, header, rawBody) {
if (!header) return false;
const { t, v1 = "" } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (!Number.isFinite(Number(t)) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // missing or stale
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
// Express: app.post("/courtbuildr", express.raw({ type: "*/*" }), (req, res) => {
// if (!verifyCourtBuildr(process.env.CB_SECRET, req.get("X-CourtBuildr-Signature"), req.body.toString())) return res.sendStatus(400);
// const event = JSON.parse(req.body); // event.event, event.data
// res.sendStatus(200); // answer fast; do the work after
// });Zapier, Make and friends
No code needed. In Zapier, add a Webhooks by Zapier → Catch Hook step (it needs Zapier's Professional plan; Make's free plan includes webhooks) and copy its URL. In your CourtBuildr dashboard, Integrations → Add endpoint, paste it, tick lead.created. Press Send sample lead, then in Zapier press Test trigger: the sample's fields appear, under data (Zapier lists them as Data Name, Data Email and so on). Add your CRM's Create or Update Contact step and map name, email, phone; then a Create Deal step with Data Estimated Value Amount as the amount and Data Design Url in a note. Turn it on. Make works the same with a Custom webhook. Signature checking is optional on those platforms.
The message is JSON with the lead under data; job events nest the job under data.job. If your tool cannot reach into nested fields, use the Customise tab's single lead URL instead, which sends a flat body (and only leads).
GoHighLevel
GoHighLevel takes our events straight in. In GHL: Automation → Workflows → new workflow → trigger Inbound Webhook (one of GHL's premium workflow features; if you cannot find it, whoever runs your GHL enables it under Agency Settings → Company → Workflow Premium Features). Copy the URL. In CourtBuildr: Integrations → Add endpoint, paste, tick lead.created, then Send sample lead so GHL shows the mapping reference. Create custom contact fields first (Sport, Court size, Hoop, Design link, Estimated value), then add Create Contact mapping {{inboundWebhookRequest.data.first_name}}, …data.last_name, …data.email, …data.phone, an Add Contact Tag, and Create/Update Opportunity in your pipeline with data.estimated_value_amount as the value. Publish. For jobs, a second endpoint on job.* feeding a second workflow; the current stage is data.job.stage.key.
Errors and limits
401 unauthorized· missing or revoked key403 plan· jobs and the customer portal need the Growth plan404 not_found· not yours, or gone400 bad_request· the message says which field429 rate_limited· more than 1,200 requests in an hour on one key, more than 3 invites an hour for one job, or more than 5 test pings a minute502 invite_failed· the invite email could not be sent503 unavailable· a hiccup on our side; retry afterretry_after_seconds
Questions, or an event you need that is not here: [email protected]. A machine-readable description of every endpoint is at /api/v1/openapi.json.