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:

    1. connect returns { url } (JSON), not a 302. The premium /api/* proxy uses redirect: "follow", which would otherwise swallow a redirect to the provider. The frontend fetches the URL and does window.location.href = url.
    2. The provider's redirect_uri points 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 daysUntilExpiry and 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/webhook with verify token = FACEBOOK_VERIFY_TOKEN.

    Testing

    • SDK unit tests — packages/api/src/__tests__/{facebook,instagram,gmail,settings}.test.ts assert each client method hits the right verb/path/body. Run with yarn workspace @branofy/api test.
    • API e2e — apps/api/test/e2e/specs/{facebook,instagram,gmail,settings}.e2e-spec.ts cover auth gating, response shapes, validation (400/404), secret masking, and the callback error‑redirect path (which short‑circuits on invalid state, so no external provider call is made). Run with yarn workspace api test:e2e.
    • Seed — scripts/seed.ts provisions a demo Facebook page + lead forms, Instagram account, Gmail connection, and Meta/WhatsApp/CAPI settings for the Acme Corp org 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.