Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.vibefollow.com/llms.txt

Use this file to discover all available pages before exploring further.

Every webhook delivery carries an HMAC signature you must verify before trusting the payload. The SDK does the work in one call — you pass the raw body, the signature header, and your webhook secret; it returns a typed event or throws.

How verification works

The full pattern

import { VibeFollow, WebhookSignatureError } from '@vibefollow/sdk';

const vf = new VibeFollow({ apiKey: process.env.VIBEFOLLOW_API_KEY! });

app.post('/webhooks/vibefollow', express.raw({ type: '*/*' }), (req, res) => {
  try {
    const event = vf.webhooks.constructEvent(
      req.body, // raw Buffer or string — NOT JSON.parse(body)
      req.headers['x-vibefollow-signature'],
      process.env.VIBEFOLLOW_WEBHOOK_SECRET!,
    );

    switch (event.type) {
      case 'email.opened':
        console.log('opened', event.data.draftId);
        break;
      case 'email.replied':
        console.log('reply tone:', event.data.tone);
        break;
      // …
    }

    res.sendStatus(204);
  } catch (err) {
    if (err instanceof WebhookSignatureError) return res.sendStatus(401);
    throw err;
  }
});

The header format

Verification is constant-time. The SDK uses crypto.timingSafeEqual so an attacker cannot derive the secret by timing your responses.

Why the raw body

The signature is computed over the exact bytes Vibefollow sent. If you parse the body first and re-serialise — key order, whitespace, escaped slashes — the recomputed HMAC differs from the header. The SDK accepts both Buffer and string for the body argument; it will not accept a parsed object.
Framework-specific notes:
Mount express.raw({ type: '*/*' }) on the webhook route only — not globally, or every other route loses JSON parsing.
Call await request.text() and pass the string.
Read request.text() inside the route handler. Pin the route to runtime = 'nodejs'.
Use the addContentTypeParser API to capture the raw buffer.

Replay protection

The header includes a Unix-seconds timestamp t. The SDK rejects any delivery whose timestamp is more than ±5 minutes from local time, which neutralises replay attacks against stale captured payloads. Override the tolerance if your environment has known clock drift:
const event = vf.webhooks.constructEvent(req.body, sigHeader, secret, {
  toleranceSeconds: 600, // accept up to ±10 minutes
});
Don’t widen it past necessity. Five minutes is the Stripe-standard window for the same reason — it balances clock drift against replay risk.

Errors you should handle

The X-Vibefollow-Signature header wasn’t on the request. Return 401.
The header didn’t parse as t=…,v1=…. Return 401.
The t is more than ±5 minutes from now. Return 401.
The HMAC didn’t match. Either the secret is wrong, the body was modified in transit, or it’s a forgery. Return 401.
The (verified) body was not valid JSON. This shouldn’t happen — return 500 and investigate.
In all WebhookSignatureError cases, return 401. Do not retry on your side — Vibefollow’s retry will pick it up if the failure was transient.

Edge-runtime caveats

Webhook verification uses node:crypto for HMAC. On Cloudflare Workers, enable the nodejs_compat flag in wrangler.toml:
compatibility_flags = ["nodejs_compat"]
Pure-edge runtimes without Node compatibility cannot use the SDK’s verifier in v1. Track this issue for a Web Crypto fallback.

Testing locally

Use the Vibefollow CLI’s webhook:send command (coming soon) or curl with a hand-computed signature:
BODY='{"type":"email.opened","id":"evt_demo","created":1747526400,"data":{}}'
TS=$(date +%s)
SIG=$(printf "%s" "$TS.$BODY" | openssl dgst -sha256 -hmac "$VIBEFOLLOW_WEBHOOK_SECRET" -binary | xxd -p -c 256)

curl -i http://localhost:3000/webhooks/vibefollow \
  -H "Content-Type: application/json" \
  -H "X-Vibefollow-Signature: t=$TS,v1=$SIG" \
  --data-raw "$BODY"