@pinklemon8/better-auth-webhooks
v0.1.0
Published
Webhook plugin for Better Auth — fire HTTP webhooks on auth events with HMAC signing and retries
Downloads
154
Maintainers
Readme
better-auth-webhooks
Webhook plugin for Better Auth — 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
npm install @pinklemon8/better-auth-webhooksQuick Start
1. Add the Plugin
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:
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:
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:
{
"id": "whk_a1b2c3d4e5f6...",
"event": "user.created",
"timestamp": "2026-04-07T12:00:00.000Z",
"data": {
"id": "user_123",
"name": "John Doe",
"email": "[email protected]",
"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:
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
timingSafeEqualto 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
