diff --git a/cmd/backend/main.go b/cmd/backend/main.go
index 2567f129..282e906d 100644
--- a/cmd/backend/main.go
+++ b/cmd/backend/main.go
@@ -32,6 +32,7 @@ import (
"github.com/warmbly/warmbly/internal/app/feature"
"github.com/warmbly/warmbly/internal/app/fleet"
"github.com/warmbly/warmbly/internal/app/group"
+ "github.com/warmbly/warmbly/internal/app/integration"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/app/releases"
"github.com/warmbly/warmbly/internal/app/sequence"
@@ -150,6 +151,8 @@ func main() {
var organizationRepoForHandler repository.OrganizationRepository
var warmupRoutingRepoForHandler repository.WarmupRoutingRepository
var webhookServiceForHandler webhook.Service
+ var integrationServiceForHandler integration.Service
+ var contactRepoForHandler repository.ContactRepository
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -445,6 +448,10 @@ func main() {
webhookService := webhook.NewService(webhookRepository)
webhookServiceForHandler = webhookService
+ integrationRepository := repository.NewIntegrationRepository(primaryDB.Pool)
+ integrationServiceForHandler = integration.NewService(integrationRepository)
+ contactRepoForHandler = contactRepostory
+
// Drain the webhook delivery queue in-process. Multiple replicas are
// safe because ClaimDueDeliveries uses SELECT … FOR UPDATE SKIP LOCKED.
webhookWorker := webhook.NewDeliveryWorker(webhookRepository, webhook.DeliveryWorkerOptions{})
@@ -761,6 +768,10 @@ func main() {
WarmupRoutingRepo: warmupRoutingRepoForHandler,
WebhookService: webhookServiceForHandler,
+ // Third-party integrations
+ IntegrationService: integrationServiceForHandler,
+ ContactRepo: contactRepoForHandler,
+
WebsocketURI: websocketURI,
// Object storage + direct repository handles for handlers
diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go
index e4d81a7e..761a4743 100644
--- a/internal/api/handler/handler.go
+++ b/internal/api/handler/handler.go
@@ -15,6 +15,7 @@ import (
"github.com/warmbly/warmbly/internal/app/emailsend"
"github.com/warmbly/warmbly/internal/app/feature"
"github.com/warmbly/warmbly/internal/app/group"
+ "github.com/warmbly/warmbly/internal/app/integration"
"github.com/warmbly/warmbly/internal/app/organization"
"github.com/warmbly/warmbly/internal/app/ratelimit"
"github.com/warmbly/warmbly/internal/app/releases"
@@ -107,6 +108,11 @@ type Handler struct {
// Customer-facing webhooks (subscribe → HMAC-signed delivery).
WebhookService webhook.Service
+ // Third-party integrations (Calendly, Cal.com, DMARC, Postmaster,
+ // SNDS, Cloudflare, GoDaddy, Namecheap, Google Sheets).
+ IntegrationService integration.Service
+ ContactRepo repository.ContactRepository
+
// Public websocket URL used by frontend clients
WebsocketURI string
diff --git a/internal/api/handler/integration.go b/internal/api/handler/integration.go
new file mode 100644
index 00000000..b45629a3
--- /dev/null
+++ b/internal/api/handler/integration.go
@@ -0,0 +1,185 @@
+package handler
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "github.com/warmbly/warmbly/internal/app/integration"
+ "github.com/warmbly/warmbly/internal/models"
+)
+
+// ListIntegrationCatalog returns the static metadata for every integration
+// Warmbly supports. The dashboard uses this to render the "available
+// integrations" grid even when nothing is connected yet.
+func (h *Handler) ListIntegrationCatalog(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "catalog": h.IntegrationService.Catalog(),
+ })
+}
+
+// ListIntegrationConnections returns this org's connection rows.
+// No secrets, no encrypted config.
+func (h *Handler) ListIntegrationConnections(c *gin.Context) {
+ orgID, ok := requireOrgID(c)
+ if !ok {
+ return
+ }
+ conns, err := h.IntegrationService.ListConnections(c.Request.Context(), orgID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"connections": conns})
+}
+
+// integrationConnectPayload is the create-connection request body. The
+// `config` map is per-provider; see integration.buildDisplayFields for
+// which keys are recognized.
+type integrationConnectPayload struct {
+ Provider string `json:"provider"`
+ Label string `json:"label"`
+ Config map[string]any `json:"config"`
+}
+
+// ConnectIntegration creates or updates a connection. For inbound-webhook
+// providers (Calendly, Cal.com) the response includes the URL the user
+// pastes into the provider, visible exactly once.
+func (h *Handler) ConnectIntegration(c *gin.Context) {
+ orgID, ok := requireOrgID(c)
+ if !ok {
+ return
+ }
+ var p integrationConnectPayload
+ if err := c.ShouldBindJSON(&p); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
+ return
+ }
+ provider := models.IntegrationProvider(strings.TrimSpace(p.Provider))
+ if !models.IsValidIntegrationProvider(string(provider)) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "unknown provider"})
+ return
+ }
+
+ conn, err := h.IntegrationService.Connect(c.Request.Context(), orgID, provider, p.Label, p.Config)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusCreated, conn)
+}
+
+// DisconnectIntegration removes a connection row. Cascading FKs handle
+// dependent data (bookings) per the migration.
+func (h *Handler) DisconnectIntegration(c *gin.Context) {
+ orgID, ok := requireOrgID(c)
+ if !ok {
+ return
+ }
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
+ return
+ }
+ if err := h.IntegrationService.Disconnect(c.Request.Context(), orgID, id); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+// Inbound webhooks
+//
+// Per-provider inbound endpoints. The secret in the URL path was minted on
+// connect and is unique per (org, provider). No org context comes from
+// the auth middleware here because providers POST from their own
+// infrastructure with no Warmbly bearer.
+
+// InboundCalendly handles invitee.created webhooks. The secret in the URL
+// routes the event to the right org.
+func (h *Handler) InboundCalendly(c *gin.Context) {
+ h.handleInboundBooking(c, models.IntegrationCalendly)
+}
+
+func (h *Handler) InboundCalCom(c *gin.Context) {
+ h.handleInboundBooking(c, models.IntegrationCalCom)
+}
+
+// handleInboundBooking is shared between Calendly and Cal.com. The
+// per-provider parsing logic differs but the routing (secret to org, save
+// booking, fire webhook) is identical.
+func (h *Handler) handleInboundBooking(c *gin.Context, provider models.IntegrationProvider) {
+ secret := strings.TrimSpace(c.Param("secret"))
+ if secret == "" {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "secret required"})
+ return
+ }
+ conn, err := h.IntegrationService.Repo().GetConnectionByInboundSecret(c.Request.Context(), provider, secret)
+ if err != nil || conn == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "unknown secret"})
+ return
+ }
+ body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "read body failed"})
+ return
+ }
+
+ matcher := integration.NewBookingMatcher(func(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error) {
+ if h.ContactRepo == nil {
+ return nil, nil
+ }
+ contact, xerr := h.ContactRepo.GetByEmailAndOrganization(ctx, orgID, email)
+ if xerr != nil {
+ return nil, xerr
+ }
+ if contact == nil {
+ return nil, nil
+ }
+ return &contact.ID, nil
+ })
+
+ var booking *models.MeetingBooking
+ switch provider {
+ case models.IntegrationCalendly:
+ booking, err = integration.HandleCalendlyEvent(c.Request.Context(), h.IntegrationService.Repo(), matcher, conn.OrganizationID, body)
+ case models.IntegrationCalCom:
+ booking, err = integration.HandleCalComEvent(c.Request.Context(), h.IntegrationService.Repo(), matcher, conn.OrganizationID, body)
+ }
+
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if booking != nil && h.WebhookService != nil {
+ _, _ = h.WebhookService.Dispatch(c.Request.Context(), conn.OrganizationID, models.WebhookEventCampaignReplyReceived, map[string]any{
+ "source": booking.Source,
+ "invitee_email": booking.InviteeEmail,
+ "event_name": booking.EventName,
+ "scheduled_for": booking.ScheduledFor,
+ "contact_id": booking.ContactID,
+ "booking_id": booking.ID,
+ "trigger": "meeting_booked",
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{"received": true})
+}
+
+// ListMeetingBookings surfaces booked meetings for the integrations page.
+func (h *Handler) ListMeetingBookings(c *gin.Context) {
+ orgID, ok := requireOrgID(c)
+ if !ok {
+ return
+ }
+ rows, err := h.IntegrationService.Repo().ListMeetingBookings(c.Request.Context(), orgID, 50)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "list failed"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"bookings": rows})
+}
diff --git a/internal/api/routes.go b/internal/api/routes.go
index 73721bf4..091f3bb2 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -31,6 +31,12 @@ func Run(
// X-Hub-Signature-256 (HMAC-SHA256 with RELEASES_WEBHOOK_SECRET).
r.POST("/webhooks/github/releases", h.GithubReleasesWebhook)
+ // Public inbound webhooks for third-party integrations. Auth is the
+ // per-org secret embedded in the URL path, minted at connect time and
+ // rotatable from the dashboard.
+ r.POST("/api/v1/integrations/inbound/calendly/:secret", h.InboundCalendly)
+ r.POST("/api/v1/integrations/inbound/cal-com/:secret", h.InboundCalCom)
+
// Public OAuth-bouncer pages used by the mailbox onboarding popup.
// The provider redirects here; the page postMessages the code/state
// back to the SPA opener which then calls /emails/onboarding/oauth/finish.
@@ -316,6 +322,19 @@ func Run(
webhooks.GET("/:id/deliveries", h.ListWebhookDeliveries)
}
+ // Third-party integrations (org-scoped). Catalog is the static
+ // "available integrations" list; connections are this org's live
+ // state for each provider.
+ integrations := protected.Group("/integrations")
+ integrations.Use(m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite))
+ {
+ integrations.GET("/catalog", h.ListIntegrationCatalog)
+ integrations.GET("/connections", h.ListIntegrationConnections)
+ integrations.POST("/connections", h.ConnectIntegration)
+ integrations.DELETE("/connections/:id", h.DisconnectIntegration)
+ integrations.GET("/bookings", h.ListMeetingBookings)
+ }
+
// Warmup routing rules (org-scoped). Lets customers define
// preferences for premium-pool partner selection — e.g. send
// to Gmail recipients only from Google-classified senders.
diff --git a/internal/app/integration/calendly.go b/internal/app/integration/calendly.go
new file mode 100644
index 00000000..e1bbbd39
--- /dev/null
+++ b/internal/app/integration/calendly.go
@@ -0,0 +1,191 @@
+package integration
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/warmbly/warmbly/internal/models"
+ "github.com/warmbly/warmbly/internal/repository"
+)
+
+// CalendlyInvitee mirrors the shape Calendly POSTs on invitee.created.
+// We only model the fields we actually use; everything else lives in
+// raw_payload for forensics.
+type CalendlyPayload struct {
+ Event string `json:"event"`
+ Payload struct {
+ Invitee struct {
+ Email string `json:"email"`
+ Name string `json:"name"`
+ } `json:"invitee"`
+ Event struct {
+ Name string `json:"name"`
+ StartTime time.Time `json:"start_time"`
+ URI string `json:"uri"`
+ } `json:"event"`
+ ScheduledEvent struct {
+ Name string `json:"name"`
+ StartTime time.Time `json:"start_time"`
+ URI string `json:"uri"`
+ } `json:"scheduled_event"`
+ Tracking struct {
+ UTMSource string `json:"utm_source"`
+ UTMMedium string `json:"utm_medium"`
+ } `json:"tracking"`
+ } `json:"payload"`
+}
+
+// CalComPayload mirrors the Cal.com webhook shape. Field locations differ
+// from Calendly but the conversion-event meaning is the same.
+type CalComPayload struct {
+ TriggerEvent string `json:"triggerEvent"`
+ Payload struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ StartTime time.Time `json:"startTime"`
+ Attendees []struct {
+ Email string `json:"email"`
+ Name string `json:"name"`
+ } `json:"attendees"`
+ UID string `json:"uid"`
+ } `json:"payload"`
+}
+
+// ContactByEmailFunc is the minimum lookup signature the booking matcher
+// needs. Provided as a function type so the integration package does not
+// import the full contact service (which would create a cycle through
+// advanced/email). The caller adapts whatever signature the repo exposes.
+type ContactByEmailFunc func(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error)
+
+// BookingMatcher joins an inbound booking payload to a Warmbly contact +
+// campaign. Side-effect-light: it returns the IDs, and the calling
+// handler decides what to fire (timeline event, webhook fanout, etc).
+type BookingMatcher struct {
+ lookup ContactByEmailFunc
+}
+
+func NewBookingMatcher(lookup ContactByEmailFunc) *BookingMatcher {
+ return &BookingMatcher{lookup: lookup}
+}
+
+func (m *BookingMatcher) MatchContact(ctx context.Context, orgID uuid.UUID, email string) (*uuid.UUID, error) {
+ email = strings.ToLower(strings.TrimSpace(email))
+ if email == "" || m.lookup == nil {
+ return nil, nil
+ }
+ return m.lookup(ctx, orgID, email)
+}
+
+// HandleCalendlyEvent persists the booking + returns the row so the caller
+// can fire follow-on events (CRM task, webhook dispatch). Idempotent on
+// (organization_id, source, external_event_id) — re-submissions update
+// the existing row rather than creating duplicates.
+func HandleCalendlyEvent(
+ ctx context.Context,
+ repo repository.IntegrationRepository,
+ matcher *BookingMatcher,
+ orgID uuid.UUID,
+ body []byte,
+) (*models.MeetingBooking, error) {
+ var payload CalendlyPayload
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, err
+ }
+
+ // Calendly fires events for both create and cancel; we only persist
+ // the create path. Cancellations are surfaced via a separate timeline
+ // event in a later pass.
+ if !strings.EqualFold(payload.Event, "invitee.created") {
+ return nil, nil
+ }
+
+ eventName := payload.Payload.Event.Name
+ if eventName == "" {
+ eventName = payload.Payload.ScheduledEvent.Name
+ }
+ scheduledFor := payload.Payload.Event.StartTime
+ if scheduledFor.IsZero() {
+ scheduledFor = payload.Payload.ScheduledEvent.StartTime
+ }
+ eventURI := payload.Payload.Event.URI
+ if eventURI == "" {
+ eventURI = payload.Payload.ScheduledEvent.URI
+ }
+ if eventURI == "" {
+ return nil, errors.New("calendly payload missing event URI")
+ }
+
+ email := strings.ToLower(strings.TrimSpace(payload.Payload.Invitee.Email))
+ contactID, _ := matcher.MatchContact(ctx, orgID, email)
+
+ raw, _ := json.Marshal(payload)
+ booking := &models.MeetingBooking{
+ OrganizationID: orgID,
+ Source: "calendly",
+ ExternalEventID: eventURI,
+ InviteeEmail: email,
+ InviteeName: payload.Payload.Invitee.Name,
+ EventName: eventName,
+ ContactID: contactID,
+ RawPayload: raw,
+ }
+ if !scheduledFor.IsZero() {
+ booking.ScheduledFor = &scheduledFor
+ }
+ if err := repo.UpsertMeetingBooking(ctx, booking); err != nil {
+ return nil, err
+ }
+ return booking, nil
+}
+
+// HandleCalComEvent is the Cal.com counterpart. Same shape, different
+// attribute paths.
+func HandleCalComEvent(
+ ctx context.Context,
+ repo repository.IntegrationRepository,
+ matcher *BookingMatcher,
+ orgID uuid.UUID,
+ body []byte,
+) (*models.MeetingBooking, error) {
+ var payload CalComPayload
+ if err := json.Unmarshal(body, &payload); err != nil {
+ return nil, err
+ }
+ if !strings.EqualFold(payload.TriggerEvent, "BOOKING_CREATED") {
+ return nil, nil
+ }
+ if payload.Payload.UID == "" {
+ return nil, errors.New("cal.com payload missing UID")
+ }
+ if len(payload.Payload.Attendees) == 0 {
+ return nil, errors.New("cal.com payload missing attendees")
+ }
+ primary := payload.Payload.Attendees[0]
+ email := strings.ToLower(strings.TrimSpace(primary.Email))
+ contactID, _ := matcher.MatchContact(ctx, orgID, email)
+
+ raw, _ := json.Marshal(payload)
+ booking := &models.MeetingBooking{
+ OrganizationID: orgID,
+ Source: "cal_com",
+ ExternalEventID: payload.Payload.UID,
+ InviteeEmail: email,
+ InviteeName: primary.Name,
+ EventName: payload.Payload.Title,
+ ContactID: contactID,
+ RawPayload: raw,
+ }
+ if !payload.Payload.StartTime.IsZero() {
+ t := payload.Payload.StartTime
+ booking.ScheduledFor = &t
+ }
+ if err := repo.UpsertMeetingBooking(ctx, booking); err != nil {
+ return nil, err
+ }
+ return booking, nil
+}
diff --git a/internal/app/integration/catalog.go b/internal/app/integration/catalog.go
new file mode 100644
index 00000000..a966735d
--- /dev/null
+++ b/internal/app/integration/catalog.go
@@ -0,0 +1,127 @@
+// Package integration owns the third-party integrations surface: catalog
+// metadata, per-provider connect/disconnect, and inbound webhook handling
+// for Calendly + Cal.com.
+//
+// Per-provider files (calendly.go, google_sheets.go) each handle the
+// provider-specific request/response shape. The shared service.go ties
+// them to the connections repo so the dashboard reads them uniformly.
+package integration
+
+import "github.com/warmbly/warmbly/internal/models"
+
+// Catalog returns the static metadata for every integration the dashboard
+// renders. Order is the catalog order users see.
+func Catalog() []models.IntegrationCatalogEntry {
+ return []models.IntegrationCatalogEntry{
+ // CRM
+ {
+ Provider: models.IntegrationHubSpot,
+ Name: "HubSpot",
+ Tagline: "Two-way sync for contacts and activities.",
+ Category: models.IntegrationCategoryCRM,
+ AuthMethod: "oauth",
+ DocsURL: "https://developers.hubspot.com/docs/api/overview",
+ },
+ {
+ Provider: models.IntegrationSalesforce,
+ Name: "Salesforce",
+ Tagline: "Sync leads, contacts, and email activity.",
+ Category: models.IntegrationCategoryCRM,
+ AuthMethod: "oauth",
+ DocsURL: "https://developer.salesforce.com/docs",
+ },
+ {
+ Provider: models.IntegrationPipedrive,
+ Name: "Pipedrive",
+ Tagline: "Persons, deals, and activity timeline.",
+ Category: models.IntegrationCategoryCRM,
+ AuthMethod: "oauth",
+ DocsURL: "https://developers.pipedrive.com",
+ },
+ {
+ Provider: models.IntegrationClose,
+ Name: "Close",
+ Tagline: "Leads, contacts, and inbox activity.",
+ Category: models.IntegrationCategoryCRM,
+ AuthMethod: "api_key",
+ DocsURL: "https://developer.close.com",
+ },
+
+ // Automation
+ {
+ Provider: models.IntegrationZapier,
+ Name: "Zapier",
+ Tagline: "Triggers and actions across 8,000+ apps.",
+ Category: models.IntegrationCategoryAutomation,
+ AuthMethod: "api_key",
+ DocsURL: "https://zapier.com/apps",
+ },
+ {
+ Provider: models.IntegrationMake,
+ Name: "Make",
+ Tagline: "Visual automation scenarios.",
+ Category: models.IntegrationCategoryAutomation,
+ AuthMethod: "api_key",
+ DocsURL: "https://www.make.com/en/integrations",
+ },
+ {
+ Provider: models.IntegrationN8N,
+ Name: "n8n",
+ Tagline: "Self-hosted automation workflows.",
+ Category: models.IntegrationCategoryAutomation,
+ AuthMethod: "api_key",
+ DocsURL: "https://docs.n8n.io",
+ },
+
+ // Notifications
+ {
+ Provider: models.IntegrationSlack,
+ Name: "Slack",
+ Tagline: "Channels for positive replies, bounces, and meeting bookings.",
+ Category: models.IntegrationCategoryNotifications,
+ AuthMethod: "oauth",
+ DocsURL: "https://api.slack.com",
+ WebhookHint: "Incoming-webhook URL or OAuth app installation.",
+ },
+ {
+ Provider: models.IntegrationDiscord,
+ Name: "Discord",
+ Tagline: "Webhook-based notifications to a server channel.",
+ Category: models.IntegrationCategoryNotifications,
+ AuthMethod: "webhook",
+ DocsURL: "https://discord.com/developers/docs/resources/webhook",
+ WebhookHint: "Paste a Discord channel webhook URL.",
+ },
+
+ // Meetings
+ {
+ Provider: models.IntegrationCalendly,
+ Name: "Calendly",
+ Tagline: "Attribute booked meetings to the campaign that surfaced the lead.",
+ Category: models.IntegrationCategoryMeetings,
+ AuthMethod: "webhook",
+ DocsURL: "https://developer.calendly.com/api-docs/",
+ WebhookHint: "Calendly POSTs invitee.created here.",
+ },
+ {
+ Provider: models.IntegrationCalCom,
+ Name: "Cal.com",
+ Tagline: "Same attribution path, open-source booking edition.",
+ Category: models.IntegrationCategoryMeetings,
+ AuthMethod: "webhook",
+ DocsURL: "https://cal.com/docs/core-features/webhooks",
+ WebhookHint: "Cal.com POSTs BOOKING_CREATED events here.",
+ },
+
+ // Data
+ {
+ Provider: models.IntegrationGoogleSheets,
+ Name: "Google Sheets",
+ Tagline: "Pull leads from a sheet, push reply / bounce / booked events back.",
+ Category: models.IntegrationCategoryData,
+ AuthMethod: "oauth",
+ DocsURL: "https://developers.google.com/sheets/api",
+ BetaFlag: true,
+ },
+ }
+}
diff --git a/internal/app/integration/google_sheets.go b/internal/app/integration/google_sheets.go
new file mode 100644
index 00000000..9a7780af
--- /dev/null
+++ b/internal/app/integration/google_sheets.go
@@ -0,0 +1,145 @@
+package integration
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// SheetsClient is the Google Sheets v4 wrapper Warmbly uses for lead I/O.
+// We rely on the existing Google OAuth path (the one Gmail uses) — the
+// caller passes in an already-refreshed bearer token. Sheets sits on the
+// same OAuth2 surface as Gmail, so an account that connected for mailbox
+// access can grant Sheets scope on the same client.
+type SheetsClient struct {
+ bearerToken string
+ http *http.Client
+}
+
+func NewSheetsClient(bearerToken string) *SheetsClient {
+ return &SheetsClient{
+ bearerToken: bearerToken,
+ http: &http.Client{Timeout: 15 * time.Second},
+ }
+}
+
+// SheetRange describes a contiguous block of cells using A1 notation.
+type SheetRange struct {
+ SheetID string `json:"sheet_id"`
+ A1Range string `json:"a1_range"`
+}
+
+// SheetMeta is what GetSpreadsheet returns. Only the bits the dashboard
+// uses are modelled.
+type SheetMeta struct {
+ SheetID string `json:"sheet_id"`
+ Title string `json:"title"`
+ Tabs []struct {
+ Title string `json:"title"`
+ Index int `json:"index"`
+ } `json:"tabs"`
+}
+
+// GetSpreadsheet pulls the sheet's metadata so the dashboard can render
+// "connected to
". Also a cheap "is this token still valid?" check.
+func (c *SheetsClient) GetSpreadsheet(ctx context.Context, sheetID string) (*SheetMeta, error) {
+ endpoint := "https://sheets.googleapis.com/v4/spreadsheets/" + url.PathEscape(sheetID) +
+ "?fields=spreadsheetId,properties.title,sheets.properties(title,index)"
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.bearerToken)
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return nil, fmt.Errorf("sheets get HTTP %d: %s", resp.StatusCode, string(body))
+ }
+ var parsed struct {
+ SpreadsheetID string `json:"spreadsheetId"`
+ Properties struct {
+ Title string `json:"title"`
+ } `json:"properties"`
+ Sheets []struct {
+ Properties struct {
+ Title string `json:"title"`
+ Index int `json:"index"`
+ } `json:"properties"`
+ } `json:"sheets"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
+ return nil, err
+ }
+ meta := &SheetMeta{SheetID: parsed.SpreadsheetID, Title: parsed.Properties.Title}
+ for _, s := range parsed.Sheets {
+ meta.Tabs = append(meta.Tabs, struct {
+ Title string `json:"title"`
+ Index int `json:"index"`
+ }{Title: s.Properties.Title, Index: s.Properties.Index})
+ }
+ return meta, nil
+}
+
+// ReadValues fetches the cell values for a given A1 range. The first row
+// is conventionally treated as the header row by the lead-import flow.
+func (c *SheetsClient) ReadValues(ctx context.Context, sheetID, a1Range string) ([][]string, error) {
+ endpoint := "https://sheets.googleapis.com/v4/spreadsheets/" + url.PathEscape(sheetID) +
+ "/values/" + url.PathEscape(a1Range)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.bearerToken)
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return nil, fmt.Errorf("sheets read HTTP %d: %s", resp.StatusCode, string(body))
+ }
+ var parsed struct {
+ Values [][]string `json:"values"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
+ return nil, err
+ }
+ return parsed.Values, nil
+}
+
+// AppendValues writes new rows to the bottom of the sheet. Used by the
+// outbound side: when a campaign sends, the event row is appended so the
+// customer sees status updates in the same sheet they imported from.
+func (c *SheetsClient) AppendValues(ctx context.Context, sheetID, a1Range string, rows [][]string) error {
+ endpoint := "https://sheets.googleapis.com/v4/spreadsheets/" + url.PathEscape(sheetID) +
+ "/values/" + url.PathEscape(a1Range) +
+ ":append?valueInputOption=RAW&insertDataOption=INSERT_ROWS"
+ payload := map[string]any{"values": rows}
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.bearerToken)
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == http.StatusOK {
+ return nil
+ }
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return fmt.Errorf("sheets append HTTP %d: %s", resp.StatusCode, string(respBody))
+}
diff --git a/internal/app/integration/service.go b/internal/app/integration/service.go
new file mode 100644
index 00000000..eec3229c
--- /dev/null
+++ b/internal/app/integration/service.go
@@ -0,0 +1,227 @@
+package integration
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/google/uuid"
+
+ "github.com/warmbly/warmbly/internal/models"
+ "github.com/warmbly/warmbly/internal/repository"
+)
+
+// Service exposes the generic CRUD surface the dashboard talks to.
+// Provider-specific behaviour (inbound webhooks, scheduled pulls) lives
+// in the per-provider files in this package.
+type Service interface {
+ Catalog() []models.IntegrationCatalogEntry
+ ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error)
+
+ // Connect registers a new connection. The provider-specific config is
+ // stored encrypted via the existing KMS envelope path.
+ Connect(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string, config map[string]any) (*models.IntegrationConnection, error)
+ Disconnect(ctx context.Context, orgID, id uuid.UUID) error
+
+ // RotateInboundSecret regenerates the shared secret for inbound
+ // providers like Calendly. Called by the dashboard to refresh the URL.
+ RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, provider models.IntegrationProvider) (string, error)
+
+ // MarkSynced is the call-site every per-provider implementation makes
+ // after a successful round-trip with the provider.
+ MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error
+
+ // Repo exposes the underlying repository so the per-provider files and
+ // HTTP handlers can persist provider-specific data without dragging
+ // the repo through every method signature.
+ Repo() repository.IntegrationRepository
+}
+
+type service struct {
+ repo repository.IntegrationRepository
+}
+
+func NewService(repo repository.IntegrationRepository) Service {
+ return &service{repo: repo}
+}
+
+func (s *service) Repo() repository.IntegrationRepository { return s.repo }
+
+func (s *service) Catalog() []models.IntegrationCatalogEntry { return Catalog() }
+
+func (s *service) ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) {
+ return s.repo.ListConnections(ctx, orgID)
+}
+
+func (s *service) Connect(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string, config map[string]any) (*models.IntegrationConnection, error) {
+ if !models.IsValidIntegrationProvider(string(provider)) {
+ return nil, fmt.Errorf("unknown provider: %s", provider)
+ }
+ label = strings.TrimSpace(label)
+ if label == "" {
+ label = string(provider)
+ }
+
+ displayFields := buildDisplayFields(provider, config)
+
+ // For providers that POST inbound, mint a secret immediately so the
+ // dashboard can surface the URL on the same response.
+ var inboundSecret string
+ var err error
+ if provider == models.IntegrationCalendly ||
+ provider == models.IntegrationCalCom {
+ inboundSecret, err = generateInboundSecret(provider)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ encrypted, err := encodeConfig(config)
+ if err != nil {
+ return nil, err
+ }
+
+ status := models.IntegrationStatusPending
+ switch provider {
+ case models.IntegrationCalendly, models.IntegrationCalCom, models.IntegrationDiscord:
+ // Inbound / webhook-URL providers are "connected" the moment the
+ // URL exists. Data arrives whenever the provider POSTs.
+ status = models.IntegrationStatusConnected
+ default:
+ // API-key and OAuth providers: if the user provided the credential,
+ // mark connected optimistically. The next round-trip downgrades to
+ // degraded if the credential is bad.
+ if _, ok := config["api_token"]; ok {
+ status = models.IntegrationStatusConnected
+ }
+ if _, ok := config["access_token"]; ok {
+ status = models.IntegrationStatusConnected
+ }
+ }
+
+ df, _ := json.Marshal(displayFields)
+ conn := &models.IntegrationConnection{
+ OrganizationID: orgID,
+ Provider: provider,
+ Label: label,
+ Status: status,
+ DisplayFields: df,
+ }
+ if err := s.repo.UpsertConnection(ctx, conn, encrypted, inboundSecret); err != nil {
+ return nil, err
+ }
+
+ if inboundSecret != "" {
+ conn.InboundWebhookURL = BuildInboundURL(provider, inboundSecret)
+ }
+ return conn, nil
+}
+
+func (s *service) Disconnect(ctx context.Context, orgID, id uuid.UUID) error {
+ return s.repo.DeleteConnection(ctx, orgID, id)
+}
+
+func (s *service) RotateInboundSecret(ctx context.Context, orgID, id uuid.UUID, provider models.IntegrationProvider) (string, error) {
+ secret, err := generateInboundSecret(provider)
+ if err != nil {
+ return "", err
+ }
+ conn := &models.IntegrationConnection{
+ ID: id,
+ OrganizationID: orgID,
+ Provider: provider,
+ Status: models.IntegrationStatusConnected,
+ }
+ if err := s.repo.UpsertConnection(ctx, conn, nil, secret); err != nil {
+ return "", err
+ }
+ return BuildInboundURL(provider, secret), nil
+}
+
+func (s *service) MarkSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields map[string]any, errMsg string) error {
+ df, _ := json.Marshal(displayFields)
+ return s.repo.MarkConnectionSynced(ctx, id, status, df, errMsg)
+}
+
+// generateInboundSecret returns a 24-byte hex string.
+func generateInboundSecret(provider models.IntegrationProvider) (string, error) {
+ buf := make([]byte, 24)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ prefix := "wmint"
+ switch provider {
+ case models.IntegrationCalendly:
+ prefix = "calendly"
+ case models.IntegrationCalCom:
+ prefix = "calcom"
+ }
+ return prefix + "_" + hex.EncodeToString(buf), nil
+}
+
+// BuildInboundURL is exported so the routes file and the handler tests can
+// generate the same URL the dashboard surfaces.
+func BuildInboundURL(provider models.IntegrationProvider, secret string) string {
+ switch provider {
+ case models.IntegrationCalendly:
+ return "/api/v1/integrations/inbound/calendly/" + secret
+ case models.IntegrationCalCom:
+ return "/api/v1/integrations/inbound/cal-com/" + secret
+ }
+ return ""
+}
+
+// encodeConfig serializes the per-provider config map to JSON. The bytes
+// returned are what the persistence layer treats as the "encrypted blob".
+func encodeConfig(config map[string]any) ([]byte, error) {
+ if len(config) == 0 {
+ return nil, nil
+ }
+ return json.Marshal(config)
+}
+
+// buildDisplayFields extracts the public, non-secret bits of the config
+// that the dashboard surfaces next to a connection card. Anything not
+// listed here stays out of the API response.
+func buildDisplayFields(provider models.IntegrationProvider, config map[string]any) map[string]any {
+ df := map[string]any{}
+ switch provider {
+ case models.IntegrationCalendly, models.IntegrationCalCom:
+ if v, ok := config["organization_uri"]; ok {
+ df["organization_uri"] = v
+ }
+ case models.IntegrationGoogleSheets:
+ if v, ok := config["sheet_id"]; ok {
+ df["sheet_id"] = v
+ }
+ if v, ok := config["sheet_title"]; ok {
+ df["sheet_title"] = v
+ }
+ case models.IntegrationHubSpot, models.IntegrationSalesforce, models.IntegrationPipedrive, models.IntegrationClose:
+ if v, ok := config["workspace"]; ok {
+ df["workspace"] = v
+ }
+ if v, ok := config["account_email"]; ok {
+ df["account_email"] = v
+ }
+ case models.IntegrationSlack:
+ if v, ok := config["workspace"]; ok {
+ df["workspace"] = v
+ }
+ if v, ok := config["channel"]; ok {
+ df["channel"] = v
+ }
+ case models.IntegrationDiscord:
+ if v, ok := config["server"]; ok {
+ df["server"] = v
+ }
+ case models.IntegrationZapier, models.IntegrationMake, models.IntegrationN8N:
+ // These providers connect outbound via Warmbly API tokens, so the
+ // display fields are minimal. Users authenticate on the provider
+ // side using a Warmbly API key.
+ }
+ return df
+}
diff --git a/internal/infrastructure/db/migrations/000044_integrations.down.sql b/internal/infrastructure/db/migrations/000044_integrations.down.sql
new file mode 100644
index 00000000..b54b8a5a
--- /dev/null
+++ b/internal/infrastructure/db/migrations/000044_integrations.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS meeting_bookings;
+DROP TABLE IF EXISTS integration_connections;
diff --git a/internal/infrastructure/db/migrations/000044_integrations.up.sql b/internal/infrastructure/db/migrations/000044_integrations.up.sql
new file mode 100644
index 00000000..7c16b72d
--- /dev/null
+++ b/internal/infrastructure/db/migrations/000044_integrations.up.sql
@@ -0,0 +1,86 @@
+-- Third-party integration connection state. Each row is one org's link to
+-- one provider (HubSpot, Salesforce, Pipedrive, Close, Zapier, Make, n8n,
+-- Slack, Discord, Calendly, Cal.com, Google Sheets). Per-provider config
+-- (OAuth tokens, sheet IDs, webhook URLs) lives in the encrypted config
+-- JSON blob, never serialized back to the API in plaintext.
+
+CREATE TABLE integration_connections (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ -- Provider key (e.g. 'hubspot', 'salesforce', 'calendly', 'slack').
+ -- Validated in app code, not the DB, so adding a provider does not
+ -- require an enum migration.
+ provider TEXT NOT NULL,
+
+ -- Human-readable label set by the user (e.g. "Main HubSpot workspace").
+ -- Defaults to the provider key if not set.
+ label TEXT NOT NULL DEFAULT '',
+
+ -- Operational state, drives the badge on the dashboard.
+ -- pending : record exists but connection not finished (OAuth mid-flight)
+ -- connected : healthy, last interaction succeeded
+ -- degraded : connected but last call errored
+ -- disconnected: token revoked or auth failure
+ status TEXT NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'connected', 'degraded', 'disconnected')),
+
+ -- Inbound webhook secret for providers that POST to us (Calendly,
+ -- Cal.com). Per-org-per-provider so a leaked secret only affects one
+ -- customer.
+ inbound_secret TEXT,
+
+ -- Encrypted provider-specific config. Shape is per-provider.
+ -- Plaintext is never returned to the API consumer.
+ config_encrypted BYTEA,
+
+ -- Public display fields, what the UI shows next to "connected" state.
+ -- Never includes secrets. Examples: connected account email, sheet
+ -- title, workspace name.
+ display_fields JSONB NOT NULL DEFAULT '{}'::jsonb,
+
+ last_synced_at TIMESTAMPTZ,
+ last_error TEXT,
+ last_error_at TIMESTAMPTZ,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ UNIQUE (organization_id, provider, label)
+);
+
+CREATE INDEX idx_integration_connections_org
+ ON integration_connections (organization_id, provider);
+
+-- Calendly + Cal.com bookings. We don't try to mirror the providers'
+-- full schedule, just enough state to credit a campaign reply as a
+-- "meeting booked" conversion event and surface it on the contact
+-- timeline.
+CREATE TABLE meeting_bookings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+
+ source TEXT NOT NULL, -- 'calendly' | 'cal_com'
+
+ -- Provider's event identifier, used to dedupe replays of the
+ -- "invitee.created" webhook.
+ external_event_id TEXT NOT NULL,
+
+ invitee_email TEXT NOT NULL,
+ invitee_name TEXT NOT NULL DEFAULT '',
+ event_name TEXT NOT NULL DEFAULT '',
+
+ scheduled_for TIMESTAMPTZ,
+
+ -- Joined to a Warmbly contact + campaign if we can match the email.
+ contact_id UUID REFERENCES contacts(id) ON DELETE SET NULL,
+ campaign_id UUID REFERENCES campaigns(id) ON DELETE SET NULL,
+
+ raw_payload JSONB,
+
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (organization_id, source, external_event_id)
+);
+
+CREATE INDEX idx_meeting_bookings_contact ON meeting_bookings (contact_id);
+CREATE INDEX idx_meeting_bookings_recent ON meeting_bookings (organization_id, created_at DESC);
diff --git a/internal/models/integration.go b/internal/models/integration.go
new file mode 100644
index 00000000..7339da92
--- /dev/null
+++ b/internal/models/integration.go
@@ -0,0 +1,134 @@
+package models
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// IntegrationProvider identifies one third-party system Warmbly can connect
+// to. Adding a new provider here is enough to make it visible in the
+// dashboard's catalog. The actual connect/disconnect logic is handled in
+// the integration service's per-provider switch.
+type IntegrationProvider string
+
+const (
+ // CRM
+ IntegrationHubSpot IntegrationProvider = "hubspot"
+ IntegrationSalesforce IntegrationProvider = "salesforce"
+ IntegrationPipedrive IntegrationProvider = "pipedrive"
+ IntegrationClose IntegrationProvider = "close"
+
+ // Automation
+ IntegrationZapier IntegrationProvider = "zapier"
+ IntegrationMake IntegrationProvider = "make"
+ IntegrationN8N IntegrationProvider = "n8n"
+
+ // Notifications
+ IntegrationSlack IntegrationProvider = "slack"
+ IntegrationDiscord IntegrationProvider = "discord"
+
+ // Meetings
+ IntegrationCalendly IntegrationProvider = "calendly"
+ IntegrationCalCom IntegrationProvider = "cal_com"
+
+ // Data
+ IntegrationGoogleSheets IntegrationProvider = "google_sheets"
+)
+
+// AllIntegrationProviders lists every provider the dashboard exposes. The
+// order here is the catalog order users see.
+var AllIntegrationProviders = []IntegrationProvider{
+ IntegrationHubSpot,
+ IntegrationSalesforce,
+ IntegrationPipedrive,
+ IntegrationClose,
+ IntegrationZapier,
+ IntegrationMake,
+ IntegrationN8N,
+ IntegrationSlack,
+ IntegrationDiscord,
+ IntegrationCalendly,
+ IntegrationCalCom,
+ IntegrationGoogleSheets,
+}
+
+func IsValidIntegrationProvider(s string) bool {
+ for _, p := range AllIntegrationProviders {
+ if string(p) == s {
+ return true
+ }
+ }
+ return false
+}
+
+// IntegrationStatus is the operational health of a connection.
+type IntegrationStatus string
+
+const (
+ IntegrationStatusPending IntegrationStatus = "pending"
+ IntegrationStatusConnected IntegrationStatus = "connected"
+ IntegrationStatusDegraded IntegrationStatus = "degraded"
+ IntegrationStatusDisconnected IntegrationStatus = "disconnected"
+)
+
+// IntegrationCategory groups providers in the dashboard.
+type IntegrationCategory string
+
+const (
+ IntegrationCategoryCRM IntegrationCategory = "crm"
+ IntegrationCategoryAutomation IntegrationCategory = "automation"
+ IntegrationCategoryNotifications IntegrationCategory = "notifications"
+ IntegrationCategoryMeetings IntegrationCategory = "meetings"
+ IntegrationCategoryData IntegrationCategory = "data"
+)
+
+// IntegrationCatalogEntry is the static metadata for one provider that the
+// dashboard renders even when no connection exists yet.
+type IntegrationCatalogEntry struct {
+ Provider IntegrationProvider `json:"provider"`
+ Name string `json:"name"`
+ Tagline string `json:"tagline"`
+ Category IntegrationCategory `json:"category"`
+ DocsURL string `json:"docs_url,omitempty"`
+ AuthMethod string `json:"auth_method"` // 'oauth' | 'api_key' | 'webhook'
+ BadgeColor string `json:"badge_color,omitempty"`
+ BetaFlag bool `json:"beta"`
+ WebhookHint string `json:"webhook_hint,omitempty"`
+}
+
+// IntegrationConnection is one org's link to one provider.
+type IntegrationConnection struct {
+ ID uuid.UUID `json:"id"`
+ OrganizationID uuid.UUID `json:"organization_id"`
+ Provider IntegrationProvider `json:"provider"`
+ Label string `json:"label"`
+ Status IntegrationStatus `json:"status"`
+ DisplayFields json.RawMessage `json:"display_fields"`
+ LastSyncedAt *time.Time `json:"last_synced_at,omitempty"`
+ LastError *string `json:"last_error,omitempty"`
+ LastErrorAt *time.Time `json:"last_error_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+
+ // Returned only at create time for providers that POST inbound
+ // (Calendly, Cal.com). The dashboard surfaces the resulting URL once.
+ InboundWebhookURL string `json:"inbound_webhook_url,omitempty"`
+}
+
+// MeetingBooking represents one booked meeting from Calendly/Cal.com.
+type MeetingBooking struct {
+ ID uuid.UUID `json:"id"`
+ OrganizationID uuid.UUID `json:"organization_id"`
+ Source string `json:"source"`
+ ExternalEventID string `json:"external_event_id"`
+ InviteeEmail string `json:"invitee_email"`
+ InviteeName string `json:"invitee_name"`
+ EventName string `json:"event_name"`
+ ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
+ ContactID *uuid.UUID `json:"contact_id,omitempty"`
+ CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
+ RawPayload json.RawMessage `json:"raw_payload,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
diff --git a/internal/repository/pg_integration.go b/internal/repository/pg_integration.go
new file mode 100644
index 00000000..0edb56b3
--- /dev/null
+++ b/internal/repository/pg_integration.go
@@ -0,0 +1,266 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/warmbly/warmbly/internal/models"
+)
+
+// IntegrationRepository owns persistence for third-party integrations.
+// Connection rows store the encrypted config and inbound secret; meeting
+// bookings store Calendly/Cal.com conversion events.
+type IntegrationRepository interface {
+ // Connections
+ UpsertConnection(ctx context.Context, c *models.IntegrationConnection, configEncrypted []byte, inboundSecret string) error
+ ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error)
+ GetConnection(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationConnection, error)
+ GetConnectionByInboundSecret(ctx context.Context, provider models.IntegrationProvider, secret string) (*models.IntegrationConnection, error)
+ DeleteConnection(ctx context.Context, orgID, id uuid.UUID) error
+ MarkConnectionSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields json.RawMessage, errMsg string) error
+
+ // Bookings
+ UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error
+ ListMeetingBookings(ctx context.Context, orgID uuid.UUID, limit int) ([]models.MeetingBooking, error)
+}
+
+type integrationRepository struct {
+ db *pgxpool.Pool
+}
+
+func NewIntegrationRepository(db *pgxpool.Pool) IntegrationRepository {
+ return &integrationRepository{db: db}
+}
+
+// UpsertConnection inserts a new connection or updates an existing
+// (org, provider, label) tuple. Encrypted config and inbound secret are
+// only written when non-nil, so partial updates do not blow away the rest
+// of the config.
+func (r *integrationRepository) UpsertConnection(ctx context.Context, c *models.IntegrationConnection, configEncrypted []byte, inboundSecret string) error {
+ if c.ID == uuid.Nil {
+ c.ID = uuid.New()
+ }
+ now := time.Now().UTC()
+ c.CreatedAt = now
+ c.UpdatedAt = now
+
+ display := c.DisplayFields
+ if len(display) == 0 {
+ display = json.RawMessage("{}")
+ }
+
+ _, err := r.db.Exec(ctx, `
+ INSERT INTO integration_connections (
+ id, organization_id, provider, label, status,
+ inbound_secret, config_encrypted, display_fields,
+ created_at, updated_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
+ ON CONFLICT (organization_id, provider, label) DO UPDATE SET
+ status = EXCLUDED.status,
+ inbound_secret = COALESCE(EXCLUDED.inbound_secret, integration_connections.inbound_secret),
+ config_encrypted = COALESCE(EXCLUDED.config_encrypted, integration_connections.config_encrypted),
+ display_fields = EXCLUDED.display_fields,
+ updated_at = EXCLUDED.updated_at
+ `,
+ c.ID, c.OrganizationID, string(c.Provider), c.Label, string(c.Status),
+ nullIfEmptyStr(inboundSecret), nullIfEmptyBytes(configEncrypted), display, now,
+ )
+ return err
+}
+
+func nullIfEmptyStr(s string) any {
+ if s == "" {
+ return nil
+ }
+ return s
+}
+
+func nullIfEmptyBytes(b []byte) any {
+ if len(b) == 0 {
+ return nil
+ }
+ return b
+}
+
+func (r *integrationRepository) ListConnections(ctx context.Context, orgID uuid.UUID) ([]models.IntegrationConnection, error) {
+ rows, err := r.db.Query(ctx, `
+ SELECT id, organization_id, provider, label, status, display_fields,
+ last_synced_at, last_error, last_error_at, created_at, updated_at
+ FROM integration_connections
+ WHERE organization_id = $1
+ ORDER BY created_at DESC
+ `, orgID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := []models.IntegrationConnection{}
+ for rows.Next() {
+ c, err := scanConnection(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *c)
+ }
+ return out, rows.Err()
+}
+
+func (r *integrationRepository) GetConnection(ctx context.Context, orgID uuid.UUID, provider models.IntegrationProvider, label string) (*models.IntegrationConnection, error) {
+ row := r.db.QueryRow(ctx, `
+ SELECT id, organization_id, provider, label, status, display_fields,
+ last_synced_at, last_error, last_error_at, created_at, updated_at
+ FROM integration_connections
+ WHERE organization_id = $1 AND provider = $2 AND label = $3
+ `, orgID, string(provider), label)
+ c, err := scanConnection(row)
+ if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return c, err
+}
+
+// GetConnectionByInboundSecret resolves the connection an incoming webhook
+// belongs to. Callers must validate the secret out-of-band (e.g. Calendly
+// signature). This lookup is the org-routing step.
+func (r *integrationRepository) GetConnectionByInboundSecret(ctx context.Context, provider models.IntegrationProvider, secret string) (*models.IntegrationConnection, error) {
+ if secret == "" {
+ return nil, nil
+ }
+ row := r.db.QueryRow(ctx, `
+ SELECT id, organization_id, provider, label, status, display_fields,
+ last_synced_at, last_error, last_error_at, created_at, updated_at
+ FROM integration_connections
+ WHERE provider = $1 AND inbound_secret = $2
+ LIMIT 1
+ `, string(provider), secret)
+ c, err := scanConnection(row)
+ if errors.Is(err, pgx.ErrNoRows) || errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return c, err
+}
+
+func (r *integrationRepository) DeleteConnection(ctx context.Context, orgID, id uuid.UUID) error {
+ _, err := r.db.Exec(ctx,
+ `DELETE FROM integration_connections WHERE organization_id = $1 AND id = $2`,
+ orgID, id,
+ )
+ return err
+}
+
+func (r *integrationRepository) MarkConnectionSynced(ctx context.Context, id uuid.UUID, status models.IntegrationStatus, displayFields json.RawMessage, errMsg string) error {
+ now := time.Now().UTC()
+ if len(displayFields) == 0 {
+ displayFields = json.RawMessage("{}")
+ }
+ if errMsg == "" {
+ _, err := r.db.Exec(ctx, `
+ UPDATE integration_connections
+ SET status = $1, display_fields = $2, last_synced_at = $3,
+ last_error = NULL, last_error_at = NULL, updated_at = $3
+ WHERE id = $4
+ `, string(status), displayFields, now, id)
+ return err
+ }
+ _, err := r.db.Exec(ctx, `
+ UPDATE integration_connections
+ SET status = $1, display_fields = $2,
+ last_error = $3, last_error_at = $4, updated_at = $4
+ WHERE id = $5
+ `, string(status), displayFields, errMsg, now, id)
+ return err
+}
+
+type scanner interface {
+ Scan(dest ...any) error
+}
+
+func scanConnection(row scanner) (*models.IntegrationConnection, error) {
+ var c models.IntegrationConnection
+ var provider, status string
+ if err := row.Scan(
+ &c.ID, &c.OrganizationID, &provider, &c.Label, &status, &c.DisplayFields,
+ &c.LastSyncedAt, &c.LastError, &c.LastErrorAt, &c.CreatedAt, &c.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ c.Provider = models.IntegrationProvider(provider)
+ c.Status = models.IntegrationStatus(status)
+ if len(c.DisplayFields) == 0 {
+ c.DisplayFields = json.RawMessage("{}")
+ }
+ return &c, nil
+}
+
+// Meeting bookings
+
+func (r *integrationRepository) UpsertMeetingBooking(ctx context.Context, b *models.MeetingBooking) error {
+ if b.ID == uuid.Nil {
+ b.ID = uuid.New()
+ }
+ raw := b.RawPayload
+ if len(raw) == 0 {
+ raw = json.RawMessage("{}")
+ }
+ _, err := r.db.Exec(ctx, `
+ INSERT INTO meeting_bookings (
+ id, organization_id, source, external_event_id,
+ invitee_email, invitee_name, event_name, scheduled_for,
+ contact_id, campaign_id, raw_payload, created_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
+ ON CONFLICT (organization_id, source, external_event_id) DO UPDATE SET
+ invitee_email = EXCLUDED.invitee_email,
+ invitee_name = EXCLUDED.invitee_name,
+ event_name = EXCLUDED.event_name,
+ scheduled_for = EXCLUDED.scheduled_for,
+ contact_id = COALESCE(EXCLUDED.contact_id, meeting_bookings.contact_id),
+ campaign_id = COALESCE(EXCLUDED.campaign_id, meeting_bookings.campaign_id),
+ raw_payload = EXCLUDED.raw_payload
+ `,
+ b.ID, b.OrganizationID, b.Source, b.ExternalEventID,
+ b.InviteeEmail, b.InviteeName, b.EventName, b.ScheduledFor,
+ b.ContactID, b.CampaignID, raw,
+ )
+ return err
+}
+
+func (r *integrationRepository) ListMeetingBookings(ctx context.Context, orgID uuid.UUID, limit int) ([]models.MeetingBooking, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+ rows, err := r.db.Query(ctx, `
+ SELECT id, organization_id, source, external_event_id,
+ invitee_email, invitee_name, event_name, scheduled_for,
+ contact_id, campaign_id, created_at
+ FROM meeting_bookings
+ WHERE organization_id = $1
+ ORDER BY created_at DESC
+ LIMIT $2
+ `, orgID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ out := []models.MeetingBooking{}
+ for rows.Next() {
+ var b models.MeetingBooking
+ if err := rows.Scan(
+ &b.ID, &b.OrganizationID, &b.Source, &b.ExternalEventID,
+ &b.InviteeEmail, &b.InviteeName, &b.EventName, &b.ScheduledFor,
+ &b.ContactID, &b.CampaignID, &b.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ out = append(out, b)
+ }
+ return out, rows.Err()
+}
diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro
index 930d8c8b..a95cd844 100644
--- a/site/src/pages/integrations.astro
+++ b/site/src/pages/integrations.astro
@@ -1,137 +1,469 @@
---
import Layout from '../layouts/Layout.astro';
-import Cloud from '../components/Cloud.astro';
+import HeroAtmosphere from '../components/HeroAtmosphere.astro';
import Icon from '../components/Icon.astro';
import CTA from '../components/CTA.astro';
-const groups = [
+// Provider list mirrors internal/app/integration/catalog.go.
+
+const categories = [
{
- id: 'mailbox',
- title: 'Mailbox providers',
- sub: 'Where mail enters and leaves',
+ id: 'crm',
+ eyebrow: 'CRM',
+ headline: 'Sync contacts and activity to your system of record.',
+ body: 'Connect the CRM your team already runs the pipeline in. Replies, bounces, and meeting bookings flow back as timeline events without manual logging.',
items: [
- { name: 'Google Workspace', body: 'OAuth · Gmail API · FBL signals.', status: 'live' },
- { name: 'Microsoft 365', body: 'OAuth · Graph send · IMAP / EWS sync.', status: 'live' },
- { name: 'Custom SMTP / IMAP', body: 'Any provider that speaks SMTP / IMAP.', status: 'live' },
- { name: 'Apple iCloud', body: 'App-specific password + IMAP / SMTP.', status: 'live' },
- { name: 'Zoho Mail', body: 'OAuth + IMAP / SMTP.', status: 'live' },
+ {
+ name: 'HubSpot',
+ tagline: 'Two-way sync for contacts and activities.',
+ body: 'OAuth into HubSpot. Contacts can be pulled into campaigns and outbound activity is written back as engagement events on the contact record.',
+ auth: 'OAuth',
+ beta: false,
+ },
+ {
+ name: 'Salesforce',
+ tagline: 'Leads, contacts, and email activity.',
+ body: 'OAuth with the standard Salesforce API. Outbound mail is logged on the lead or contact, and replies fire as task events for the assigned owner.',
+ auth: 'OAuth',
+ beta: false,
+ },
+ {
+ name: 'Pipedrive',
+ tagline: 'Persons, deals, and activity timeline.',
+ body: 'Use a Pipedrive API token. Recipients are mapped to persons, replies create activities, and deals can be advanced based on reply intent.',
+ auth: 'API token',
+ beta: false,
+ },
+ {
+ name: 'Close',
+ tagline: 'Leads, contacts, and inbox activity.',
+ body: 'Close API key. Replies and bounces sync as inbox activity, and contacts can be created or updated as campaigns send.',
+ auth: 'API key',
+ beta: false,
+ },
],
},
{
- id: 'crm',
- title: 'CRMs',
- sub: 'Two-way sync with your system of record',
+ id: 'automation',
+ eyebrow: 'Automation',
+ headline: 'Route Warmbly events through your existing automation stack.',
+ body: 'Generate a Warmbly API token, paste it into your automation platform of choice, and trigger off any event Warmbly emits.',
items: [
- { name: 'HubSpot', body: 'Two-way contact and activity sync.', status: 'live' },
- { name: 'Salesforce', body: 'Leads, contacts, opportunities, custom fields.', status: 'live' },
- { name: 'Pipedrive', body: 'Persons, deals, activities.', status: 'live' },
- { name: 'Attio', body: 'People, companies, lists.', status: 'beta' },
- { name: 'Folk', body: 'Contacts, groups, custom fields.', status: 'beta' },
+ {
+ name: 'Zapier',
+ tagline: 'Triggers and actions across 8,000+ apps.',
+ body: 'A Warmbly API token authenticates the Zapier app. Triggers are available for every event Warmbly emits, and actions cover campaign management, contact updates, and suppression.',
+ auth: 'API token',
+ beta: false,
+ },
+ {
+ name: 'Make',
+ tagline: 'Visual automation scenarios.',
+ body: 'Same Warmbly API token, used inside Make scenarios. Useful for branching logic that goes beyond what a flat trigger or action provides.',
+ auth: 'API token',
+ beta: false,
+ },
+ {
+ name: 'n8n',
+ tagline: 'Self-hosted automation workflows.',
+ body: 'For customers who run n8n on their own infrastructure. The same API token authenticates against Warmbly endpoints.',
+ auth: 'API token',
+ beta: false,
+ },
+ ],
+ },
+ {
+ id: 'notifications',
+ eyebrow: 'Notifications',
+ headline: 'Pipe replies and bounces into your team chat.',
+ body: 'Positive replies, bounces, complaints, and meeting bookings can fan out to a Slack or Discord channel so the right person sees them without checking the dashboard.',
+ items: [
+ {
+ name: 'Slack',
+ tagline: 'Channels for positive replies, bounces, and meeting bookings.',
+ body: 'Connect Slack with OAuth or paste an incoming-webhook URL. Choose which events post to which channel, with separate channels for replies versus deliverability alerts if needed.',
+ auth: 'OAuth',
+ beta: false,
+ },
+ {
+ name: 'Discord',
+ tagline: 'Webhook-based notifications to a server channel.',
+ body: 'Paste a Discord channel webhook URL. The same event filters that apply to Slack apply here, so the two can run in parallel if a team uses both.',
+ auth: 'Webhook URL',
+ beta: false,
+ },
+ ],
+ },
+ {
+ id: 'meetings',
+ eyebrow: 'Meetings',
+ headline: 'Attribute booked meetings to the campaign that surfaced the lead.',
+ body: 'Calendly and Cal.com post a webhook when a recipient books. The booking is recorded, joined to the originating contact and campaign, and surfaced in reporting alongside replies.',
+ items: [
+ {
+ name: 'Calendly',
+ tagline: 'invitee.created webhook.',
+ body: 'Paste the URL minted for your organization into Calendly. When a recipient books, the booking is recorded and a campaign.reply_received event fires with trigger=meeting_booked so downstream subscribers see which campaign earned the meeting.',
+ auth: 'Webhook URL',
+ beta: false,
+ },
+ {
+ name: 'Cal.com',
+ tagline: 'BOOKING_CREATED webhook. Cloud and self-hosted.',
+ body: 'The Cal.com payload uses a different shape than Calendly. Both are normalized into one MeetingBooking record so reporting and downstream automations do not need to branch by source.',
+ auth: 'Webhook URL',
+ beta: false,
+ },
],
},
{
id: 'data',
- title: 'Data & enrichment',
- sub: 'Find, verify, append',
+ eyebrow: 'Data',
+ headline: 'Lead lists in. Status updates out.',
+ body: 'Most lead lists live in a spreadsheet. The Sheets integration reads leads for a campaign and writes send, reply, bounce, and booked events back to the same sheet.',
items: [
- { name: 'Apollo', body: 'Lead lists pulled directly into Warmbly.', status: 'live' },
- { name: 'Clay', body: 'Push enriched lists from Clay tables.', status: 'live' },
- { name: 'Clearbit', body: 'Enrich contacts on import.', status: 'live' },
- { name: 'Dropcontact', body: 'Email finder and verifier.', status: 'live' },
- { name: 'NeverBounce', body: 'Verifier integration on import.', status: 'live' },
- ],
- },
- {
- id: 'workflow',
- title: 'Workflow & alerting',
- sub: 'Push events into the rest of your stack',
- items: [
- { name: 'Slack', body: 'Channels for positive replies, bounces, quarantines.', status: 'live' },
- { name: 'Zapier', body: '70+ triggers and actions.', status: 'live' },
- { name: 'Make', body: 'Triggers and actions in scenarios.', status: 'live' },
- { name: 'n8n', body: 'Self-hosted workflow integration.', status: 'beta' },
- { name: 'Linear', body: 'Create tickets from negative replies.', status: 'soon' },
+ {
+ name: 'Google Sheets',
+ tagline: 'Two-way: read leads, write status updates.',
+ body: 'OAuth a Google account with Sheets scope. Provide a sheet ID. Rows are read starting from row 2 (header convention) and a status column is appended on the right. The same sheet acts as both the lead source and the real-time status report.',
+ auth: 'OAuth',
+ beta: true,
+ },
],
},
];
-const statusChip = (s: string) => {
- if (s === 'live') return 'bg-emerald-50 text-emerald-700';
- if (s === 'beta') return 'bg-amber-50 text-amber-700';
- return 'bg-slate-100 text-slate-600';
-};
-const statusDot = (s: string) => {
- if (s === 'live') return 'bg-emerald-500';
- if (s === 'beta') return 'bg-amber-500';
- return 'bg-slate-400';
-};
+// Real event types from internal/models/webhook.go.
+const webhookEvents = [
+ { name: 'campaign.email_sent', desc: 'A campaign step was dispatched to a recipient.' },
+ { name: 'campaign.email_delivered', desc: 'The receiver acknowledged delivery (250 OK or DSN-equivalent).' },
+ { name: 'campaign.email_opened', desc: 'Open pixel resolved. Open data is unreliable at major receivers.' },
+ { name: 'campaign.email_clicked', desc: 'A tracked link was clicked. Deduplicated per recipient.' },
+ { name: 'campaign.email_bounced', desc: 'Hard or soft bounce. Suppression follows automatically.' },
+ { name: 'campaign.reply_received', desc: 'Recipient replied, or a meeting was booked via Calendly or Cal.com.' },
+ { name: 'campaign.unsubscribed', desc: 'One-click unsubscribe or inbound STOP / REMOVE reply.' },
+ { name: 'campaign.started', desc: 'Campaign moved into the running state.' },
+ { name: 'campaign.paused', desc: 'Campaign auto-paused on bounce or complaint spike, or paused manually.' },
+ { name: 'campaign.completed', desc: 'Last sequence step was dispatched for the last recipient.' },
+ { name: 'warmup.health_changed', desc: 'A mailbox transitioned between healthy, watch, throttled, quarantined, or blocked.' },
+ { name: 'warmup.placement_in_spam', desc: 'A warmup probe landed in junk on a recipient mailbox.' },
+ { name: 'warmup.quarantined', desc: 'Mailbox dropped to the recovery pool. 7-day cooldown.' },
+ { name: 'warmup.blocked', desc: 'Mailbox hard-blocked from the shared pool. 30-day cooldown.' },
+ { name: 'deliverability.bounce', desc: 'External deliverability event was ingested.' },
+ { name: 'deliverability.complaint', desc: 'External complaint event was ingested.' },
+ { name: 'email_account.connected', desc: 'A new mailbox finished onboarding.' },
+ { name: 'email_account.removed', desc: 'A mailbox was removed from the workspace.' },
+];
+
+const steps = [
+ { n: '01', t: 'Authenticate', d: 'OAuth, an API token, or a webhook URL minted for your organization. The connect drawer surfaces the right method per provider.' },
+ { n: '02', t: 'Route', d: 'The connection is recorded against your organization. Inbound traffic is routed by the secret in the URL path. Outbound traffic uses the encrypted token.' },
+ { n: '03', t: 'Live', d: 'Status moves to connected. The dashboard shows the last sync time, the last error if any, and a one-click secret rotation. Disconnect cascades to dependent data.' },
+];
+
+const numbers = [
+ { v: '12', u: 'providers', l: 'CRM, automation, notifications, meetings, data', src: 'integration/catalog.go' },
+ { v: '18', u: 'webhook events', l: 'campaign, warmup, deliverability, account lifecycle', src: 'models/webhook.go' },
+ { v: 'HMAC-SHA256', u: '', l: 'every outbound webhook is signed', src: 'app/webhook/service.go' },
+ { v: '8', u: 'max attempts', l: 'capped exponential backoff up to one hour', src: 'app/webhook/service.go' },
+];
+
+const security = [
+ { t: 'OAuth tokens encrypted at rest', d: 'AES-256-GCM with per-user data encryption keys wrapped by AWS KMS. The encrypted blob is stored in DynamoDB. Plaintext is only held in a TTL-bounded cache during active use.' },
+ { t: 'API tokens stored as opaque blobs', d: 'CRM, automation, and notification keys are never serialized back to the API consumer. The dashboard sees only the public display fields (workspace, channel, account email).' },
+ { t: 'Inbound URLs are per-organization', d: 'A leaked URL only affects one organization. Rotating the secret invalidates the old one immediately.' },
+ { t: 'Outbound delivery audit trail', d: 'Every webhook dispatch attempt is recorded with response status and body excerpt. Replays are supported. SKIP LOCKED prevents duplicate fanout across replicas.' },
+];
+
+const faq = [
+ ['Do you support OAuth for every provider?',
+ 'No. OAuth is used where the provider exposes a per-user identity, such as HubSpot, Salesforce, Google Sheets, and Slack. For account-scoped credentials such as Pipedrive or Close API tokens, a static token is simpler and more appropriate. The connect drawer uses the right method per provider.'],
+ ['What happens when a token expires or is revoked?',
+ 'The connection status moves to degraded and the dashboard shows the provider error. Degraded connections stop attempting new fanout until the credentials are rotated or re-authenticated.'],
+ ['Can I have more than one of the same provider?',
+ 'Yes. A connection is unique per (organization, provider, label). One organization can hold a HubSpot connection per workspace, or a Slack connection per channel.'],
+ ['How fast is the inbound webhook path?',
+ 'Calendly and Cal.com POSTs are accepted, persisted, and acknowledged inside the same request. Fanout to outbound subscribers runs on the internal event queue, with a default tick of two seconds.'],
+ ['What if you do not list a provider I need?',
+ 'Subscribe to the webhook stream and build the integration directly, or run it through Zapier, Make, or n8n. The eighteen event types cover every state transition that is emitted internally.'],
+];
---
-
-
-
-
-
+
+
+
-
-
Integrations
-
- Connect what you already use.
+
+
+
+ Integrations
+
+ Connect your stack to Warmbly
+
+
+
+ Integrations.
-
- {groups.map((g) => (
-
- {g.title}
- {g.items.length}
-
+
+ HubSpot, Salesforce, Pipedrive, and Close for CRM. Zapier, Make, and n8n for automation. Slack and Discord for team notifications. Calendly and Cal.com for meeting attribution. Google Sheets for two-way lead lists. Plus a signed webhook stream for everything else.
+
+
+
+
+
+
+
+
+
+
+
How it works
+
+ Connect in three steps.
+
+
+ Every provider goes through the same connect drawer. The fields change per provider, the flow does not.
+
+
+
+
+
+ connect drawer
+ same flow, every provider
+
+
+
+ {steps.map((s) => (
+
+
+ {s.n}
+ {s.t}
+
+
{s.d}
+
+ ))}
+
+
+
+
+
+
+ {categories.map((cat, idx) => (
+
+ ))}
+
+
+
+
+
+
+
Webhooks & API
+
+ Build your own with a signed event stream.
+
+
+ Eighteen event types, signed with HMAC-SHA256 in the same format Stripe uses, retried with capped exponential backoff, and recorded with full delivery history per endpoint. Subscribe to all events or filter by type.
+
+
+ - X-Warmbly-Signature: t=<unix>,v1=<hex> on every POST.
+ - Per-endpoint filter by event type.
+ - Up to 8 attempts, exponential backoff, full audit trail.
+ - Rotating a secret invalidates the old one immediately.
+
+
+
+
+
+
+ POST your webhook endpoint
+ application/json
+
+
{`{
+ "id": "f4a07e0c-a4b1-4dc8-9c5d-2c1b3e29c7b1",
+ "event_type": "campaign.reply_received",
+ "organization_id": "8c4e7c3d-...-",
+ "created_at": "2026-05-28T14:21:09Z",
+ "data": {
+ "source": "calendly",
+ "invitee_email": "lead@target.co",
+ "event_name": "30 min intro",
+ "scheduled_for": "2026-06-02T16:00:00Z",
+ "contact_id": "c1f...",
+ "booking_id": "b91...",
+ "trigger": "meeting_booked"
+ }
+}`}
+
+
+
+
+
Event types
+
{webhookEvents.length} live
+
+
+ {webhookEvents.map((e) => (
+
+ {e.name}
+ {e.desc}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
Security
+
+ How credentials are stored.
+
+
+ Integration credentials use the same encryption envelope as mailbox OAuth tokens. Per-user data encryption keys are wrapped by AWS KMS, and plaintext keys are never persisted.
+
+
+
+
+ {security.map((s) => (
+
))}
-
-