Webhooks

How FastFollow uses webhooks

FastFollow ingests real-time events from every integrated service — calendars, CRMs, transcription providers, and email infrastructure. Outbound webhooks (FastFollow → your endpoints) are on the near-term roadmap.

Inbound — available today

Third-party services (Google, Resend, HubSpot, etc.) push events to FastFollow. You don't register these directly — FastFollow sets them up automatically when you connect each integration.

Every inbound endpoint verifies a provider-specific signature before processing. Verification failures return HTTP 401 and never touch your data.

Outbound — on the roadmap

Outbound webhooks let your systems subscribe to FastFollow events without polling. The system is in design — the event catalog and security model below are stable, but the subscription API is not yet live.

If outbound webhooks are blocking your project, reach out — we're running a private beta.

Inbound webhook endpoints

Each connected integration pushes events to a dedicated endpoint

Google Calendar

/api/webhooks/calendar
Live
Events:event.created, event.updated, event.deleted
Auth:Google Calendar Watch channel ID + token validation
Effect:Triggers transcript fetch from Drive when a Meet recording finishes. Drives the auto-ingestion pipeline.

Microsoft Graph

/api/webhooks/microsoft
Live
Events:event.created, event.updated, message.created
Auth:Microsoft Graph subscription validation handshake + clientState
Effect:Same role as Google Calendar for Outlook + Teams users.

HubSpot

/api/webhooks/hubspot
Live
Events:contact.propertyChange, deal.propertyChange, deal.creation, deal.deletion
Auth:X-HubSpot-Signature-v3 (HMAC-SHA256)
Effect:Real-time CRM sync. Property changes propagate to FastFollow within seconds.

Resend

/api/webhooks/resend
Live
Events:email.sent, email.delivered, email.bounced, email.complained, email.opened, email.clicked
Auth:Svix signature (svix-id, svix-timestamp, svix-signature)
Effect:Updates follow-up delivery status, drives the suppression list, and feeds engagement signals.

Recall.ai

/api/webhooks/recall
Live
Events:bot.status_change, transcript.ready
Auth:Svix signature (HMAC-SHA256)
Effect:Notifies FastFollow when a recording bot finishes and a transcript is ready for ingest.

Fathom

/api/webhooks/fathom
Live
Events:call.ended, transcript.ready
Auth:X-Fathom-Signature (HMAC-SHA256)
Effect:Ingests Fathom call transcripts and triggers AI follow-up generation.

Gong

/api/webhooks/gong
Live
Events:call.analyzed
Auth:Workspace-scoped webhook subscription registered via the Gong API
Effect:Ingests Gong call transcripts post-analysis.

Slack

/api/webhooks/slack
Live
Events:Event API: message.channels, app_mention, interactivity payloads
Auth:X-Slack-Signature (HMAC-SHA256, v0 scheme) + timestamp tolerance
Effect:Powers Slack notifications and the FastFollow Slack app interactions.

Stripe

/api/webhooks/stripe
Live
Events:invoice.*, customer.subscription.*, checkout.session.completed
Auth:Stripe-Signature (timestamped HMAC-SHA256)
Effect:Billing and subscription state for the FastFollow tenant.

Signature verification

How FastFollow validates inbound webhooks — and how outbound webhooks will be signed

How we verify inbound

  1. Read the raw request body before JSON parsing
  2. Look up the provider's signing secret from encrypted tenant config
  3. Compute HMAC-SHA256 over the canonical signed message (varies per provider)
  4. Compare to the header value using crypto.timingSafeEqual
  5. If timestamp is present, reject anything older than 5 minutes
  6. Only then parse and dispatch the payload

Outbound (planned) — how you'll verify in your endpoint

import { createHmac, timingSafeEqual } from "crypto";

export function verify(rawBody: string, header: string, secret: string) {
  const [tsPart, sigPart] = header.split(",");
  const timestamp = tsPart.split("=")[1];
  const signature = sigPart.split("=")[1];

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    throw new Error("stale_signature");
  }

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  if (!timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(signature, "hex")
  )) {
    throw new Error("invalid_signature");
  }
}

Always verify over the raw body — not the parsed JSON. Frameworks like Express body-parser can re-serialize JSON with different whitespace, which breaks HMAC comparison.

Planned outbound event catalog

Coming in the outbound webhook beta — the names below are stable

meeting.ended

A connected meeting (Meet, Zoom, Teams) ended and transcripts are queued.

followup.generated

An AI-drafted follow-up is ready for human approval.

followup.sent

An approved follow-up was dispatched through Resend.

approval.completed

A human approved or rejected a drafted follow-up.

deal.score_changed

The deal intelligence score on an Opportunity Room crossed a threshold.

room.engagement

A customer interacted with an Opportunity Room (page view, MAP item completion).

Retry policy

Every inbound webhook handler returns HTTP 200 as quickly as possible, then processes the event asynchronously. We'll do the same on outbound:

  • Initial attempt fires within seconds of the event
  • On 5xx or timeout: retry with exponential backoff (1m, 5m, 15m, 1h, 6h)
  • 5 total attempts before the event is moved to a dead-letter log
  • Manual replay available from /admin/health

Idempotency

Every webhook event carries an X-FastFollow-Event-Id header. Your handler must be idempotent over this ID:

  • Store the ID in a deduplication table on first receipt
  • On retry: detect the duplicate, return 200 immediately
  • Never re-process side effects from a duplicate delivery
  • IDs are UUIDv7 (lexicographically sortable by issue time)

Local testing

Tunnel a public URL to localhost so providers can reach your dev environment

ngrok

# Forward a public URL to your local server
ngrok http 3000

# Use the https forwarding URL in
# Resend/HubSpot/Stripe webhook config:
# https://abc123.ngrok.io/api/webhooks/resend

Cloudflare Tunnel

# Stable named tunnels survive restarts
cloudflared tunnel --url http://localhost:3000

# Replays from the provider dashboard
# are essential — never assume first-try delivery
# in production handlers.