Adds OAuth-backed integration connection management across the API, repository, event dispatch, migrations, docs, and dashboard UI. Includes realtime invalidation and small dashboard type compatibility fixes needed for the web typecheck gate.
6.9 KiB
Integrations — OAuth provider setup
Warmbly's integrations connect via real OAuth 2.0 wherever the provider supports it (HubSpot, Slack, Google Sheets, Pipedrive, Salesforce). The connect flow, CSRF/PKCE handling, token exchange, encrypted-at-rest token storage, and automatic refresh are all built in. The only thing the platform operator must supply is one developer app per provider, registered in that provider's console, plus its client ID/secret as environment variables.
Until a provider's credentials are present, it renders in the dashboard catalog as "Coming soon" (the Connect button is disabled) — no code change is needed to light it up; just add the env vars and restart the backend.
How it works (no code changes required to enable a provider)
- User clicks Connect with X in the dashboard.
- SPA calls
POST /integrations/oauth/start→ backend mints a CSRFstate(+ PKCE verifier where supported), stores it inintegration_oauth_states, and returns the provider authorization URL. - SPA opens that URL in a popup. The user authorizes in the provider's UI.
- The provider redirects to
GET /integrations/oauth/callback(a public bouncer page) whichpostMessages{code, state}back to the SPA opener. - SPA calls
POST /integrations/oauth/finish→ backend validates+consumes thestate, exchanges the code for tokens, resolves the connected account, and stores the access/refresh tokens sealed with the connecting user's envelope-encryption DEK (KMS → per-user DEK → AES-GCM, the same path used for mailbox OAuth tokens). Plaintext tokens never touch a database column.
Access tokens are refreshed automatically (60s before expiry) using the stored
refresh token; if a refresh fails the connection flips to reauth_required and
the user is prompted to reconnect from the connection drawer.
Required environment variables
Set these on the backend service. The redirect/callback URL must be allow- listed in each provider's app config.
# Shared callback URL the providers redirect to. Defaults to
# $BACKEND_PUBLIC_URL/integrations/oauth/callback, else http://localhost:8080/...
INTEGRATIONS_OAUTH_REDIRECT_URL=https://api.yourdomain.com/integrations/oauth/callback
# HubSpot — https://developers.hubspot.com/ (create a public app)
HUBSPOT_OAUTH_CLIENT_ID=
HUBSPOT_OAUTH_CLIENT_SECRET=
# Slack — https://api.slack.com/apps (OAuth & Permissions → bot scopes:
# chat:write, channels:read, groups:read)
SLACK_OAUTH_CLIENT_ID=
SLACK_OAUTH_CLIENT_SECRET=
# Google Sheets — https://console.cloud.google.com/ (OAuth client, enable the
# Google Sheets API; scopes: spreadsheets, userinfo.email)
GOOGLE_SHEETS_OAUTH_CLIENT_ID=
GOOGLE_SHEETS_OAUTH_CLIENT_SECRET=
# Pipedrive — https://developers.pipedrive.com/ (Marketplace Manager app;
# scopes: contacts:full, deals:full)
PIPEDRIVE_OAUTH_CLIENT_ID=
PIPEDRIVE_OAUTH_CLIENT_SECRET=
# Salesforce — https://developer.salesforce.com/ (Connected App;
# scopes: api, refresh_token)
SALESFORCE_OAUTH_CLIENT_ID=
SALESFORCE_OAUTH_CLIENT_SECRET=
The env var prefix is <PROVIDER>_OAUTH_CLIENT_ID / _CLIENT_SECRET — wiring a
new OAuth provider is just registering it in internal/app/integration/oauth.go
(NewOAuthManager) and adding the matching env vars.
Redirect URL to register with each provider
<INTEGRATIONS_OAUTH_REDIRECT_URL>
# e.g. https://api.yourdomain.com/integrations/oauth/callback
For local development the default is http://localhost:8080/integrations/oauth/callback.
Providers that do NOT use OAuth
- Close — no public OAuth app; the user pastes a Close API key.
- Zapier / Make / n8n — authenticate into Warmbly using a scoped Warmbly API key the user generates under Settings → API keys.
- Discord — the user pastes a channel webhook URL (outbound only).
- Calendly / Cal.com — inbound only; Warmbly mints a signed inbound URL the user pastes into the provider's webhook config.
All pasted secrets are sealed with the same envelope encryption as OAuth tokens before they are stored — nothing sensitive is ever persisted in plaintext.
How a user actually uses an integration
Connecting is step one; the value is the automations a user builds on a connection. After connecting, the user opens the connection's management drawer (click any connected card on the Integrations page) and adds rules:
When a prospect replies — only positive replies, ≥60% confidence — notify
#saleswith “🔥 {{contact_email}} is interested — {{subject}}”.
Each rule is fully customizable in the UI (no code, no API keys to paste):
- Trigger — which Warmbly event fires the rule (reply, bounce, unsubscribe, warmup-health, complaint, meeting booked).
- Filters (reply triggers) — restrict to specific reply intents
(
positive,question,negative, …) and a minimum classifier confidence. - Destination — Slack channel, Google Sheet ID, or an outbound URL, depending on the provider.
- Message template — a custom string with
{{placeholder}}substitution over the event payload ({{contact_email}},{{subject}},{{intent}},{{campaign_id}},{{reason}}, …).
Rules are stored in integration_event_subscriptions.config (JSONB) and applied
at dispatch time by internal/app/integration/dispatch.go
(subscriptionMatchesFilter, renderTemplate).
Event-driven actions — the wiring
Examples of trigger → action pairs:
campaign.reply_received→slack.notify(ping #sales)campaign.reply_received→hubspot.upsert_contact(create/update + log note)campaign.email_bounced→discord.notify- meeting booked (Calendly/Cal.com) → Slack / CRM
Events reach integration actions through the webhook dispatch sink
(webhook.Service.WireDispatchSink), so any event already delivered to customer
webhooks also drives integration actions. The sink is wired in both binaries:
cmd/backend— deliverability ingest (bounce/complaint/unsubscribe), meeting-booked, email-account lifecycle, warmup health.cmd/consumer— inbound campaign replies. The consumer is where replies are detected (advanced.ProcessIncomingReply), which now emitscampaign.reply_receivedwithintent/confidencein the payload. This is what makes "notify me when a prospect replies → Slack/CRM" fire for real.
advanced.Service exposes WireDispatcher(EventDispatcher) (see
internal/app/advanced/events.go); ProcessIncomingReply and
IngestDeliverabilityEvent call emit(...) to fan their events out.
Each executed action is recorded as an integration_sync_runs row for
observability and surfaced in the connection drawer's "Recent activity".
Access / gating
Integrations are a paid-plan feature (enforced in the integration handlers
via FeatureGateService.IsPaidOrganization). Browsing the catalog is open so
non-paid orgs see what's available; connecting requires an active paid plan.