This page is the ground-truth catalog of every integration under app/integrations/.
Each entry is verified against the code (not the marketing copy), with file:line
evidence. Read the credential wall-off model first — it governs how every
integration below gets its keys.
Legend — Status badge
- 🟢 Live-capable — real implementation; works today once its (per-tenant or
platform) credentials are present.- 🟡 Partial — real code, but only part of the advertised surface is built, or
it runs on a weaker credential model than its siblings.- ⚪ Dormant — real code, but gated off and unused because its keys/app are not
configured anywhere yet.- ❌ Scaffold / vaporware — advertised but effectively not implemented.
Every integration reads its keys through one place:
app.services.credentials.CredentialResolver (app/services/credentials.py). Call
sites never read get_settings().<key> directly for a tenant action — isolation is
enforced in a single module (credentials.py:10-13).
Resolution order (CredentialResolver.resolve, credentials.py:194-203):
own (encrypted tenant_credentials) → legacy org.settings JSON → global .env
…but the last two steps are only available to siloed / single-tenant orgs.
A pooled org gets its OWN keys or nothing — never a shared/global fallback — so
one tenant can never send through another's Twilio or charge through another's
Stripe (credentials.py:190-203). This is the "fail-closed" rule: twilio_for /
email_for return None when an org can't send, and callers must treat None
as do-not-send (credentials.py:219-222).
Three credential classes (credentials.py:62-79):
| Class | Providers | Behaviour |
|---|---|---|
| BYO / walled-off | stripe, twilio, builddata |
Pooled org must bring its own key; no shared fallback. Fail-closed. |
MANAGED (credentials.py:68) |
openproject, chatwoot, documenso, ai |
Platform-run infra or shared platform keys. Every org uses the shared account automatically; the operator never enters a key and it never counts as a "gap". |
SHARED_DEFAULT (credentials.py:75) |
email |
Defaults to the one shared GoLive Resend key/domain (dressed up per-org via email_for), but an org may still connect its OWN Resend key (white-label override). |
Where orgs connect keys. The Settings → Integrations page
(app/routers/integrations_creds.py) drives the BYO flow: owner/office role only
(integrations_creds.py:20-23), values encrypted at rest into tenant_credentials
via secretbox (credentials.py:120-134), resolved per-org at send/charge time.
The PROVIDERS registry (credentials.py:29-60) is the ONLY set of providers this
page exposes: stripe, twilio, email, openproject, documenso, chatwoot, ai,
builddata. Everything else (QuickBooks, Xero, Google Calendar, Google Ads, Meta)
connects through its own OAuth router under /portal/integrations/..., not this
page — and push, voice, templated have no connect UI at all (platform-managed
or global-only).
⚠️ Wall-off inconsistency to know: SMS resolves per-tenant (
twilio_for), but
Voice (call-tracking number provisioning) reads the global Twilio settings
directly (voice/twilio_voice.py:21-25). So a pooled org's tracking numbers are
bought on GoBuild's master Twilio account even though its texts go through its own.
See the Voice entry.
| Integration | Provider | Direction | Status | Needs per-org creds? |
|---|---|---|---|---|
| quickbooks | Intuit QuickBooks Online | Read + Write | 🟢 Live | Yes — per-org OAuth (global app id/secret) |
| xero | Xero Accounting | — | ❌ Scaffold (empty pkg) | Advertised, not built |
| Resend (HTTPS API) | Write (send) | 🟢 Live | No (shared default) / optional BYO | |
| esign | Documenso (self-hosted) | Read + Write | 🟢 Live | No — MANAGED |
| sms | Twilio | Write + inbound verify | 🟢 Live | Yes — walled off (twilio_for) |
| voice | Twilio | Read + Write (paid) | 🟡 Global creds only | No — uses instance Twilio |
| push | Web Push (VAPID) | Write | ⚪ Dormant (no VAPID) | No |
| ai | DeepSeek + Google Gemini | Write (one-way) | 🟢 Live | No — MANAGED (shared keys) |
| social | Meta Facebook/Instagram (+ Templated.io) | Write (publish) | ⚪ Dormant (no Meta app) | Per-org Page token, once app exists |
| ads | Google Ads | Read / creative only | 🟡 Publish unbuilt | Per-org OAuth (global app) |
| gcal | Google Calendar | Read + Write | ⚪ Dormant (unconfigured) | Per-org OAuth |
| builddata | BuildData.ca (RapidAPI) | Read | 🟡 Live client, dormant | Yes — per-org key/host |
| chatwoot | Chatwoot (self-hosted) | Read + Write | 🟢 Live | No — MANAGED |
| openproject | OpenProject (self-hosted) | Read + Write | 🟢 Live (fragile) | No — MANAGED, single instance |
| payments | Stripe Checkout | Write + webhook verify | 🟢 Live | Yes — per-org key |
What it does. Real two-way OAuth2 + Accounting API client
(quickbooks/client.py). Full OAuth dance (authorize_url, exchange_code,
refresh_token, revoke — client.py:53-125), generic Accounting API caller
(api_request, client.py:131-159, e.g. /invoice), and webhook signature
verification (verify_webhook, client.py:162-171). Direction is both —
reads and writes QBO entities.
How an org connects it. Not through the Integrations creds page. It has its own
OAuth router (app/routers/quickbooks.py): /connect builds the Intuit consent URL,
/callback exchanges the code and stores tokens in a QuickBooksConnection row
(quickbooks.py:62-93). The app-level client id/secret is one global GoBuild
Intuit app (Settings.qbo_*, config.py:199-207); each org OAuths its own company
file, so tokens + realm_id are per-org (persisted + refreshed by
app/services/quickbooks_sync.py:42-50). Per-tenant at the data level, but via its
own connection table — not tenant_credentials.
Status. 🟢 Live-capable. is_configured() gates the Connect button on the global
app id/secret (client.py:39-47); when unset nothing runs. Sandbox/prod switch via
qbo_environment (config.py:206-212).
Gotchas. The Settings copy and entitlements advertise "QuickBooks / Xero sync"
as one feature (app/services/entitlements.py:28) — the Xero half does not exist
(see below).
What it does. Nothing. app/integrations/xero/__init__.py is empty (0 bytes)
and the package has no client, router, or service module.
Status. ❌ Scaffold / vaporware. Yet it is advertised as parity with
QuickBooks in three places:
app/services/entitlements.py:28 — "quickbooks": "QuickBooks / Xero sync".app/config.py:214-224 — a full xero_* settings block whose docstring claimsapp/models/tenant_credential.py:1 — docstring lists Xero as a BYO provider.→ Flag: advertised-but-unbuilt. Either build it or hide the "Xero" wording from
the entitlement label and any Settings copy. Setting xero_client_id/secret today
does nothing — xero_configured (config.py:223-224) is never read by any code.
What it does. Transactional email over Resend's HTTPS API
(email/resend_client.py), chosen because DigitalOcean blocks outbound SMTP (module
docstring). send_email (resend_client.py:25-73) is write-only (send).
How an org connects it. Defaults to the single shared GoLive Resend key/domain,
but the message is dressed up to represent the org: display name = org name,
reply-to = org owner (credentials.email_for, credentials.py:236-249). An org can
connect its OWN Resend key on the Integrations page to send from its own domain
(BYO white-label). Sending is one-way, so a shared key leaks no tenant data
(credentials.py:70-75).
Status. 🟢 Live. Callers resolve via email_for (e.g.
app/services/outbound_email.py:124, comms.py:150, report_digest.py:116 —
"org's OWN Resend only, no global fallback" for pooled orgs).
Gotchas. Platform/marketing mail (e.g. live_ai.py:267) calls send_email with
no creds and falls back to the bare GoBuild <updates@…> identity
(resend_client.py:48-49) — intentional, but it is the one path that bypasses the
per-org dressing.
What it does. E-signature via a self-hosted Documenso v1 REST API
(esign/documenso.py). The hub generates the PDF, creates the document, places a
SIGNATURE field per recipient (incl. multi/counter-sign), sends, and downloads the
completed PDF with its audit certificate (documenso.py:33-139). Direction: both.
How an org connects it. MANAGED — no per-org key. One global admin token
(Settings.documenso_api_token), internal calls over the docker network
(documenso:3000), signer links use the public sign.gobuild.ca
(documenso.py:14-15, config.py:241-246). Per-org data is scoped by externalId.
Status. 🟢 Live-capable (gated by is_configured() on the token,
documenso.py:22-23). Documenso is a real service in docker-compose.
Gotchas. The GET-document response returns recipientId=None, so signing URLs are
matched by email with a positional fallback (documenso.py:99-113) — fragile if
two signers share an email.
What it does. Twilio SMS over the raw REST API (no SDK) — send_sms
(sms/twilio_client.py:75-122), plus E.164 normalization (normalize_number,
:57-72) and Twilio webhook signature verification (verify_signature, :45-51).
How an org connects it. BYO / walled off. Callers resolve twilio_for(db, org)
(credentials.py:252-253) which returns the org's OWN Twilio {account_sid, auth_token, from} or None — pooled orgs never fall back to a shared account
(notify.py:70, reviews.py:58, workflows.py:195, comms.py:183). send_sms
accepts a creds override (twilio_client.py:75-87).
Status. 🟢 Live, fail-closed. Invalid/short numbers raise InvalidNumberError
(:24-33, :91-95) so callers suppress rather than retry forever.
Gotchas. verify_signature returns True when no auth token is configured
(:45-51) — "can't verify, so allow". Fine while unconfigured; make sure prod always
has the token set or inbound webhooks are effectively unauthenticated.
What it does. Provision per-channel call-tracking numbers and fetch recordings for
transcription (voice/twilio_voice.py): search_available_numbers, buy_number
(a real paid action), set_voice_url, release_number, fetch_recording
(:28-130). Direction: both (provision + read recordings). Called from
app/services/call_tracking.py:63-72 and app/routers/marketing_cc.py.
Status. 🟡 Real and used, but on a weaker credential model than SMS:
_creds() reads the global Settings.twilio_account_sid / twilio_auth_token
directly (twilio_voice.py:21-25) — it does not go through CredentialResolver
and takes no per-org override. So tracking numbers are bought on GoBuild's master
Twilio account, not the org's own.
→ Flag (credential-security / billing): a pooled org's SMS is walled off but its
tracking numbers (a paid line item) land on the shared instance account. This is a
real wall-off gap and a cost-attribution problem.
What it does. Browser web-push via pywebpush (push/webpush_client.py).
send_one (:25-52) sends one notification; a 404/410 raises PushGone so the caller
prunes the dead subscription. Write-only.
Status. ⚪ Dormant. send_one returns False immediately unless
push_configured (:29-31), which requires VAPID keys — none are set
(config.py:178). Code is complete but never fires today.
What it does. The platform AI stack, an intentional provider split (per owner):
ai/deepseek.py) — OpenAI-compatible text chat; the primary entrydeepseek.py:21-51,stream_chat :99-144).ai/gemini.py) — vision/multimodal: receipt/invoice OCRextract :195-263), image generation/branding (generate_image :75-105),grounded_search :108-150), and call-audiotranscribe_audio :153-192). Also the automatic text fallbackdeepseek.chat → gemini.chat, deepseek.py:39-51).Direction: write / one-way calls (no tenant data mixing).
How an org connects it. MANAGED — shared platform keys
(Settings.deepseek_api_key, gemini_api_key), every org uses them automatically
(credentials.py:62-68). Never a "connect your key" integration.
Status. 🟢 Live wherever the shared keys are set. Base URLs are configurable, so
swapping DeepSeek to a US-hosted inference host is a one-line .env change
(deepseek.py:3-5).
Gotchas. The ai provider block also carries a deepgram_api_key field
(credentials.py:54-57), but there is no Deepgram client module under
app/integrations/; Deepgram is referenced only inline in
app/routers/daily_logs.py / app/models/daily_log.py. Treat the Deepgram key as a
loose shared key, not a first-class integration.
What it does. Organic social publishing via the Meta Graph API
(social/meta.py): OAuth connect (auth_url / exchange_code :37-77), list the
user's Pages + long-lived Page tokens + connected IG account (get_pages :118-131),
and publish (publish_facebook_photo :97-102, publish_instagram :105-115).
Creative is rendered by Templated.io (social/templated.py) — one shared GoBuild
key rendering designer templates (render :78-107).
How an org connects it. Per-instance Meta app model: this clone's
meta_app_id/secret belong to a Meta app where the client is an admin (works in
Development Mode, no App Review). The org OAuths its own account; we keep their
long-lived Page token (encrypted), never a master token (meta.py:1-6).
Status. ⚪ Dormant. oauth_ready() is False until a Meta app is configured
(meta.py:29-30, config.py:334); Templated is gated on templated_configured
(templated.py:20-21, config.py:320). Publish code exists but has never run in
prod. Ads scopes (ads_management) are deliberately deferred (meta.py:16-22).
What it does. Google Ads account-linking only (ads/google_ads.py): OAuth
consent/exchange/refresh (:43-78) and list_accessible_customers to discover which
customer accounts the connected login manages (:81-111). Direction is read /
creative half only.
Status. 🟡 Publish unbuilt. The module docstring is explicit: "Campaign
create/edit/optimize (Phases 4-5) come later and require the developer token's Basic
access to be live" (google_ads.py:1-7). The adwords scope is RESTRICTED and needs
OAuth-consent-screen verification before non-test users can grant it (:19-21).
list_accessible_customers is explicitly best-effort and may return nothing on a Test
developer token (:82-108). Gated by google_ads_oauth_ready (config.py:282).
→ Flag: advertised as "ads" but only linking + read exist; no campaign is created
or published anywhere. Do not represent this as a working ad manager.
What it does. Two-way Google Calendar sync (gcal/client.py): OAuth
(:29-64), userinfo, and event upsert/delete/get (insert_event,
update_event, delete_event, get_event :91-118). Pushes schedule items out as
all-day events (_event_body :80-88) and reads them back. Direction: both.
How an org connects it. Per-org OAuth (google_client_id/secret/redirect_uri),
tokens stored per org.
Status. ⚪ Dormant. Gated by gcal_configured (client.py:25-26,
config.py:167); the Google client is not configured, so it never runs. Code is
complete. Errors are swallowed by callers so a calendar hiccup never blocks the
schedule edit (module docstring).
What it does. Canadian construction intelligence via RapidAPI
(builddata/client.py): search permits / development permits / inspections /
contractor profiles / city permit stats / coverage (:83-114). Read-only. Powers
permit intelligence and sub vetting. Includes a best-effort city parser
(city_from_address :26-35).
How an org connects it. BYO / walled off — per-org {api_key, host} on the
Integrations page; _get accepts explicit api_key/host (:42-65).
Status. 🟡 Real client, dormant until a key is set (is_configured()
:38-39, config.py:145). Priced per request + daily-quota limited on BASIC → the
client surfaces 429 as "daily quota reached" and callers are expected to cache
aggressively (:55-64, module docstring).
What it does. Support / omnichannel sync over the Chatwoot v1 account-scoped API
(chatwoot/client.py): contacts CRUD + labels (:53-104), conversations + messages
(:119-135), inbox/agent management (:138-170), and — notably —
send_outbound_sms which routes an SMS through Chatwoot's Twilio inbox so it
lands in the unified inbox (:187-222). Direction: both.
How an org connects it. MANAGED — one global account admin token
(chatwoot_api_token + chatwoot_account_id), internal calls over
chatwoot-rails:3000, public agent UI at chat.gobuild.ca
(client.py:13,20-33, config.py:249-257).
Status. 🟢 Live-capable (gated by is_configured() on token+account_id,
:20-22). Chatwoot is a real docker-compose service.
Gotchas. A shared Chatwoot account is explicitly called out as the reason
Chatwoot must stay MANAGED, not shared-default — a shared inbox would mix tenant
data (credentials.py:70-74). Confirm per-org inbox scoping is actually enforced if
multiple orgs ever share one Chatwoot account.
What it does. The invisible scheduling backend: job → project and schedule-item →
work-package sync (openproject/adapter.py over openproject/client.py). Creates
projects (sync_job_to_project :81-112), pushes tasks/milestones/phases with
optimistic lockVersion locking, mirrors OpenProject's computed dates back onto
hub rows (push_schedule_item :151-276), mirrors dependency relations
(add_dependency :279-321, mirror_relations :324-375), and reconciles nightly
(reconcile_project :499-542). Webhook verification is best-effort — webhooks are a
freshness optimization, the nightly poller is the source of truth
(webhooks.py:1-11). Direction: both.
How it's wired. Heavily and genuinely used — push_schedule_item /
sync_job_to_project are invoked from ~10 call sites: routers/schedule.py:72,99,
routers/jobs.py:74, services/field_capture.py, services/workload_planner.py,
services/co_schedule.py, services/ai_assistant.py, services/scheduler.py,
portal/router.py, and services/openproject_sync.py. The owner is right that it
is live — this is not dead code.
Status. 🟢 Live, but fragile — verify before trusting:
is_configured() guard anywhere. Unlike every other integration,build_client() reads the global openproject_base_url / openproject_api_keyadapter.py:29-31) with no configured-check. The defaultopenproject_base_url is http://localhost:8080 and the api_key defaults to emptyconfig.py:77-78). MANAGED, single shared instance.routers/schedule.py:70-75 callspush_schedule_item and re-raises OpenProjectError as HTTP 502 — if theopenproject_sync.py:37-38, workload_planner.py:255), so behaviour isadd_dependency writes a ScheduleDependencyfrom_wp_id/to_wp_id and no predecessor_item_id/successor_item_id (adapter.py:312-316), while mirror_relations filters onsuccessor_item_id.in_(...) and predecessor_item_id.is_not(None)adapter.py:336-339). Rows created by add_dependency are invisible tomirror_relations. The two dependency paths don't reconcile against each other —→ Flag: it is "live" in that the plumbing exists and is called everywhere, but the
missing configured-guard + the 502-on-request-path mean it behaves as broken the
moment the OpenProject instance isn't perfectly reachable. This matches the "believed
live but plumbing looks broken" concern — the risk is operational, not missing code.
What it does. One-off invoice payment links via Stripe Checkout Sessions with
inline price_data (payments/stripe_client.py): create_checkout (:35-69,
defaults to us_bank_account + card — ACH preferred for large construction
invoices), retrieve_checkout to confirm payment_status on the success redirect
(:72-85), and full webhook signature verification with replay-window protection
(verify_webhook :88-113). Direction: write (+ webhook read).
How an org connects it. BYO / walled off — create_checkout takes a
secret_key override so per-org keys apply (:38,46). Callers resolve the org's key
via preferences.stripe_config(...) (routers/money.py:602-611,
invoice_portal.py:128); a pooled org with no key simply gets no pay button (the
invoice still renders) — StripeError is swallowed (money.py:614-615).
Status. 🟢 Live wherever a key is present. Webhook verify rejects stale timestamps
(replay protection, :104-108).