diff --git a/site/src/pages/integrations.astro b/site/src/pages/integrations.astro index d978ca63..5d00d047 100644 --- a/site/src/pages/integrations.astro +++ b/site/src/pages/integrations.astro @@ -1,506 +1,235 @@ --- import Layout from '../layouts/Layout.astro'; -import HeroAtmosphere from '../components/HeroAtmosphere.astro'; +import Cloud from '../components/Cloud.astro'; import Icon from '../components/Icon.astro'; import CTA from '../components/CTA.astro'; -// ========================================================================= -// All providers wired in the backend's integration catalog. Keep parity -// with internal/app/integration/catalog.go — the API exposes the same -// list, so listing a provider here that doesn't exist there is a 1:1 -// promise of a broken connect button. -// ========================================================================= - -// Floating dashboard mock: live connection state for the hero. Numbers -// reflect the per-tier story we tell on the deliverability page. -const mockConnections = [ - { name: 'Cloudflare', cat: 'dns', state: 'connected', detail: 'acme.com · zone live', age: '2m ago' }, - { name: 'Google Postmaster', cat: 'deliverability', state: 'connected', detail: 'acme.com · pulled daily', age: '4h ago' }, - { name: 'Calendly', cat: 'meetings', state: 'connected', detail: '3 bookings attributed', age: '12m ago' }, - { name: 'DMARC reports', cat: 'deliverability', state: 'connected', detail: 'rua= forwarder live', age: '1h ago' }, - { name: 'Microsoft SNDS', cat: 'deliverability', state: 'connected', detail: '198.51.100.4 · green', age: '6h ago' }, - { name: 'Google Sheets', cat: 'data', state: 'pending', detail: 'Q2 outbound list · awaiting OAuth', age: '—' }, -]; - -const catTone = (c: string) => { - if (c === 'deliverability') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; - if (c === 'dns') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; - if (c === 'meetings') return { dot: 'bg-rose-400', chip: 'bg-rose-50 text-rose-700' }; - return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; -}; - -const stateTone = (s: string) => { - if (s === 'connected') return { dot: 'bg-emerald-500', chip: 'bg-emerald-50 text-emerald-700' }; - if (s === 'pending') return { dot: 'bg-sky-500', chip: 'bg-sky-50 text-sky-700' }; - if (s === 'degraded') return { dot: 'bg-amber-500', chip: 'bg-amber-50 text-amber-700' }; - return { dot: 'bg-slate-400', chip: 'bg-slate-100 text-slate-600' }; -}; - -// ========================================================================= -// The categories themselves. Each one is a section on the page. -// ========================================================================= -const categories = [ +// Provider list mirrors internal/app/integration/catalog.go. +// Categories follow the model.IntegrationCategory enum. +const groups = [ { id: 'deliverability', - eyebrow: 'Deliverability moat', - headline: 'Provider truth, in the same dashboard you already live in.', - body: 'Most outreach tools stop at "we send mail." Warmbly pulls reputation, complaint, and authentication data straight from the providers and surfaces it next to each mailbox\'s health state.', + title: 'Deliverability', + sub: 'Provider-side reputation signals, ingested daily.', items: [ { name: 'Google Postmaster', - tagline: 'Domain reputation, spam-rate, and authentication ratios pulled daily.', - body: 'OAuth into Google Postmaster Tools and we pull domain reputation, IP reputation, SPF / DKIM / DMARC success ratios, and user-reported spam rate as one snapshot per day. The data joins the per-mailbox health state so a domain reputation drop downgrades all mailboxes sending on it.', + body: 'Domain reputation, spam-rate, and SPF / DKIM / DMARC success ratios from Gmail Postmaster Tools.', auth: 'OAuth', - beta: false, + status: 'live', }, { name: 'Microsoft SNDS', - tagline: 'IP reputation + complaint-rate bucket for Outlook + Hotmail.', - body: 'Paste your SNDS data-access key. We poll the CSV daily, parse the filter-result and complaint-rate columns, and translate them into the same green / yellow / red reputation tier the Postmaster integration uses, so the two providers are comparable on the same axis.', + body: 'IP reputation and complaint-rate bucket for Outlook and Hotmail.', auth: 'API key', - beta: false, + status: 'live', }, { name: 'DMARC reports', - tagline: 'Ingest aggregate (RUA) reports and flag misaligned senders.', - body: 'We mint a per-org URL you point your rua= forwarder at. Every XML report is parsed, deduped, and broken out per source IP, so an unexpected forwarder breaking SPF alignment surfaces with a hostname, not just a percentage. No third-party DMARC service required.', - auth: 'Webhook', - beta: false, + body: 'Aggregate (RUA) XML ingestion. Per-source-IP breakdown of SPF and DKIM alignment.', + auth: 'Webhook URL', + status: 'live', }, ], }, { id: 'dns', - eyebrow: 'One-click DNS', - headline: 'SPF, DKIM, DMARC, and the tracking CNAME, written for you.', - body: 'A new mailbox should take a minute to configure, not an afternoon of DNS console wrangling. Connect a DNS provider once and the records publish from inside the dashboard.', + title: 'DNS providers', + sub: 'Publish SPF, DKIM, DMARC, and the tracking CNAME from inside the dashboard.', items: [ { name: 'Cloudflare', - tagline: 'Zone-scoped API token. We verify before saving.', - body: 'Paste a token scoped to Zone:DNS:Edit. We verify it round-trips before we persist it, look up your zone ID, and publish the three records we recommend. If a record already exists, we surface the diff before overwriting.', + body: 'Zone-scoped API token. Verified on save before any record is written.', auth: 'API token', - beta: false, + status: 'live', }, { name: 'GoDaddy', - tagline: 'Write records from the dashboard with your API key:secret pair.', - body: 'GoDaddy\'s developer portal mints a key:secret. We accept both, persist them encrypted, and publish the three records on save. Reads are batched so a GoDaddy-side rate limit never partially-applies a record set.', - auth: 'API key:secret', - beta: true, + body: 'API key:secret pair from the GoDaddy developer portal.', + auth: 'API key', + status: 'beta', }, { name: 'Namecheap', - tagline: 'Same one-click setup, Namecheap API edition.', - body: 'Namecheap requires an IP allowlist on their API endpoint — we surface the egress IP you need to allow inside the connect drawer so you do not have to read their docs. Once saved, record writes round-trip in seconds.', + body: 'API token with egress IP allowlist surfaced in the connect drawer.', auth: 'API token', - beta: true, + status: 'beta', }, ], }, { id: 'meetings', - eyebrow: 'Meeting attribution', - headline: 'Stop reporting on replies. Start reporting on meetings.', - body: 'A reply is a hint. A meeting on the calendar is the real conversion event. We close the loop by accepting booking webhooks and joining them to the campaign that triggered the contact.', + title: 'Meeting bookings', + sub: 'Attribute booked meetings to the campaign that surfaced the lead.', items: [ { name: 'Calendly', - tagline: 'invitee.created webhook, joined to the campaign that surfaced the lead.', - body: 'Paste the URL we mint into Calendly\'s webhook UI. When a recipient books, we record the booking, look up the campaign that triggered them, and fire a campaign.reply_received webhook to your stack with trigger=meeting_booked so downstream dashboards know which campaign earned the meeting.', - auth: 'Webhook', - beta: false, + body: 'invitee.created webhook. Joined to the campaign that originated the contact.', + auth: 'Webhook URL', + status: 'live', }, { name: 'Cal.com', - tagline: 'Same attribution path. Open-source booking edition.', - body: 'Cal.com\'s BOOKING_CREATED event uses a different JSON shape than Calendly. We normalize both into one MeetingBooking record so reporting code does not branch by source. Self-hosted Cal.com works the same way.', - auth: 'Webhook', - beta: false, + body: 'BOOKING_CREATED webhook. Cloud and self-hosted both supported.', + auth: 'Webhook URL', + status: 'live', }, ], }, { id: 'data', - eyebrow: 'Data + sheets', - headline: 'Where your operators already keep the lists.', - body: 'Lead lists do not live in your dashboard — they live in a spreadsheet. Warmbly\'s sheets integration is two-way: we pull lead rows in for a campaign and write the status of each row back as it sends, replies, bounces, or books.', + title: 'Data', + sub: 'Lead lists in, status updates out.', items: [ { name: 'Google Sheets', - tagline: 'Pull leads in. Push send / reply / bounce / booked events back.', - body: 'OAuth a Google account with Sheets scope. Point us at a sheet ID — we read rows starting from row 2 (header convention) and append a status column to the right. The same sheet becomes both your lead source and your real-time report.', + body: 'Two-way: read rows for a campaign, append send / reply / bounce / booked events back.', auth: 'OAuth', - beta: true, + status: 'beta', }, ], }, ]; -// ========================================================================= -// Webhook events we emit. Real event types from internal/models/webhook.go. -// ========================================================================= +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 dispatched to a recipient.' }, - { name: 'campaign.email_delivered', desc: 'Receiver acknowledged delivery (250 OK or DSN-equivalent).' }, - { name: 'campaign.email_opened', desc: 'Open pixel resolved. Note: open data is unreliable at major receivers.' }, - { name: 'campaign.email_clicked', desc: 'Tracked link clicked. Dedupe applied 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/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/complaint spike or manual pause.' }, - { name: 'campaign.completed', desc: 'Last sequence step dispatched for the last recipient.' }, - { name: 'warmup.health_changed', desc: 'A mailbox transitioned between healthy / watch / throttled / quarantined / 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 ingested (SES, Postmark, etc.).' }, - { name: 'deliverability.complaint', desc: 'External complaint event ingested (FBL, ARF, in-product reports).' }, - { name: 'email_account.connected', desc: 'A new mailbox finished onboarding.' }, - { name: 'email_account.removed', desc: 'A mailbox was removed from the workspace.' }, -]; - -// ========================================================================= -// "How it connects" three-step diagram source. Visual story for the hero. -// ========================================================================= -const steps = [ - { n: '01', t: 'Authenticate', d: 'OAuth, paste an API token, or copy a webhook URL we mint per-org. Inbound providers like Calendly need nothing else.' }, - { n: '02', t: 'Route', d: 'The connection joins your org\'s integration table. Inbound traffic routes by the secret in the URL path. Outbound traffic uses the encrypted token.' }, - { n: '03', t: 'Live', d: 'Status flips to connected. The dashboard surfaces the last sync, last error, and a one-click rotate. Disconnect cascades to dependent data.' }, -]; - -// ========================================================================= -// "By the numbers" strip. Provider count, event count. -// ========================================================================= -const numbers = [ - { v: '9', u: 'providers', l: 'Calendly, Cal.com, Sheets, Postmaster, SNDS, DMARC, Cloudflare, GoDaddy, Namecheap', 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 with t=,v1=', src: 'app/webhook/service.go' }, - { v: '8', u: 'max attempts', l: 'exponential backoff, capped at 1h, abandoned after ~2h', src: 'app/webhook/service.go' }, -]; - -// ========================================================================= -// Security strip. Threading the KMS envelope story so trust is not a -// separate page-load. -// ========================================================================= -const security = [ - { t: 'OAuth tokens encrypted with AES-256-GCM', d: 'Per-user data encryption keys (DEKs) wrapped with AWS KMS. The encrypted blob lands in DynamoDB; plaintext never persists outside a TTL-bounded Redis cache.' }, - { t: 'API tokens stored as opaque blobs', d: 'Cloudflare / GoDaddy / Namecheap / SNDS keys never serialize back to the API consumer. We expose only public display fields (zone name, IP, domain) for the dashboard.' }, - { t: 'Inbound URLs carry per-org secrets', d: 'A leaked URL only affects one organization. Rotation regenerates the secret and invalidates the old one immediately.' }, - { t: 'Outbound delivery audit trail', d: 'Every dispatch attempt is persisted with response status and body excerpt. Re-replay supported. SKIP LOCKED prevents duplicate fanout across replicas.' }, -]; - -// ========================================================================= -// FAQ. Real product behaviour. -// ========================================================================= -const faq = [ - ['Do you support OAuth for everything?', - 'No. OAuth is the right choice for providers that expose a per-user identity (Google Sheets, Google Postmaster). For provider-account-scoped credentials (Cloudflare API token, SNDS data-access key) a token is both simpler and safer. The connect drawer surfaces the right method per provider.'], - ['What happens to the connection if a token expires?', - 'The status flips to degraded and the dashboard shows the provider error. We do not silently retry forever — degraded connections stop attempting new fanout until the user re-authenticates or rotates.'], - ['Can I have more than one of the same provider?', - 'Yes. The connection is unique per (org, provider, label). Useful when one organization sends from two domains and you want one Cloudflare connection per zone instead of cramming both into one token.'], - ['How fast is the inbound webhook path?', - 'Calendly/Cal.com/DMARC POSTs are accepted, persisted, and acknowledged in the same request. The fan-out to your outbound webhook subscribers happens through the same queue our internal events use, so the latency budget is bounded by the queue tick (default 2s).'], - ['What if I do not want a provider you list?', - 'You can subscribe to the raw webhook stream and build whatever integration you want on top of it. The 18 event types cover every state transition we emit. The catalog is a curated convenience layer — the API surface underneath is open.'], + 'campaign.email_sent', + 'campaign.email_delivered', + 'campaign.email_opened', + 'campaign.email_clicked', + 'campaign.email_bounced', + 'campaign.reply_received', + 'campaign.unsubscribed', + 'campaign.started', + 'campaign.paused', + 'campaign.completed', + 'warmup.health_changed', + 'warmup.placement_in_spam', + 'warmup.quarantined', + 'warmup.blocked', + 'deliverability.bounce', + 'deliverability.complaint', + 'email_account.connected', + 'email_account.removed', ]; --- - -
- + +
+
+ +
-
-
- - Integrations - - Built around the moat, not the marketplace -
- -

