Integrations
This document explains how Branofy connects workspaces to third‑party platforms (Facebook Lead Ads / Messenger, Instagram DMs, Workspace Gmail) and — importantly — how connecting and reconnecting works end to end.
All integration endpoints live in apps/api and are mirrored by the typed client
in packages/api (@branofy/api/{facebook,instagram,gmail,settings}). The
premium UI lives under apps/premium/app/(dashboard)/settings/integrations/.
| Integration | Docs | API prefix | SDK |
| --- | --- | --- | --- |
| Facebook Lead Ads + Messenger | facebook.md | /v1/facebook | @branofy/api/facebook |
| Instagram DMs | instagram.md | /v1/instagram | @branofy/api/instagram |
| Workspace Gmail | gmail.md | /v1/workspace/gmail | @branofy/api/gmail |
| Meta app + WhatsApp/CAPI creds | this file | /v1/platform-settings, /v1/workspace-settings | @branofy/api/settings |
The shared OAuth model
Every connect flow follows the same three‑legged OAuth shape, designed to work
both locally and in production despite the premium app proxying /api/* to the
NestJS API.
Browser Premium (/api proxy) NestJS API Provider
│ click "Connect" │ │ │
│ ── GET /api/<x>/connect ───▶ │ ── GET /v1/<x>/connect ─▶│ build signed-state URL │
│ ◀──────── { url } ──────────────────────────────────── │ │
│ ── window.location = url ───────────────────────────────────────────────────────▶ consent
│ │ ◀── redirect_uri w/ code ─│
│ ── GET {API}/v1/<x>/callback?code&state ───────────────▶│ verify state → exchange │
│ │ code → store token │
│ ◀──────────────── 302 → {APP}/settings/...?x=connected ─│ │
Rendered as a sequence:
sequenceDiagram
actor U as User (browser)
participant P as Premium /api proxy
participant A as NestJS API
participant X as Provider (Meta / Google)
U->>P: GET /api/<x>/connect
P->>A: GET /v1/<x>/connect
A->>A: build OAuth URL with signed state
A-->>U: { url }
U->>X: window.location = url (consent screen)
X-->>A: redirect_uri ?code & state
A->>A: verify state (HMAC, < 1h)
A->>X: exchange code → long-lived token
A->>A: upsert connection + subscribe webhooks
A-->>U: 302 → {APP}/settings/...?x=connected
Two deliberate decisions make this robust:
connectreturns{ url }(JSON), not a 302. The premium/api/*proxy usesredirect: "follow", which would otherwise swallow a redirect to the provider. The frontend fetches the URL and doeswindow.location.href = url.- The provider's
redirect_uripoints directly at the API ({API_PUBLIC_URL}/v1/<x>/callback), not at the premium proxy. The callback runs server‑side, stores the token, and issues a browser‑visible 302 back to{APP_PUBLIC_URL}/settings/integrations/....
Signed state
connect encodes the caller's organizationId into the OAuth state parameter,
HMAC‑signed with BETTER_AUTH_SECRET and stamped with a timestamp:
state = base64url( "<orgId>.<timestamp>.<hmac_sha256(orgId.timestamp)>" )
The callback verifies the signature (constant‑time) and rejects state older than 1 hour. This means the callback does not depend on a session cookie — it trusts the signed state — so it works even when the provider redirect lands on a different origin than the one that started the flow.
Reconnecting
"Reconnecting" happens whenever a user runs the connect flow again for an account that already exists. It is a first‑class, idempotent operation — not an error.
What triggers a reconnect
- Token expiry. Long‑lived tokens are finite: Instagram ~60 days, Google
access tokens ~1 hour (refreshed via the stored refresh token), Facebook page
tokens are long‑lived but can be invalidated by password changes or permission
revocation. The UI surfaces an expiry warning (e.g. Instagram shows
daysUntilExpiryand turns amber at ≤ 7 days). - Permission changes. A user re‑grants or changes scopes on the provider side.
- Page / account changes. A user connects an additional Facebook page or a second Instagram account, or switches the Gmail address.
The shared reconnect decision — connect always upserts by identity key and never duplicates or clobbers user state:
flowchart TD
S[connect runs again] --> E{connection exists<br/>for identity key?}
E -- no --> I[INSERT new connection<br/>defaults: bots off, all forms tracked]
E -- yes --> U[UPDATE in place:<br/>refresh token + expiry + profile]
U --> K[preserve user state:<br/>bot toggles, tracked forms, refresh token]
I --> W[re-subscribe webhooks]
K --> W
W --> D[done — no duplicates]
How reconnects are handled (by integration)
| Integration | Identity key | Reconnect behaviour |
| --- | --- | --- |
| Facebook | facebook_page.id (the FB page id) | connect/connect-token re‑imports pages and upserts each by page id: pageName, pageToken, igAccountId are refreshed; bot toggles and tracked forms are preserved. Webhooks are re‑subscribed every time. |
| Instagram | instagram_connections.ig_user_id (unique) | Re‑connect updates the existing row (new accessToken, fresh tokenExpiresAt, igUsername) instead of inserting a duplicate. |
| Gmail | gmail_connections.organization_id (one per workspace) | Re‑connect updates the row. Google omits refresh_token on re‑consent, so the old refresh token is preserved when the new response lacks one. |
Key invariant: reconnecting never creates duplicate connections and never loses user‑configured state (bot toggles, tracked forms, refresh tokens). It only refreshes credentials and re‑subscribes webhooks.
Disconnect → reconnect
Disconnecting deletes the stored connection row(s) for the org. A subsequent connect is a clean first‑time connect. Disconnect also best‑effort unsubscribes provider webhooks where supported.
Where credentials live
| Credential | Storage | Resolution order |
| --- | --- | --- |
| Meta App ID / Secret / verify token | organization_profiles.settings jsonb (meta_app_id, meta_app_secret, meta_webhook_verify_token) | workspace settings → env (FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET / FACEBOOK_VERIFY_TOKEN) |
| Instagram App ID / Secret | organization_profiles.settings (instagram_app_id, instagram_app_secret) | env (INSTAGRAM_APP_*) → workspace settings → Meta app |
| Google OAuth client | env only (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET) | env |
| WhatsApp Cloud creds + CAPI | organization_profiles.settings (wa_*, meta_pixel_id, meta_capi_token) | workspace settings |
| Facebook page tokens | facebook_page.page_token | per page |
| Instagram tokens | instagram_connections.access_token | per account |
| Gmail tokens | gmail_connections.access_token / refresh_token | per workspace |
Secrets read back through /v1/workspace-settings and /v1/platform-settings are
masked (••••••••). Writing a masked value back is treated as "unchanged" so
the UI never overwrites a secret with its own mask.
Required environment variables
| Var | Purpose | Fallback |
| --- | --- | --- |
| FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRET | Platform‑level Meta app | — |
| FACEBOOK_VERIFY_TOKEN | Meta webhook verification | 1Timepa$word |
| INSTAGRAM_APP_ID / INSTAGRAM_APP_SECRET | Instagram Login app | falls back to Meta app |
| GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET | Gmail OAuth | — |
| API_PUBLIC_URL | Public API base used to build redirect_uri | BETTER_AUTH_URL → http://localhost:4001 |
| APP_PUBLIC_URL | Premium base the callback bounces back to | first ALLOWED_ORIGINS → http://localhost:3001 |
| BETTER_AUTH_SECRET | Signs the OAuth state | change-me-in-production |
Redirect URIs to whitelist on the provider
- Facebook:
{API_PUBLIC_URL}/v1/facebook/callback - Instagram:
{API_PUBLIC_URL}/v1/instagram/callback - Google:
{API_PUBLIC_URL}/v1/workspace/gmail/callback
Webhook callback URLs (set on the provider)
- Facebook / Messenger / Instagram (via Page):
{APP_PUBLIC_URL}/api/facebook/webhookwith verify token =FACEBOOK_VERIFY_TOKEN.
Testing
- SDK unit tests —
packages/api/src/__tests__/{facebook,instagram,gmail,settings}.test.tsassert each client method hits the right verb/path/body. Run withyarn workspace @branofy/api test. - API e2e —
apps/api/test/e2e/specs/{facebook,instagram,gmail,settings}.e2e-spec.tscover auth gating, response shapes, validation (400/404), secret masking, and the callback error‑redirect path (which short‑circuits on invalidstate, so no external provider call is made). Run withyarn workspace api test:e2e. - Seed —
scripts/seed.tsprovisions a demo Facebook page + lead forms, Instagram account, Gmail connection, and Meta/WhatsApp/CAPI settings for theAcme Corporg so the integration UIs render in a connected state locally.
e2e specs that exercise the real OAuth token exchange are intentionally not included — they would require live provider credentials and network calls. Token exchange is covered structurally (state signing, upsert/reconnect logic) and the callbacks are verified on their deterministic error paths.