Webhook Best Practices¶
Error handling, retries, idempotency, and testing strategies.
See also: Webhooks Guide | Event Handler Reference
Error Handling & Retries¶
Webhook Retry Policy¶
AuthVital retries failed webhooks with exponential backoff:
| Attempt | Delay | Total Time |
|---|---|---|
| 1st | Immediate | 0 |
| 2nd | 1 minute | 1 min |
| 3rd | 5 minutes | 6 min |
After 3 failed attempts, the webhook is marked as failed and no further retries occur.
What Triggers a Retry?¶
| Response | Retried? |
|---|---|
| 2xx status | ✅ No (success) |
| 4xx status | ❌ No (client error, won't retry) |
| 5xx status | ✅ Yes (server error) |
| Timeout (30s) | ✅ Yes |
| Connection error | ✅ Yes |
Proper Response Handling¶
// ✅ Success - acknowledge receipt
res.status(200).json({ received: true });
// ✅ Also valid
res.sendStatus(200);
res.sendStatus(204);
// ❌ Will NOT be retried - use for permanent failures
res.status(400).json({ error: 'Invalid event format' });
res.status(401).json({ error: 'Invalid signature' });
// ✅ WILL be retried - use for temporary failures
res.status(500).json({ error: 'Database unavailable' });
res.status(503).json({ error: 'Service temporarily unavailable' });
Error Handling in Event Handlers¶
class MyEventHandler extends AuthVitalEventHandler {
async onSubjectCreated(event: SubjectCreatedEvent): Promise<void> {
try {
await this.processNewUser(event.data);
} catch (error) {
// Log the error
console.error('Failed to process user:', error);
// Re-throw to trigger retry (500 response)
// Only do this for transient errors!
if (this.isTransientError(error)) {
throw error;
}
// For permanent failures, log and don't rethrow
// (returns 200, no retry)
await this.logFailure(event, error);
}
}
private isTransientError(error: unknown): boolean {
// Database connection errors, rate limits, etc.
return error instanceof DatabaseConnectionError ||
error instanceof RateLimitError;
}
}
Idempotency¶
⚠️ Webhooks may be delivered more than once! Always design handlers to be idempotent.
Strategy 1: Use Upserts¶
async onSubjectCreated(event: SubjectCreatedEvent): Promise<void> {
// Use upsert instead of create
await prisma.user.upsert({
where: { id: event.data.sub },
create: {
id: event.data.sub,
email: event.data.email!,
firstName: event.data.given_name,
lastName: event.data.family_name,
},
update: {
// Update on re-delivery (idempotent)
email: event.data.email!,
firstName: event.data.given_name,
lastName: event.data.family_name,
},
});
}
Strategy 2: Track Processed Events¶
async onEvent(event: WebhookEvent): Promise<void> {
// Use the event ID for deduplication
const eventId = event.id;
// Check if already processed
const existing = await prisma.processedWebhook.findUnique({
where: { eventId },
});
if (existing) {
console.log('Duplicate webhook, skipping:', eventId);
// Don't throw - return normally (200 response)
return;
}
// Mark as processing (with TTL for cleanup)
await prisma.processedWebhook.create({
data: {
eventId,
eventType: event.type,
processedAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
}
Strategy 3: Conditional Updates¶
async onMemberRoleChanged(event: MemberRoleChangedEvent): Promise<void> {
// Only update if the timestamp is newer
await prisma.tenantMembership.updateMany({
where: {
id: event.data.membership_id,
// Only update if this event is newer
lastEventTimestamp: { lt: new Date(event.timestamp) },
},
data: {
roles: event.data.tenant_roles,
lastEventTimestamp: new Date(event.timestamp),
},
});
}
Database Schema for Deduplication¶
// schema.prisma
model ProcessedWebhook {
eventId String @id
eventType String
processedAt DateTime @default(now())
expiresAt DateTime
@@index([expiresAt])
}
Cleanup job:
// Run periodically (e.g., daily cron job)
async function cleanupOldWebhooks() {
const result = await prisma.processedWebhook.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
});
console.log(`Cleaned up ${result.count} old webhook records`);
}
Testing Webhooks¶
Local Development with Tunnels¶
Use a tunnel service to expose your local server:
ngrok:
Cloudflare Tunnel:
localtunnel:
Then configure your webhook URL in AuthVital:
Unit Testing Event Handlers¶
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MyEventHandler } from './event-handler';
import type { SubjectCreatedEvent, MemberRoleChangedEvent } from '@authvital/sdk/webhooks';
import { prisma } from './lib/prisma';
vi.mock('./lib/prisma');
describe('MyEventHandler', () => {
let handler: MyEventHandler;
beforeEach(() => {
handler = new MyEventHandler();
vi.clearAllMocks();
});
describe('onSubjectCreated', () => {
it('should create a user in the database', async () => {
const event: SubjectCreatedEvent = {
id: 'evt_test001',
type: 'subject.created',
timestamp: '2024-01-15T10:30:00.000Z',
tenant_id: 'tnt_test',
application_id: 'app_test',
data: {
sub: 'usr_test001',
email: 'test@example.com',
given_name: 'Test',
family_name: 'User',
subject_type: 'user',
},
};
await handler.onSubjectCreated(event);
expect(prisma.user.create).toHaveBeenCalledWith({
data: {
id: 'usr_test001',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
},
});
});
it('should skip non-user subjects', async () => {
const event: SubjectCreatedEvent = {
id: 'evt_test002',
type: 'subject.created',
timestamp: '2024-01-15T10:30:00.000Z',
tenant_id: 'tnt_test',
application_id: 'app_test',
data: {
sub: 'svc_test001',
subject_type: 'service_account',
},
};
await handler.onSubjectCreated(event);
expect(prisma.user.create).not.toHaveBeenCalled();
});
});
describe('onMemberRoleChanged', () => {
it('should update roles and log audit event', async () => {
const event: MemberRoleChangedEvent = {
id: 'evt_test003',
type: 'member.role_changed',
timestamp: '2024-01-15T10:30:00.000Z',
tenant_id: 'tnt_test',
application_id: 'app_test',
data: {
membership_id: 'mem_test001',
sub: 'usr_test001',
email: 'test@example.com',
tenant_roles: ['admin', 'member'],
previous_roles: ['member'],
},
};
await handler.onMemberRoleChanged(event);
expect(prisma.tenantMembership.update).toHaveBeenCalledWith({
where: { id: 'mem_test001' },
data: { roles: ['admin', 'member'] },
});
expect(prisma.auditLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'MEMBER_ROLE_CHANGED',
metadata: {
previousRoles: ['member'],
newRoles: ['admin', 'member'],
},
}),
});
});
});
});
Integration Testing with Test Events¶
Create a test utility to send webhook-like requests:
// test/webhook-test-utils.ts
import crypto from 'crypto';
interface TestWebhookParams {
endpoint: string;
event: Record<string, unknown>;
}
export async function sendTestWebhook(params: TestWebhookParams) {
const { endpoint, event } = params;
const body = JSON.stringify(event);
const timestamp = Math.floor(Date.now() / 1000).toString();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-AuthVital-Event-Id': event.id as string,
'X-AuthVital-Event-Type': event.type as string,
'X-AuthVital-Timestamp': timestamp,
'X-AuthVital-Key-Id': 'test-key',
'X-AuthVital-Signature': 'test-signature', // Mock for testing
},
body,
});
return response;
}
Testing with Signature Verification Disabled¶
For local testing, you can create a test-mode router:
// test/test-webhook-router.ts
import { WebhookRouter, AuthVitalEventHandler } from '@authvital/sdk/webhooks';
export function createTestRouter(handler: AuthVitalEventHandler) {
return new WebhookRouter({
authVitalHost: 'http://localhost:9999', // Fake host
handler,
// In test mode, skip signature verification
skipVerification: process.env.NODE_ENV === 'test',
});
}
E2E Testing with Supertest¶
import request from 'supertest';
import { app } from '../src/app';
import { prisma } from '../src/lib/prisma';
describe('Webhook E2E', () => {
beforeEach(async () => {
// Clear test data
await prisma.user.deleteMany();
await prisma.processedWebhook.deleteMany();
});
it('should process subject.created webhook', async () => {
const event = {
id: 'evt_e2e_001',
type: 'subject.created',
timestamp: new Date().toISOString(),
tenant_id: 'tnt_test',
application_id: 'app_test',
data: {
sub: 'usr_e2e_001',
email: 'e2e@example.com',
given_name: 'E2E',
family_name: 'Test',
subject_type: 'user',
},
};
const response = await request(app)
.post('/webhooks/authvital')
.set('Content-Type', 'application/json')
.set('X-AuthVital-Event-Id', event.id)
.set('X-AuthVital-Event-Type', event.type)
.set('X-AuthVital-Timestamp', String(Math.floor(Date.now() / 1000)))
.set('X-AuthVital-Key-Id', 'test-key')
.set('X-AuthVital-Signature', 'test-sig')
.send(event);
expect(response.status).toBe(200);
// Verify user was created
const user = await prisma.user.findUnique({
where: { id: 'usr_e2e_001' },
});
expect(user).not.toBeNull();
expect(user?.email).toBe('e2e@example.com');
});
it('should handle duplicate webhooks idempotently', async () => {
const event = {
id: 'evt_e2e_dup',
type: 'subject.created',
timestamp: new Date().toISOString(),
tenant_id: 'tnt_test',
application_id: 'app_test',
data: {
sub: 'usr_e2e_dup',
email: 'dup@example.com',
given_name: 'Dup',
family_name: 'Test',
subject_type: 'user',
},
};
// Send first webhook
await request(app)
.post('/webhooks/authvital')
.send(event)
.set('X-AuthVital-Event-Id', event.id);
// Send duplicate
const response = await request(app)
.post('/webhooks/authvital')
.send(event)
.set('X-AuthVital-Event-Id', event.id);
expect(response.status).toBe(200);
// Should still only have one user
const users = await prisma.user.findMany({
where: { id: 'usr_e2e_dup' },
});
expect(users.length).toBe(1);
});
});
Monitoring & Observability¶
Logging Best Practices¶
import { AuthVitalEventHandler } from '@authvital/sdk/webhooks';
class MyEventHandler extends AuthVitalEventHandler {
async onEvent(event) {
// Structured logging
console.log(JSON.stringify({
level: 'info',
message: 'Webhook received',
event_id: event.id,
event_type: event.type,
tenant_id: event.tenant_id,
timestamp: event.timestamp,
}));
}
async onSubjectCreated(event) {
const startTime = Date.now();
try {
await this.processUser(event.data);
console.log(JSON.stringify({
level: 'info',
message: 'User created',
event_id: event.id,
user_id: event.data.sub,
duration_ms: Date.now() - startTime,
}));
} catch (error) {
console.log(JSON.stringify({
level: 'error',
message: 'User creation failed',
event_id: event.id,
user_id: event.data.sub,
error: error.message,
duration_ms: Date.now() - startTime,
}));
throw error;
}
}
}
Metrics¶
import { Counter, Histogram } from 'prom-client';
const webhookCounter = new Counter({
name: 'webhooks_received_total',
help: 'Total webhooks received',
labelNames: ['event_type', 'status'],
});
const webhookDuration = new Histogram({
name: 'webhook_processing_duration_seconds',
help: 'Webhook processing duration',
labelNames: ['event_type'],
});
class MetricsEventHandler extends AuthVitalEventHandler {
async onEvent(event) {
const end = webhookDuration.startTimer({ event_type: event.type });
try {
// Process will be handled by specific handler
webhookCounter.inc({ event_type: event.type, status: 'received' });
} finally {
end();
}
}
}
Security Considerations¶
Always Verify Signatures¶
Never skip signature verification in production:
// ❌ NEVER do this in production
const router = new WebhookRouter({
handler: new MyEventHandler(),
skipVerification: true, // DON'T!
});
// ✅ Always verify
const router = new WebhookRouter({
authVitalHost: process.env.AV_HOST!,
handler: new MyEventHandler(),
});
Use HTTPS¶
Always use HTTPS for webhook endpoints in production.
Validate Event Data¶
Don't trust event data blindly:
async onSubjectCreated(event: SubjectCreatedEvent) {
// Validate required fields
if (!event.data.sub || !event.data.email) {
console.error('Invalid event data:', event);
return; // Don't throw - this is a permanent failure
}
// Validate email format
if (!this.isValidEmail(event.data.email)) {
console.error('Invalid email:', event.data.email);
return;
}
// Process...
}
Related Documentation¶
- Webhooks Guide - Overview and quick start
- Event Types & Payloads - All event types
- Event Handler Reference - AuthVitalEventHandler class
- Framework Integration - Express, Next.js, NestJS
- Manual Verification - Low-level API