npm package: @pinklemon8/better-auth-webhooks
Description: Webhook plugin for Better Auth — fire HTTP webhooks on auth events with HMAC signing and retries
Latest version: 0.1.0

# better-auth-webhooks

Webhook plugin for [Better Auth](https://www.better-auth.com/) — fire HTTP webhooks on auth events with HMAC-SHA256 signing and automatic retries.

## Features

- **9 auth events** — user, session, and account create/update/delete
- **HMAC-SHA256 signing** — every payload is signed for verification
- **Automatic retries** — exponential backoff with configurable retry count
- **Sensitive field stripping** — passwords, tokens, and secrets are never sent
- **Fire and forget** — webhooks are delivered asynchronously, never blocking auth flows
- **Dynamic endpoints** — configure statically or resolve from a database at runtime
- **Verify helper** — timing-safe signature verification with timestamp tolerance

## Installation

```bash
npm install @pinklemon8/better-auth-webhooks
```

## Quick Start

### 1. Add the Plugin

```ts
import { betterAuth } from "better-auth";
import { webhooks } from "@pinklemon8/better-auth-webhooks";

export const auth = betterAuth({
  // ...your config
  plugins: [
    webhooks({
      endpoints: [
        {
          url: "https://your-app.com/api/webhooks/auth",
          secret: process.env.WEBHOOK_SECRET!,
          // Optional: only receive specific events
          events: ["user.created", "user.deleted", "session.created"],
        },
        {
          url: "https://your-crm.com/webhooks",
          secret: process.env.CRM_WEBHOOK_SECRET!,
          // Omit events to receive all
        },
      ],
      // Optional configuration
      retry: {
        maxRetries: 3,         // default: 3
        initialDelayMs: 1000,  // default: 1000
        backoffMultiplier: 2,  // default: 2
      },
      timeoutMs: 10000,        // default: 10000
      onError: (err) => console.error(`Webhook failed: ${err.endpoint} - ${err.error}`),
      onSuccess: (res) => console.log(`Webhook delivered: ${res.endpoint} - ${res.event}`),
    }),
  ],
});
```

### 2. Verify Incoming Webhooks

On the receiving end, verify the signature:

```ts
import { verifyWebhook } from "@pinklemon8/better-auth-webhooks/verify";

// Express / Node.js
app.post("/api/webhooks/auth", (req, res) => {
  try {
    const payload = verifyWebhook({
      body: req.body, // raw string body
      signature: req.headers["x-webhook-signature"],
      secret: process.env.WEBHOOK_SECRET!,
      tolerance: 300, // reject if older than 5 minutes (default)
    });

    switch (payload.event) {
      case "user.created":
        // Provision resources, send welcome email, etc.
        break;
      case "session.created":
        // Track login analytics
        break;
      case "user.deleted":
        // Cleanup external resources
        break;
    }

    res.status(200).json({ received: true });
  } catch (err) {
    res.status(401).json({ error: err.message });
  }
});
```

Next.js App Router:

```ts
import { verifyWebhookRequest } from "@pinklemon8/better-auth-webhooks/verify";

export async function POST(request: Request) {
  const body = await request.text();
  try {
    const payload = verifyWebhookRequest(
      { body, headers: request.headers },
      process.env.WEBHOOK_SECRET!
    );

    // Handle payload.event...
    return Response.json({ received: true });
  } catch {
    return Response.json({ error: "Invalid signature" }, { status: 401 });
  }
}
```

## Events

| Event | Trigger |
|---|---|
| `user.created` | New user registered |
| `user.updated` | User profile updated |
| `user.deleted` | User account deleted |
| `session.created` | User signed in |
| `session.updated` | Session refreshed or modified |
| `session.deleted` | User signed out or session revoked |
| `account.created` | OAuth/wallet account linked |
| `account.updated` | Linked account updated |
| `account.deleted` | Linked account removed |

## Webhook Payload

Every webhook POST request includes:

**Headers:**
| Header | Description |
|---|---|
| `X-Webhook-Id` | Unique delivery ID (e.g. `whk_a1b2c3...`) |
| `X-Webhook-Event` | Event type (e.g. `user.created`) |
| `X-Webhook-Timestamp` | ISO 8601 timestamp |
| `X-Webhook-Signature` | `sha256=<hex>` HMAC signature |

**Body:**
```json
{
  "id": "whk_a1b2c3d4e5f6...",
  "event": "user.created",
  "timestamp": "2026-04-07T12:00:00.000Z",
  "data": {
    "id": "user_123",
    "name": "John Doe",
    "email": "john@example.com",
    "emailVerified": true,
    "createdAt": "2026-04-07T12:00:00.000Z"
  }
}
```

Sensitive fields (passwords, tokens, secrets) are automatically stripped from the `data` object.

## Dynamic Endpoints

Load webhook endpoints from a database:

```ts
webhooks({
  endpoints: async () => {
    const rows = await db.select().from(webhookEndpoints);
    return rows.map((row) => ({
      url: row.url,
      secret: row.secret,
      events: row.events as WebhookEvent[],
    }));
  },
});
```

## Retry Behavior

- **4xx errors (except 429)**: Not retried (client error)
- **5xx errors, timeouts, network failures**: Retried with exponential backoff
- Default: 3 retries with delays of 1s, 2s, 4s

## Security

- Payloads are signed with HMAC-SHA256 using a per-endpoint secret
- Verification uses `timingSafeEqual` to prevent timing attacks
- Timestamp tolerance rejects replayed webhooks (default: 5 minutes)
- Sensitive fields (password, accessToken, refreshToken, etc.) are stripped before delivery

## API Reference

### `webhooks(options)`

| Option | Type | Default | Description |
|---|---|---|---|
| `endpoints` | `WebhookEndpoint[] \| () => Promise<WebhookEndpoint[]>` | required | Webhook destinations |
| `retry.maxRetries` | `number` | `3` | Max delivery attempts |
| `retry.initialDelayMs` | `number` | `1000` | First retry delay |
| `retry.backoffMultiplier` | `number` | `2` | Backoff multiplier |
| `timeoutMs` | `number` | `10000` | Request timeout |
| `onError` | `(err) => void` | — | Error callback |
| `onSuccess` | `(res) => void` | — | Success callback |

### `verifyWebhook(options)`

| Option | Type | Default | Description |
|---|---|---|---|
| `body` | `string` | required | Raw request body |
| `signature` | `string` | required | `X-Webhook-Signature` header value |
| `secret` | `string` | required | Webhook secret |
| `tolerance` | `number` | `300` | Max age in seconds |

### `verifyWebhookRequest(request, secret, tolerance?)`

Convenience wrapper that extracts the signature header automatically.

## License

MIT