> ## 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.

# Signature verification

> Verify Vibefollow webhook deliveries with `vf.webhooks.constructEvent()`.

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

```mermaid theme={null}
sequenceDiagram
    participant VF as Vibefollow server
    participant Net as HTTPS
    participant You as Your handler
    participant SDK as vf.webhooks.constructEvent
    VF->>VF: HMAC-SHA256 over "t.body" using secret
    VF->>Net: POST + X-Vibefollow-Signature: t=...,v1=...
    Net->>You: Raw request body + signature header
    You->>SDK: constructEvent(rawBody, sigHeader, secret)
    SDK->>SDK: Parse header, recompute HMAC, timingSafeEqual
    SDK->>SDK: Check timestamp within tolerance (default ±5 min)
    SDK-->>You: Typed WebhookEvent (or throws WebhookSignatureError)
    You-->>Net: 2xx (verified) or 401 (signature error)
```

## The full pattern

<Tabs>
  <Tab title="Express">
    ```ts theme={null}
    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 &mdash; 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;
      }
    });
    ```
  </Tab>

  <Tab title="Hono">
    ```ts theme={null}
    import { Hono } from 'hono';
    import { VibeFollow, WebhookSignatureError } from '@vibefollow/sdk';

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

    app.post('/webhooks/vibefollow', async (c) => {
      const rawBody = await c.req.text();
      const sig = c.req.header('x-vibefollow-signature');

      try {
        const event = vf.webhooks.constructEvent(
          rawBody,
          sig,
          process.env.VIBEFOLLOW_WEBHOOK_SECRET!,
        );
        // handle event.type
        return c.body(null, 204);
      } catch (err) {
        if (err instanceof WebhookSignatureError) return c.body(null, 401);
        throw err;
      }
    });
    ```
  </Tab>

  <Tab title="Next.js App Router">
    ```ts theme={null}
    // app/api/webhooks/vibefollow/route.ts
    import { NextRequest, NextResponse } from 'next/server';
    import { VibeFollow, WebhookSignatureError } from '@vibefollow/sdk';

    export const runtime = 'nodejs'; // not 'edge' &mdash; needs node:crypto

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

    export async function POST(req: NextRequest) {
      const rawBody = await req.text();
      const sig = req.headers.get('x-vibefollow-signature');

      try {
        const event = vf.webhooks.constructEvent(
          rawBody,
          sig,
          process.env.VIBEFOLLOW_WEBHOOK_SECRET!,
        );
        // handle event.type
        return new NextResponse(null, { status: 204 });
      } catch (err) {
        if (err instanceof WebhookSignatureError) {
          return new NextResponse(null, { status: 401 });
        }
        throw err;
      }
    }
    ```
  </Tab>

  <Tab title="Raw fetch / HTTP">
    ```ts theme={null}
    // Anywhere you have raw bytes + the signature header
    import { VibeFollow, WebhookSignatureError } from '@vibefollow/sdk';

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

    function handle(rawBody: Buffer | string, sigHeader: string) {
      try {
        const event = vf.webhooks.constructEvent(
          rawBody,
          sigHeader,
          process.env.VIBEFOLLOW_WEBHOOK_SECRET!,
        );
        return event;
      } catch (err) {
        if (err instanceof WebhookSignatureError) {
          throw new Error('401 unauthorized');
        }
        throw err;
      }
    }
    ```
  </Tab>
</Tabs>

## The header format

<Snippet file="webhook-signature.mdx" />

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

## Why the raw body

<Warning>
  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.
</Warning>

Framework-specific notes:

<AccordionGroup>
  <Accordion title="Express" icon="server">
    Mount `express.raw({ type: '*/*' })` *on the webhook route only* — not globally, or every other route loses JSON parsing.
  </Accordion>

  <Accordion title="Hono / Cloudflare Workers" icon="cloud">
    Call `await request.text()` and pass the string.
  </Accordion>

  <Accordion title="Next.js App Router" icon="layer-group">
    Read `request.text()` inside the route handler. Pin the route to `runtime = 'nodejs'`.
  </Accordion>

  <Accordion title="Fastify" icon="bolt">
    Use the `addContentTypeParser` API to capture the raw buffer.
  </Accordion>
</AccordionGroup>

## 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:

```ts theme={null}
const event = vf.webhooks.constructEvent(req.body, sigHeader, secret, {
  toleranceSeconds: 600, // accept up to &plusmn;10 minutes
});
```

<Note>
  Don't widen it past necessity. Five minutes is the Stripe-standard window for the same reason — it balances clock drift against replay risk.
</Note>

## Errors you should handle

<AccordionGroup>
  <Accordion title="WebhookSignatureError — missing header" icon="circle-xmark">
    The `X-Vibefollow-Signature` header wasn't on the request. Return `401`.
  </Accordion>

  <Accordion title="WebhookSignatureError — malformed header" icon="circle-xmark">
    The header didn't parse as `t=…,v1=…`. Return `401`.
  </Accordion>

  <Accordion title="WebhookSignatureError — timestamp outside tolerance" icon="clock">
    The `t` is more than ±5 minutes from now. Return `401`.
  </Accordion>

  <Accordion title="WebhookSignatureError — signature mismatch" icon="key">
    The HMAC didn't match. Either the secret is wrong, the body was modified in transit, or it's a forgery. Return `401`.
  </Accordion>

  <Accordion title="SyntaxError" icon="bug">
    The (verified) body was not valid JSON. This shouldn't happen — return `500` and investigate.
  </Accordion>
</AccordionGroup>

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`:

```toml theme={null}
compatibility_flags = ["nodejs_compat"]
```

<Info>
  Pure-edge runtimes without Node compatibility cannot use the SDK's verifier in v1. A Web Crypto fallback is planned for a future major release.
</Info>

## Testing locally

Send a synthetic delivery to your handler with `curl` and a hand-computed signature:

```bash theme={null}
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"
```
