Page status: π‘ Partial β the connect flows below are code-grounded and accurate,
but a few of them (Meta, Google Calendar, Google Ads) are βͺ dormant in prod because
the platform-level app/keys aren't set yet. This page tells you how to connect each
one when it's live; it does not re-derive what each integration does.
This is the HOW-TO companion. For what each integration is, its direction, and its
real implementation status, read the reference catalog first:
Integrations Catalog. Don't duplicate that here β this page is
"how do I actually connect X for an org, and how do I know it worked."
Audience: internal ops + onboarding. If you're onboarding a builder and they ask "can
you turn on QuickBooks / Stripe / texting," this is the page.
Legend β connect badge (per provider below)
- π’ Connectable & live β a real connect UI/flow exists and works today for an org.
- π‘ Connectable, weaker model β works, but on a different credential model than its
siblings (e.g. reads global creds β a wall-off gap).- βͺ Dormant β connect code exists but the platform-level app/key isn't configured,
so the Connect button no-ops. Nothing an org does turns it on until ops sets the app.- β Not built β advertised but no working connect path.
There are two different places an org connects things, and they are not the same page:
Settings β Integrations (/portal/settings/integrations,
app/routers/integrations_creds.py:20-23). This is the BYO key-entry page. The
org pastes provider API keys into a form; they're encrypted at rest into
tenant_credentials via secretbox and resolved per-org at send/charge time
(app/services/credentials.py:120-134). Owner/office role only
(integrations_creds.py:22). The providers this page exposes are exactly the
PROVIDERS registry (credentials.py:29-60): stripe, twilio, email, openproject,
documenso, chatwoot, ai, builddata β no more, no less.
Per-provider OAuth routers under /portal/integrations/<name>/β¦. These are the
click-Connect / redirect-to-provider flows. They do not use the key-entry
page and do not store into tenant_credentials β each keeps its own connection
table. This is how QuickBooks, Google (Calendar), Google Ads, and Meta connect.
So: Stripe / Twilio / Resend / BuildData = paste a key. QuickBooks / Google / Meta
= click Connect and log into the provider. Mixing these up is the #1 onboarding
confusion.
Every integration resolves its keys through one function:
CredentialResolver.resolve (credentials.py:194-203). Resolution order:
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 (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 β never fall back to a shared account
(credentials.py:219-222).
| Tier | Providers | What onboarding does |
|---|---|---|
| BYO / walled-off | stripe, twilio, builddata |
Org must bring its own key. No shared fallback for pooled orgs. Empty β silent no-op. |
MANAGED (credentials.py:68) |
openproject, chatwoot, documenso, ai |
Platform-run. Every org uses the shared account automatically. Ops enters nothing per-org; never counts as a gap. |
SHARED_DEFAULT (credentials.py:75) |
email |
Defaults to the shared GoLive Resend key/domain (dressed up per-org), but an org may connect its own Resend key to white-label. |
β οΈ Encryption must be configured for the BYO key-entry page to work at all. The page
readssecretbox.is_ready()(integrations_creds.py:43); if the secret box isn't
configured, saving a key raises and the org sees an error banner
(integrations_creds.py:57-60). Check this first if "Connect" appears to do nothing on
Settings β Integrations.
Tier: per-org OAuth, global Intuit app. Connect surface: OAuth router, not the
key-entry page.
Prerequisites (platform, ops does once): a single GoBuild Intuit app with
QBO_CLIENT_ID / QBO_CLIENT_SECRET set (config.py:199-207). Until both are set,
qbo.is_configured() is False and the Connect button no-ops
(quickbooks/client.py:39-47, router guard quickbooks.py:66-67). Also register the
redirect URI on the Intuit app to match QBO_REDIRECT_URI (ends in
/portal/integrations/quickbooks/callback, config.py:202).
Sandbox vs prod: one switch β QBO_ENVIRONMENT = sandbox (default) or production
(config.py:201). It only changes the API base (sandbox-quickbooks.api.intuit.com
vs quickbooks.api.intuit.com, config.py:210-214); the OAuth endpoints are identical
for both (client.py:24-27). A sandbox app id will only OAuth against a sandbox company.
Where the org clicks it: Settings page, Integrations section β Connect QuickBooks,
which hits GET /portal/integrations/quickbooks/connect. That redirects to Intuit's
consent screen (quickbooks.py:61-69). The org picks their company file; on return,
/callback exchanges the code and stores a QuickBooksConnection row with the
realm_id (the company id), access + refresh tokens (quickbooks.py:72-107). Tokens are
per-org and auto-refreshed by app/services/quickbooks_sync.py.
Credential keys: platform QBO_CLIENT_ID, QBO_CLIENT_SECRET, QBO_ENVIRONMENT,
QBO_REDIRECT_URI, QBO_WEBHOOK_VERIFIER_TOKEN (for verify_webhook,
client.py:162-171). Per-org: nothing to type β it's the OAuth realm_id + tokens.
After connecting: the org maps cost categories β QBO expense accounts and sets the
invoice line item via the "QuickBooks accounts" card
(POST β¦/quickbooks/accounts, quickbooks.py:110-133).
How to verify it worked: callback redirects to β¦/settings?qbo=connected#integrations
(quickbooks.py:107). The QuickBooksConnection row has status="active" and a
non-empty realm_id. A failed exchange redirects with ?qbo=error; a user-denied consent
gives ?qbo=denied; app-not-configured gives ?qbo=unconfigured
(quickbooks.py:36-37, 75-87).
Common failure: if the global app id/secret aren't set, Connect silently bounces back
to Settings with qbo=unconfigured and no OAuth ever starts. Also: a sandbox app can't
connect a real company (and vice-versa) β mismatched QBO_ENVIRONMENT is the classic
"it redirected but nothing linked."
Tier: BYO / walled-off. Connect surface: key-entry page.
Prerequisites: the org has a Stripe account and can pull its API keys. secretbox
must be ready (see the encryption warning above).
Where the org clicks it: Settings β Integrations β the Stripe (payments) card.
Fields (credentials.py:30-33): Secret key (secret_key, masked), Publishable
key (publishable_key), Webhook signing secret (webhook_secret, masked). Saved
via POST /portal/settings/integrations/stripe β encrypted into tenant_credentials.
Credential keys: per-org stripe.secret_key (required), publishable_key,
webhook_secret. There is a global .env fallback (stripe_secret_key,
stripe_webhook_secret) but only for siloed orgs β a pooled org must bring its own
(credentials.py:82-84, 194-203).
Sandbox vs prod: there is no separate toggle β it's implicit in the key. A sk_test_β¦
key runs test mode, a sk_live_β¦ key runs live. Onboarding should confirm which the org
pasted.
How to verify it worked: the resolver requires secret_key present
(credentials.stripe(), credentials.py:210-212). If set, invoices render a Pay
button (Stripe Checkout, payments/stripe_client.py); callers resolve the org key via
preferences.stripe_config(...) (routers/money.py:602-611). Create a checkout on a test
invoice and confirm redirect to Stripe.
Common failure β empty creds = silent no-op: a pooled org with no Stripe key simply
gets no pay button β the invoice still renders and StripeError is swallowed
(money.py:614-615). Nothing errors loudly; onboarding must actually click "pay" to know
it's wired.
Tier: BYO / walled-off for SMS. Connect surface: key-entry page.
π‘ Wall-off gap to know up front: SMS resolves per-tenant, but Voice
(call-tracking number provisioning) reads global Twilio creds directly
(voice/twilio_voice.py:21-25) β it does not go throughCredentialResolverand
takes no per-org override. So a pooled org's texts go through its own Twilio, but
its tracking numbers are bought on GoBuild's master Twilio account. This is a real
credential-security + cost-attribution gap. See the Voice entry in the catalog.
Prerequisites: the org has a Twilio account, an Account SID + Auth Token, and a
messaging-capable From number in E.164 (+15551234567).
Where the org clicks it: Settings β Integrations β Twilio (SMS) card. Fields
(credentials.py:34-37): Account SID (account_sid), Auth token (auth_token,
masked), From number (E.164) (from). Saved to tenant_credentials.
Credential keys: per-org twilio.account_sid, auth_token, from β all three
required; the resolver returns None if any is missing (credentials.twilio(),
credentials.py:206-208). Global .env fallback exists but siloed-only.
How to verify it worked: twilio_for(db, org) returns a non-None dict
(credentials.py:252-253). Send a test text from a flow that calls it (notify.py:70,
reviews.py:58, workflows.py:195, comms.py:183). Invalid/short numbers raise
InvalidNumberError rather than silently dropping (sms/twilio_client.py:24-33).
Common failure β empty creds = silent no-op: miss any one of the three fields and
twilio_for returns None; callers treat that as do-not-send and no text goes out,
no error surfaces. Also note: inbound webhook verification returns True when no auth
token is configured (twilio_client.py:45-51) β make sure prod has the token set or
inbound webhooks are effectively unauthenticated.
Tier: SHARED_DEFAULT β works with zero setup, BYO is optional. Connect surface:
key-entry page (only if the org wants its own domain).
The default (no action needed): every org already sends email through the one 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).
Sending is one-way, so a shared key leaks no tenant data (credentials.py:70-75). So an
org can send email on day one with nothing connected.
BYO white-label (optional): to send from their own domain, the org connects its own
Resend key. Settings β Integrations β Email (Resend) card. Fields
(credentials.py:38-40): Resend API key (resend_api_key, masked), From address
(from). Once set, the resolver prefers the org's own key over the shared one
(credentials.py:194-203).
Credential keys: platform RESEND_API_KEY / RESEND_FROM (shared default,
config.py:119-120); per-org optional email.resend_api_key, email.from.
How to verify it worked: email_for(db, org) returns a sender profile
(credentials.py:236-249). For BYO, confirm the profile's from is the org's own address
(not the shared RESEND_FROM). Trigger a real send (outbound_email.py:124,
comms.py:150).
Common failure: BYO Resend key set but the sender domain isn't verified in Resend β
Resend rejects the send. Also, platform/marketing mail (live_ai.py:267) sends with no
creds and falls back to the bare GoBuild <updates@β¦> identity
(resend_client.py:48-49) β that path intentionally bypasses per-org dressing, so don't
be alarmed if a system email doesn't carry the org's name.
Tier: MANAGED β platform-run, no per-org key. Connect surface: none for the
org.
Prerequisites (platform, ops does once): DOCUMENSO_API_TOKEN set on one shared
self-hosted Documenso instance; internal calls go over the docker network
(documenso:3000); signer links use the public sign.gobuild.ca
(esign/documenso.py:14-15, config.py:241-246). Gated by is_configured() on the token
(documenso.py:22-23).
Where the org clicks it: nowhere. It shows in the key-entry PROVIDERS list
(credentials.py:44-47) but is in the MANAGED set (credentials.py:68), so every org
uses the shared instance automatically. Per-org data is scoped by externalId. Ops never
enters a per-org token and it never counts as a "gap."
How to verify it worked: send an esign request from a doc flow and confirm the signer
link resolves at sign.gobuild.ca. On the platform side, is_configured() must be true.
Common failure: if DOCUMENSO_API_TOKEN is unset platform-wide, esign silently no-ops
for everyone β this is a platform config issue, not an org onboarding one. Fragility
note: signing URLs are matched by email with a positional fallback
(documenso.py:99-113) β brittle if two signers share an email.
Tier: per-instance Meta app, per-org Page token. Connect surface: OAuth router
under /portal/integrations/meta.
βͺ Dormant today.
meta.oauth_ready()isFalseuntil a Meta app is configured
(config.py:334-337, guardsocial.py:621-622). The compose/design/schedule surface
is fully live, but publishing to Meta is stubbed until an account is connected
(social.py:1-6). Until ops creates the app, the Connect flow shows a setup checklist,
not an OAuth redirect (social.py:553-567).
Prerequisites (platform, ops does once): a Meta app where the client is an admin β
it works in Development Mode (no App Review) precisely because the org is an app admin.
Set META_APP_ID / META_APP_SECRET (config.py:303-304) and register the redirect URI
to match META_REDIRECT_URI (defaults to
β¦/portal/integrations/meta/callback, config.py:306). Also ad_token_crypto must be
ready to encrypt the Page token (social.py:623-624).
Where the org clicks it: Marketing β Social β Connect Facebook & Instagram β
GET /portal/integrations/meta/connect (social.py:615-630). The org logs into their own
Meta account; on callback we list their Pages (get_pages) and, if there are several, show
a Page picker (social.py:633-687). We store the chosen Page id + IG account + the
encrypted long-lived Page token β never a master token (_finalize, social.py:601-612).
Credential keys: platform META_APP_ID, META_APP_SECRET, META_REDIRECT_URI;
per-org the encrypted Page token on the social account row.
How to verify it worked: the callback redirects to β¦/social?meta=connected and the
account row's status="connected" with an fb_page_id set. Flags for the other outcomes:
meta=unconfigured (no app), meta=nokey (token crypto not ready), meta=denied,
meta=state (CSRF mismatch), meta=nopages (social.py:596-651).
Common failure: with no Meta app set, Connect bounces to meta=unconfigured and no
OAuth starts β the org can design and schedule posts, but they'll never publish. Ads scopes
(ads_management) are deliberately deferred (social/meta.py:16-22).
Two separate Google integrations, two separate OAuth routers, two separate Google Cloud
OAuth clients. Don't conflate them.
Tier: per-org OAuth. Connect surface: /portal/integrations/google.
Prerequisites (platform): GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET set
(config.py:162-168); gcal_configured gates the flow. Redirect URI must match
GOOGLE_REDIRECT_URI (β¦/portal/integrations/google/callback, config.py:164). Dormant
in prod because the client isn't configured (catalog: gcal βͺ).
Where the org clicks it: Settings β Integrations β Connect Google Calendar β
GET /portal/integrations/google/connect (gcal.py:27-35). Owner role only
(gcal.py:18-19). On callback we store a GoogleCalendarConnection (tokens + the
connected Google email), default calendar_id="primary", and immediately push existing
schedule items so the calendar fills (gcal.py:38-72).
How to verify it worked: redirects to β¦/settings?gcal=connected#integrations; the
connection row is enabled=True with a google_email. Force a sync with
POST β¦/google/sync, which returns β¦?gcal=synced-<pushed>-<pulled> (gcal.py:90-101).
Common failure: if the Google client isn't configured, Connect bounces to
gcal=unconfigured (gcal.py:32-33) β no OAuth. Calendar errors are otherwise swallowed
so a hiccup never blocks a schedule edit β meaning a broken sync can be silent.
Tier: per-org OAuth, global app. Connect surface: /portal/integrations/google-ads.
Prerequisites (platform): GOOGLE_ADS_CLIENT_ID / GOOGLE_ADS_CLIENT_SECRET,
GOOGLE_ADS_DEVELOPER_TOKEN, and the MCC GOOGLE_ADS_LOGIN_CUSTOMER_ID
(config.py:270-275). google_ads_oauth_ready gates the Connect button
(ads.py:38, config.py:282-285). Redirect URI matches GOOGLE_ADS_REDIRECT_URI
(β¦/portal/integrations/google-ads/callback, config.py:275).
What it actually does: account-linking + read only. OAuth + list_accessible_customers
to discover which customer accounts the login manages (ads/google_ads.py:81-111).
No campaign create/edit exists β that's deferred and needs the developer token's Basic
access + consent-screen verification for the restricted adwords scope
(google_ads.py:1-21). Do not represent this as a working ad manager.
Common failure: on a Test developer token, list_accessible_customers is best-effort
and may return nothing even after a successful OAuth (google_ads.py:82-108).
| Provider | Badge | Surface | Who enters what | Sandbox/prod switch |
|---|---|---|---|---|
| QuickBooks | π’ | OAuth /β¦/quickbooks |
Org OAuths company; ops sets global app | QBO_ENVIRONMENT |
| Stripe | π’ | Key-entry page | Org pastes sk_β¦ + webhook secret |
implicit in sk_test/sk_live |
| Twilio (SMS) | π‘ | Key-entry page | Org pastes SID + token + From | none |
| Voice (tracking #s) | π‘ | none (global) | Ops global Twilio only β wall-off gap | none |
| Resend (email) | π’ shared-default | Key-entry (BYO only) | Nothing by default; optional own key | none |
| Documenso | π’ MANAGED | none | Ops sets platform token; org does nothing | none |
| Meta (FB/IG) | βͺ dormant | OAuth /β¦/meta |
Org OAuths Page; ops sets Meta app | Dev Mode (no App Review) |
| Google Calendar | βͺ dormant | OAuth /β¦/google |
Org OAuths; ops sets Google client | none |
| Google Ads | βͺ/π‘ link-only | OAuth /β¦/google-ads |
Org OAuths; ops sets ads app + dev token | Test vs Basic dev token |
Empty creds β silent no-op. Almost every integration is written to fail closed and
quiet: twilio_for / email_for return None β callers don't send
(credentials.py:219-222); Stripe with no key β no pay button, StripeError swallowed
(money.py:614-615); an unconfigured OAuth app β Connect bounces back with an
β¦=unconfigured flag and no redirect. Nothing throws a red error in the org's face. So
onboarding must actively verify β click Pay, send a test text, check for the
β¦=connected redirect flag β rather than trust that a saved form means a live integration.
app/services/credentials.py:1-79 (the fail-closed rule).