Audience: Internal + integrators. π’ Reference β code-grounded. This maps the HTTP
surface of a single hub (app/main.py:208-297) plus the control-plane surface it exposes.
Everything here is a FastAPI router β there is no separate versioned public API product; the
"API" is the app's own routes. Most of the surface is a server-rendered app (HTML responses,
cookie sessions), not a JSON API. Treat the JSON-shaped routes (/api/jobs, /api/schedule,
/_admin/*) as the closest thing to a programmatic interface.
β οΈ This is not a customer-facing API. No public docs, no API keys for tenants, no
Authorization: Bearer <tenant-key>scheme for third parties. The only signed, machine-to-machine
surface is the control plane β hub Admin API (/_admin/*), which is VPC-private.
Every route belongs to exactly one of five auth models. Know which one before you touch a route.
| Model | Badge | How it's enforced | Where |
|---|---|---|---|
| Cookie / JWT (staff) | π Staff | OAuth2PasswordBearer + session cookie β get_current_user, tenant-guarded to the org's host; roles via require_roles(...) |
app/auth/deps.py:21,30,57 |
| Portal token (customer) | ποΈ Token | Random unguessable token stored on the record (public_token, clock_token, client_token, lead_form_token); no login. Some add a phone OTP gate |
app/routers/proposal_portal.py:27, sub_portal.py:35,48 |
| CP-signed (god-mode) | π°οΈ CP-signed | Asymmetric JWT signed by the control plane, verified against the hub's cp_public_key with audience/issuer checks |
app/routers/admin_api.py:51-68 |
| Public | π Public | No auth. Rate/abuse controls only. Reachable from the open internet | track.py, chat_public.py, marketing_forms.py |
| Webhook signature | π‘ Signed hook | Provider HMAC/signature verified per-request (Stripe, Twilio, QBO, OpenProject, Documenso) | app/routers/webhooks.py |
Edge enforcement matters too. Caddy blocks two path families from the public internet
regardless of app auth (Caddyfile:22-24,104-106):
/internal/* β respond 404 (cron trigger + TLS-allow oracle, hub-private)/internal/* blocked/_admin/* is not blocked at Caddy β it fails closed in the app instead: if the hub has no
cp_public_key configured, every /_admin route returns 403 Admin API is disabled
(admin_api.py:54-56). In the fleet it's reached over the VPC-private signed Admin API, never the
public edge.
π/π /api/auth/*app/auth/router.py:17. The only genuinely JSON, token-issuing surface.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/api/auth/register |
π Public | Create a user (router.py:20) |
POST |
/api/auth/login |
π Public | Exchange credentials for a bearer Token (router.py:57) |
GET |
/api/auth/me |
π Staff | Current user (router.py:93) |
Staff also authenticate through the HTML portal login at POST /portal/login
(app/portal/router.py:88), which sets the session cookie the rest of /portal/* reads.
π /api/jobs/* + /portal/jobs/*Two distinct surfaces for the same domain object.
/api/jobs is the JSON surface (app/routers/jobs.py:20) β the only clean REST resource in
the app:
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/api/jobs |
π Staff | List jobs for the caller's org (jobs.py:23) |
POST |
/api/jobs |
π Staff (pm/office) | Create a job (jobs.py:30) |
GET |
/api/jobs/{id} |
π Staff | One job, org-scoped (jobs.py:55) |
POST |
/api/jobs/{id}/provision |
π Staff (pm/office) | Eagerly create the backing OpenProject project (jobs.py:64) |
β Who consumes
/api/jobs? It returnsJobOutJSON and is org-scoped by cookie, but the
HTML app drives job creation through/portal/jobs/*instead. See open questions β this looks
like the intended-but-underused "real" API.
/portal/jobs/* is the server-rendered Jobs Command Center (app/portal/router.py:341+) and
the many per-job tabs (?view=classic&tab=β¦) β this is what staff actually click. See the Jobs
Command Center hub page.
Related JSON-ish staff routes: GET/POST /api/schedule/* (schedule.py:20),
/api/daily-logs/* (daily_logs.py:20), /clock/* time clock (timeclock.py:23).
π /portal/*, /portal/sales/*Large server-rendered surface. Cookie-authed, org-scoped.
| Area | Prefix | File | Notes |
|---|---|---|---|
| Sales pipeline / estimates | /portal |
sales.py:45 (45 routes) |
The big one β leads, proposals, estimate builder |
| Sales command-center shell | /portal/sales |
sales_cc.py:30 (16 routes) |
Newer consolidated shell over sales.py |
| People (unified contacts) | /portal |
people.py:31 |
Unified Person page; the home for contacts |
| Contacts (legacy) | /portal |
contacts.py:16 |
β οΈ Legacy per-Contact URLs redirect to the Person page (contacts.py:46) |
| Nurture sequences | /portal |
nurture.py:25 |
Drip / follow-up automation |
| Catalog / line items | /portal |
catalog.py:21 |
Estimate line-item library |
| CRM lead intake (admin) | /portal |
lead_admin.py:23 |
Inbound-lead triage |
Public leads flow into this CRM from the prospect surfaces (embed form, site contact form,
site chat) and from the control plane (POST /_admin/orgs/{id}/leads).
π /portal/*Money, cost, procurement, payroll β all cookie-authed staff surfaces.
| Area | Prefix | File | Notes |
|---|---|---|---|
| Invoices / draws / payments | /portal |
money.py:26 (40 routes) |
Homeowner-facing invoices are issued here; paid via Stripe webhook |
| Job costing / bills | /portal |
cost.py:31 (41 routes) |
β οΈ Legacy per-job PO write routes removed (cost.py:579) |
| Procurement / POs | /portal |
procurement.py:35 (30 routes) |
Plus public PO portal below |
| Payroll | /portal |
payroll.py:25 |
Time β pay |
| QuickBooks connect | /portal/integrations/quickbooks |
quickbooks.py:26 |
Per-org OAuth; two-way sync via QBO webhook |
| Lien waivers (admin) | /portal |
lien_waivers.py:23 |
Issues the public /waiver/{token} links |
Customer-facing finance is token portals, not cookie routes β see below.
ποΈ token-gated, no loginEach portal is an unguessable token link. These are the routes homeowners, subs, and vendors
actually touch. No account, no cookie (a few add a phone OTP).
| Portal | Route | Auth | Purpose |
|---|---|---|---|
| Proposal | GET /proposal/{token} |
ποΈ public_token |
Branded proposal view + media (proposal_portal.py:59) |
| Invoice | GET /invoice/{token}, /pay, /sign-status |
ποΈ public_token |
View + Stripe pay + e-sign status (invoice_portal.py:34,107) |
| Bid | GET /bid/{token}, POST /bid/{token}/submit |
ποΈ public_token |
Sub/vendor submits an itemized bid (bid_portal.py:27,39) |
| Sub field app | /sub/{clock_token} |
ποΈ token + π± OTP | Subcontractor's walled field PWA; OTP gate (sub_portal.py:32,48) |
| Lien waiver | GET /waiver/{token}, POST /sign |
ποΈ lien_waiver_token |
Vendor signs a waiver β flips bill status (lien.py:23,50) |
| PO portal | /po/* |
ποΈ Public | Vendor-facing PO acknowledgement (procurement.py:41) |
| Client (single job) | /client/* |
ποΈ client_token |
Homeowner read-only view of one job (client_portal.py:30) |
Field app for crew (π/token hybrid): /field/* (field.py:33, 70 routes) is the crew PWA;
staff reach it cookie-authed, subs reach the walled /sub/{token} variant.
π admin + π public siteTwo halves: the staff marketing console (cookie) and the live public website each org
publishes.
Staff console (π, /portal/marketing/*):
| Area | Prefix | File |
|---|---|---|
| Marketing dashboard (new) | /portal/marketing |
marketing_cc.py:43 β new default; ?classic=1 keeps old tabbed view |
| Marketing (classic) | /portal/marketing |
marketing.py:29 |
| Social auto-post | /portal/marketing/social |
social.py:30 |
| Ad builder | /portal/marketing |
ads_build.py:25 |
| Google Ads connect | /portal/integrations/google-ads |
ads.py:24 |
| Email studio / dashboard | /portal, /portal/email |
email_studio.py:26, email_dashboard.py:24 |
| Website builder | /portal |
website.py:29 |
| Blog admin | /portal/blog |
blog_admin.py:31 |
Live public site (π, served on the org's domain/subdomain):
| Method | Path | Purpose |
|---|---|---|
GET |
/p/{slug} (+ /page/, /blog, /local/{service}/{city}) |
Rendered marketing site pages (landing_portal.py:465-536) |
GET |
/p/{slug}/robots.txt, /sitemap.xml, /blog/feed.xml |
SEO surface (landing_portal.py:511,556,566) |
POST |
/p/{slug}/contact |
Site contact form β CRM lead (landing_portal.py:671) |
POST |
/p/{slug}/chat |
Site chat β CRM (landing_portal.py:764) |
GET/POST |
/p/{slug}/login, /projects |
Homeowner login on the org's own domain (landing_portal.py:600-649) |
GET/POST |
/f/{token} |
Embeddable (iframe) lead-capture form, keyed by lead_form_token (landing_portal.py:822,840) |
GET |
/live/* |
Public "live" project map/feature (live.py:27) β distinct from /field/live |
β οΈ
/livevs/field/livecollision./live/*(live.py) is a public feature;/field/live
(field.py:369) is the staff crew map. Different routers, similar names.
π°οΈ CP-signed, VPC-privateapp/routers/admin_api.py:34, prefix /_admin (29 routes). Every route depends on
require_cp (admin_api.py:51): a control-plane-signed JWT verified against the hub's public key.
Fails closed β no key configured β 403 for all routes. This is the one machine-to-machine,
cross-service API in the fleet.
| Method | Path | Purpose |
|---|---|---|
GET |
/_admin/health, /version, /usage, /vitals, /telemetry |
Fleet dashboard probes (admin_api.py:98-322) |
GET |
/_admin/orgs, /_admin/orgs/{id}, /{id}/users, /{id}/support |
Read tenant state (admin_api.py:199,403-724) |
POST/PATCH |
/_admin/orgs, /_admin/orgs/{id} |
Create / edit org (tier, entitlements, settings) (admin_api.py:430,484) |
POST |
/_admin/orgs/{id}/site/import, /site/domain |
Provision live site + attach custom domain (admin_api.py:547,626) |
POST |
/_admin/orgs/{id}/leads |
Route estimator/prospect leads into a live org's CRM (admin_api.py:659) |
POST |
/_admin/orgs/{id}/suspend, /enable |
Billing/lifecycle gates (admin_api.py:712,718) |
POST |
/_admin/orgs/{id}/comms/{comm_id}/resend |
Support: resend a communication (admin_api.py:232) |
POST |
/_admin/orgs/{id}/impersonate |
Mint an impersonation ticket (god-mode "Login as") (admin_api.py:734) |
DELETE |
/_admin/orgs/{id} |
Delete an org (admin_api.py:764) |
Impersonation handoff (browser side, god-mode): /_impersonate/* (impersonation.py:23).
The CP mints a signed imp_ticket; GET /_impersonate/consume?t=β¦ exchanges it for a hub session
cookie (impersonation.py:28-53); GET /_impersonate/exit ends it. The ticket is verified via
decode_access_token + purpose == "imp_ticket" β this is the only path that grants a staff
session without a password.
π open internetReachable from anywhere; the only routes Caddy explicitly allows through on marketing hosts
(Caddyfile:74-85). These feed GoBuild's own sales funnel and each org's site analytics.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/api/demo-request |
π Public | GoBuild demo/lead request β team email + auto-reply (marketing_forms.py:128) |
POST |
/api/track, /api/track/ping |
π Public | Site visitor analytics / attribution collector (track.py:43,130) |
GET/POST |
/api/chat/poll, /send, /history, /config, /avatar/{org_id}, /demo |
π Public | On-site chat widget; /demo files a demo request (chat_public.py:39-195) |
GET/POST |
/f/{token} |
π Public | Embeddable lead form (see Marketing) |
GET |
/p/{slug} β¦ |
π Public | Live org site (see Marketing) |
Health/readiness (unauthenticated, for load balancers): GET /healthz, GET /readyz
(health.py:11,18).
π‘ provider-signed, /api/webhooks/*app/routers/webhooks.py:17. Inbound from third parties; each verifies the provider's signature
before acting. Not for integrator use β the provider is the only legitimate caller.
| Method | Path | Verification | Purpose |
|---|---|---|---|
POST |
/api/webhooks/stripe |
Stripe-Signature, global then per-org secret (webhooks.py:87-101) |
Draw paid β cascade invoice |
POST |
/api/webhooks/quickbooks |
Intuit HMAC (webhooks.py:65) |
QBO change-data-capture |
POST |
/api/webhooks/openproject |
X-OP-Signature (webhooks.py:24-28) |
Schedule/Gantt sync back |
POST |
/api/webhooks/documenso |
X-Documenso-Secret (webhooks.py:141-145) |
E-signature completion |
POST |
/api/webhooks/email/inbound |
RESEND_INBOUND_SECRET |
Inbound email reply capture (webhooks.py:169) |
POST |
/api/webhooks/twilio/sms, /voice, /voice/complete, /voice/recording |
Twilio signature | Inbound SMS + call tracking (webhooks.py:237-357) |
POST |
/api/webhooks/chatwoot |
Chatwoot secret (webhooks.py:357) |
Omnichannel message events |
π« blocked at the edgeNot app-authed by cookie β kept private by Caddy (Caddyfile:22-24,104-106, respond 404).
| Method | Path | Purpose |
|---|---|---|
GET |
/internal/tls-allow |
On-demand-TLS oracle: tells Caddy which hostnames may get a cert (landing_portal.py:237) |
GET/POST |
/internal/run-alerts |
Cron-triggered alert/digest run (planning.py:607) |
/api/auth/*, /api/jobs/*, /api/schedule/*, /_admin/*, /api/webhooks/*,/api/track*, /api/chat/*, /api/demo-request.?view=classic&tab=β¦ redirectscost.py:47, contacts.py:46, documents.py:597, jobextras.py:120, catalog.py:278). Thesesales_cc, marketing_cc, reports hub) are?classic=1 as the escape hatch (marketing_cc.py:4, reports.py:65).cost.py:579); legacy per-Contact detail is now acontacts.py:366).When integrating or scripting, prefer the
/api/*and/_admin/*JSON routes. Everything under
/portal/*is HTML meant for the browser session and can change shape without notice.