From 7ea238c99a7d2182c19eb577b91d47ba447054c3 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 9 Jun 2026 08:05:26 +0000 Subject: [PATCH] feat: redesign integrations page --- site/src/pages/integrations.astro | 909 +++++++++++++++++++----------- 1 file changed, 594 insertions(+), 315 deletions(-) diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro index b1d79ab5..93e8ee5d 100644 --- a/site/src/pages/integrations.astro +++ b/site/src/pages/integrations.astro @@ -4,378 +4,643 @@ import HeroAtmosphere from '../components/HeroAtmosphere.astro'; import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; -// Provider list mirrors internal/app/integration/catalog.go. +// =========================================================================== +// Integrations page. Every provider, auth method, event and guarantee here is +// real, sourced from: +// internal/app/integration/catalog.go (11 providers · 4 categories) +// internal/models/integration.go (oauth | api_key | webhook) +// internal/models/webhook.go (AllWebhookEventTypes · 21 events) +// internal/app/webhook/service.go (HMAC-SHA256 signing + backoff) +// +// The page is built around one idea: a single Warmbly event fans out to every +// connected tool at once. That framing (not a flat provider directory) drives +// the structure: the bus diagram, the three connect methods, a filterable +// marketplace, the signed event stream, and the delivery anatomy. +// +// Astro/JSX note (mirrors crm.astro): index comparisons like `i < n - 1` are +// fine inside {} and ${}; literal `{{ }}` in markup is not. +// =========================================================================== -const categories = [ +// --- Per-brand glyph tints (mirror IntegrationsMock.astro / ProviderGlyph) --- +const brand: Record = { + hubspot: { bg: 'bg-orange-50', ring: 'ring-orange-200', text: 'text-orange-600' }, + salesforce: { bg: 'bg-sky-50', ring: 'ring-sky-200', text: 'text-sky-600' }, + pipedrive: { bg: 'bg-emerald-50', ring: 'ring-emerald-200', text: 'text-emerald-600' }, + close: { bg: 'bg-blue-50', ring: 'ring-blue-200', text: 'text-blue-600' }, + zapier: { bg: 'bg-orange-50', ring: 'ring-orange-200', text: 'text-orange-600' }, + make: { bg: 'bg-violet-50', ring: 'ring-violet-200', text: 'text-violet-600' }, + n8n: { bg: 'bg-rose-50', ring: 'ring-rose-200', text: 'text-rose-600' }, + slack: { bg: 'bg-fuchsia-50', ring: 'ring-fuchsia-200', text: 'text-fuchsia-600' }, + discord: { bg: 'bg-indigo-50', ring: 'ring-indigo-200', text: 'text-indigo-600' }, + calendly: { bg: 'bg-cyan-50', ring: 'ring-cyan-200', text: 'text-cyan-600' }, + cal_com: { bg: 'bg-slate-100', ring: 'ring-slate-300', text: 'text-slate-700' }, + webhooks: { bg: 'bg-slate-100', ring: 'ring-slate-300', text: 'text-slate-700' }, +}; + +// --- The catalog · real providers from catalog.go ---------------------------- +const authLabel: Record = { + oauth: 'OAuth', + api_key: 'API key', + webhook: 'Webhook URL', +}; + +type Provider = { + key: string; name: string; initial: string; cat: string; + auth: 'oauth' | 'api_key' | 'webhook'; tagline: string; docs: string; +}; + +const providers: Provider[] = [ + // CRM + { key: 'hubspot', name: 'HubSpot', initial: 'H', cat: 'crm', auth: 'oauth', tagline: 'Push positive replies and new leads straight into your CRM.', docs: 'https://developers.hubspot.com/docs/api/overview' }, + { key: 'salesforce', name: 'Salesforce', initial: 'S', cat: 'crm', auth: 'oauth', tagline: 'Sync leads, contacts, and email activity to Salesforce.', docs: 'https://developer.salesforce.com/docs' }, + { key: 'pipedrive', name: 'Pipedrive', initial: 'P', cat: 'crm', auth: 'oauth', tagline: 'Persons, deals, and an activity timeline that stays in sync.', docs: 'https://developers.pipedrive.com' }, + { key: 'close', name: 'Close', initial: 'C', cat: 'crm', auth: 'api_key', tagline: 'Leads, contacts, and inbox activity for Close.', docs: 'https://developer.close.com' }, + // Automation + { key: 'zapier', name: 'Zapier', initial: 'Z', cat: 'automation', auth: 'api_key', tagline: 'Triggers and actions across 8,000+ apps.', docs: 'https://zapier.com/apps' }, + { key: 'make', name: 'Make', initial: 'M', cat: 'automation', auth: 'api_key', tagline: 'Visual automation scenarios.', docs: 'https://www.make.com/en/integrations' }, + { key: 'n8n', name: 'n8n', initial: 'n', cat: 'automation', auth: 'api_key', tagline: 'Self-hosted automation workflows.', docs: 'https://docs.n8n.io' }, + // Notifications + { key: 'slack', name: 'Slack', initial: 'S', cat: 'notifications', auth: 'oauth', tagline: 'Real-time alerts for positive replies, bounces, and deliverability.', docs: 'https://api.slack.com' }, + { key: 'discord', name: 'Discord', initial: 'D', cat: 'notifications', auth: 'webhook', tagline: 'Webhook-based notifications to a server channel.', docs: 'https://discord.com/developers/docs/resources/webhook' }, + // Meetings + { key: 'calendly', name: 'Calendly', initial: 'C', cat: 'meetings', auth: 'webhook', tagline: 'Attribute booked meetings to the campaign that surfaced the lead.', docs: 'https://developer.calendly.com/api-docs/' }, + { key: 'cal_com', name: 'Cal.com', initial: 'C', cat: 'meetings', auth: 'webhook', tagline: 'Same attribution path, open-source booking edition.', docs: 'https://cal.com/docs/core-features/webhooks' }, +]; + +const catMeta: { key: string; label: string }[] = [ + { key: 'all', label: 'All' }, + { key: 'crm', label: 'CRM' }, + { key: 'automation', label: 'Automation' }, + { key: 'notifications', label: 'Notifications' }, + { key: 'meetings', label: 'Meetings' }, +]; +const catLabel: Record = { + crm: 'CRM', automation: 'Automation', notifications: 'Notifications', meetings: 'Meetings', +}; + +// --- Fan-out · one event, every destination ---------------------------------- +const fanout: { key: string; name: string; icon?: string; initial?: string; tag: string; does: string }[] = [ + { key: 'hubspot', name: 'HubSpot', initial: 'H', tag: 'CRM', does: 'Contact upserted, the reply logged as a timeline note.' }, + { key: 'slack', name: 'Slack', initial: 'S', tag: 'Notifications', does: 'A channel pinged with the prospect, mailbox, and thread.' }, + { key: 'zapier', name: 'Zapier', initial: 'Z', tag: 'Automation', does: 'Your Zap fires and branches across 8,000+ apps.' }, + { key: 'webhooks', name: 'Your endpoint', icon: 'webhook', tag: 'Webhook', does: 'A signed POST lands on your own server in real time.' }, +]; + +// --- Three connect methods · real auth methods ------------------------------- +const methods = [ { - 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: '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, - }, - ], + n: '01', icon: 'lock', name: 'OAuth', + used: 'HubSpot · Salesforce · Pipedrive · Slack', + body: 'A one-click authorization handshake. You approve scopes inside the provider, and Warmbly stores only the encrypted token. Revoke from either side at any time.', + foot: 'Token sealed with AES-256-GCM', }, { - 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: '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, - }, - ], + n: '02', icon: 'key', name: 'API key', + used: 'Close · Zapier · Make · n8n', + body: 'For account-scoped tools and automation platforms. Paste a provider key, or mint a scoped Warmbly key for Zapier, Make and n8n to authenticate against your data.', + foot: 'Stored opaque, never returned', }, { - 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', - 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: '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, - }, - ], + n: '03', icon: 'webhook', name: 'Webhook URL', + used: 'Calendly · Cal.com · Discord', + body: 'Warmbly mints an inbound URL per organization. Paste it into the provider, and their POSTs route to you by the secret in the path. Rotate it to invalidate instantly.', + foot: 'Per-org URL · rotate to revoke', }, ]; -// 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' }, +// --- The signed event stream · 21 real webhook event types ------------------- +const eventGroups: { domain: string; color: string; events: [string, string][] }[] = [ + { + domain: 'Account', color: '#0ea5e9', + events: [ + ['email_account.connected', 'A mailbox finished onboarding.'], + ['email_account.removed', 'A mailbox left the workspace.'], + ], + }, + { + domain: 'Campaign', color: '#6366f1', + events: [ + ['campaign.email_sent', 'A sequence step dispatched to a recipient.'], + ['campaign.email_delivered', 'The receiver acknowledged delivery.'], + ['campaign.email_opened', 'Open pixel resolved. Unreliable at scale.'], + ['campaign.email_clicked', 'A tracked link was clicked, deduped per recipient.'], + ['campaign.email_bounced', 'Hard or soft bounce. Suppression follows.'], + ['campaign.reply_received', 'A prospect replied, or a meeting was booked.'], + ['campaign.unsubscribed', 'One-click unsubscribe or a STOP reply.'], + ['campaign.started', 'Campaign moved into the running state.'], + ['campaign.paused', 'Auto-paused on a spike, or paused by hand.'], + ['campaign.completed', 'The last step dispatched for the last recipient.'], + ['campaign.deliverability_warning', 'A campaign tripped a deliverability guardrail.'], + ['campaign.action', 'A sequence action node executed.'], + ], + }, + { + domain: 'Warmup', color: '#f59e0b', + events: [ + ['warmup.email_sent', 'A warmup message went out to a pool partner.'], + ['warmup.health_changed', 'A mailbox moved between health states.'], + ['warmup.placement_in_spam', 'A warmup probe landed in junk.'], + ['warmup.quarantined', 'Mailbox dropped to the recovery pool. 7-day cooldown.'], + ['warmup.blocked', 'Mailbox hard-blocked from the pool. 30-day cooldown.'], + ], + }, + { + domain: 'Deliverability', color: '#10b981', + events: [ + ['deliverability.bounce', 'An external bounce event was ingested.'], + ['deliverability.complaint', 'An external complaint event was ingested.'], + ], + }, +]; +const eventCount = eventGroups.reduce((a, g) => a + g.events.length, 0); + +// --- Delivery anatomy · real backoff from webhook/service.go ----------------- +// backoffFor: attempt 1 → 30s, then 1m, 2m, 4m, 8m, 16m, 32m, capped 60m. +const backoff = ['30s', '1m', '2m', '4m', '8m', '16m', '32m', '1h']; + +const guarantees = [ + { icon: 'lock', k: 'HMAC-SHA256 signed', v: 'X-Warmbly-Signature', d: 'Every POST carries t=,v1=, the same scheme Stripe uses. Verify the digest before you trust the body.' }, + { icon: 'clock', k: 'Retried with backoff', v: 'up to 8 attempts', d: 'A non-2xx doubles the wait each try, capped at one hour, then gives up. Nothing is dropped silently.' }, + { icon: 'shuffle', k: 'Idempotent + deduped', v: 'replay-safe', d: 'Each delivery carries a stable id, and SKIP LOCKED keeps two replicas from fanning the same event out twice.' }, + { icon: 'list', k: 'Full audit trail', v: 'every attempt', d: 'Status, response, and a body excerpt are recorded per attempt. Replay any delivery from the dashboard.' }, ]; +// --- Security ---------------------------------------------------------------- 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 PostgreSQL. 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.' }, + { icon: 'lock', t: 'OAuth tokens encrypted at rest', d: 'AES-256-GCM with a per-user data key wrapped by AWS KMS. Plaintext lives only in a TTL-bounded cache during active use.' }, + { icon: 'key', t: 'API keys are opaque blobs', d: 'CRM, automation and notification keys are never serialized back to the API. The dashboard sees only public display fields.' }, + { icon: 'link', t: 'Inbound URLs are per-organization', d: 'A leaked URL touches one organization. Rotating the secret invalidates the old path immediately.' }, + { icon: 'shield', t: 'SSRF-guarded by default', d: 'Outbound webhook targets are HTTPS-only and blocked from obvious internal addresses. Self-hosted can opt into unsafe URLs.' }, ]; -const faq = [ +// --- FAQ --------------------------------------------------------------------- +const faq: [string, string][] = [ + ['How does one event reach four tools at once?', + 'Every state change Warmbly makes publishes one internal event. Connected integrations and your own webhook endpoints each subscribe to the event types they care about, and the fan-out runs on an internal queue. A single campaign.reply_received can upsert a HubSpot contact, ping Slack, fire a Zap, and POST your endpoint, all from one reply.'], ['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.'], + 'No, and on purpose. OAuth is used where the provider exposes a per-user identity: HubSpot, Salesforce, Pipedrive and Slack. Account-scoped tools like Close, and automation platforms like Zapier, Make and n8n, authenticate with an API key. Calendly, Cal.com and Discord use a webhook URL. The connect drawer picks 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.'], + 'The connection moves to a degraded state and the dashboard surfaces the provider error. Degraded connections stop attempting new fan-out until you rotate the key or re-authenticate, so a dead token never silently drops events on the floor.'], + ['How are booked meetings attributed to a campaign?', + 'Calendly POSTs invitee.created and Cal.com POSTs BOOKING_CREATED to the URL we mint. Both are normalized into one booking record, joined to the originating contact and campaign, and a campaign.reply_received fires with trigger=meeting_booked so downstream subscribers see which sequence earned the meeting.'], + ['Can I run more than one of the same provider?', + 'Yes. A connection is unique per organization, provider and label, so you can hold a HubSpot connection per workspace or a Slack connection per channel, side by side.'], ['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.'], + 'Subscribe to the webhook stream and build it directly, or route it through Zapier, Make or n8n. The twenty-one event types cover every state transition Warmbly emits internally, each HMAC-signed in the same format Stripe uses.'], ]; --- - +
-
+
Integrations - Connect your stack to Warmbly + Eleven providers, one signed event stream