- Connect what already
runs your stack. +
+
Integrations
+

+ Connect what you already use.

-

- Postmaster + SNDS for provider truth. DMARC ingestion for alignment. Cloudflare-class one-click DNS. Calendly attribution for the conversion event that actually pays the bill. Plus a signed webhook stream the rest builds on. -

- -

- -
-
-
- - -
-
-
Integrations
- 5 connected · 1 pending · 0 degraded -
-
- live -
-
- - -
- {[ - { l: 'Deliverability', n: '3', c: 'bg-emerald-500' }, - { l: 'DNS', n: '1', c: 'bg-sky-500' }, - { l: 'Meetings', n: '1', c: 'bg-rose-400' }, - { l: 'Data', n: '1', c: 'bg-amber-500' }, - ].map((s) => ( -
-
- - {s.l} -
- {s.n} + +
+
+ {groups.map((g) => ( +
+
+
+
{g.title}
+

{g.sub}

- ))} -
+
{g.items.length} · integrations
+
- -
- {mockConnections.map((c) => { - const ct = catTone(c.cat); - const st = stateTone(c.state); - return ( +
+ {g.items.map((it) => (
-
-
- {c.name.charAt(0)} -
-
-
{c.name}
- - {c.cat} - -
-
- - {c.state} - -
-
{c.detail}
-
- last sync - {c.age} -
-
- ); - })} -
- - -
- 9 providers in the catalog · 18 webhook event types - - - evaluated every event - -
-
-
- app.warmbly.com/integrations -
-
-
- - -
-
-
-
Stance
-

- Why this list is short on purpose. -

-

- Every cold-outreach platform competes on the length of an integrations grid. We made the opposite call. Nine providers, all directly load-bearing for deliverability, DNS, meetings, or operator workflow. Everything else is a Zapier/Make/n8n template away. -

-
- -
- {[ - { h: 'A long list of CRM logos is a parity game.', - b: 'Customers compare "HubSpot · Salesforce · Pipedrive · Close · HighLevel" on every vendor page. The truth is most of these turn into a one-way push the first week. We would rather build one CRM connection that is genuinely two-way than ten that look the same on a logo wall.' }, - { h: 'The moat is provider truth, not provider count.', - b: 'Google Postmaster, Microsoft SNDS, and DMARC reports tell you whether the platform is working. No competitor in the cold outreach segment ingests all three. We do, and we surface the data next to each mailbox so a regression downgrades health before a customer notices replies dropping.' }, - { h: 'Conversion is meetings, not replies.', - b: 'A reply is a hint. A meeting is the conversion event. Calendly and Cal.com close the loop directly: the booking webhook joins the booking to the campaign that earned it, and the same campaign-level reporting that counts replies now counts meetings.' }, - { h: 'DNS belongs inside the dashboard.', - b: 'The worst part of mailbox onboarding is publishing three records in someone else\'s DNS console. Cloudflare, GoDaddy, and Namecheap let us write those records for the customer. We verify the API token before saving so the failure mode is at connect time, not the first failed send.' }, - ].map((c, i) => ( -
-
- {String(i + 1).padStart(2, '0')} -
{c.h}
-
-

{c.b}

-
- ))} -
-
-
- - -
-
-
-
Connection model
-

- Three steps. One drawer. -

-

- The dashboard\'s connect drawer is the same shape for every provider — only the fields change. OAuth providers launch the auth popup. Token providers paste a key. Inbound providers get a URL minted on save. -

-
- -
-
- connect drawer · v1 - same surface, every provider -
- -
- {steps.map((s) => ( -
-
- {s.n} - {s.t} -
-

{s.d}

-
- ))} -
-
-
-
- - - {categories.map((cat, idx) => ( -
-
-
-
{cat.eyebrow}
-

- {cat.headline} -

-

{cat.body}

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

{it.body}

+
+ {it.auth}
-

{it.tagline}

-

{it.body}

-
- ))} + ))} +
-
-
- ))} + ))} - -
-
-
-
-
Webhooks + API
-

