Webhook plugin for Better Auth — fire HTTP webhooks on auth events with HMAC-SHA256 signing and automatic retries.
- 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
npm install @pinklemon8/better-auth-webhooksimport { 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}`),
}),
],
});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 });
}
}| 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 |
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": "john@example.com",
"emailVerified": true,
"createdAt": "2026-04-07T12:00:00.000Z"
}
}Sensitive fields (passwords, tokens, secrets) are automatically stripped from the data object.
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[],
}));
},
});- 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
- 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
| 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 |
| 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 |
Convenience wrapper that extracts the signature header automatically.
MIT