- Integrations. + One reply, everywhere
your team already works.

- 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. + Every event Warmbly fires rides one wire to your whole stack. A positive reply upserts your CRM, pings Slack, fires a Zap, and POSTs your own endpoint, all at once. Connect over OAuth, an API key, or a webhook URL, then build the rest on a signed stream.

- -
+ +
-
-
How it works
+
+
The wire

- Connect in three steps. + One event. Every destination.

- Every provider goes through the same connect drawer. The fields change per provider, the flow does not. + Integrations are not bolt-on connectors that each poll for changes. Every state change publishes one internal event, and each connected tool subscribes to what it cares about. A single positive reply reaches your CRM, your team chat, your automation platform, and your own server in the same beat.

-
-
- connect drawer - same flow, every provider +
+
+ +
+
+ + + + + event fired +
+ campaign.reply_received +
+
"trigger": "positive_reply",
+
"contact": "rowan@northwind.co",
+
"campaign": "q1-outbound",
+
"mailbox": "ben@acme.com"
+
+
+ fans out to {fanout.length} subscribers + +
+
+ + +
+ + +
+ {fanout.map((d) => { + const b = brand[d.key] ?? brand.webhooks; + return ( +
+ + +
+
+ {d.icon ? : {d.initial}} +
+
+
+ {d.name} + {d.tag} +
+

{d.does}

+
+ +
+
+ ); + })} +
+
-
- {steps.map((s) => ( -
-
- {s.n} - {s.t} -
-

{s.d}

-
- ))} +
+ {eventCount} event types ride the same wire + Fan-out runs on the internal event queue
- - {categories.map((cat, idx) => ( -
-
-
-
{cat.eyebrow}
-

- {cat.headline} -

-

{cat.body}

-
+ +
+
+
+
Connecting
+

+ Three ways in. One connect drawer. +

+

+ The method follows the provider, not the other way around. OAuth where there is a per-user identity, an API key for account-scoped tools and automation platforms, and a minted webhook URL for inbound bookings. The drawer surfaces the right one; the flow is the same every time. +

+
-
- {cat.items.map((it) => ( -
-
-
-
- {it.name.charAt(0)} -
-
-
{it.name}
-
- {it.auth}{it.beta && · beta} -
-
-
-
-

{it.tagline}

-

{it.body}

+
+ {methods.map((m) => ( +
+
+ + + + {m.n}
+
{m.name}
+
{m.used}
+

{m.body}

+
+ + {m.foot} +
+
+ ))} +
+
+
+ + +
+
+
+
+
Catalog
+

+ Eleven providers, four categories. +

+

+ The whole directory the dashboard renders, in catalog order. Filter by what you are wiring up. Each tile shows the real authentication method, so you know what you will paste before you start. +

+
+ +
+ {catMeta.map((c) => ( + ))}
-
- ))} - -
-
-
-
-
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.
  • -
+
+ {providers.map((p) => { + const b = brand[p.key] ?? brand.webhooks; + return ( +
+
+
+
{p.initial}
+
+
{p.name}
+
{catLabel[p.cat]}
+
+
+ + {authLabel[p.auth]} + +
+

{p.tagline}

+
+ + + Docs + + + Connect + + +
+
+ ); + })} +
+ +

+ Plus on-demand Google Sheets lead sync, which reads a spreadsheet into your contacts from the Contacts tab rather than the catalog. +

+
+
+ + +
+
+
+
Meetings
+

+ A booked meeting knows which campaign earned it. +

+

+ Calendly and Cal.com are two different webhook shapes. Warmbly normalizes both into one booking record, joins it to the contact and the campaign that surfaced the lead, and fires a reply event so the rest of your stack treats a booking like the win it is. +

+
    +
  • One inbound URL per organization, minted for you. Paste it into the provider.
  • +
  • invitee.created and BOOKING_CREATED become one record.
  • +
  • Reporting credits the meeting to the sequence and mailbox that opened the door.
  • +
+
+ +
+ +
+
+ + + +
+
Calendly · invitee.created
+
inbound webhook received
+
+ + booked + +
+
+ {[['Invitee', 'rowan@northwind.co'], ['Event', '30 min intro'], ['Scheduled', 'Jun 12 · 4:00 PM'], ['Campaign', 'q1-outbound']].map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
-
-
-
- POST your webhook endpoint - application/json -
-
{`{
+        
+        
+ + + +
+ + +
+
+ + + + emitted event +
+
+
{'{'}
+
"event_type": "campaign.reply_received",
+
"trigger": "meeting_booked",
+
"source": "calendly",
+
"campaign_id": "cmp · q1-outbound"
+
{'}'}
+
+
+ Calendly + Cal.com → one record + credited to the campaign +
+
+
+
+
+ + +
+
+
+
Webhooks & API
+

+ Not on the list? Subscribe to the stream. +

+

+ {eventCount} 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 everything, or filter to the handful you act on. +

+
    +
  • X-Warmbly-Signature: t=<unix>,v1=<hex> on every POST.
  • +
  • Per-endpoint filter by event type.
  • +
  • Rotate a secret and the old one dies immediately.
  • +
+ + Read the developer docs + + +
+ +
+ +
+
+ POST your endpoint + application/json +
+
{`POST /your/webhook  HTTP/1.1
+X-Warmbly-Signature: t=1748443269,v1=9f2a…c1b7
+
+{
   "id": "f4a07e0c-a4b1-4dc8-9c5d-2c1b3e29c7b1",
   "event_type": "campaign.reply_received",
-  "organization_id": "8c4e7c3d-...-",
+  "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"
-  }
+  "data": { "trigger": "positive_reply", "campaign": "q1-outbound" }
 }`}
-
+
-
-
-
Event types
- {webhookEvents.length} live -
-
- {webhookEvents.map((e) => ( -
- {e.name} - {e.desc} + +
+
+
Event types
+ {eventCount} live +
+
+ {eventGroups.map((g) => ( +
+
+ + {g.domain} + {g.events.length} +
+
+ {g.events.map(([name, desc]) => ( +
+ {name} + {desc} +
+ ))} +
+
+ ))} +
+
+
+
+
+ + +
+
+
+
Delivery
+

+ What happens after we POST you. +

+

+ A webhook you cannot trust or cannot recover is not a webhook. Every delivery is signed so you can verify it, retried so a blip never costs you an event, deduped so a retry never doubles up, and recorded so you can replay it. +

+
+ + +
+
+
+ + Capped exponential backoff +
+ non-2xx → retry · doubles each time · cap 1h · 8 attempts +
+
+
+
+
+ {backoff.map((w, i) => ( +
+ + {w} + {i === 0 ? 'send' : `retry ${i}`}
))}
+
+ first attempt + backoff window + final attempt, then given up +
+ + +
+ {guarantees.map((g) => ( +
+ + + +
{g.k}
+ {g.v} +

{g.d}

+
+ ))} +
- -
-
-
-
Security
+ +
+
+
+
Security

- How credentials are stored. + Credentials are sealed, not 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. +

+ 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 written to disk.

-
+
{security.map((s) => (
- -
{s.t}
+ + + +
{s.t}

{s.d}

))} @@ -383,44 +648,20 @@ const faq = [
- -
-
-
-
At a glance
-

- What the catalog covers. -

-
- -
- {numbers.map((n) => ( -
-
- {n.v} - {n.u && {n.u}} -
-
{n.l}
-
{n.src}
-
- ))} -
-
-
- - -
+ +
-
FAQ
+
Integrations FAQ

- Common questions. + The questions we get a lot.

More detail in the developer docs.

-
{faq.map(([q, a]) => (
@@ -460,11 +701,49 @@ const faq = [
+ + + + +