- One signed stream. Everything else builds on it. -

-

- The catalog is convenience. The webhook surface is the API. Eighteen event types, HMAC-SHA256 signed in the Stripe format, retry queue with exponential backoff, full delivery history per endpoint. If a provider is not in the catalog yet, your own subscriber can do anything any of the listed integrations does. -

-
    -
  • X-Warmbly-Signature: t=<unix>,v1=<hex> on every POST.
  • -
  • Per-endpoint event-type filter. Subscribe selectively.
  • -
  • Up to 8 attempts with capped exponential backoff. Full audit trail.
  • -
  • Secret rotation invalidates the old secret immediately.
  • -
+ +
+
+
+
Webhooks & API
+

+ Build your own with a signed event stream. +

+
+
{webhookEvents.length} · event types
-
- -
-
- 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"
-  }
-}`}
+
+
+

+ Every outbound webhook is signed with HMAC-SHA256 in the Stripe format + (t=<unix>,v1=<hex>), + retried with exponential backoff up to 8 attempts, and recorded with + full delivery history per endpoint. Subscribe selectively by event type + or accept all events. +

+
    +
  • HMAC-SHA256 signature header on every POST.
  • +
  • Per-endpoint event-type filter.
  • +
  • Secret rotation invalidates the old secret immediately.
  • +
  • Delivery history retained per endpoint for debugging.
  • +
+ + Read the API docs + +
- -
-
-
Event types
- {webhookEvents.length} live +
+
+ Event types
-
+
{webhookEvents.map((e) => ( -
- {e.name} - {e.desc} -
+
{e}
))}
@@ -509,121 +238,11 @@ const faq = [
- -
-
-
-
Security
-

- Credentials sit behind the same envelope as your mailbox tokens. -

-

- Integrations do not get a different security story than the rest of the platform — the OAuth tokens you connected your Gmail with already round-trip through KMS-wrapped per-user DEKs. Integration tokens follow the same path. -

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

{s.d}

-
- ))} -
-
-
- - -
-
-
-
By the numbers
-

- Shape of the surface, in one strip. -

-

- Real values from the codebase. The catalog is short for the reason in the stance section above. -

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

- Five questions worth asking. -

-

- More depth in the developer docs. -

-
- -
- {faq.map(([q, a]) => ( -
- -
-
-

{a}

-
-
-
- ))} -
- - -
-
-